Skip to content

Commit

Permalink
chore(workbench): migrate deprecated component construction in `WbCom…
Browse files Browse the repository at this point in the history
…ponentPortal`

Angular 13 has deprecated the `ComponentFactoryResolver`. Angular 14 provides a new API to construct components without having to attach them to the DOM, which was not possible in Angular 13.

However, we can no longer construct views and viewparts in the `WorkbenchUrlObserver`. Otherwise the routing injection context would be incorrect. This was also the case with the deprecated `ComponentFactoryResolver`, but we managed to replace the injection context. However, this behavior was never documented by Angular and consequently is not supported anymore.

In order to still create inactive views, e.g., when loading a layout into the workbench, we moved their creation to the viewpart component, giving us the correct injection context.
  • Loading branch information
danielwiehl committed Sep 10, 2022
1 parent 6ce5a89 commit 43d7d51
Show file tree
Hide file tree
Showing 13 changed files with 196 additions and 262 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,8 @@ export class PartsLayoutComponent implements OnInit, OnDestroy {

// Detach parts from the Angular component tree that are to be moved in the layout tree.
// Detaching those parts prevents them from being disposed during re-rendering the layout.
partPortalsToBeMoved.forEach(portal => portal.detach());

// Return a function for re-attaching the moved parts to the Angular component tree.
return (): void => partPortalsToBeMoved.forEach(portal => portal.attach());
const reattachParts = partPortalsToBeMoved.map(part => part.detach());
return () => reattachParts.forEach(reattachPart => reattachPart());
}

