import type { RouteMatch, Router, RouterState } from "@openclaw/uirouter"; import { html, LitElement, nothing } from "lit"; import { AsyncDirective } from "lit/async-directive.js"; import { property } from "lit/decorators.js"; import { directive } from "lit/directive.js"; import { t } from "../i18n/index.ts"; const PENDING_UI_DELAY_MS = 1_000; type RenderableModule = { render: (data: TData | undefined) => unknown; }; export type RouterOutletOptions = { retryContext?: TLoadContext; }; export type RouterOutletBoundaryOptions = { onNotFound?: () => void; }; export type RouterOutletSelection< TRouteId extends string = string, TModule = unknown, TData = unknown, > = { status: RouterState["status"]; active: RouteMatch | undefined; pending: RouteMatch | undefined; showPending: boolean; }; export function selectRenderedRouteMatch( active: RouteMatch | undefined, pending: RouteMatch | undefined, ): RouteMatch | undefined { const coldPending = pending?.status === "pending" && pending.module === undefined && pending.error === undefined; return coldPending && active ? active : (pending ?? active); } function selectRouterOutletState( state: RouterState, ): RouterOutletSelection { return { status: state.status, active: state.matches[0], pending: state.pendingMatches[0], showPending: false, }; } function equalRouterOutletState( previous: RouterOutletSelection, next: RouterOutletSelection, ): boolean { return ( previous.status === next.status && previous.active === next.active && previous.pending === next.pending ); } function isRenderableModule(module: unknown): module is RenderableModule { return ( typeof module === "object" && module !== null && "render" in module && typeof module.render === "function" ); } function measureRoutedRender(routeId: string, render: () => T): T { const startedAt = globalThis.performance?.now() ?? 0; const result = render(); const durationMs = Math.round((globalThis.performance?.now() ?? startedAt) - startedAt); if (durationMs >= 16) { console.debug("[openclaw] routed render", { routeId, durationMs }); } return result; } function renderPending() { return html`
${t("lazyView.loadingTitle")}
${t("common.loading")}
`; } function renderError( router: Router, retryContext: TLoadContext | undefined, error: unknown, routeId: TRouteId, render?: () => unknown, ) { const routeError = error instanceof Error ? error.message : String(error); return html` ${render?.() ?? nothing} `; } export function renderRouterOutlet( router: Router, selection: RouterOutletSelection, options: RouterOutletOptions = {}, ): unknown { const pending = selection.pending; const renderedMatch = selectRenderedRouteMatch(selection.active, pending); if (renderedMatch?.status === "notFound") { return nothing; } if (renderedMatch?.status === "redirected") { return nothing; } if (!renderedMatch) { return nothing; } const routeId = renderedMatch.routeId; if (!renderedMatch?.module) { return renderedMatch.error ? renderError( router, options.retryContext, renderedMatch.error, routeId, ) : selection.showPending ? renderPending() : nothing; } const routeModule = renderedMatch.module; if (!isRenderableModule(routeModule)) { return renderedMatch.error ? renderError( router, options.retryContext, renderedMatch.error, routeId, ) : null; } const renderedPage = () => measureRoutedRender(routeId, () => routeModule.render(renderedMatch.data)); return renderedMatch.error ? renderError( router, options.retryContext, renderedMatch.error, routeId, renderedPage, ) : renderedPage(); } class RouterOutletDirective extends AsyncDirective { private router?: Router; private retryContext: unknown; private unsubscribe?: () => void; private boundaryOptions?: RouterOutletBoundaryOptions; private notFoundScheduled = false; private pendingMatchId?: string; private pendingTimer?: ReturnType; private pendingSelection?: RouterOutletSelection; private showPending = false; override render( router: unknown, retryContext: unknown, boundaryOptions: RouterOutletBoundaryOptions, ) { const nextRouter = router as Router; this.updateSubscription(nextRouter); this.router = nextRouter; this.retryContext = retryContext; this.boundaryOptions = boundaryOptions; return this.renderSelection(selectRouterOutletState(nextRouter.getState())); } override disconnected() { this.unsubscribe?.(); this.unsubscribe = undefined; this.clearPendingTimer(); this.pendingSelection = undefined; this.boundaryOptions = undefined; this.retryContext = undefined; this.notFoundScheduled = false; } override reconnected() { if (this.router) { this.updateSubscription(this.router); } } private updateSubscription(router: Router) { if (this.router === router && this.unsubscribe) { return; } this.unsubscribe?.(); this.unsubscribe = router.subscribeSelector( selectRouterOutletState, (selection) => { if (this.isConnected) { this.setValue(this.renderSelection(selection)); } }, equalRouterOutletState, ); } private renderSelection(selection: RouterOutletSelection) { this.pendingSelection = selection; const pending = selection.pending; const coldPending = pending?.status === "pending" && pending.module === undefined && pending.error === undefined; const needsPendingFallback = coldPending && !selection.active; if (!needsPendingFallback) { this.clearPendingTimer(); this.pendingMatchId = undefined; this.showPending = false; } else if (this.pendingMatchId !== pending.id) { this.clearPendingTimer(); this.pendingMatchId = pending.id; this.showPending = false; this.pendingTimer = globalThis.setTimeout(() => { this.pendingTimer = undefined; const pendingSelection = this.pendingSelection; if (!pendingSelection || pendingSelection.pending?.id !== this.pendingMatchId) { return; } this.showPending = true; this.setValue(this.renderSelection(pendingSelection)); }, PENDING_UI_DELAY_MS); } if (selection.status === "notFound") { if (!this.notFoundScheduled) { this.notFoundScheduled = true; queueMicrotask(() => { this.notFoundScheduled = false; this.boundaryOptions?.onNotFound?.(); }); } } else { this.notFoundScheduled = false; } const router = this.router; if (!router) { return nothing; } return renderRouterOutlet( router, { ...selection, showPending: this.showPending }, { retryContext: this.retryContext, }, ); } private clearPendingTimer() { if (this.pendingTimer !== undefined) { globalThis.clearTimeout(this.pendingTimer); this.pendingTimer = undefined; } } } const routerOutletDirective = directive(RouterOutletDirective); export function routerOutlet( router: Router, boundaryOptions: RouterOutletBoundaryOptions, options: RouterOutletOptions = {}, ): unknown { return routerOutletDirective(router, options.retryContext, boundaryOptions); } export class OpenClawRouterOutlet< TRouteId extends string = string, TLoadContext = unknown, TModule = unknown, TData = unknown, > extends LitElement { @property({ attribute: false }) router?: Router; @property({ attribute: false }) retryContext?: TLoadContext; @property({ attribute: false }) onNotFound?: () => void; override createRenderRoot() { return this; } override render() { if (!this.router) { return nothing; } return routerOutlet( this.router, { onNotFound: this.onNotFound }, { retryContext: this.retryContext, }, ); } } if (!customElements.get("openclaw-router-outlet")) { customElements.define("openclaw-router-outlet", OpenClawRouterOutlet); }