Skip to content

Commit 16bc2d3

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 881edec commit 16bc2d3

File tree

6 files changed

+136
-63
lines changed

6 files changed

+136
-63
lines changed

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

+27-3
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';
@@ -91,10 +91,11 @@ describe('FocusTrap', () => {
9191
describe('with bindings', () => {
9292
let fixture: ComponentFixture<FocusTrapWithBindings>;
9393

94-
beforeEach(() => {
94+
beforeEach(fakeAsync(() => {
9595
fixture = TestBed.createComponent(FocusTrapWithBindings);
9696
fixture.detectChanges();
97-
});
97+
tick();
98+
}));
9899

99100
it('should clean up its anchor sibling elements on destroy', () => {
100101
const rootElement = fixture.debugElement.nativeElement as HTMLElement;
@@ -318,6 +319,29 @@ describe('FocusTrap', () => {
318319
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
319320
.withContext('Expected two focus trap anchors in the outlet after attaching.').toBe(2);
320321
});
322+
323+
it('should put anchors inside the outlet when set at the root of a template portal',
324+
fakeAsync(() => {
325+
const fixture = TestBed.createComponent(FocusTrapInsidePortal);
326+
const instance = fixture.componentInstance;
327+
fixture.detectChanges();
328+
const outlet: HTMLElement = fixture.nativeElement.querySelector('.portal-outlet');
329+
330+
expect(outlet.querySelectorAll('button').length)
331+
.toBe(0, 'Expected no buttons inside the outlet on init.');
332+
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
333+
.toBe(0, 'Expected no focus trap anchors inside the outlet on init.');
334+
335+
const portal = new TemplatePortal(instance.template, instance.viewContainerRef);
336+
instance.portalOutlet.attachTemplatePortal(portal);
337+
fixture.detectChanges();
338+
tick();
339+
340+
expect(outlet.querySelectorAll('button').length)
341+
.toBe(1, 'Expected one button inside the outlet after attaching.');
342+
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
343+
.toBe(2, 'Expected two focus trap anchors in the outlet after attaching.');
344+
}));
321345
});
322346

323347
/** 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
@@ -392,6 +392,9 @@ export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoC
392392
/** Previously focused element to restore focus to upon destroy when using autoCapture. */
393393
private _previouslyFocusedElement: HTMLElement | null = null;
394394

395+
/** Whether the focus trap has been initialized. */
396+
private _hasInitialized: boolean;
397+
395398
/** Whether the focus trap is active. */
396399
@Input('cdkTrapFocus')
397400
get enabled(): boolean { return this.focusTrap.enabled; }
@@ -413,7 +416,12 @@ export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoC
413416
* @deprecated No longer being used. To be removed.
414417
* @breaking-change 13.0.0
415418
*/
416-
@Inject(DOCUMENT) _document: any) {
419+
@Inject(DOCUMENT) _document: any,
420+
/**
421+
* @deprecated _ngZone parameter to become required.
422+
* @breaking-change 14.0.0
423+
*/
424+
private _ngZone?: NgZone) {
417425
this.focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, true);
418426
}
419427

@@ -429,15 +437,32 @@ export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoC
429437
}
430438

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

439464
ngDoCheck() {
440-
if (!this.focusTrap.hasAttached()) {
465+
if (this._hasInitialized && !this.focusTrap.hasAttached()) {
441466
this.focusTrap.attachAnchors();
442467
}
443468
}

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 '../testing/private';
@@ -1032,6 +1034,31 @@ describe('Overlay', () => {
10321034
}));
10331035

10341036
});
1037+
1038+
it('should insert component that adds content through ViewContainerRef', () => {
1039+
componentPortal = new ComponentPortal(FoodRenderer);
1040+
const overlayRef = overlay.create();
1041+
overlayRef.attach(componentPortal);
1042+
1043+
expect(overlayContainerElement.textContent).toContain('Food:Pizza');
1044+
overlayRef.dispose();
1045+
});
1046+
1047+
it('should insert template that has component that adds content via ViewContainerRef', () => {
1048+
const fixture = TestBed.createComponent(FoodRendererInTemplate);
1049+
fixture.detectChanges();
1050+
1051+
const portal = new TemplatePortal(
1052+
fixture.componentInstance.foodRendererTemplate,
1053+
fixture.componentInstance.viewContainerRef
1054+
);
1055+
const overlayRef = overlay.create();
1056+
overlayRef.attach(portal);
1057+
1058+
expect(overlayContainerElement.textContent).toContain('Food:Pizza');
1059+
overlayRef.dispose();
1060+
});
1061+
10351062
});
10361063

