Skip to content

Commit f128ca4

Browse files
committed
fix(cdk/portal): run change detection before inserting portal content
In #16407 we made some changes where we'd run change detection after transferring the portal content to the outlet, in order to handle a case where focus traps may be attached too early. This PR reverts back to triggering change detection before moving the content, because doing it after may leave behind nodes that were inserted through `ViewContainerRef` inside one one of the init hooks. The initial focus trap issue is resolved by delaying the attachment instead. These changes fix another issue where we were assuming that component portals can only have one root node. This is true for most cases, except for when a sibling is inserted through `ViewContainerRef`. Fixes #20343.
1 parent 2646ce9 commit f128ca4

File tree

6 files changed

+135
-83
lines changed

6 files changed

+135
-83
lines changed

src/cdk/a11y/focus-trap/focus-trap.spec.ts

+26-23
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
ViewContainerRef,
77
ViewEncapsulation,
88
} from '@angular/core';
9-
import {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing';
9+
import {waitForAsync, ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing';
1010
import {PortalModule, CdkPortalOutlet, TemplatePortal} from '@angular/cdk/portal';
1111
import {A11yModule, FocusTrap, CdkTrapFocus} from '../index';
1212
import {By} from '@angular/platform-browser';
@@ -89,10 +89,11 @@ describe('FocusTrap', () => {
8989
describe('with bindings', () => {
9090
let fixture: ComponentFixture<FocusTrapWithBindings>;
9191

92-
beforeEach(() => {
92+
beforeEach(fakeAsync(() => {
9393
fixture = TestBed.createComponent(FocusTrapWithBindings);
9494
fixture.detectChanges();
95-
});
95+
tick();
96+
}));
9697

9798
it('should clean up its anchor sibling elements on destroy', () => {
9899
const rootElement = fixture.debugElement.nativeElement as HTMLElement;
@@ -296,26 +297,28 @@ describe('FocusTrap', () => {
296297

297298
});
298299

299-
it('should put anchors inside the outlet when set at the root of a template portal', () => {
300-
const fixture = TestBed.createComponent(FocusTrapInsidePortal);
301-
const instance = fixture.componentInstance;
302-
fixture.detectChanges();
303-
const outlet: HTMLElement = fixture.nativeElement.querySelector('.portal-outlet');
304-
305-
expect(outlet.querySelectorAll('button').length)
306-
.toBe(0, 'Expected no buttons inside the outlet on init.');
307-
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
308-
.toBe(0, 'Expected no focus trap anchors inside the outlet on init.');
309-
310-
const portal = new TemplatePortal(instance.template, instance.viewContainerRef);
311-
instance.portalOutlet.attachTemplatePortal(portal);
312-
fixture.detectChanges();
313-
314-
expect(outlet.querySelectorAll('button').length)
315-
.toBe(1, 'Expected one button inside the outlet after attaching.');
316-
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
317-
.toBe(2, 'Expected two focus trap anchors in the outlet after attaching.');
318-
});
300+
it('should put anchors inside the outlet when set at the root of a template portal',
301+
fakeAsync(() => {
302+
const fixture = TestBed.createComponent(FocusTrapInsidePortal);
303+
const instance = fixture.componentInstance;
304+
fixture.detectChanges();
305+
const outlet: HTMLElement = fixture.nativeElement.querySelector('.portal-outlet');
306+
307+
expect(outlet.querySelectorAll('button').length)
308+
.toBe(0, 'Expected no buttons inside the outlet on init.');
309+
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
310+
.toBe(0, 'Expected no focus trap anchors inside the outlet on init.');
311+
312+
const portal = new TemplatePortal(instance.template, instance.viewContainerRef);
313+
instance.portalOutlet.attachTemplatePortal(portal);
314+
fixture.detectChanges();
315+
tick();
316+
317+
expect(outlet.querySelectorAll('button').length)
318+
.toBe(1, 'Expected one button inside the outlet after attaching.');
319+
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
320+
.toBe(2, 'Expected two focus trap anchors in the outlet after attaching.');
321+
}));
319322
});
320323

321324
/** Gets the currently-focused element while accounting for the shadow DOM. */

src/cdk/a11y/focus-trap/focus-trap.ts

+31-6
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,9 @@ export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoC
396396
/** Previously focused element to restore focus to upon destroy when using autoCapture. */
397397
private _previouslyFocusedElement: HTMLElement | null = null;
398398

399+
/** Whether the focus trap has been initialized. */
400+
private _hasInitialized: boolean;
401+
399402
/** Whether the focus trap is active. */
400403
@Input('cdkTrapFocus')
401404
get enabled(): boolean { return this.focusTrap.enabled; }
@@ -413,7 +416,12 @@ export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoC
413416
constructor(
414417
private _elementRef: ElementRef<HTMLElement>,
415418
private _focusTrapFactory: FocusTrapFactory,
416-
@Inject(DOCUMENT) _document: any) {
419+
@Inject(DOCUMENT) _document: any,
420+
/**
421+
* @deprecated _ngZone parameter to become required.
422+
* @breaking-change 11.0.0
423+
*/
424+
private _ngZone?: NgZone) {
417425

418426
this._document = _document;
419427
this.focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, true);
@@ -431,15 +439,32 @@ export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoC
431439
}
432440

433441
ngAfterContentInit() {
434-
this.focusTrap.attachAnchors();
435-
436-
if (this.autoCapture) {
437-
this._captureFocus();
442+
// We have to delay attaching the anchors, because the lifecycle hook may be fired before the
443+
// focus trap element is at its final place in the DOM when inserted via a portal (see #16346).
444+
// It has to happen outside the `NgZone`, because the promise can delay `NgZone.onStable` which
445+
// overlays depend on for positioning.
446+
const attachAnchor = () => {
447+
Promise.resolve().then(() => {
448+
this.focusTrap.attachAnchors();
449+
450+
if (this.autoCapture) {
451+
this._captureFocus();
452+
}
453+
454+
this._hasInitialized = true;
455+
});
456+
};
457+
458+
// @breaking-change 11.0.0 Remove null check for `_ngZone`.
459+
if (this._ngZone) {
460+
this._ngZone.runOutsideAngular(attachAnchor);
461+
} else {
462+
attachAnchor();
438463
}
439464
}
440465

441466
ngDoCheck() {
442-
if (!this.focusTrap.hasAttached()) {
467+
if (this._hasInitialized && !this.focusTrap.hasAttached()) {
443468
this.focusTrap.attachAnchors();
444469
}
445470
}

src/cdk/overlay/overlay.spec.ts

+61-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
Injectable,
1616
EventEmitter,
1717
NgZone,
18+
ComponentFactoryResolver,
19+
TemplateRef,
1820
} from '@angular/core';
1921
import {Direction, Directionality} from '@angular/cdk/bidi';
2022
import {MockNgZone, dispatchFakeEvent} from '@angular/cdk/testing/private';
@@ -997,6 +999,31 @@ describe('Overlay', () => {
997999
}));
9981000

9991001
});
1002+
1003+
it('should insert component that adds content through ViewContainerRef', () => {
1004+
componentPortal = new ComponentPortal(FoodRenderer);
1005+
const overlayRef = overlay.create();
1006+
overlayRef.attach(componentPortal);
1007+
1008+
expect(overlayContainerElement.textContent).toContain('Food:Pizza');
1009+
overlayRef.dispose();
1010+
});
1011+
1012+
it('should insert template that has component that adds content via ViewContainerRef', () => {
1013+
const fixture = TestBed.createComponent(FoodRendererInTemplate);
1014+
fixture.detectChanges();
1015+
1016+
const portal = new TemplatePortal(
1017+
fixture.componentInstance.foodRendererTemplate,
1018+
fixture.componentInstance.viewContainerRef
1019+
);
1020+
const overlayRef = overlay.create();
1021+
overlayRef.attach(portal);
1022+
1023+
expect(overlayContainerElement.textContent).toContain('Food:Pizza');
1024+
overlayRef.dispose();
1025+
});
1026+
10001027
});
10011028

10021029
/** Simple component for testing ComponentPortal. */
@@ -1015,9 +1042,42 @@ class TestComponentWithTemplatePortals {
10151042
constructor(public viewContainerRef: ViewContainerRef) { }
10161043
}
10171044

1045+
@Component({
1046+
selector: 'food-renderer',
1047+
template: '<p>Food:</p>',
1048+
})
1049+
class FoodRenderer {
1050+
constructor(
1051+
private _componentFactoryResolver: ComponentFactoryResolver,
1052+
private _viewContainerRef: ViewContainerRef) {
1053+
const factory = this._componentFactoryResolver.resolveComponentFactory(PizzaMsg);
1054+
this._viewContainerRef.clear();
1055+
this._viewContainerRef.createComponent(factory);
1056+
}
1057+
}
1058+
1059+
@Component({
1060+
template: `
1061+
<ng-template #foodRenderer>
1062+
<food-renderer></food-renderer>
1063+
</ng-template>
1064+
`
1065+
})
1066+
class FoodRendererInTemplate {
1067+
@ViewChild('foodRenderer') foodRendererTemplate: TemplateRef<any>;
1068+
1069+
constructor(public viewContainerRef: ViewContainerRef) { }
1070+
}
1071+
10181072
// Create a real (non-test) NgModule as a workaround for
10191073
// https://github.com/angular/angular/issues/10760
1020-
const TEST_COMPONENTS = [PizzaMsg, TestComponentWithTemplatePortals];
1074+
const TEST_COMPONENTS = [
1075+
PizzaMsg,
1076+
TestComponentWithTemplatePortals,
1077+
FoodRenderer,
1078+
FoodRendererInTemplate,
1079+
];
1080+
10211081
@NgModule({
10221082
imports: [OverlayModule, PortalModule],
10231083
exports: TEST_COMPONENTS,

src/cdk/portal/dom-portal-outlet.ts

+14-14
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export class DomPortalOutlet extends BasePortalOutlet {
7070
}
7171
// At this point the component has been instantiated, so we move it to the location in the DOM
7272
// where we want it to be rendered.
73-
this.outletElement.appendChild(this._getComponentRootNode(componentRef));
73+
this._transferRootNodes(componentRef.hostView as EmbeddedViewRef<any>);
7474
this._attachedPortal = portal;
7575

7676
return componentRef;
@@ -82,23 +82,23 @@ export class DomPortalOutlet extends BasePortalOutlet {
8282
* @returns Reference to the created embedded view.
8383
*/
8484
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
85-
let viewContainer = portal.viewContainerRef;
86-
let viewRef = viewContainer.createEmbeddedView(portal.templateRef, portal.context);
85+
const viewContainer = portal.viewContainerRef;
86+
const viewRef = viewContainer.createEmbeddedView(portal.templateRef, portal.context);
87+
88+
// Trigger change detection so that we invoke the lifecycle hooks of any components inside the
89+
// template **before** moving the DOM nodes into their new position, because it can have an
90+
// effect on the amount of `rootNodes` (e.g. if something is inserted via a `ViewContainerRef`).
91+
viewRef.detectChanges();
8792

8893
// The method `createEmbeddedView` will add the view as a child of the viewContainer.
8994
// But for the DomPortalOutlet the view can be added everywhere in the DOM
9095
// (e.g Overlay Container) To move the view to the specified host element. We just
9196
// re-append the existing root nodes.
92-
viewRef.rootNodes.forEach(rootNode => this.outletElement.appendChild(rootNode));
93-
94-
// Note that we want to detect changes after the nodes have been moved so that
95-
// any directives inside the portal that are looking at the DOM inside a lifecycle
96-
// hook won't be invoked too early.
97-
viewRef.detectChanges();
97+
this._transferRootNodes(viewRef);
9898

9999
this.setDisposeFn((() => {
100-
let index = viewContainer.indexOf(viewRef);
101-
if (index !== -1) {
100+
const index = viewContainer.indexOf(viewRef);
101+
if (index > -1) {
102102
viewContainer.remove(index);
103103
}
104104
}));
@@ -153,9 +153,9 @@ export class DomPortalOutlet extends BasePortalOutlet {
153153
}
154154
}
155155

156-
/** Gets the root HTMLElement for an instantiated component. */
157-
private _getComponentRootNode(componentRef: ComponentRef<any>): HTMLElement {
158-
return (componentRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement;
156+
/** Moves all of the root nodes of an embedded view into the outlet element. */
157+
private _transferRootNodes(viewRef: EmbeddedViewRef<any>): void {
158+
viewRef.rootNodes.forEach(node => this.outletElement.appendChild(node));
159159
}
160160
}
161161

src/cdk/portal/portal.spec.ts

+1-38
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ import {
1414
ViewChild,
1515
ViewChildren,
1616
ViewContainerRef,
17-
Directive,
18-
AfterViewInit,
1917
} from '@angular/core';
2018
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';
2119
import {DomPortalOutlet} from './dom-portal-outlet';
@@ -446,19 +444,6 @@ describe('Portals', () => {
446444
expect(someDomElement.innerHTML).toBe('');
447445
});
448446

449-
it('should move the DOM nodes before running change detection', () => {
450-
someFixture.detectChanges();
451-
let portal = new TemplatePortal(someFixture.componentInstance.template, someViewContainerRef);
452-
453-
host.attachTemplatePortal(portal);
454-
someFixture.detectChanges();
455-
456-
expect(someFixture.componentInstance.saveParentNodeOnInit.parentOnViewInit)
457-
.toBe(someDomElement);
458-
459-
host.dispose();
460-
});
461-
462447
it('should attach and detach a component portal with a given injector', () => {
463448
let fixture = TestBed.createComponent(ArbitraryViewContainerRefComponent);
464449
someViewContainerRef = fixture.componentInstance.viewContainerRef;
@@ -677,37 +662,15 @@ class PizzaMsg {
677662
constructor(@Optional() public snack: Chocolate) { }
678663
}
679664

680-
/**
681-
* Saves the parent node that the directive was attached to on init.
682-
* Useful to see where the element was in the DOM when it was first attached.
683-
*/
684-
@Directive({
685-
selector: '[savesParentNodeOnInit]'
686-
})
687-
class SaveParentNodeOnInit implements AfterViewInit {
688-
parentOnViewInit: HTMLElement;
689-
690-
constructor(private _elementRef: ElementRef<HTMLElement>) {}
691-
692-
ngAfterViewInit() {
693-
this.parentOnViewInit = this._elementRef.nativeElement.parentElement!;
694-
}
695-
}
696-
697665
/** Simple component to grab an arbitrary ViewContainerRef */
698666
@Component({
699667
selector: 'some-placeholder',
700668
template: `
701669
<p>Hello</p>
702-
703-
<ng-template #template>
704-
<div savesParentNodeOnInit></div>
705-
</ng-template>
706670
`
707671
})
708672
class ArbitraryViewContainerRefComponent {
709673
@ViewChild('template') template: TemplateRef<any>;
710-
@ViewChild(SaveParentNodeOnInit) saveParentNodeOnInit: SaveParentNodeOnInit;
711674

712675
constructor(public viewContainerRef: ViewContainerRef, public injector: Injector) { }
713676
}
@@ -800,7 +763,7 @@ const TEST_COMPONENTS = [
800763
@NgModule({
801764
imports: [CommonModule, PortalModule],
802765
exports: TEST_COMPONENTS,
803-
declarations: [...TEST_COMPONENTS, SaveParentNodeOnInit],
766+
declarations: TEST_COMPONENTS,
804767
entryComponents: TEST_COMPONENTS,
805768
})
806769
class PortalTestModule { }

tools/public_api_guard/cdk/a11y.d.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ export declare class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChan
5151
get enabled(): boolean;
5252
set enabled(value: boolean);
5353
focusTrap: FocusTrap;
54-
constructor(_elementRef: ElementRef<HTMLElement>, _focusTrapFactory: FocusTrapFactory, _document: any);
54+
constructor(_elementRef: ElementRef<HTMLElement>, _focusTrapFactory: FocusTrapFactory, _document: any,
55+
_ngZone?: NgZone | undefined);
5556
ngAfterContentInit(): void;
5657
ngDoCheck(): void;
5758
ngOnChanges(changes: SimpleChanges): void;

0 commit comments

Comments
 (0)