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
This commit is contained in:
parent
a3e17190e7
commit
d3705b3284
|
@ -26,4 +26,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,9 +52,7 @@ export class NgTemplateOutlet implements OnChanges {
|
|||
constructor(private _viewContainerRef: ViewContainerRef) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
const recreateView = this._shouldRecreateView(changes);
|
||||
|
||||
if (recreateView) {
|
||||
if (changes['ngTemplateOutlet']) {
|
||||
const viewContainerRef = this._viewContainerRef;
|
||||
|
||||
if (this._viewRef) {
|
||||
|
@ -64,44 +62,9 @@ export class NgTemplateOutlet implements OnChanges {
|
|||
this._viewRef = this.ngTemplateOutlet ?
|
||||
viewContainerRef.createEmbeddedView(this.ngTemplateOutlet, this.ngTemplateOutletContext) :
|
||||
null;
|
||||
} else if (this._viewRef && this.ngTemplateOutletContext) {
|
||||
this._updateExistingContext(this.ngTemplateOutletContext);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to re-create existing embedded view if:
|
||||
* - templateRef has changed
|
||||
* - context has changes
|
||||
*
|
||||
* We mark context object as changed when the corresponding object
|
||||
* shape changes (new properties are added or existing properties are removed).
|
||||
* In other words we consider context with the same properties as "the same" even
|
||||
* if object reference changes (see https://github.com/angular/angular/issues/13407).
|
||||
*/
|
||||
private _shouldRecreateView(changes: SimpleChanges): boolean {
|
||||
const ctxChange = changes['ngTemplateOutletContext'];
|
||||
return !!changes['ngTemplateOutlet'] || (ctxChange && this._hasContextShapeChanged(ctxChange));
|
||||
}
|
||||
|
||||
private _hasContextShapeChanged(ctxChange: SimpleChange): boolean {
|
||||
const prevCtxKeys = Object.keys(ctxChange.previousValue || {});
|
||||
const currCtxKeys = Object.keys(ctxChange.currentValue || {});
|
||||
|
||||
if (prevCtxKeys.length === currCtxKeys.length) {
|
||||
for (let propName of currCtxKeys) {
|
||||
if (prevCtxKeys.indexOf(propName) === -1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _updateExistingContext(ctx: Object): void {
|
||||
for (let propName of Object.keys(ctx)) {
|
||||
(<any>this._viewRef!.context)[propName] = (<any>this.ngTemplateOutletContext)[propName];
|
||||
} else if (
|
||||
this._viewRef && changes['ngTemplateOutletContext'] && this.ngTemplateOutletContext) {
|
||||
this._viewRef.context = this.ngTemplateOutletContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ describe('NgTemplateOutlet', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [TestComponent, CaptureTplRefs, DestroyableCmpt],
|
||||
declarations: [TestComponent, CaptureTplRefs, DestroyableCmpt, MultiContextComponent],
|
||||
imports: [CommonModule],
|
||||
providers: [DestroyedSpyService]
|
||||
});
|
||||
|
@ -142,7 +142,7 @@ describe('NgTemplateOutlet', () => {
|
|||
expect(spyService.destroyed).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should recreate embedded view when context shape changes', () => {
|
||||
it('should update but not destroy embedded view when context shape changes', () => {
|
||||
const template =
|
||||
`<ng-template let-foo="foo" #tpl><destroyable-cmpt></destroyable-cmpt>:{{foo}}</ng-template>` +
|
||||
`<ng-template [ngTemplateOutlet]="tpl" [ngTemplateOutletContext]="context"></ng-template>`;
|
||||
|
@ -155,7 +155,7 @@ describe('NgTemplateOutlet', () => {
|
|||
|
||||
fixture.componentInstance.context = {foo: 'baz', other: true};
|
||||
detectChangesAndExpectText('Content to destroy:baz');
|
||||
expect(spyService.destroyed).toBeTruthy();
|
||||
expect(spyService.destroyed).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should destroy embedded view when context value changes and templateRef becomes undefined', () => {
|
||||
|
@ -241,6 +241,27 @@ describe('NgTemplateOutlet', () => {
|
|||
detectChangesAndExpectText('foo');
|
||||
}).not.toThrow();
|
||||
}));
|
||||
|
||||
it('should not mutate context object if two contexts with an identical shape are swapped', () => {
|
||||
fixture = TestBed.createComponent(MultiContextComponent);
|
||||
const {componentInstance, nativeElement} = fixture;
|
||||
componentInstance.context1 = {name: 'one'};
|
||||
componentInstance.context2 = {name: 'two'};
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(nativeElement.textContent.trim()).toBe('one | two');
|
||||
expect(componentInstance.context1).toEqual({name: 'one'});
|
||||
expect(componentInstance.context2).toEqual({name: 'two'});
|
||||
|
||||
const temp = componentInstance.context1;
|
||||
componentInstance.context1 = componentInstance.context2;
|
||||
componentInstance.context2 = temp;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(nativeElement.textContent.trim()).toBe('two | one');
|
||||
expect(componentInstance.context1).toEqual({name: 'two'});
|
||||
expect(componentInstance.context2).toEqual({name: 'one'});
|
||||
});
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
|
@ -271,6 +292,19 @@ class TestComponent {
|
|||
value = 'bar';
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-template #template let-name="name">{{name}}</ng-template>
|
||||
<ng-template [ngTemplateOutlet]="template" [ngTemplateOutletContext]="context1"></ng-template>
|
||||
|
|
||||
<ng-template [ngTemplateOutlet]="template" [ngTemplateOutletContext]="context2"></ng-template>
|
||||
`
|
||||
})
|
||||
class MultiContextComponent {
|
||||
context1: {name: string}|undefined;
|
||||
context2: {name: string}|undefined;
|
||||
}
|
||||
|
||||
function createTestComponent(template: string): ComponentFixture<TestComponent> {
|
||||
return TestBed.overrideComponent(TestComponent, {set: {template: template}})
|
||||
.configureTestingModule({schemas: [NO_ERRORS_SCHEMA]})
|
||||
|
|
Loading…
Reference in New Issue