10371064
/** Simple component for testing ComponentPortal. */
@@ -1050,9 +1077,42 @@ class TestComponentWithTemplatePortals {
10501077
constructor(public viewContainerRef: ViewContainerRef) { }
10511078
}
10521079

1080+
@Component({
1081+
selector: 'food-renderer',
1082+
template: '<p>Food:</p>',
1083+
})
1084+
class FoodRenderer {
1085+
constructor(
1086+
private _componentFactoryResolver: ComponentFactoryResolver,
1087+
private _viewContainerRef: ViewContainerRef) {
1088+
const factory = this._componentFactoryResolver.resolveComponentFactory(PizzaMsg);
1089+
this._viewContainerRef.clear();
1090+
this._viewContainerRef.createComponent(factory);
1091+
}
1092+
}
1093+
1094+
@Component({
1095+
template: `
1096+
<ng-template #foodRenderer>
1097+
<food-renderer></food-renderer>
1098+
</ng-template>
1099+
`
1100+
})
1101+
class FoodRendererInTemplate {
1102+
@ViewChild('foodRenderer') foodRendererTemplate: TemplateRef<any>;
1103+
1104+
constructor(public viewContainerRef: ViewContainerRef) { }
1105+
}
1106+
10531107
// Create a real (non-test) NgModule as a workaround for
10541108
// https://github.com/angular/angular/issues/10760
1055-
const TEST_COMPONENTS = [PizzaMsg, TestComponentWithTemplatePortals];
1109+
const TEST_COMPONENTS = [
1110+
PizzaMsg,
1111+
TestComponentWithTemplatePortals,
1112+
FoodRenderer,
1113+
FoodRendererInTemplate,
1114+
];
1115+
10561116
@NgModule({
10571117
imports: [OverlayModule, PortalModule],
10581118
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';
@@ -448,19 +446,6 @@ describe('Portals', () => {
448446
expect(someDomElement.innerHTML).toBe('');
449447
});
450448

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

685-
/**
686-
* Saves the parent node that the directive was attached to on init.
687-
* Useful to see where the element was in the DOM when it was first attached.
688-
*/
689-
@Directive({
690-
selector: '[savesParentNodeOnInit]'
691-
})
692-
class SaveParentNodeOnInit implements AfterViewInit {
693-
parentOnViewInit: HTMLElement;
694-
695-
constructor(private _elementRef: ElementRef<HTMLElement>) {}
696-
697-
ngAfterViewInit() {
698-
this.parentOnViewInit = this._elementRef.nativeElement.parentElement!;
699-
}
700-
}
701-
702670
/** Simple component to grab an arbitrary ViewContainerRef */
703671
@Component({
704672
selector: 'some-placeholder',
705673
template: `
706674
<p>Hello</p>
707-
708-
<ng-template #template>
709-
<div savesParentNodeOnInit></div>
710-
</ng-template>
711675
`
712676
})
713677
class ArbitraryViewContainerRefComponent {
714678
@ViewChild('template') template: TemplateRef<any>;
715-
@ViewChild(SaveParentNodeOnInit) saveParentNodeOnInit: SaveParentNodeOnInit;
716679

717680
constructor(public viewContainerRef: ViewContainerRef, public injector: Injector) { }
718681
}
@@ -805,7 +768,7 @@ const TEST_COMPONENTS = [
805768
@NgModule({
806769
imports: [CommonModule, PortalModule],
807770
exports: TEST_COMPONENTS,
808-
declarations: [...TEST_COMPONENTS, SaveParentNodeOnInit],
771+
declarations: TEST_COMPONENTS,
809772
entryComponents: TEST_COMPONENTS,
810773
})
811774
class PortalTestModule { }

tools/public_api_guard/cdk/a11y.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ export class CdkMonitorFocus implements AfterViewInit, OnDestroy {
9595
// @public
9696
export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoCheck {
9797
constructor(_elementRef: ElementRef<HTMLElement>, _focusTrapFactory: FocusTrapFactory,
98-
_document: any);
98+
_document: any,
99+
_ngZone?: NgZone | undefined);
99100
get autoCapture(): boolean;
100101
set autoCapture(value: boolean);
101102
get enabled(): boolean;

0 commit comments

Comments
 (0)