diff --git a/goldens/size-tracking/aio-payloads.json b/goldens/size-tracking/aio-payloads.json
index d7e719e4a526..90aebc5a03bf 100755
--- a/goldens/size-tracking/aio-payloads.json
+++ b/goldens/size-tracking/aio-payloads.json
@@ -19,4 +19,4 @@
"dark-theme": 34598
}
}
-}
+}
\ No newline at end of file
diff --git a/modules/benchmarks/src/ng_template_outlet_context/BUILD.bazel b/modules/benchmarks/src/ng_template_outlet_context/BUILD.bazel
new file mode 100644
index 000000000000..8930db9a4ecf
--- /dev/null
+++ b/modules/benchmarks/src/ng_template_outlet_context/BUILD.bazel
@@ -0,0 +1,14 @@
+load("//tools:defaults.bzl", "ts_library")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_library(
+ name = "perf_lib",
+ testonly = True,
+ srcs = ["ng_template_outlet_context.perf-spec.ts"],
+ tsconfig = "//modules/benchmarks:tsconfig-e2e.json",
+ deps = [
+ "@npm//@angular/build-tooling/bazel/benchmark/driver-utilities",
+ "@npm//protractor",
+ ],
+)
diff --git a/modules/benchmarks/src/ng_template_outlet_context/ng2/BUILD.bazel b/modules/benchmarks/src/ng_template_outlet_context/ng2/BUILD.bazel
new file mode 100644
index 000000000000..72978f7a71be
--- /dev/null
+++ b/modules/benchmarks/src/ng_template_outlet_context/ng2/BUILD.bazel
@@ -0,0 +1,39 @@
+load("//tools:defaults.bzl", "app_bundle", "http_server", "ng_module")
+load("@npm//@angular/build-tooling/bazel/benchmark/component_benchmark:benchmark_test.bzl", "benchmark_test")
+
+package(default_visibility = ["//modules/benchmarks:__subpackages__"])
+
+ng_module(
+ name = "ng2",
+ srcs = glob(["*.ts"]),
+ strict_templates = True,
+ tsconfig = "//modules/benchmarks:tsconfig-build.json",
+ deps = [
+ "//modules/benchmarks/src:util_lib",
+ "//packages/core",
+ "//packages/platform-browser",
+ ],
+)
+
+app_bundle(
+ name = "bundle",
+ entry_point = ":index.ts",
+ deps = [
+ ":ng2",
+ ],
+)
+
+http_server(
+ name = "prodserver",
+ srcs = ["index.html"],
+ deps = [
+ ":bundle.debug.min.js",
+ "//packages/zone.js/bundles:zone.umd.js",
+ ],
+)
+
+benchmark_test(
+ name = "perf",
+ server = ":prodserver",
+ deps = ["//modules/benchmarks/src/ng_template_outlet_context:perf_lib"],
+)
diff --git a/modules/benchmarks/src/ng_template_outlet_context/ng2/index.html b/modules/benchmarks/src/ng_template_outlet_context/ng2/index.html
new file mode 100644
index 000000000000..206f7b0e568f
--- /dev/null
+++ b/modules/benchmarks/src/ng_template_outlet_context/ng2/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/benchmarks/src/ng_template_outlet_context/ng2/index.ts b/modules/benchmarks/src/ng_template_outlet_context/ng2/index.ts
new file mode 100644
index 000000000000..a09f9c37788f
--- /dev/null
+++ b/modules/benchmarks/src/ng_template_outlet_context/ng2/index.ts
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import {NgIf, NgTemplateOutlet} from '@angular/common';
+import {Component, enableProdMode, Input} from '@angular/core';
+import {bootstrapApplication} from '@angular/platform-browser';
+
+@Component({
+ selector: 'deep',
+ standalone: true,
+ imports: [NgIf],
+ template: ` 1" [depth]="depth - 1" /> Level: {{depth}}`,
+})
+class Deep {
+ @Input({required: true}) depth: number;
+}
+
+@Component({
+ selector: 'app-component',
+ standalone: true,
+ imports: [NgTemplateOutlet, Deep],
+ template: `
+
+
+
+
+
+
+ Implicit: {{implicit}}
+ A: {{a}}
+ B: {{b}}
+ Deep: {{deep.next.text}}
+ New: {{new}}
+
+
+
+
+
+ `,
+})
+class AppComponent {
+ context:
+ {$implicit: unknown, a: unknown, b: unknown, deep: {next: {text: unknown}}, new?: unknown} = {
+ $implicit: 'Default Implicit',
+ a: 'Default A',
+ b: 'Default B',
+ deep: {next: {text: 'Default deep text'}},
+ };
+
+ swapOutFull() {
+ this.context = {
+ $implicit: 'New Implicit new Object',
+ a: 'New A new Object',
+ b: 'New B new Object',
+ deep: {next: {text: 'New Deep text new Object'}},
+ };
+ }
+
+ modifyProperty() {
+ this.context.a = 'Modified a';
+ }
+
+ modifyDeepProperty() {
+ this.context.deep.next.text = 'Modified deep a';
+ }
+
+ addNewProperty() {
+ this.context.new = 'New property set';
+ }
+}
+
+enableProdMode();
+bootstrapApplication(AppComponent);
diff --git a/modules/benchmarks/src/ng_template_outlet_context/ng_template_outlet_context.perf-spec.ts b/modules/benchmarks/src/ng_template_outlet_context/ng_template_outlet_context.perf-spec.ts
new file mode 100644
index 000000000000..50c099a657ee
--- /dev/null
+++ b/modules/benchmarks/src/ng_template_outlet_context/ng_template_outlet_context.perf-spec.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import {runBenchmark, verifyNoBrowserErrors} from '@angular/build-tooling/bazel/benchmark/driver-utilities';
+import {$} from 'protractor';
+
+interface Worker {
+ id: string;
+ prepare?(): void;
+ work(): void;
+}
+
+const SwapFullContext = {
+ id: 'swapFullContext',
+ work: () => {
+ $('#swapOutFull').click();
+ }
+};
+
+const ModifyContextProperty = {
+ id: 'modifyContextProperty',
+ work: () => {
+ $('#modifyProperty').click();
+ }
+};
+
+const ModifyContextDeepProperty = {
+ id: 'modifyContextDeepProperty',
+ work: () => {
+ $('#modifyDeepProperty').click();
+ }
+};
+
+const AddNewContextProperty = {
+ id: 'addNewContextProperty',
+ work: () => {
+ $('#addNewProperty').click();
+ }
+};
+
+const scenarios = [
+ SwapFullContext,
+ ModifyContextProperty,
+ ModifyContextDeepProperty,
+ AddNewContextProperty,
+];
+
+describe('ng_template_outlet_context benchmark spec', () => {
+ afterEach(verifyNoBrowserErrors);
+
+ scenarios.forEach((worker) => {
+ describe(worker.id, () => {
+ it('should run for ng2', async () => {
+ await runBenchmarkScenario(
+ {url: '/', id: `ngTemplateOutletContext.ng2.${worker.id}`, worker: worker});
+ });
+ });
+ });
+
+ function runBenchmarkScenario(config: {id: string, url: string, worker: Worker}) {
+ return runBenchmark({
+ id: config.id,
+ url: config.url,
+ ignoreBrowserSynchronization: true,
+ prepare: config.worker.prepare,
+ work: config.worker.work
+ });
+ }
+});
diff --git a/modules/benchmarks/tsconfig.json b/modules/benchmarks/tsconfig.json
index 020672cb0065..aa2d8c2d3616 100644
--- a/modules/benchmarks/tsconfig.json
+++ b/modules/benchmarks/tsconfig.json
@@ -30,6 +30,5 @@
"no-floating-promises": true,
"no-unused-expression": true,
"no-unused-variable": true
- },
- "include": ["../../node_modules/@angular/build-tooling/bazel/benchmark/driver-utilities/"]
+ }
}
diff --git a/packages/common/src/directives/ng_template_outlet.ts b/packages/common/src/directives/ng_template_outlet.ts
index 3d75130103be..46068372c8c9 100644
--- a/packages/common/src/directives/ng_template_outlet.ts
+++ b/packages/common/src/directives/ng_template_outlet.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {Directive, EmbeddedViewRef, Injector, Input, OnChanges, SimpleChanges, TemplateRef, ViewContainerRef} from '@angular/core';
+import {Directive, EmbeddedViewRef, Injector, Input, OnChanges, SimpleChange, SimpleChanges, TemplateRef, ViewContainerRef} from '@angular/core';
/**
* @ngModule CommonModule
@@ -57,30 +57,57 @@ export class NgTemplateOutlet implements OnChanges {
constructor(private _viewContainerRef: ViewContainerRef) {}
- /** @nodoc */
ngOnChanges(changes: SimpleChanges) {
- if (changes['ngTemplateOutlet'] || changes['ngTemplateOutletInjector']) {
+ if (this._shouldRecreateView(changes)) {
const viewContainerRef = this._viewContainerRef;
if (this._viewRef) {
viewContainerRef.remove(viewContainerRef.indexOf(this._viewRef));
}
- if (this.ngTemplateOutlet) {
- const {
- ngTemplateOutlet: template,
- ngTemplateOutletContext: context,
- ngTemplateOutletInjector: injector,
- } = this;
- this._viewRef =
- viewContainerRef.createEmbeddedView(
- template, context, injector ? {injector} : undefined) as EmbeddedViewRef;
- } else {
+ // If there is no outlet, clear the destroyed view ref.
+ if (!this.ngTemplateOutlet) {
this._viewRef = null;
+ return;
}
- } else if (
- this._viewRef && changes['ngTemplateOutletContext'] && this.ngTemplateOutletContext) {
- this._viewRef.context = this.ngTemplateOutletContext;
+
+ // Create a context forward `Proxy` that will always bind to the user-specified context,
+ // without having to destroy and re-create views whenever the context changes.
+ const viewContext = this._createContextForwardProxy();
+ this._viewRef = viewContainerRef.createEmbeddedView(this.ngTemplateOutlet, viewContext, {
+ injector: this.ngTemplateOutletInjector ?? undefined,
+ });
}
}
+
+ /**
+ * We need to re-create existing embedded view if either is true:
+ * - the outlet changed.
+ * - the injector changed.
+ */
+ private _shouldRecreateView(changes: SimpleChanges): boolean {
+ return !!changes['ngTemplateOutlet'] || !!changes['ngTemplateOutletInjector'];
+ }
+
+ /**
+ * For a given outlet instance, we create a proxy object that delegates
+ * to the user-specified context. This allows changing, or swapping out
+ * the context object completely without having to destroy/re-create the view.
+ */
+ private _createContextForwardProxy(): C {
+ return new Proxy({}, {
+ set: (_target, prop, newValue) => {
+ if (!this.ngTemplateOutletContext) {
+ return false;
+ }
+ return Reflect.set(this.ngTemplateOutletContext, prop, newValue);
+ },
+ get: (_target, prop, receiver) => {
+ if (!this.ngTemplateOutletContext) {
+ return undefined;
+ }
+ return Reflect.get(this.ngTemplateOutletContext, prop, receiver);
+ },
+ });
+ }
}
diff --git a/packages/common/test/directives/ng_template_outlet_spec.ts b/packages/common/test/directives/ng_template_outlet_spec.ts
index eb3fdd2a0b66..f6ffc47d48ff 100644
--- a/packages/common/test/directives/ng_template_outlet_spec.ts
+++ b/packages/common/test/directives/ng_template_outlet_spec.ts
@@ -318,6 +318,30 @@ describe('NgTemplateOutlet', () => {
expect(fixture.nativeElement.textContent).toBe('Hello World');
});
+
+ it('should properly bind context if context is unset initially', () => {
+ @Component({
+ imports: [NgTemplateOutlet],
+ template: `
+ Name:{{name}}
+
+ `,
+ standalone: true,
+ })
+ class TestComponent {
+ ctx: {$implicit: string}|undefined = undefined;
+ }
+
+ const fixture = TestBed.createComponent(TestComponent);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).toBe('Name:');
+
+ fixture.componentInstance.ctx = {$implicit: 'Angular'};
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).toBe('Name:Angular');
+ });
});
const templateToken = new InjectionToken('templateToken');
diff --git a/packages/core/src/render3/view_ref.ts b/packages/core/src/render3/view_ref.ts
index 2139b1c0f8f2..40080ea84b6d 100644
--- a/packages/core/src/render3/view_ref.ts
+++ b/packages/core/src/render3/view_ref.ts
@@ -63,7 +63,19 @@ export class ViewRef implements EmbeddedViewRef, InternalViewRef, ChangeDe
return this._lView[CONTEXT] as unknown as T;
}
+ /**
+ * @deprecated Replacing the full context object is not supported. Modify the context
+ * directly, or consider using a `Proxy` if you need to replace the full object.
+ * // TODO(devversion): Remove this.
+ */
set context(value: T) {
+ if (ngDevMode) {
+ // Note: We have a warning message here because the `@deprecated` JSDoc will not be picked
+ // up for assignments on the setter. We want to let users know about the deprecated usage.
+ console.warn(
+ 'Angular: Replacing the `context` object of an `EmbeddedViewRef` is deprecated.');
+ }
+
this._lView[CONTEXT] = value as unknown as {};
}
diff --git a/packages/core/test/acceptance/template_ref_spec.ts b/packages/core/test/acceptance/template_ref_spec.ts
index 58bc1fecb74b..c1c22702e6c6 100644
--- a/packages/core/test/acceptance/template_ref_spec.ts
+++ b/packages/core/test/acceptance/template_ref_spec.ts
@@ -317,5 +317,26 @@ describe('TemplateRef', () => {
button.click();
expect(events).toEqual(['Frodo', 'Bilbo']);
});
+
+ it('should warn if the context of an embedded view ref is replaced', () => {
+ TestBed.configureTestingModule({declarations: [App]});
+ const fixture = TestBed.createComponent(App);
+ fixture.detectChanges();
+ const viewRef = fixture.componentInstance.templateRef.createEmbeddedView({name: 'Frodo'});
+ fixture.componentInstance.containerRef.insert(viewRef);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).toBe('Frodo');
+ spyOn(console, 'warn');
+
+ viewRef.context = {name: 'Bilbo'};
+ fixture.detectChanges();
+
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn)
+ .toHaveBeenCalledWith(jasmine.stringContaining(
+ 'Replacing the `context` object of an `EmbeddedViewRef` is deprecated'));
+ expect(fixture.nativeElement.textContent).toBe('Bilbo');
+ });
});
});