diff --git a/packages/webui-framework/src/decorators.ts b/packages/webui-framework/src/decorators.ts index b74a9ac6..dc870684 100644 --- a/packages/webui-framework/src/decorators.ts +++ b/packages/webui-framework/src/decorators.ts @@ -15,44 +15,11 @@ /** * Map of camelCase property names to their HTML attribute names. * - * Covers two categories of irregular mappings: - * - * 1. Multi-word ARIA attributes — concatenated lowercase after `aria-` - * (e.g., `ariaDescribedBy` → `aria-describedby`), per the ARIAMixin spec. - * 2. HTML global/element attributes — concatenated lowercase attribute names - * with camelCase property counterparts (e.g., `readOnly` → `readonly`). + * ARIA attributes (`ariaXxxYyy → aria-` + lowercase remainder) are handled + * algorithmically in `toKebabCase`. Only HTML global/element attributes + * with irregular mappings (concatenated lowercase) need explicit entries. */ const propertyToAttribute: Record = Object.assign(Object.create(null) as Record, { - // --- ARIA (ARIAMixin) --- - ariaActiveDescendant: 'aria-activedescendant', - ariaAutoComplete: 'aria-autocomplete', - ariaBrailleLabel: 'aria-braillelabel', - ariaBrailleRoleDescription: 'aria-brailleroledescription', - ariaColCount: 'aria-colcount', - ariaColIndex: 'aria-colindex', - ariaColIndexText: 'aria-colindextext', - ariaColSpan: 'aria-colspan', - ariaDescribedBy: 'aria-describedby', - ariaDropEffect: 'aria-dropeffect', - ariaErrorMessage: 'aria-errormessage', - ariaFlowTo: 'aria-flowto', - ariaHasPopup: 'aria-haspopup', - ariaKeyShortcuts: 'aria-keyshortcuts', - ariaLabelledBy: 'aria-labelledby', - ariaMultiLine: 'aria-multiline', - ariaMultiSelectable: 'aria-multiselectable', - ariaPosInSet: 'aria-posinset', - ariaReadOnly: 'aria-readonly', - ariaRoleDescription: 'aria-roledescription', - ariaRowCount: 'aria-rowcount', - ariaRowIndex: 'aria-rowindex', - ariaRowIndexText: 'aria-rowindextext', - ariaRowSpan: 'aria-rowspan', - ariaSetSize: 'aria-setsize', - ariaValueMax: 'aria-valuemax', - ariaValueMin: 'aria-valuemin', - ariaValueNow: 'aria-valuenow', - ariaValueText: 'aria-valuetext', // --- HTML global/element attributes --- accessKey: 'accesskey', autoCapitalize: 'autocapitalize', @@ -77,10 +44,48 @@ const propertyToAttribute: Record = Object.assign(Object.create( useMap: 'usemap', }); -/** Convert camelCase to kebab-case for attribute reflection. */ +/** + * Convert a camelCase DOM property name into its kebab-case HTML attribute form. + * + * This function is optimized for framework-level hot paths where attribute + * normalization may run thousands of times per render. It performs three + * progressively cheaper checks: + * + * 1. **Direct lookup for irregular mappings** + * Many DOM properties (e.g., `readOnly`, `tabIndex`, `crossOrigin`) do not + * follow simple camelCase → kebab-case rules. These are resolved through a + * precomputed `propertyToAttribute` map for O(1) returns with no string + * processing. + * + * 2. **Fast path for ARIA attributes** + * ARIA properties always begin with `aria` followed by an uppercase letter + * (e.g., `ariaDescribedBy`). These map to `aria-` + the lowercase remainder. + * This branch avoids the general loop and uses the engine-optimized + * `.toLowerCase()` for the suffix. + * + * 3. **General camelCase → kebab-case conversion** + * For all other inputs, the function performs a tight ASCII-only scan: + * uppercase A–Z (65–90) are converted to lowercase and prefixed with `-`, + * while all other characters are copied as-is. This avoids regex engines, + * callback allocations, and match objects, producing predictable, + * allocation-minimal performance ideal for DOM attribute reflection. + * + * The result is a predictable, JIT-friendly transformation suitable for + * attribute diffing, SSR serialization, and runtime DOM patching. + */ export function toKebabCase(str: string): string { const mapped = propertyToAttribute[str]; - return mapped ?? str.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + if (mapped) return mapped; + // ARIA properties: ariaXxxYyy → aria- + lowercase remainder + if (str.length > 4 && str.charCodeAt(0) === 97 /* a */ && str.startsWith('aria') && str.charCodeAt(4) >= 65 && str.charCodeAt(4) <= 90) { + return 'aria-' + str.slice(4).toLowerCase(); + } + let out = ''; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + out += code >= 65 && code <= 90 ? '-' + String.fromCharCode(code + 32) : str[i]; + } + return out; } /** diff --git a/packages/webui-framework/src/element.ts b/packages/webui-framework/src/element.ts index f8c329dd..14110969 100644 --- a/packages/webui-framework/src/element.ts +++ b/packages/webui-framework/src/element.ts @@ -84,8 +84,8 @@ import type { /** Parsed template cache — cloneNode(true) is faster than re-parsing. */ const templateCache = new WeakMap(); -/** Parsed template DOM for SSR path mapping, keyed by meta.h string. */ -const templateDOMCache = new Map(); +/** Parsed template DOM for SSR path mapping, keyed by TemplateBlockMeta. */ +const templateDOMCache = new WeakMap(); /** Pre-computed ordinals for template nodes: childIndex → [nodeType, ordinal]. * Avoids re-counting element/text siblings on every $resolveSSR call. */ @@ -124,11 +124,11 @@ function childNodesArray(parent: Node): Node[] { // ── Helper: parse template HTML into a temp container ──────────── function getTemplateDom(meta: TemplateBlockMeta): Element { - let cached = templateDOMCache.get(meta.h); + let cached = templateDOMCache.get(meta); if (cached) return cached; const div = document.createElement('div'); div.innerHTML = meta.h; - templateDOMCache.set(meta.h, div); + templateDOMCache.set(meta, div); return div; } @@ -267,7 +267,12 @@ export class WebUIElement extends HTMLElement { hydrationEnd(); } - disconnectedCallback(): void {} + disconnectedCallback(): void { + // Note: event listeners wired by $addEvent target child nodes owned by + // this component — they will be GC'd together with the component. + // We intentionally do NOT remove them here because connectedCallback + // does not re-wire events when a hydrated component is reattached. + } /** Dispatch a bubbling custom event. Uses composed:true when in shadow DOM. */ $emit(name: string, detail?: unknown): boolean { diff --git a/packages/webui-framework/src/element/diff.ts b/packages/webui-framework/src/element/diff.ts index fb72918b..194b49d2 100644 --- a/packages/webui-framework/src/element/diff.ts +++ b/packages/webui-framework/src/element/diff.ts @@ -47,7 +47,7 @@ export function resolveRepeatValue( path: string, ): unknown { if (path === scopeVar) return scope; - if (!path.startsWith(`${scopeVar}.`)) return undefined; + if (path.length <= scopeVar.length || path.charCodeAt(scopeVar.length) !== 46 /* '.' */ || !path.startsWith(scopeVar)) return undefined; return dotWalk(scope, path, scopeVar.length + 1); } diff --git a/packages/webui-router/src/router.test.ts b/packages/webui-router/src/router.test.ts index fe3a8de2..136b990a 100644 --- a/packages/webui-router/src/router.test.ts +++ b/packages/webui-router/src/router.test.ts @@ -117,6 +117,30 @@ describe('WebUIRouter', () => { }); }); + describe('destroy', () => { + test('clears in-flight loadPromises so the router can be restarted cleanly', async () => { + const router = new WebUIRouter(); + + // Stub fetch to return a never-resolving promise (simulates in-flight request) + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = () => new Promise(() => {}); + + try { + // Start ensureLoaded without awaiting — leaves an in-flight promise in loadPromises + const loadPromises = (router as any).loadPromises as Map>; + router.ensureLoaded('some-dialog'); + + assert.ok(loadPromises.size > 0, 'loadPromises should have in-flight entries'); + + router.destroy(); + + assert.equal(loadPromises.size, 0, 'destroy() should clear loadPromises for clean GC/restart'); + } finally { + (globalThis as any).fetch = origFetch; + } + }); + }); + describe('template execution in fetchPartial', () => { test('Function-based execution registers templates without DOM', () => { // Simulate what fetchPartial does with the template script string diff --git a/packages/webui-router/src/router.ts b/packages/webui-router/src/router.ts index 2ad504ef..218d5d9e 100644 --- a/packages/webui-router/src/router.ts +++ b/packages/webui-router/src/router.ts @@ -183,6 +183,46 @@ interface PartialResponse { css?: string[]; } +/** + * Apply route params, allowed query params, and initial state to a component. + * Shared by both initial mount and subsequent state updates. Stale query-param + * attributes from a previous navigation are automatically removed. + */ +function applyParamsQueryState( + component: Element, + routeEl: HTMLElement, + params: Record, + data: PartialResponse, + query?: Record, +): void { + for (const [key, value] of Object.entries(params)) { + component.setAttribute(toKebab(key), value); + } + + const allowed = routeAllowedQuery(routeEl); + const filtered = query ? filterQuery(query, allowed, params) : {}; + const newAttrs = new Set(); + for (const [key, value] of Object.entries(filtered)) { + const attr = toKebab(key); + component.setAttribute(attr, value); + newAttrs.add(attr); + } + + const prevAttrs = queryAttrsMap.get(component); + if (prevAttrs) { + for (const attr of prevAttrs) { + if (!newAttrs.has(attr)) { + component.removeAttribute(attr); + } + } + } + queryAttrsMap.set(component, newAttrs); + + if (typeof (component as any).setInitialState === 'function') { + (component as any).setInitialState(data.state); + } +} + export class WebUIRouter { private config: RouterConfig = {}; private started = false; @@ -198,6 +238,12 @@ export class WebUIRouter { private loaderPromises = new Map>(); /** Current active route chain for reconciliation on next navigation. */ private activeChain: RouteChainEntry[] = []; + /** Cached base path from config (avoids repeated nullish coalescing). */ + private basePath = ''; + /** In-memory dedup for injected CSS link hrefs (avoids DOM queries). */ + private injectedCss = new Set(); + /** In-memory dedup for injected module style specifiers (avoids DOM queries). */ + private injectedStyles = new Set(); /** The component tag of the currently active leaf route. */ get activeComponent(): string { @@ -217,6 +263,7 @@ export class WebUIRouter { this.started = true; this.config = config; this.loaders = config.loaders ?? {}; + this.basePath = config.basePath ?? ''; if (!customElements.get(ROUTE_SELECTOR)) { customElements.define(ROUTE_SELECTOR, WebUIRouteElement); @@ -225,6 +272,14 @@ export class WebUIRouter { this.inventory = document.querySelector('meta[name="webui-inventory"]')?.getAttribute('content') ?? ''; this.nonce = document.querySelector('meta[name="webui-nonce"]')?.getAttribute('content') ?? ''; + // Seed dedup sets from SSR-injected elements + for (const link of document.querySelectorAll('link[rel="stylesheet"][href]')) { + this.injectedCss.add(link.getAttribute('href')!); + } + for (const style of document.querySelectorAll('style[type="module"][specifier]')) { + this.injectedStyles.add(style.getAttribute('specifier')!); + } + const nav = window.navigation; const handler = (event: NavigateEvent) => { if (!event.canIntercept || event.hashChange) return; @@ -233,7 +288,7 @@ export class WebUIRouter { event.intercept({ handler: async () => { try { - await this.handleNavigation(buildNavigationTarget(url, this.config.basePath ?? ''), event.signal); + await this.handleNavigation(buildNavigationTarget(url, this.basePath), event.signal); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; console.error('[Router] Navigation error:', err); @@ -249,7 +304,7 @@ export class WebUIRouter { /** Navigate to a new path. */ navigate(path: string): void { - const fullPath = prependBasePath(path, this.config.basePath ?? ''); + const fullPath = prependBasePath(path, this.basePath); window.navigation.navigate(fullPath); } @@ -359,13 +414,16 @@ export class WebUIRouter { if (valEnd > valStart) specifier = trimmed.substring(valStart, valEnd); } - if (specifier && document.querySelector(`style[type="module"][specifier="${specifier}"]`)) { + if (specifier && this.injectedStyles.has(specifier)) { continue; } const style = document.createElement('style'); style.type = 'module'; - if (specifier) style.setAttribute('specifier', specifier); + if (specifier) { + style.setAttribute('specifier', specifier); + this.injectedStyles.add(specifier); + } style.textContent = trimmed.substring(openTagEnd + 1, closeTagStart); document.head.appendChild(style); } @@ -418,11 +476,15 @@ export class WebUIRouter { /** Tear down. */ destroy(): void { this.loaderPromises.clear(); + this.loadPromises.clear(); this.loaders = {}; this.activeChain = []; for (const fn of this.cleanupFns) fn(); this.cleanupFns = []; this.started = false; + this.ssrPreloadsCleared = false; + this.injectedCss.clear(); + this.injectedStyles.clear(); } // ── Route matching ────────────────────────────────────────────── @@ -448,9 +510,11 @@ export class WebUIRouter { // SSR-bootstrap path again. this.isInitialNavigation = false; this.activeChain = this.buildChainFromSSR(); - for (const entry of this.activeChain) { - if (entry.component) await this.ensureComponentLoaded(entry.component); - } + await Promise.all( + this.activeChain + .filter(entry => entry.component) + .map(entry => this.ensureComponentLoaded(entry.component)), + ); if (this.config.dev) { this.validateRoutes(); } @@ -472,16 +536,32 @@ export class WebUIRouter { if (newChain.length === 0) { console.warn(`[Router] No route matched for path: ${requestPath}`); - window.location.href = prependBasePath(requestPath, this.config.basePath ?? ''); + window.location.href = prependBasePath(requestPath, this.basePath); return; } - // Pre-load all component modules before the DOM swap so the - // view transition only covers the synchronous mount. - for (const entry of newChain) { - if (signal?.aborted) return; - if (entry.component) await this.ensureComponentLoaded(entry.component); + // Pre-load all component modules in parallel before the DOM swap so + // the view transition only covers the synchronous mount. + // Race against abort so a superseding navigation doesn't wait for + // in-flight imports from the aborted one. ensureComponentLoaded + // caches module promises, so any work done here is reused by the + // winning navigation. + if (signal?.aborted) return; + const preload = Promise.all( + newChain + .filter(entry => entry.component) + .map(entry => this.ensureComponentLoaded(entry.component)), + ); + if (signal) { + const aborted = new Promise<'aborted'>(resolve => { + signal.addEventListener('abort', () => resolve('aborted'), { once: true }); + }); + const result = await Promise.race([preload.then(() => 'loaded' as const), aborted]); + if (result === 'aborted') return; + } else { + await preload; } + if (signal?.aborted) return; const changeLevel = this.findChangeLevel(this.activeChain, newChain); @@ -574,7 +654,11 @@ export class WebUIRouter { window.dispatchEvent(new CustomEvent('webui:route:navigated', { detail })); } + private ssrPreloadsCleared = false; + private clearSsrPreloads(): void { + if (this.ssrPreloadsCleared) return; + this.ssrPreloadsCleared = true; for (const link of document.head.querySelectorAll(SSR_PRELOAD_SELECTOR)) { link.remove(); } @@ -744,7 +828,7 @@ export class WebUIRouter { // ── Fetch + Mount ────────────────────────────────────────────── private async fetchPartial(requestPath: string, signal?: AbortSignal): Promise<(PartialResponse & { inventory?: string }) | null> { - const fullPath = prependBasePath(requestPath, this.config.basePath ?? ''); + const fullPath = prependBasePath(requestPath, this.basePath); const headers: Record = { 'Accept': 'application/json' }; if (this.inventory) headers['X-WebUI-Inventory'] = this.inventory; const resp = await fetch(fullPath, { headers, signal }); @@ -756,7 +840,7 @@ export class WebUIRouter { // Server returned HTML (e.g. login page) instead of JSON partial. // Trigger a full page navigation so the browser handles it. if (signal?.aborted) return null; - window.location.href = prependBasePath(requestPath, this.config.basePath ?? ''); + window.location.href = prependBasePath(requestPath, this.basePath); return null; } @@ -771,7 +855,8 @@ export class WebUIRouter { // Inject CSS stylesheet links (used by some server implementations) if (data.css) { for (const href of data.css) { - if (!document.querySelector(`link[href="${href}"]`)) { + if (!this.injectedCss.has(href)) { + this.injectedCss.add(href); const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; @@ -798,26 +883,7 @@ export class WebUIRouter { // connectedCallback fires synchronously on appendChild, populating // the component's light DOM immediately. - // Set route params as attributes (for @attr reflection) - for (const [key, value] of Object.entries(params)) { - component.setAttribute(toKebab(key), value); - } - - // Set allowed query params as attributes (deny-by-default) - const allowed = routeAllowedQuery(routeEl); - const filtered = query ? filterQuery(query, allowed, params) : {}; - const setAttrs = new Set(); - for (const [key, value] of Object.entries(filtered)) { - const attr = toKebab(key); - component.setAttribute(attr, value); - setAttrs.add(attr); - } - queryAttrsMap.set(component, setAttrs); - - // Set state via the framework's setInitialState (handles @observable + flush) - if (typeof (component as any).setInitialState === 'function') { - (component as any).setInitialState(data.state); - } + applyParamsQueryState(component, routeEl, params, data, query); } /** @@ -834,49 +900,7 @@ export class WebUIRouter { const compEl = entry.el.querySelector(entry.component) as any; if (!compEl) return; - // Set route params as attributes (for @attr reflection) - for (const [key, value] of Object.entries(entry.params)) { - compEl.setAttribute(toKebab(key), value); - } - - // Set allowed query params as attributes (deny-by-default) - const allowed = routeAllowedQuery(entry.el); - const filtered = query ? filterQuery(query, allowed, entry.params) : {}; - const newAttrs = new Set(); - for (const [key, value] of Object.entries(filtered)) { - const attr = toKebab(key); - compEl.setAttribute(attr, value); - newAttrs.add(attr); - } - - // Remove stale query-param attributes from the previous navigation - const prevAttrs = queryAttrsMap.get(compEl); - if (prevAttrs) { - for (const attr of prevAttrs) { - if (!newAttrs.has(attr)) { - compEl.removeAttribute(attr); - } - } - } - queryAttrsMap.set(compEl, newAttrs); - - // Set state via the framework's setInitialState (handles @observable + flush) - if (typeof compEl.setInitialState === 'function') { - compEl.setInitialState(data.state); - } - } - - /** - * Wait for an element's light DOM to be populated with template content. - * defer-and-hydrate components render their template asynchronously after - * connectedCallback. After `whenDefined` resolves, a single animation frame - * is sufficient for the content to populate. - */ - private waitForRenderReady(el: HTMLElement): Promise { - if (el.children.length > 0) { - return Promise.resolve(); - } - return new Promise(resolve => requestAnimationFrame(() => resolve())); + applyParamsQueryState(compEl, entry.el, entry.params, data, query); } // ── Lazy Loading ──────────────────────────────────────────────── @@ -974,7 +998,7 @@ export class WebUIRouter { } private currentTarget(): NavigationTarget { - return buildNavigationTarget(new URL(window.location.href), this.config.basePath ?? ''); + return buildNavigationTarget(new URL(window.location.href), this.basePath); } // ── Component Inventory ────────────────────────────────────────