Skip to content

Commit 7426948

Browse files
devversionalxhub
authored andcommitted
refactor(common): update NgTemplateOutlet to no longer rely on context swapping (#51887)
The context of an embedded view ref at some point was switched from a getter to an actual assignable property. This is something we reverted with the previous commit as it introduces additional complexity for our generated code (in terms of closures capturing the `ctx`). This change impacted the template outlet code because we actively relied on swapping out the full context if the user changes it. Previousl, before we allowed to swap out the context (in v16), we mutated the initial view context if it didn't change structurally- and in other cases the view was re-created. We improved this performance aspect with the changes to allow for the context to be swapped out + actually also fixed a bug where the initial context object was mutated and the user could observe this change. This commit adjusts for context not being replacable- while still keeping the bugs fixed and preserving the performance wins of not having to destroy/re-create the view whenever the context changes. Benchmarks: https://hackmd.io/J0Ci_JzxQ0K1AA1omXhIQQ PR Close #51887
1 parent 9b9e11f commit 7426948

File tree

3 files changed

+68
-17
lines changed

3 files changed

+68
-17
lines changed

goldens/size-tracking/aio-payloads.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@
1919
"dark-theme": 34598
2020
}
2121
}
22-
}
22+
}

packages/common/src/directives/ng_template_outlet.ts

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Directive, EmbeddedViewRef, Injector, Input, OnChanges, SimpleChanges, TemplateRef, ViewContainerRef} from '@angular/core';
9+
import {Directive, EmbeddedViewRef, Injector, Input, OnChanges, SimpleChange, SimpleChanges, TemplateRef, ViewContainerRef} from '@angular/core';
1010

1111
/**
1212
* @ngModule CommonModule
@@ -57,30 +57,57 @@ export class NgTemplateOutlet<C = unknown> implements OnChanges {
5757

5858
constructor(private _viewContainerRef: ViewContainerRef) {}
5959

60-
/** @nodoc */
6160
ngOnChanges(changes: SimpleChanges) {
62-
if (changes['ngTemplateOutlet'] || changes['ngTemplateOutletInjector']) {
61+
if (this._shouldRecreateView(changes)) {
6362
const viewContainerRef = this._viewContainerRef;
6463

6564
if (this._viewRef) {
6665
viewContainerRef.remove(viewContainerRef.indexOf(this._viewRef));
6766
}
6867

69-
if (this.ngTemplateOutlet) {
70-
const {
71-
ngTemplateOutlet: template,
72-
ngTemplateOutletContext: context,
73-
ngTemplateOutletInjector: injector,
74-
} = this;
75-
this._viewRef =
76-
viewContainerRef.createEmbeddedView(
77-
template, context, injector ? {injector} : undefined) as EmbeddedViewRef<C>;
78-
} else {
68+
// If there is no outlet, clear the destroyed view ref.
69+
if (!this.ngTemplateOutlet) {
7970
this._viewRef = null;
71+
return;
8072
}
81-
} else if (
82-
this._viewRef && changes['ngTemplateOutletContext'] && this.ngTemplateOutletContext) {
83-
this._viewRef.context = this.ngTemplateOutletContext;
73+
74+
// Create a context forward `Proxy` that will always bind to the user-specified context,
75+
// without having to destroy and re-create views whenever the context changes.
76+
const viewContext = this._createContextForwardProxy();
77+
this._viewRef = viewContainerRef.createEmbeddedView(this.ngTemplateOutlet, viewContext, {
78+
injector: this.ngTemplateOutletInjector ?? undefined,
79+
});
8480
}
8581
}
82+
83+
/**
84+
* We need to re-create existing embedded view if either is true:
85+
* - the outlet changed.
86+
* - the injector changed.
87+
*/
88+
private _shouldRecreateView(changes: SimpleChanges): boolean {
89+
return !!changes['ngTemplateOutlet'] || !!changes['ngTemplateOutletInjector'];
90+
}
91+
92+
/**
93+
* For a given outlet instance, we create a proxy object that delegates
94+
* to the user-specified context. This allows changing, or swapping out
95+
* the context object completely without having to destroy/re-create the view.
96+
*/
97+
private _createContextForwardProxy(): C {
98+
return <C>new Proxy({}, {
99+
set: (_target, prop, newValue) => {
100+
if (!this.ngTemplateOutletContext) {
101+
return false;
102+
}
103+
return Reflect.set(this.ngTemplateOutletContext, prop, newValue);
104+
},
105+
get: (_target, prop, receiver) => {
106+
if (!this.ngTemplateOutletContext) {
107+
return undefined;
108+
}
109+
return Reflect.get(this.ngTemplateOutletContext, prop, receiver);
110+
},
111+
});
112+
}
86113
}

packages/common/test/directives/ng_template_outlet_spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,30 @@ describe('NgTemplateOutlet', () => {
318318

319319
expect(fixture.nativeElement.textContent).toBe('Hello World');
320320
});
321+
322+
it('should properly bind context if context is unset initially', () => {
323+
@Component({
324+
imports: [NgTemplateOutlet],
325+
template: `
326+
<ng-template #tpl let-name>Name:{{name}}</ng-template>
327+
<ng-template [ngTemplateOutlet]="tpl" [ngTemplateOutletContext]="ctx"></ng-template>
328+
`,
329+
standalone: true,
330+
})
331+
class TestComponent {
332+
ctx: {$implicit: string}|undefined = undefined;
333+
}
334+
335+
const fixture = TestBed.createComponent(TestComponent);
336+
fixture.detectChanges();
337+
338+
expect(fixture.nativeElement.textContent).toBe('Name:');
339+
340+
fixture.componentInstance.ctx = {$implicit: 'Angular'};
341+
fixture.detectChanges();
342+
343+
expect(fixture.nativeElement.textContent).toBe('Name:Angular');
344+
});
321345
});
322346

323347
const templateToken = new InjectionToken<string>('templateToken');

0 commit comments

Comments
 (0)