import { LitElement, html } from 'lit'; import type { PropertyValues, TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; import { ActiveUIView, ResolveContext, Transition, ViewConfig, applyPairs, StateDeclaration, PathNode, trace, TransitionHookFn, isFunction, isString, unnestR, Param, filter, ViewContext, StateObject, } from '@uirouter/core'; import { RoutedLitTemplate, UIViewInjectedProps, UiOnExit, UiOnParamsChanged, NormalizedLitViewDeclaration, } from './interface.js'; import { LitViewConfig, UIRouterLit, isRoutedLitElement } from './core.js'; import { routedLitElementRenderer } from './routed-element.js'; import { warnMissingRouter } from './dev-warn.js'; import { UIRouterLitElement, UiRouterContextEvent } from './ui-router.js'; /** @internal */ let viewIdCounter = 0; /** @internal */ export interface UiViewAddress { context: ViewContext | StateObject; fqn: string; } interface UiViewContextEventDetail { parentView: UiView | null; } type UiViewContextEvent = CustomEvent; type deregisterFn = () => void; /** * @hideconstructor * @category components * * @slot - <ui-view> renders slotted content as fallback * whenever no routed component is active. * * @fires {CustomEvent} ui-router-context * * This event is fired to obtain the uiRouter instance, * when not directly provided. * Once obtained, the <ui-view> listens and * provides the uiRouter to descendants. * * @fires {CustomEvent} ui-view-context * * This event is fired to obtain the parent <ui-view>. * * @summary * * This is the <ui-view> component. * * The <ui-view> component is a viewport for routed components. * Routed components will be rendered inside the <ui-view> viewport. * */ export class UiView extends LitElement { /** the view name this viewport fills; empty selects the `$default` view */ @property() name = ''; /** * <ui-view> can be used without <ui-router> * by providing the uiRouter property directly. */ @property({ attribute: false }) uiRouter!: UIRouterLit; @state() private viewAddress!: UiViewAddress; /** Replaced only by a real view config change, so a retained view keeps its subtree. */ @state() private component: RoutedLitTemplate | null = null; /** Created on connect: the @lit-labs/ssr DOM shim has no `createDocumentFragment`. */ private inner?: DocumentFragment; /** @internal */ createRenderRoot(): this { return this; } private readonly viewId = viewIdCounter++; private _uiViewData!: ActiveUIView; private resolveContext!: ResolveContext; private _viewConfigUpdated(config: ViewConfig) { if (!config) { this.component = null; this.requestUpdate(); return; } if (!(config instanceof LitViewConfig)) { return; } // The "new" viewconfig is already applied, so exit early if (this._uiViewData.config === config) { return; } trace.traceUIViewConfigUpdated(this._uiViewData, config.viewDecl.$context!); this._applyUpdatedConfig(config); } private _applyUpdatedConfig(config: LitViewConfig) { this._uiViewData.config = config; if (!this.viewAddress && config?.viewDecl?.$context) { this.viewAddress = { fqn: this._uiViewData.fqn, context: config.viewDecl.$context, }; } this.resolveContext = new ResolveContext(config.path); // Past the identity gate, so the config genuinely changed: a fresh renderer // here is what drops the old element. const { component } = config.viewDecl as NormalizedLitViewDeclaration; this.component = isRoutedLitElement(component) ? routedLitElementRenderer(component) : component; this.requestUpdate(); } @state() private parentView!: UiView; private readonly onUiViewContextEvent = (event: UiViewContextEvent) => { // can't adopt self if (event.target === this) { return; } // handle event; provide self as parent event.stopPropagation(); event.detail.parentView = this; }; /** @internal */ connectedCallback(): void { super.connectedCallback(); this.addEventListener( this.constructor.uiViewContextEventName, this.onUiViewContextEvent as EventListener, ); this.setupUiView(); this.captureContent(); } private static readonly uiViewContextEventName = 'ui-view-context'; private static uiViewContextEvent(): UiViewContextEvent { return new CustomEvent(this.uiViewContextEventName, { bubbles: true, composed: true, detail: { parentView: null, }, }); } /** @internal */ static seekParentView(candidate: Element): UiView | null { const uiViewContextEvent = this.uiViewContextEvent(); candidate.dispatchEvent(uiViewContextEvent); return uiViewContextEvent.detail.parentView; } private seekParentView() { this.parentView = this.constructor.seekParentView(this)!; } private readonly onUiRouterContextEvent = (event: UiRouterContextEvent) => { // Answering our own re-seek would just hand back the router we are replacing. if (this.seekingProvidedRouter) { return; } UIRouterLitElement.onUiRouterContextEvent(this.uiRouter)(event); }; private seekRouter() { if (!this.uiRouter) { this.uiRouter = UIRouterLitElement.seekRouter(this)!; // A sought router can be superseded; an app-provided one never is. this.routerFromProvider = !!this.uiRouter; } this.addEventListener( UIRouterLitElement.uiRouterContextEventName, this.onUiRouterContextEvent as EventListener, ); } /** Whether `uiRouter` came from the context event rather than the app. */ private routerFromProvider = false; /** The router this view registered with; a late upgrade can supersede it. */ private registeredRouter?: UIRouterLit; /** Set only for the synchronous span of our own re-seek. */ private seekingProvidedRouter = false; /** * Seeks the provider's router, past our own answer. * * `` answers this event for descendants and stops it, deliberately — * see `listener-identity.spec.ts`. Suppressing that for the span of our own * dispatch keeps the guarantee intact for everyone else. */ private seekProvidedRouter(): UIRouterLit | undefined { this.seekingProvidedRouter = true; try { return UIRouterLitElement.seekRouter(this); } finally { this.seekingProvidedRouter = false; } } /** * Adopts the router the provider now offers, when this view registered * without it. * * lit replays a pre-upgrade property inside the element's first update, not * at upgrade, so `` can run `connectedCallback` with `uiRouter` * still undefined and provide an instance of its own; a `` * connecting in between registers with that one and never sees a transition. * Firefox upgrades a detached subtree later than Chrome and WebKit, so * declarative shadow DOM parsed off-document reaches this there first. * * `registerUIView` syncs, so the re-registered view picks up the current * state without waiting for the next transition. */ private adoptProvidedRouter(): void { const router = this.routerFromProvider ? this.seekProvidedRouter() : this.uiRouter; if (!router || router === this.registeredRouter) { return; } this.deregisterAll(); this.uiRouter = router; this.setupUiView(); } private deregisterAll(): void { while (this.disconnectedHandlers.length) { const handler = this.disconnectedHandlers.shift(); handler?.(); } } private captureContent() { this.inner ??= document.createDocumentFragment(); this.inner.append(...this.childNodes.values()); } private readonly disconnectedHandlers: deregisterFn[] = []; private setupUiView() { this.seekRouter(); this.seekParentView(); const { viewId, uiRouter: router, parentView } = this; const name = this.name || '$default'; const parentFqn = parentView?._uiViewData?.fqn; const creationContext = parentView?.viewContext || router?.stateRegistry.root(); const fqn = parentFqn ? parentFqn + '.' + name : name; this._uiViewData = { $type: 'lit', id: viewId, name, fqn, creationContext, configUpdated: this._viewConfigUpdated.bind(this), config: undefined as unknown as ViewConfig, }; if (!router) { return; } this.disconnectedHandlers.push( router.transitionService.onBefore({}, (trans) => { return this._invokeUiCanExitHook(trans); }) as deregisterFn, ); this.disconnectedHandlers.push( router.transitionService.onSuccess({}, (trans) => this._invokeUiOnParamsChangedHook(trans), ) as deregisterFn, ); this.disconnectedHandlers.push( router.viewService.registerUIView(this._uiViewData), ); this.registeredRouter = router; } /** @internal */ disconnectedCallback(): void { super.disconnectedCallback(); this.removeEventListener( this.constructor.uiViewContextEventName, this.onUiViewContextEvent as EventListener, ); this.deregisterAll(); } /** * For each transition, checks the component loaded in the ui-view for: * * - has a uiCanExit() component hook * - is being exited * * If both are true, adds the uiCanExit component function as a hook to that singular Transition. */ private _invokeUiCanExitHook(trans: Transition) { const instance = this.firstElementChild as UiOnExit & Element; const uiCanExitFn: TransitionHookFn = instance?.uiCanExit; if (isFunction(uiCanExitFn)) { const state: StateDeclaration = this.state; if (trans.exiting().includes(state)) { trans.onStart({}, function () { return uiCanExitFn.call(instance, trans); }); } } } /** @internal */ requestUpdate(...args: Parameters): void { super.requestUpdate(...args); const instance = this.firstElementChild as LitElement; if (isFunction(instance?.requestUpdate)) { instance.requestUpdate(); } } /** * For each transition, checks if any param values changed and notify component */ private _invokeUiOnParamsChangedHook($transition$: Transition) { // Fresh resolves need a re-render; `render` reuses the element rather than rebuilding it. this.requestUpdate(); const instance = this.firstElementChild as UiOnParamsChanged & Element; const uiOnParamsChanged: TransitionHookFn = instance?.uiOnParamsChanged; if (isFunction(uiOnParamsChanged)) { const viewState = this.state; const resolveContext: ResolveContext = new ResolveContext( this._uiViewData.config.path, ); const viewCreationTrans: unknown = resolveContext.getResolvable('$transition$').data; // Exit early if the $transition$ is the same as the view was created within. // Exit early if the $transition$ will exit the state the view is for. if ( $transition$ === viewCreationTrans || $transition$.exiting().includes(viewState) ) return; const toParams: { [paramName: string]: unknown } = $transition$.params('to'); const fromParams: { [paramName: string]: unknown } = $transition$.params('from'); const getNodeSchema = (node: PathNode) => node.paramSchema; const toSchema: Param[] = $transition$ .treeChanges('to') .map(getNodeSchema) .reduce(unnestR, []); const fromSchema: Param[] = $transition$ .treeChanges('from') .map(getNodeSchema) .reduce(unnestR, []); // Find the to params that have different values than the from params const changedToParams = toSchema.filter((param: Param) => { const idx = fromSchema.indexOf(param); return ( idx === -1 || !fromSchema[idx].type.equals(toParams[param.id], fromParams[param.id]) ); }); // Only trigger callback if a to param has changed or is new if (changedToParams.length) { const changedKeys: string[] = changedToParams.map((x) => x.id); // Filter the params to only changed/new to params. `$transition$.params()` may be used to get all params. const newValues = filter(toParams, (_, key) => changedKeys.includes(key!), ); instance.uiOnParamsChanged(newValues, $transition$); } } } /** @internal */ public get viewContext(): ViewContext | undefined { return this?._uiViewData?.config?.viewDecl.$context; } /** @internal */ public get state(): StateDeclaration { return (this.viewContext as StateObject).self; } /** * Reports a missing provider once this view has actually rendered nothing. * * Deliberately here rather than at the failed seek in `setupUiView`: the seek * runs in `connectedCallback`, and custom-element upgrade order is not * guaranteed, so a correct app can connect a `` before `` * upgrades. By the first completed update the no-op is observable. * * @internal */ protected firstUpdated(changed: PropertyValues): void { super.firstUpdated(changed); this.adoptProvidedRouter(); if (!this.uiRouter) { warnMissingRouter(this, '', 'will never render a routed view'); } } /** @internal */ render(): Node | TemplateResult { if (!this.component || !this.viewAddress) { // Never connected (server render): an empty declarative shadow root would hide the light DOM. return this.inner?.cloneNode(true) ?? html``; } const { uiRouter: router } = this; const injector = this.resolveContext.injector(); const resolvables = this.resolveContext .getTokens() .filter((token) => isString(token)) .map((token) => this.resolveContext.getResolvable(token)) .filter((r) => r.resolved); const resolves = resolvables .map(({ token }) => [token as string, injector.get(token) as unknown]) .reduce(applyPairs, {}); const transition = injector.get(Transition) as Transition; const props: UIViewInjectedProps = { router, resolves, transition }; return this.component(props); } } export interface UiView { /** @internal */ constructor: typeof UiView; }