public ngOnDestroy(): void {
Expand Down
9 changes: 8 additions & 1 deletion projects/scion/workbench/src/lib/layout/parts-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,17 @@ export class PartsLayout {
/**
* Returns all parts of the layout.
*/
public get parts(): Readonly<MPart>[] {
public get parts(): ReadonlyArray<MPart> {
return this._findTreeElements(element => element instanceof MPart) as MPart[];
}

/**
* Returns all views of the layout.
*/
public get viewsIds(): string[] {
return this.parts.reduce((viewIds, part) => viewIds.concat(part.viewIds), new Array<string>());
}

/**
* Finds the part with the given id.
*
Expand Down
222 changes: 88 additions & 134 deletions projects/scion/workbench/src/lib/portal/wb-component-portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,167 +9,151 @@
*/

import {ComponentType} from '@angular/cdk/portal';
import {ComponentFactory, ComponentFactoryResolver, ComponentRef, InjectFlags, InjectionToken, Injector, Type, ViewContainerRef} from '@angular/core';
import {ComponentRef, createComponent, EnvironmentInjector, inject, Injector, StaticProvider, ViewContainerRef} from '@angular/core';
import {noop} from 'rxjs';

/**
* Like Angular CDK 'ComponentPortal' but with functionality to detach the portal from its outlet without destroying the component.
*
* DI injection tokens are resolved by first checking the portal's custom tokens, and then resolution defaults to the element injector.
* IMPORTANT: In order for the component to have the "correct" injection context, we construct it the time attaching it to the Angular component tree,
* or by calling {@link createComponentFromInjectionContext} with the passed context. The "correct" injection context is crucial, for example, when the
* portal is displayed in a router outlet, so that child outlets can register with their logical parent outlet. The Angular router uses the logical outlet
* hierarchy to resolve and activate child routes.
*/
export class WbComponentPortal<T> {

private _config!: PortalConfig;
private _componentFactory: ComponentFactory<T>;
private _viewContainerRef: ViewContainerRef | null | undefined;
private _componentRef: ComponentRef<T> | null | undefined;

private _viewContainerRef: ViewContainerRef | null = null;
private _reattachFn: (() => void) | null = null;
private _componentRef: ComponentRef<T> | null = null;
private _portalInjector!: WbPortalInjector;

constructor(componentFactoryResolver: ComponentFactoryResolver, componentType: ComponentType<T>) {
this._componentFactory = componentFactoryResolver.resolveComponentFactory(componentType);
}

public init(config: PortalConfig): void {
this._portalInjector = new WbPortalInjector(config.injectorTokens, Injector.NULL);
this._componentRef = this._componentFactory.create(this._portalInjector);
this._componentRef.onDestroy(this.onDestroy.bind(this));
this._config = config;
constructor(private componentType: ComponentType<T>, private _options?: PortalOptions) {
// Do not construct the component here but the time attaching it to the Angular component tree. See the comment above.
}

/**
* Attaches this portal to the specified {ViewContainerRef}, if any, or to its previous outlet if detached.
*
* @see detach
* Constructs the portal's component using given injection context.
*/
public attach(viewContainerRef?: ViewContainerRef): void {
if (viewContainerRef) {
this.setViewContainerRef(viewContainerRef);
}
else if (this._reattachFn) {
this._reattachFn();
}

this._reattachFn = null;
private createComponent(elementInjector: Injector, environmentInjector: EnvironmentInjector): ComponentRef<T> {
const componentRef = createComponent(this.componentType, {
elementInjector: Injector.create({
name: 'WbComponentPortalInjector',
parent: elementInjector,
providers: this._options?.providers || [],
}),
environmentInjector: environmentInjector,
});
componentRef.onDestroy(() => this.onDestroy());
return componentRef;
}

/**
* Detaches this portal from its outlet without destroying it.
*
* The portal is removed from the DOM and its change detector detached from the change detector tree,
* so it will not be checked until it is reattached.
*
* @see attach
* Constructs the portal's component using the current injection context.
*/
public detach(): void {
const viewContainerRef = this.viewContainerRef;
this._reattachFn = (): void => this.setViewContainerRef(viewContainerRef);
this.setViewContainerRef(null);
}

private setViewContainerRef(viewContainerRef: ViewContainerRef | null): void {
if (viewContainerRef === this._viewContainerRef) {
return;
}
if (!viewContainerRef && this.isDestroyed) {
return;
public createComponentFromInjectionContext(): void {
if (this.isConstructed) {
throw Error('[PortalError] Component already constructed.');
}

if (this.isDestroyed) {
throw Error('[IllegalStateError] component is destroyed');
}

this.detachFromComponentTree();
this._viewContainerRef = viewContainerRef;
this._portalInjector.elementInjector = this.viewContainerRef ? this.viewContainerRef.injector : Injector.NULL;
this.attachToComponentTree();
this._componentRef = this.createComponent(inject(Injector), inject(EnvironmentInjector));
}

/**
* Attaches this portal to its outlet.
* Attaches this portal to the given {@link ViewContainerRef} according to the following rules:
*
* @see detachFromComponentTree
* - If the component is not yet constructed, constructs it based on the given view container's injection context.
* - If already attached to the given view container, does nothing.
* - If already attached to a different view container, detaches it first.
*/
private attachToComponentTree(): void {
if (this.isAttached || this.isDestroyed || !this.hasOutlet) {
public attach(viewContainerRef: ViewContainerRef): void {
if (this.isAttachedTo(viewContainerRef)) {
return;
}
if (this.isAttached) {
this.detach();
}

// Attach this portlet
this._viewContainerRef!.insert(this._componentRef!.hostView, 0);
this._componentRef!.changeDetectorRef.reattach();

// Invoke 'onAttach' lifecycle hook
this._config.onAttach && this._config.onAttach();
this._viewContainerRef = viewContainerRef;
this._componentRef = this._componentRef || this.createComponent(this._viewContainerRef.injector, this._viewContainerRef.injector.get(EnvironmentInjector));
this._componentRef.changeDetectorRef.reattach();
this._viewContainerRef.insert(this._componentRef.hostView);
this._options?.onAttach?.();
}

/**
* Detaches this portal from its outlet, but does not destroy the portal's component.
* Detaches this portal from its view container without destroying it. Does nothing if not attached.
*
* The portal is removed from the DOM and its change detector detached from the change detector tree,
* so it will not be checked until it is reattached.
* The portal is removed from the DOM and its change detector detached from the Angular change detector tree,
* so it will not be checked for changes until it is reattached.
*
* @see attachToComponentTree
* @return Function to "undo" detaching, i.e., to re-attach the portal.
* Note that when calling the function and if the portal has been attached to another {@link ViewContainerRef} in the meantime,
* calling the "undo" function does nothing.
*/
private detachFromComponentTree(): void {
public detach(): ReattachFn {
if (!this.isAttached) {
return;
return noop;
}

// Invoke 'onDetach' lifecycle hook
this._config.onDetach && this._config.onDetach();

// Detach this portlet
const index = this._viewContainerRef!.indexOf(this._componentRef!.hostView);
this._viewContainerRef!.detach(index);
this._componentRef!.changeDetectorRef.detach();
}
this._options?.onDetach?.();

/**
* Destroys the component instance and all of the data structures associated with it.
*/
public destroy(): void {
this._componentRef?.destroy();
}
const viewContainerRef = this._viewContainerRef!;

public get componentRef(): ComponentRef<T> {
if (!this._componentRef) {
throw Error('[PortalError] Illegal state: Portal already destroyed.');
}
return this._componentRef;
// Return function to "undo" detaching, i.e., to re-attach the portal, but only if not being attached to another {@link ViewContainerRef} in the meantime.
return () => {
if (this._viewContainerRef === viewContainerRef) {
this.attach(viewContainerRef);
}
};
}

public get viewContainerRef(): ViewContainerRef | null {
return this._viewContainerRef;
public get isConstructed(): boolean {
return !!this._componentRef;
}

public get isAttached(): boolean {
private get isAttached(): boolean {
return this._componentRef && this._viewContainerRef && this._viewContainerRef.indexOf(this._componentRef.hostView) > -1 || false;
}

public get isDestroyed(): boolean {
return !this._componentRef;
public isAttachedTo(viewContainerRef: ViewContainerRef): boolean {
return this._viewContainerRef === viewContainerRef && this.isAttached;
}

public get hasOutlet(): boolean {
return !!this._viewContainerRef;
public get isDestroyed(): boolean {
return this._componentRef === null;
}

public get injector(): Injector {
return this._portalInjector;
public get componentRef(): ComponentRef<T> {
if (this._componentRef === null) {
throw Error('[NullPortalComponentError] Portal destroyed.');
}
if (this._componentRef === undefined) {
throw Error('[NullPortalComponentError] Component not constructed yet.');
}
return this._componentRef;
}

private onDestroy(): void {
this.setViewContainerRef(null);
this._componentRef = null;
this._viewContainerRef = null;
}

/**
* Destroys the component instance and all of the data structures associated with it.
*/
public destroy(): void {
this._componentRef?.destroy();
}
}

export interface PortalConfig {
/**
* Controls instantiation of the component.
*/
export interface PortalOptions {
/**
* Provides DI injection tokens available in the component attached to the portal.
* Providers registered with the injector for the instantiation of the component.
*/
injectorTokens: WeakMap<any, any>;
providers?: StaticProvider[];
/**
* Lifecycle hook which is invoked when the portal is attached to the DOM.
*/
Expand All @@ -181,36 +165,6 @@ export interface PortalConfig {
}

/**
* Resolves a token by first checking its custom tokens, and then defaults to the element injector, if any.
* Function to "undo" detaching, i.e., to re-attach the portal, but only if not being attached to another {@link ViewContainerRef} in the meantime.
*/
class WbPortalInjector implements Injector {

constructor(private _customTokens: WeakMap<any, any>, public elementInjector: Injector) {
}

public get<T>(token: Type<T> | InjectionToken<T>, notFoundValue?: T, flags?: InjectFlags): T {
const value = this._customTokens.get(token);
if (value !== undefined) {
return value;
}

/*
* DO NOT USE the root injector or a module injector as the element parent injector due to Angular resolution rules,
* as this would prevent overwriting or extending (multi-provider) tokens in lazily loaded modules.
*
* See following comment from the Angular source code [file=`provider.ts`, function=`resolveDep`]:
*
* mod1
* /
* el1 mod2
* \ /
* el2
*
* When requesting el2.injector.get(token), we should check in the following order and return the first found value:
* - el2.injector.get(token, default)
* - el1.injector.get(token, NOT_FOUND_CHECK_ONLY_ELEMENT_INJECTOR) -> do not check the module
* - mod2.injector.get(token, default)
*/
return this.elementInjector.get(token, notFoundValue, flags);
}
}
export type ReattachFn = () => void;
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,22 @@ export class WbPortalOutletComponent implements OnDestroy {

@Input('wbPortal') // eslint-disable-line @angular-eslint/no-input-rename
public set portal(portal: WbComponentPortal<any> | null) {
this.detachPortal();
this.detach();
this._portal = portal;
this.attachPortal();
this.attach();
}

public ngOnDestroy(): void {
this.detachPortal();
private attach(): void {
this._portal?.attach(this._viewContainerRef);
}

private attachPortal(): void {
if (this._portal) {
this._portal.attach(this._viewContainerRef);
private detach(): void {
if (this._portal?.isAttachedTo(this._viewContainerRef)) {
this._portal.detach();
}
}

private detachPortal(): void {
if (this._portal && this._portal.viewContainerRef === this._viewContainerRef) {
this._portal.detach();
}
public ngOnDestroy(): void {
this.detach();
}
}
Loading

0 comments on commit 43d7d51

Please sign in to comment.