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 {