Skip to content

Commit d3705b3

Browse files
crisbetoatscott
authored andcommitted
fix(common): avoid mutating context object in NgTemplateOutlet (#40360)
Currently `NgTemplateOutlet` recreates its view if its template is swapped out or a context object with a different shape is passed in. If an object with the same shape is passed in, we preserve the old view and we mutate the previous object. This mutation of the original object can be undesirable if two objects with the same shape are swapped between two different template outlets. The current behavior is a result of a limitation in `core` where the `context` of an embedded view is read-only, however a previous commit made it writeable. These changes resolve the context mutation issue and clean up a bunch of unnecessary logic from `NgTemplateOutlet` by taking advantage of the earlier change. Fixes #24515. PR Close #40360
1 parent a3e1719 commit d3705b3

File tree

3 files changed

+42
-45
lines changed

3 files changed

+42
-45
lines changed

goldens/size-tracking/aio-payloads.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,4 @@
2626
}
2727
}
2828
}
29-
}
29+
}

packages/common/src/directives/ng_template_outlet.ts

Lines changed: 4 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,7 @@ export class NgTemplateOutlet implements OnChanges {
5252
constructor(private _viewContainerRef: ViewContainerRef) {}
5353

5454
ngOnChanges(changes: SimpleChanges) {
55-
const recreateView = this._shouldRecreateView(changes);
56-
57-
if (recreateView) {
55+
if (changes['ngTemplateOutlet']) {
5856
const viewContainerRef = this._viewContainerRef;
5957

6058
if (this._viewRef) {
@@ -64,44 +62,9 @@ export class NgTemplateOutlet implements OnChanges {
6462
this._viewRef = this.ngTemplateOutlet ?
6563
viewContainerRef.createEmbeddedView(this.ngTemplateOutlet, this.ngTemplateOutletContext) :
6664
null;
67-
} else if (this._viewRef && this.ngTemplateOutletContext) {
68-
this._updateExistingContext(this.ngTemplateOutletContext);
69-
}
70-
}
71-
72-
/**
73-
* We need to re-create existing embedded view if:
74-
* - templateRef has changed
75-
* - context has changes
76-
*
77-
* We mark context object as changed when the corresponding object
78-
* shape changes (new properties are added or existing properties are removed).
79-
* In other words we consider context with the same properties as "the same" even
80-
* if object reference changes (see https://github.com/angular/angular/issues/13407).
81-
*/
82-
private _shouldRecreateView(changes: SimpleChanges): boolean {
83-
const ctxChange = changes['ngTemplateOutletContext'];
84-
return !!changes['ngTemplateOutlet'] || (ctxChange && this._hasContextShapeChanged(ctxChange));
85-
}
86-
87-
private _hasContextShapeChanged(ctxChange: SimpleChange): boolean {
88-
const prevCtxKeys = Object.keys(ctxChange.previousValue || {});
89-
const currCtxKeys = Object.keys(ctxChange.currentValue || {});
90-
91-
if (prevCtxKeys.length === currCtxKeys.length) {
92-
for (let propName of currCtxKeys) {
93-
if (prevCtxKeys.indexOf(propName) === -1) {
94-
return true;
95-
}
96-
}
97-
return false;
98-
}
99-
return true;
100-
}
101-
102-
private _updateExistingContext(ctx: Object): void {
103-
for (let propName of Object.keys(ctx)) {
104-
(<any>this._viewRef!.context)[propName] = (<any>this.ngTemplateOutletContext)[propName];
65+
} else if (
66+
this._viewRef && changes['ngTemplateOutletContext'] && this.ngTemplateOutletContext) {
67+
this._viewRef.context = this.ngTemplateOutletContext;
10568
}
10669
}
10770
}

packages/common/test/directives/ng_template_outlet_spec.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe('NgTemplateOutlet', () => {
2929

3030
beforeEach(() => {
3131
TestBed.configureTestingModule({
32-
declarations: [TestComponent, CaptureTplRefs, DestroyableCmpt],
32+
declarations: [TestComponent, CaptureTplRefs, DestroyableCmpt, MultiContextComponent],
3333
imports: [CommonModule],
3434
providers: [DestroyedSpyService]
3535
});
@@ -142,7 +142,7 @@ describe('NgTemplateOutlet', () => {
142142
expect(spyService.destroyed).toBeFalsy();
143143
});
144144

145-
it('should recreate embedded view when context shape changes', () => {
145+
it('should update but not destroy embedded view when context shape changes', () => {
146146
const template =
147147
`<ng-template let-foo="foo" #tpl><destroyable-cmpt></destroyable-cmpt>:{{foo}}</ng-template>` +
148148
`<ng-template [ngTemplateOutlet]="tpl" [ngTemplateOutletContext]="context"></ng-template>`;
@@ -155,7 +155,7 @@ describe('NgTemplateOutlet', () => {
155155

156156
fixture.componentInstance.context = {foo: 'baz', other: true};
157157
detectChangesAndExpectText('Content to destroy:baz');
158-
expect(spyService.destroyed).toBeTruthy();
158+
expect(spyService.destroyed).toBeFalsy();
159159
});
160160

161161
it('should destroy embedded view when context value changes and templateRef becomes undefined', () => {
@@ -241,6 +241,27 @@ describe('NgTemplateOutlet', () => {
241241
detectChangesAndExpectText('foo');
242242
}).not.toThrow();
243243
}));
244+
245+
it('should not mutate context object if two contexts with an identical shape are swapped', () => {
246+
fixture = TestBed.createComponent(MultiContextComponent);
247+
const {componentInstance, nativeElement} = fixture;
248+
componentInstance.context1 = {name: 'one'};
249+
componentInstance.context2 = {name: 'two'};
250+
fixture.detectChanges();
251+
252+
expect(nativeElement.textContent.trim()).toBe('one | two');
253+
expect(componentInstance.context1).toEqual({name: 'one'});
254+
expect(componentInstance.context2).toEqual({name: 'two'});
255+
256+
const temp = componentInstance.context1;
257+
componentInstance.context1 = componentInstance.context2;
258+
componentInstance.context2 = temp;
259+
fixture.detectChanges();
260+
261+
expect(nativeElement.textContent.trim()).toBe('two | one');
262+
expect(componentInstance.context1).toEqual({name: 'two'});
263+
expect(componentInstance.context2).toEqual({name: 'one'});
264+
});
244265
});
245266

246267
@Injectable()
@@ -271,6 +292,19 @@ class TestComponent {
271292
value = 'bar';
272293
}
273294

295+
@Component({
296+
template: `
297+
<ng-template #template let-name="name">{{name}}</ng-template>
298+
<ng-template [ngTemplateOutlet]="template" [ngTemplateOutletContext]="context1"></ng-template>
299+
|
300+
<ng-template [ngTemplateOutlet]="template" [ngTemplateOutletContext]="context2"></ng-template>
301+
`
302+
})
303+
class MultiContextComponent {
304+
context1: {name: string}|undefined;
305+
context2: {name: string}|undefined;
306+
}
307+
274308
function createTestComponent(template: string): ComponentFixture<TestComponent> {
275309
return TestBed.overrideComponent(TestComponent, {set: {template: template}})
276310
.configureTestingModule({schemas: [NO_ERRORS_SCHEMA]})

0 commit comments

Comments
 (0)