Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 43 additions & 38 deletions packages/webui-framework/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = Object.assign(Object.create(null) as Record<string, string>, {
// --- 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',
Expand All @@ -77,10 +44,48 @@ const propertyToAttribute: Record<string, string> = 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;
}

/**
Expand Down
15 changes: 10 additions & 5 deletions packages/webui-framework/src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ import type {
/** Parsed template cache — cloneNode(true) is faster than re-parsing. */
const templateCache = new WeakMap<TemplateBlockMeta, DocumentFragment>();

/** Parsed template DOM for SSR path mapping, keyed by meta.h string. */
const templateDOMCache = new Map<string, Element>();
/** Parsed template DOM for SSR path mapping, keyed by TemplateBlockMeta. */
const templateDOMCache = new WeakMap<TemplateBlockMeta, Element>();

/** Pre-computed ordinals for template nodes: childIndex → [nodeType, ordinal].
* Avoids re-counting element/text siblings on every $resolveSSR call. */
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/webui-framework/src/element/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
24 changes: 24 additions & 0 deletions packages/webui-router/src/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(() => {});

try {
// Start ensureLoaded without awaiting — leaves an in-flight promise in loadPromises
const loadPromises = (router as any).loadPromises as Map<string, Promise<void>>;
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
Expand Down
Loading
Loading