From 43d7d516cff0c394d0d1b81e6687110cad929dd1 Mon Sep 17 00:00:00 2001 From: danielwiehl Date: Sat, 10 Sep 2022 09:57:58 +0200 Subject: [PATCH] chore(workbench): migrate deprecated component construction in `WbComponentPortal` 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. --- .../src/lib/layout/parts-layout.component.ts | 6 +- .../workbench/src/lib/layout/parts-layout.ts | 9 +- .../src/lib/portal/wb-component-portal.ts | 222 +++++++----------- .../lib/portal/wb-portal-outlet.component.ts | 20 +- .../routing/workbench-url-observer.service.ts | 40 +--- .../lib/view-part/view-part.component.html | 1 - .../src/lib/view-part/view-part.component.ts | 22 ++ .../\311\265workbench-view-part.model.ts" | 22 +- .../src/lib/view/view-move-handler.service.ts | 3 +- .../src/lib/view/view.component.html | 26 +- .../workbench/src/lib/view/view.component.ts | 30 --- .../lib/view/\311\265workbench-view.model.ts" | 52 ++-- .../src/lib/\311\265workbench.service.ts" | 5 +- 13 files changed, 196 insertions(+), 262 deletions(-) diff --git a/projects/scion/workbench/src/lib/layout/parts-layout.component.ts b/projects/scion/workbench/src/lib/layout/parts-layout.component.ts index 10be269a4..0ac428837 100644 --- a/projects/scion/workbench/src/lib/layout/parts-layout.component.ts +++ b/projects/scion/workbench/src/lib/layout/parts-layout.component.ts @@ -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 { diff --git a/projects/scion/workbench/src/lib/layout/parts-layout.ts b/projects/scion/workbench/src/lib/layout/parts-layout.ts index 2da04c1ae..b578b6a6d 100644 --- a/projects/scion/workbench/src/lib/layout/parts-layout.ts +++ b/projects/scion/workbench/src/lib/layout/parts-layout.ts @@ -171,10 +171,17 @@ export class PartsLayout { /** * Returns all parts of the layout. */ - public get parts(): Readonly[] { + public get parts(): ReadonlyArray { 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()); + } + /** * Finds the part with the given id. * diff --git a/projects/scion/workbench/src/lib/portal/wb-component-portal.ts b/projects/scion/workbench/src/lib/portal/wb-component-portal.ts index ffa6ab3f8..bb3d675e0 100644 --- a/projects/scion/workbench/src/lib/portal/wb-component-portal.ts +++ b/projects/scion/workbench/src/lib/portal/wb-component-portal.ts @@ -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 { - private _config!: PortalConfig; - private _componentFactory: ComponentFactory; + private _viewContainerRef: ViewContainerRef | null | undefined; + private _componentRef: ComponentRef | null | undefined; - private _viewContainerRef: ViewContainerRef | null = null; - private _reattachFn: (() => void) | null = null; - private _componentRef: ComponentRef | null = null; - private _portalInjector!: WbPortalInjector; - - constructor(componentFactoryResolver: ComponentFactoryResolver, componentType: ComponentType) { - 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, 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 { + 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 { - 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 { + 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; + providers?: StaticProvider[]; /** * Lifecycle hook which is invoked when the portal is attached to the DOM. */ @@ -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, public elementInjector: Injector) { - } - - public get(token: Type | InjectionToken, 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; diff --git a/projects/scion/workbench/src/lib/portal/wb-portal-outlet.component.ts b/projects/scion/workbench/src/lib/portal/wb-portal-outlet.component.ts index 7587c3955..f4ae5fa02 100644 --- a/projects/scion/workbench/src/lib/portal/wb-portal-outlet.component.ts +++ b/projects/scion/workbench/src/lib/portal/wb-portal-outlet.component.ts @@ -25,24 +25,22 @@ export class WbPortalOutletComponent implements OnDestroy { @Input('wbPortal') // eslint-disable-line @angular-eslint/no-input-rename public set portal(portal: WbComponentPortal | 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(); } } diff --git a/projects/scion/workbench/src/lib/routing/workbench-url-observer.service.ts b/projects/scion/workbench/src/lib/routing/workbench-url-observer.service.ts index cf9979080..442b84f8d 100644 --- a/projects/scion/workbench/src/lib/routing/workbench-url-observer.service.ts +++ b/projects/scion/workbench/src/lib/routing/workbench-url-observer.service.ts @@ -8,22 +8,19 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {ChildrenOutletContexts, GuardsCheckEnd, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router, RouterEvent} from '@angular/router'; +import {GuardsCheckEnd, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router, RouterEvent} from '@angular/router'; import {filter, takeUntil} from 'rxjs/operators'; -import {ComponentFactoryResolver, Injectable, Injector, OnDestroy} from '@angular/core'; +import {EnvironmentInjector, Injectable, OnDestroy} from '@angular/core'; import {Subject} from 'rxjs'; import {WorkbenchAuxiliaryRoutesRegistrator} from './workbench-auxiliary-routes-registrator.service'; -import {PARTS_LAYOUT_QUERY_PARAM, ROUTER_OUTLET_NAME} from '../workbench.constants'; +import {PARTS_LAYOUT_QUERY_PARAM} from '../workbench.constants'; import {WorkbenchViewRegistry} from '../view/workbench-view.registry'; import {WorkbenchViewPartRegistry} from '../view-part/workbench-view-part.registry'; import {WorkbenchLayoutService} from '../layout/workbench-layout.service'; import {ɵWorkbenchViewPart} from '../view-part/ɵworkbench-view-part.model'; -import {WbComponentPortal} from '../portal/wb-component-portal'; import {ViewPartComponent} from '../view-part/view-part.component'; -import {WorkbenchViewPart} from '../view-part/workbench-view-part.model'; import {ɵWorkbenchView} from '../view/ɵworkbench-view.model'; import {ViewComponent} from '../view/view.component'; -import {WorkbenchView} from '../view/workbench-view.model'; import {WorkbenchNavigationContext, WorkbenchRouter} from './workbench-router.service'; import {PartsLayoutFactory} from '../layout/parts-layout.factory'; import {WorkbenchLayoutDiffer} from './workbench-layout-differ'; @@ -52,8 +49,7 @@ export class WorkbenchUrlObserver implements OnDestroy { private _viewRegistry: WorkbenchViewRegistry, private _viewPartRegistry: WorkbenchViewPartRegistry, private _layoutService: WorkbenchLayoutService, - private _componentFactoryResolver: ComponentFactoryResolver, - private _injector: Injector, + private _environmentInjector: EnvironmentInjector, private _workbenchRouter: WorkbenchRouter, private _partsLayoutFactory: PartsLayoutFactory, private _workbenchLayoutDiffer: WorkbenchLayoutDiffer, @@ -248,35 +244,11 @@ export class WorkbenchUrlObserver implements OnDestroy { } private createWorkbenchViewPart(partId: string): ɵWorkbenchViewPart { - const portal = new WbComponentPortal(this._componentFactoryResolver, ViewPartComponent); - const viewPart = new ɵWorkbenchViewPart(partId, portal, this._injector); - - portal.init({ - injectorTokens: new WeakMap() - .set(WorkbenchViewPart, viewPart) - .set(ɵWorkbenchViewPart, viewPart), - }); - - return viewPart; + return this._environmentInjector.runInContext(() => new ɵWorkbenchViewPart(partId, ViewPartComponent)); } private createWorkbenchView(viewId: string, active: boolean): ɵWorkbenchView { - const portal = new WbComponentPortal(this._componentFactoryResolver, ViewComponent); - const view = new ɵWorkbenchView(viewId, portal, active, this._injector); - - portal.init({ - injectorTokens: new WeakMap() - .set(ROUTER_OUTLET_NAME, viewId) - .set(WorkbenchView, view) - .set(ɵWorkbenchView, view) - // Provide the root parent outlet context, crucial if the outlet is not instantiated at the time the route gets activated (e.g., if inside a `ngIf`, as it is in {ViewComponent}). - // Otherwise, the outlet would not render the activated route. - .set(ChildrenOutletContexts, this._injector.get(ChildrenOutletContexts)), - onAttach: (): void => view.activate(true), - onDetach: (): void => view.activate(false), - }); - - return view; + return this._environmentInjector.runInContext(() => new ɵWorkbenchView(viewId, ViewComponent, active)); } private installRouterEventListeners(): void { diff --git a/projects/scion/workbench/src/lib/view-part/view-part.component.html b/projects/scion/workbench/src/lib/view-part/view-part.component.html index f84dc973e..92407072b 100644 --- a/projects/scion/workbench/src/lib/view-part/view-part.component.html +++ b/projects/scion/workbench/src/lib/view-part/view-part.component.html @@ -3,7 +3,6 @@ [wbPortal]="activeViewId$ | async | wbViewPortal" wbViewDropZone (wbViewDropZoneDrop)="onDrop($event)"> - 0; this.hasActions = globalActions.length > 0 || localActions.length > 0; }); + + // Construct view components of inactive views so they can initialize, e.g., to set the view tab title, + // important when loading an existing layout into the workbench. + this.constructInactiveViewComponents(); } @HostListener('focusin') @@ -83,6 +89,22 @@ export class ViewPartComponent implements OnDestroy { return this._part.activeViewId$; } + /** + * Constructs view components of inactive views so they can initialize, e.g., to set the view tab title. + */ + private constructInactiveViewComponents(): void { + this._part.viewIds + .map(viewId => this._viewRegistry.getElseThrow(viewId)) + .filter(view => !view.active) + .filter(view => !view.portal.isConstructed) // Construct the view only once, i.e., not when dragging it to a new view part. + .forEach(inactiveView => { + inactiveView.portal.createComponentFromInjectionContext(); + this._logger.debug(() => `Constructing view after initial navigation. [viewId=${inactiveView.viewId}]`, LoggerNames.LIFECYCLE); + // Trigger manual change detection cycle because the view is not yet added to the Angular component tree. Otherwise, routed content would not be attached. + inactiveView.portal.componentRef.changeDetectorRef.detectChanges(); + }); + } + public ngOnDestroy(): void { this._logger.debug(() => `Destroying ViewPartComponent [partId=${this.partId}]'`, LoggerNames.LIFECYCLE); this._destroy$.next(); diff --git "a/projects/scion/workbench/src/lib/view-part/\311\265workbench-view-part.model.ts" "b/projects/scion/workbench/src/lib/view-part/\311\265workbench-view-part.model.ts" index 613bcb97e..3b61dd0ae 100644 --- "a/projects/scion/workbench/src/lib/view-part/\311\265workbench-view-part.model.ts" +++ "b/projects/scion/workbench/src/lib/view-part/\311\265workbench-view-part.model.ts" @@ -11,12 +11,13 @@ import {MPart} from '../layout/parts-layout.model'; import {BehaviorSubject, Observable} from 'rxjs'; import {WorkbenchLayoutService} from '../layout/workbench-layout.service'; import {WbComponentPortal} from '../portal/wb-component-portal'; -import {Injector} from '@angular/core'; +import {inject} from '@angular/core'; import {Arrays} from '@scion/toolkit/util'; import {Disposable} from '../disposable'; import {WorkbenchViewPartAction} from '../workbench.model'; import {WorkbenchViewPart} from './workbench-view-part.model'; import {WorkbenchRouter} from '../routing/workbench-router.service'; +import {ComponentType} from '@angular/cdk/portal'; export class ɵWorkbenchViewPart implements WorkbenchViewPart { @@ -30,12 +31,21 @@ export class ɵWorkbenchViewPart implements WorkbenchViewPart { public readonly viewIds$ = new BehaviorSubject([]); public readonly actions$ = new BehaviorSubject([]); public readonly activeViewId$ = new BehaviorSubject(null); + public readonly portal: WbComponentPortal; - constructor(public readonly partId: string, - public readonly portal: WbComponentPortal, // do not reference `ViewPartComponent` to avoid import cycles - injector: Injector) { - this._layoutService = injector.get(WorkbenchLayoutService); - this._wbRouter = injector.get(WorkbenchRouter); + constructor(public readonly partId: string, viewPartComponent: ComponentType) { // do not reference `ViewPartComponent` to avoid import cycles + this._layoutService = inject(WorkbenchLayoutService); + this._wbRouter = inject(WorkbenchRouter); + this.portal = this.createPortal(viewPartComponent); + } + + private createPortal(viewPartComponent: ComponentType): WbComponentPortal { + return new WbComponentPortal(viewPartComponent, { + providers: [ + {provide: ɵWorkbenchViewPart, useValue: this}, + {provide: WorkbenchViewPart, useExisting: ɵWorkbenchViewPart}, + ], + }); } public setPart(part: MPart): void { diff --git a/projects/scion/workbench/src/lib/view/view-move-handler.service.ts b/projects/scion/workbench/src/lib/view/view-move-handler.service.ts index 555103835..6a0ef569c 100644 --- a/projects/scion/workbench/src/lib/view/view-move-handler.service.ts +++ b/projects/scion/workbench/src/lib/view/view-move-handler.service.ts @@ -98,8 +98,7 @@ export class ViewMoveHandler implements OnDestroy { private async moveViewToNewWindow(event: ViewMoveEvent): Promise { const urlTree = await this._wbRouter.createUrlTree(layout => ({ layout: layout.clear(), - viewOutlets: layout.parts - .reduce((viewIds, part) => viewIds.concat(part.viewIds), new Array()) + viewOutlets: layout.viewsIds .filter(viewId => viewId !== event.source.viewId) .reduce((acc, viewId) => ({...acc, [viewId]: null}), {}), })); diff --git a/projects/scion/workbench/src/lib/view/view.component.html b/projects/scion/workbench/src/lib/view/view.component.html index 282f50eec..b69283f5a 100644 --- a/projects/scion/workbench/src/lib/view/view.component.html +++ b/projects/scion/workbench/src/lib/view/view.component.html @@ -1,15 +1,13 @@ - - - - - + + + + - - - - + + + diff --git a/projects/scion/workbench/src/lib/view/view.component.ts b/projects/scion/workbench/src/lib/view/view.component.ts index 0748bd83c..1492219a4 100644 --- a/projects/scion/workbench/src/lib/view/view.component.ts +++ b/projects/scion/workbench/src/lib/view/view.component.ts @@ -18,7 +18,6 @@ import {WB_VIEW_HEADING_PARAM, WB_VIEW_TITLE_PARAM} from '../routing/routing.con import {MessageBoxService} from '../message-box/message-box.service'; import {ViewMenuService} from '../view-part/view-context-menu/view-menu.service'; import {ɵWorkbenchView} from './ɵworkbench-view.model'; -import {WorkbenchStartup} from '../startup/workbench-launcher.service'; import {Logger, LoggerNames} from '../logging'; import {VIEW_LOCAL_MESSAGE_BOX_HOST, ViewContainerReference} from '../content-projection/view-container.reference'; import {PopupService} from '../popup/popup.service'; @@ -73,42 +72,13 @@ export class ViewComponent implements OnDestroy { constructor(private _view: ɵWorkbenchView, private _logger: Logger, private _host: ElementRef, - public workbenchStartup: WorkbenchStartup, messageBoxService: MessageBoxService, viewContextMenuService: ViewMenuService, private _cd: ChangeDetectorRef, @Inject(VIEW_LOCAL_MESSAGE_BOX_HOST) viewLocalMessageBoxHost: ViewContainerReference) { - - // IMPORTANT: - // Wait mounting this view's named router outlet until the workbench startup has completed. - // - // This component is constructed when the workbench detects view outlets in the browser URL, e.g., when workbench navigation - // occurs or when Angular triggers the initial navigation for an URL that contains view outlets. See {WorkbenchUrlObserver}. - // Depending on the used workbench launcher, this may happen before starting the workbench or before it completed the startup. - // In any case, we must not mount the view's named router outlet until the workbench startup is completed. Otherwise, the - // outlet's routed component would be constructed as well, leading to unexpected or wrong behavior, e.g., because the - // Microfrontend Platform may not be fully initialized yet, or because the workbench should not be started since the user is - // not authorized. - // - // It would be simplest to install a route guard to protect routed components from being loaded before workbench startup completed. - // However, this does not work in a situation where the `` component is displayed in a router outlet. Then, we - // would end up in a deadlock, as the view outlet guard would wait until the workbench startup completes, but the workbench component - // never gets mounted because the navigation never ends, thus cannot initiate the workbench startup. - this._logger.debug(() => `Constructing ViewComponent. [viewId=${this.viewId}]`, LoggerNames.LIFECYCLE); this.viewLocalMessageBoxHost = viewLocalMessageBoxHost.get(); - // Trigger a manual change detection cycle if this view is not the active view in the tabbar. Otherwise, since inactive views are not added - // to the Angular component tree, their routed component would not be activated until activating the view. But, view components typically - // initialize their view in the constructor, setting a title, for example. This requires eagerly activating the routed components of inactive views - // by triggering a manual change detection cycle, but only after workbench startup completed. - if (!this._view.active) { - workbenchStartup.whenStarted.then(() => { - this._logger.debug(() => `Activating view router outlet after initial navigation. [viewId=${this.viewId}]`, LoggerNames.LIFECYCLE); - this._cd.detectChanges(); - }); - } - messageBoxService.messageBoxes$({includeParents: true}) .pipe(takeUntil(this._destroy$)) .subscribe(messageBoxes => this._view.blocked = messageBoxes.length > 0); diff --git "a/projects/scion/workbench/src/lib/view/\311\265workbench-view.model.ts" "b/projects/scion/workbench/src/lib/view/\311\265workbench-view.model.ts" index 2fc61f20c..18561e441 100644 --- "a/projects/scion/workbench/src/lib/view/\311\265workbench-view.model.ts" +++ "b/projects/scion/workbench/src/lib/view/\311\265workbench-view.model.ts" @@ -9,21 +9,23 @@ */ import {BehaviorSubject, combineLatest, Observable} from 'rxjs'; -import {WbComponentPortal} from '../portal/wb-component-portal'; import {ViewActivationInstantProvider} from './view-activation-instant-provider.service'; -import {Router, UrlSegment} from '@angular/router'; +import {ChildrenOutletContexts, Router, UrlSegment} from '@angular/router'; import {ViewDragService} from '../view-dnd/view-drag.service'; import {WorkbenchViewPartRegistry} from '../view-part/workbench-view-part.registry'; import {WorkbenchLayoutService} from '../layout/workbench-layout.service'; import {map} from 'rxjs/operators'; import {filterArray, mapArray} from '@scion/toolkit/operators'; import {Arrays} from '@scion/toolkit/util'; -import {Injector, Type} from '@angular/core'; import {Disposable} from '../disposable'; import {WorkbenchMenuItem, WorkbenchMenuItemFactoryFn} from '../workbench.model'; import {WorkbenchView} from './workbench-view.model'; import {WorkbenchViewPart} from '../view-part/workbench-view-part.model'; import {ɵWorkbenchService} from '../ɵworkbench.service'; +import {ComponentType} from '@angular/cdk/portal'; +import {WbComponentPortal} from '../portal/wb-component-portal'; +import {ROUTER_OUTLET_NAME} from '../workbench.constants'; +import {inject} from '@angular/core'; export class ɵWorkbenchView implements WorkbenchView { @@ -48,17 +50,17 @@ export class ɵWorkbenchView implements WorkbenchView { public readonly cssClasses$: BehaviorSubject; public readonly menuItems$: Observable; public readonly blocked$ = new BehaviorSubject(false); + public readonly portal: WbComponentPortal; constructor(public readonly viewId: string, - public readonly portal: WbComponentPortal, // do not reference `ViewComponent` to avoid import cycles - active: boolean, - injector: Injector) { - this._workbench = injector.get(ɵWorkbenchService); - this._layoutService = injector.get(WorkbenchLayoutService); - this._viewPartRegistry = injector.get(WorkbenchViewPartRegistry); - this._viewDragService = injector.get(ViewDragService); - this._router = injector.get(Router); - this._viewActivationInstantProvider = injector.get(ViewActivationInstantProvider); + viewComponent: ComponentType, // do not reference `ViewComponent` to avoid import cycles + active: boolean) { + this._workbench = inject(ɵWorkbenchService); + this._layoutService = inject(WorkbenchLayoutService); + this._viewPartRegistry = inject(WorkbenchViewPartRegistry); + this._viewDragService = inject(ViewDragService); + this._router = inject(Router); + this._viewActivationInstantProvider = inject(ViewActivationInstantProvider); this.active$ = new BehaviorSubject(active); this.cssClasses$ = new BehaviorSubject([]); @@ -71,6 +73,23 @@ export class ɵWorkbenchView implements WorkbenchView { mapArray(menuItemFactoryFn => menuItemFactoryFn(this)), filterArray(Boolean), ); + + this.portal = this.createPortal(viewComponent); + } + + private createPortal(viewComponent: ComponentType): WbComponentPortal { + return new WbComponentPortal(viewComponent, { + providers: [ + {provide: ROUTER_OUTLET_NAME, useValue: this.viewId}, + {provide: ɵWorkbenchView, useValue: this}, + {provide: WorkbenchView, useExisting: ɵWorkbenchView}, + // Provide the root parent outlet context, crucial if the outlet is not instantiated at the time the route gets activated (e.g., if inside a `ngIf`, as it is in {ViewComponent}). + // Otherwise, the outlet would not render the activated route. + {provide: ChildrenOutletContexts, useValue: inject(ChildrenOutletContexts)}, + ], + onAttach: (): void => this.activate(true), + onDetach: (): void => this.activate(false), + }); } public get first(): boolean { @@ -106,15 +125,6 @@ export class ɵWorkbenchView implements WorkbenchView { public get part(): WorkbenchViewPart { // DO NOT resolve the part at construction time because it can change, e.g. when this view is moved to another part. - - // Lookup the part from the element injector. - // The element injector is only available for the currently active view. Inactive views are removed - // from the Angular component tree and have, therefore, no element injector. - const viewPart = this.portal.injector.get(WorkbenchViewPart as Type, null); - if (viewPart !== null) { - return viewPart; - } - const part = this._layoutService.layout!.findPartByViewId(this.viewId, {orElseThrow: true}); return this._viewPartRegistry.getElseThrow(part.partId); } diff --git "a/projects/scion/workbench/src/lib/\311\265workbench.service.ts" "b/projects/scion/workbench/src/lib/\311\265workbench.service.ts" index aafeeb131..fbaf86533 100644 --- "a/projects/scion/workbench/src/lib/\311\265workbench.service.ts" +++ "b/projects/scion/workbench/src/lib/\311\265workbench.service.ts" @@ -31,10 +31,7 @@ export class ɵWorkbenchService implements WorkbenchService { constructor(private _wbRouter: WorkbenchRouter, private _viewRegistry: WorkbenchViewRegistry, private _layoutService: WorkbenchLayoutService) { - this.views$ = this._layoutService.layout$.pipe(map(layout => layout.parts.reduce( - (viewIds, part) => viewIds.concat(part.viewIds), - new Array())), - ); + this.views$ = this._layoutService.layout$.pipe(map(layout => layout.viewsIds)); } public destroyView(...viewIds: string[]): Promise {