From 45ae03f12258ab8d04abfa1f33ff19b93d6ad082 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 10 Apr 2026 23:55:12 -0700 Subject: [PATCH] fix(router): replace bloom filter inventory with index-based bitfield The FNV-1a bloom filter (256-bit, hash mod 256) caused false-positive collisions when many components mapped to the same bit position. This broke client-side navigation in apps with 20+ components -- the server would skip sending templates it thought the client already had. Replace with sequential index-based bitfield: - Server assigns each custom element a sequential index (0, 1, 2...) based on alphabetically sorted component names from both protocol.fragments and protocol.components - Inventory is a hex-encoded bitfield where bit N = component N loaded - Zero hash collisions guaranteed - Bitfield size = ceil(N/8) bytes (e.g. 3 bytes for 19 components vs 32 bytes for the old bloom filter) Client changes: - Inventory is now an opaque hex string -- client stores it from SSR meta tag, sends it back via X-WebUI-Inventory, receives updated hex from partial responses - Renamed releaseTemplates() to gc() -- clears all cached templates and resets inventory to empty - Removed all client-side hash functions and bit manipulation - inventory.ts simplified to just a module doc comment Server changes (route_handler.rs): - build_component_index() assigns sequential indices from the union of protocol.fragments and protocol.components (hyphenated names only) - filter_needed_components() uses bitwise has_component/set_component with the index map instead of FNV-1a hash - SSR emits compact inventory meta tag Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/perf/SKILL.md | 4 +- crates/webui-handler/src/lib.rs | 10 +- crates/webui-handler/src/route_handler.rs | 211 ++++++++++---------- docs/guide/concepts/routing.md | 13 +- packages/webui-router/README.md | 9 +- packages/webui-router/src/inventory.test.ts | 79 -------- packages/webui-router/src/inventory.ts | 48 ----- packages/webui-router/src/router.test.ts | 148 +++----------- packages/webui-router/src/router.ts | 38 +--- 9 files changed, 164 insertions(+), 396 deletions(-) delete mode 100644 packages/webui-router/src/inventory.test.ts delete mode 100644 packages/webui-router/src/inventory.ts diff --git a/.github/skills/perf/SKILL.md b/.github/skills/perf/SKILL.md index a4bc7980..92103c4b 100644 --- a/.github/skills/perf/SKILL.md +++ b/.github/skills/perf/SKILL.md @@ -51,7 +51,7 @@ These apply to `packages/webui-framework` (the client-side Web Component runtime ### Memory 1. **No framework in the GC.** Minimize object allocations during reactive updates. Reuse binding objects, don't recreate them. -2. **Template cache is `WeakMap`-keyed.** Parsed template DOMs are cached per metadata object. When metadata is released (e.g., via `Router.releaseTemplates()`), the cache entry becomes GC-eligible. +2. **Template cache is `WeakMap`-keyed.** Parsed template DOMs are cached per metadata object. When metadata is released (e.g., via `Router.gc()`), the cache entry becomes GC-eligible. 3. **No per-update array allocations.** Avoid `.filter()`, `.map()`, `.slice()` in the update hot path. Use index-based iteration. 4. **Strip SSR markers after hydration.** Comment nodes used as markers are removed from the DOM once wiring is complete - they don't persist as memory overhead. 5. **Scope frames are stack-allocated.** `` loop item variables use a linked-list scope chain, not cloned Maps or Objects. @@ -68,7 +68,7 @@ These apply to `packages/webui-router` (the client-side SPA router). ### Memory -1. **Release unused templates.** `Router.releaseTemplates()` clears cached component templates for routes the user hasn't visited recently. Active route components are never released. +1. **Release unused templates.** `Router.gc()` clears cached component templates for routes the user hasn't visited recently. Active route components are never released. 2. **Inventory bitmask prevents duplicate downloads.** The server tracks which component templates the client already has via a bitmask. Re-navigation never re-sends templates. 3. **Minimal state per navigation.** Route-scoped state means the JSON partial contains only what the active route needs, not the full app state. diff --git a/crates/webui-handler/src/lib.rs b/crates/webui-handler/src/lib.rs index a0224e9d..51634c5e 100644 --- a/crates/webui-handler/src/lib.rs +++ b/crates/webui-handler/src/lib.rs @@ -776,12 +776,18 @@ impl WebUIHandler { // inside unrendered conditional blocks are excluded — they'll be delivered // via templateStyles[] + templates[] during SPA partial navigation. if signal.raw && signal.value == "body_end" && context.plugin.is_some() { + // Build the component → index map for the inventory bitfield. + let comp_index = crate::route_handler::build_component_index(context.protocol); + // Emit inventory meta tag based on actually rendered components. // Placed here (not head_end) because rendered_components is only // complete after the full SSR pass. The router reads it via // document.querySelector regardless of position. - let (_, inventory_hex) = - crate::route_handler::filter_needed_components(&context.rendered_components, ""); + let (_, inventory_hex) = crate::route_handler::filter_needed_components( + &context.rendered_components, + "", + &comp_index, + ); context .writer .write(" u32 { - let mut hash: u32 = 0x811c_9dc5; - for byte in name.as_bytes() { - hash ^= u32::from(*byte); - hash = hash.wrapping_mul(0x0100_0193); +/// Build a deterministic component-name → bit-index map from the protocol. +/// +/// Derives names from fragment keys (hyphenated = custom element) since that +/// is the source of truth regardless of whether a plugin populated +/// `protocol.components`. Components are sorted alphabetically; index = +/// position in that order. +pub fn build_component_index(protocol: &WebUIProtocol) -> HashMap { + let mut names: HashSet<&String> = HashSet::new(); + for key in protocol.fragments.keys() { + if key.contains('-') { + names.insert(key); + } } - hash % 256 + let mut sorted: Vec<&String> = names.into_iter().collect(); + sorted.sort_unstable(); + sorted + .iter() + .enumerate() + .map(|(i, n)| ((*n).clone(), i as u32)) + .collect() } -/// Check if a component is present in an inventory bitmask. -fn has_component(inventory: &[u8], name: &str) -> bool { - let bit = component_bit_position(name); - let byte_idx = (bit / 8) as usize; - let bit_idx = bit % 8; +/// Check if a component's bit is set in the inventory bitfield. +fn has_component(inventory: &[u8], index: u32) -> bool { + let byte_idx = (index / 8) as usize; + let bit_idx = index % 8; byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0 } -/// Parse a hex string into bytes for the inventory bitmask. +/// Set a component's bit in the inventory bitfield. +fn set_component(inventory: &mut Vec, index: u32) { + let byte_idx = (index / 8) as usize; + let bit_idx = index % 8; + if byte_idx >= inventory.len() { + inventory.resize(byte_idx + 1, 0); + } + inventory[byte_idx] |= 1 << bit_idx; +} + +/// Parse a hex-encoded inventory string into bytes. pub fn parse_inventory(hex: &str) -> Vec { let mut bytes = Vec::with_capacity(hex.len() / 2); let mut chars = hex.bytes(); while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) { - if let (Some(hi_nibble), Some(lo_nibble)) = (hex_nibble(hi), hex_nibble(lo)) { - bytes.push((hi_nibble << 4) | lo_nibble); + if let (Some(h), Some(l)) = (hex_nibble(hi), hex_nibble(lo)) { + bytes.push((h << 4) | l); } } bytes @@ -55,7 +75,7 @@ fn hex_nibble(byte: u8) -> Option { } } -/// Encode an inventory bitmask as a hex string. +/// Encode an inventory bitfield as a hex string. pub fn encode_inventory(inv: &[u8]) -> String { inv.iter() .fold(String::with_capacity(inv.len() * 2), |mut acc, b| { @@ -68,9 +88,6 @@ pub fn encode_inventory(inv: &[u8]) -> String { /// Walk the protocol fragment graph from `entry_id` and return the names of /// all components the route needs that are NOT in the client's inventory. /// -/// `entry_id` is the route's component name (e.g., `"cb-page-group"`). -/// `inventory` is the client's bitmask of already-loaded components. -/// /// Returns `(needed_names, updated_inventory_hex)`. pub fn get_needed_components( protocol: &WebUIProtocol, @@ -78,20 +95,13 @@ pub fn get_needed_components( inventory_hex: &str, ) -> (Vec, String) { let component_names = collect_inventoryable_components(protocol, entry_id, None, true); - filter_needed_components(&component_names, inventory_hex) + let index = build_component_index(protocol); + filter_needed_components(&component_names, inventory_hex, &index) } /// Walk the protocol from the persistent `entry_id` and return the component /// templates needed for the current `request_path`. /// -/// The traversal is route-aware but state-agnostic: -/// -/// - sibling `` fragments are pruned to the single best match for the -/// request path -/// - nested route groups are traversed recursively via that same rule -/// - `if`, `for`, and attribute-template edges are still followed -/// conservatively, without evaluating runtime state -/// /// Returns `(needed_names, updated_inventory_hex)`. pub fn get_needed_components_for_request( protocol: &WebUIProtocol, @@ -101,52 +111,39 @@ pub fn get_needed_components_for_request( ) -> (Vec, String) { let component_names = collect_inventoryable_components(protocol, entry_id, Some(request_path), false); - filter_needed_components(&component_names, inventory_hex) + let index = build_component_index(protocol); + filter_needed_components(&component_names, inventory_hex, &index) } -/// Filter an inventoryable component set against the client's inventory bitmask. -/// -/// Only skips components whose bit is set in the client's **original** inventory. -/// Components that hash-collide (FNV-1a mod 256) with an already-processed -/// component are NOT skipped — the check uses the immutable client inventory, -/// not the accumulating one built during iteration. +/// Filter components against the client's inventory bitfield using sequential indices. +/// Zero collisions — each component has a unique bit. /// /// Returns the missing component names and the updated inventory hex string. #[must_use] pub fn filter_needed_components( component_names: &HashSet, inventory_hex: &str, + index: &HashMap, ) -> (Vec, String) { let client_inv = parse_inventory(inventory_hex); let mut updated_inv = client_inv.clone(); - updated_inv.resize(32, 0); let mut ordered_names: Vec<&String> = component_names.iter().collect(); ordered_names.sort_unstable(); let mut needed = Vec::with_capacity(ordered_names.len()); for name in ordered_names { - // Only skip if the component bit was set in the CLIENT's original inventory. - // Do not skip based on bits set during this iteration — that would cause - // false negatives when two component names hash to the same bit position. - if has_component(&client_inv, name) { - // Still set the bit in the updated inventory to maintain it - let bit = component_bit_position(name); - let byte_idx = (bit / 8) as usize; - let bit_idx = bit % 8; - if byte_idx < updated_inv.len() { - updated_inv[byte_idx] |= 1 << bit_idx; + if let Some(&idx) = index.get(name.as_str()) { + if !has_component(&client_inv, idx) { + needed.push(name.clone()); } - continue; - } - - let bit = component_bit_position(name); - let byte_idx = (bit / 8) as usize; - let bit_idx = bit % 8; - if byte_idx < updated_inv.len() { - updated_inv[byte_idx] |= 1 << bit_idx; + set_component(&mut updated_inv, idx); + } else { + // Component exists in the fragment graph but has no index entry + // (no protocol.components record). It can't be tracked in the + // bitfield, so we must always send it. + needed.push(name.clone()); } - needed.push(name.clone()); } (needed, encode_inventory(&updated_inv)) @@ -734,30 +731,6 @@ mod tests { use std::collections::HashMap; use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute}; - #[test] - fn test_component_bit_position_deterministic() { - let pos1 = component_bit_position("my-component"); - let pos2 = component_bit_position("my-component"); - assert_eq!(pos1, pos2); - assert!(pos1 < 256); - } - - #[test] - fn test_has_component_present() { - let mut inv = vec![0u8; 32]; - let bit = component_bit_position("test-comp"); - let byte_idx = (bit / 8) as usize; - let bit_idx = bit % 8; - inv[byte_idx] |= 1 << bit_idx; - assert!(has_component(&inv, "test-comp")); - } - - #[test] - fn test_has_component_absent() { - let inv = vec![0u8; 32]; - assert!(!has_component(&inv, "test-comp")); - } - #[test] fn test_parse_encode_inventory_roundtrip() { let original = vec![0xABu8, 0xCD, 0xEF, 0x01]; @@ -767,32 +740,42 @@ mod tests { } #[test] - fn test_filter_needed_components_hash_collision_does_not_drop_components() { - // mp-app and mp-cart-panel both hash to bit 218 (FNV-1a mod 256). - // With empty inventory, BOTH must appear in the needed set. - assert_eq!( - component_bit_position("mp-app"), - component_bit_position("mp-cart-panel"), - "test precondition: mp-app and mp-cart-panel should collide" - ); + fn test_bitfield_set_and_check() { + let mut inv = vec![0u8; 4]; + set_component(&mut inv, 0); + set_component(&mut inv, 7); + set_component(&mut inv, 8); + set_component(&mut inv, 15); + assert!(has_component(&inv, 0)); + assert!(has_component(&inv, 7)); + assert!(has_component(&inv, 8)); + assert!(has_component(&inv, 15)); + assert!(!has_component(&inv, 1)); + assert!(!has_component(&inv, 9)); + } + + #[test] + fn test_filter_needed_components_no_false_positives() { + // With sequential indices, no two components share a bit + let mut index = HashMap::new(); + index.insert("email-message".to_string(), 0); + index.insert("o-button".to_string(), 1); + index.insert("o-avatar".to_string(), 2); let mut names = HashSet::new(); - names.insert("mp-app".to_string()); - names.insert("mp-cart-panel".to_string()); - names.insert("mp-footer".to_string()); + names.insert("email-message".to_string()); + names.insert("o-button".to_string()); - let (needed, _inv) = filter_needed_components(&names, ""); - assert!( - needed.contains(&"mp-app".to_string()), - "mp-app should be needed: {needed:?}" - ); - assert!( - needed.contains(&"mp-cart-panel".to_string()), - "mp-cart-panel should be needed despite hash collision with mp-app: {needed:?}" - ); + // Only o-button (index 1) is loaded — bit 1 set + let mut inv = vec![0u8; 1]; + set_component(&mut inv, 1); // o-button + let inv_hex = encode_inventory(&inv); + + let (needed, _) = filter_needed_components(&names, &inv_hex, &index); + assert_eq!(needed.len(), 1); assert!( - needed.contains(&"mp-footer".to_string()), - "mp-footer should be needed: {needed:?}" + needed.contains(&"email-message".to_string()), + "email-message must be needed: {needed:?}" ); } @@ -834,7 +817,17 @@ mod tests { }, ); - let protocol = WebUIProtocol::with_tokens(fragments, Vec::new()); + let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new()); + protocol + .components + .entry("app-shell".to_string()) + .or_default() + .template = "".to_string(); + protocol + .components + .entry("my-card".to_string()) + .or_default() + .template = "".to_string(); let (_needed, inv_hex) = get_needed_components(&protocol, "app-shell", ""); let (needed2, _) = get_needed_components(&protocol, "app-shell", &inv_hex); @@ -1188,11 +1181,13 @@ mod tests { ); let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new()); - protocol - .components - .entry("mp-search-page".to_string()) - .or_default() - .template = "".to_string(); + for name in ["mp-app", "mp-search-page", "mp-product-grid"] { + protocol + .components + .entry(name.to_string()) + .or_default() + .template = format!(""); + } let (_needed, inventory) = get_needed_components_for_request(&protocol, "index.html", "/search", ""); diff --git a/docs/guide/concepts/routing.md b/docs/guide/concepts/routing.md index 294fb22f..36e272b1 100644 --- a/docs/guide/concepts/routing.md +++ b/docs/guide/concepts/routing.md @@ -146,24 +146,19 @@ console.log(Router.activeParams); // { id: "42" } Tears down the router and removes event listeners. -### `Router.releaseTemplates(tags?)` +### `Router.gc()` -Release cached component templates to free memory. Removes entries from `window.__webui_templates` and clears their inventory bits so the server will re-send them on the next navigation that needs them. - -Active route components are always skipped - you cannot release a template that is currently rendered. +Release all cached component templates to free memory. Removes all entries from `window.__webui_templates` and clears their inventory bits so the server will re-send them on the next navigation that needs them. ```typescript -// Release specific templates -Router.releaseTemplates(['user-detail', 'user-list']); - // Release all non-active templates -Router.releaseTemplates(); +Router.gc(); ``` The framework's internal template cache is a `WeakMap` keyed by the same meta objects, so its entries become GC-eligible automatically when the template is released. ::: tip When to use this -Most apps don't need this - the number of unique component templates is bounded by the route tree (typically 10–30). The server's inventory system already prevents duplicate downloads. Use `releaseTemplates()` in long-lived SPAs with many routes where memory pressure is a concern. +Most apps don't need this - the number of unique component templates is bounded by the route tree (typically 10–30). The server's inventory system already prevents duplicate downloads. Use `gc()` in long-lived SPAs with many routes where memory pressure is a concern. ::: ## Lazy Loading diff --git a/packages/webui-router/README.md b/packages/webui-router/README.md index 20561eef..4f62b7d5 100644 --- a/packages/webui-router/README.md +++ b/packages/webui-router/README.md @@ -143,9 +143,9 @@ console.log(Router.activeParams); // { id: "42" } Tear down the router and remove event listeners. -### `Router.releaseTemplates(tags?)` +### `Router.gc(tags?)` -Release cached component templates to free memory. Removes entries from +Release cached component templates to free memory. Removes all entries from `window.__webui_templates` and clears their inventory bits so the server will re-send them on the next navigation that needs them. @@ -153,11 +153,8 @@ Active route components are always skipped — you cannot release a template that is currently rendered. ```typescript -// Release specific templates -Router.releaseTemplates(['user-detail', 'user-list']); - // Release all non-active templates -Router.releaseTemplates(); +Router.gc(); ``` The framework's internal `templateCache` (`WeakMap`) is keyed by the same diff --git a/packages/webui-router/src/inventory.test.ts b/packages/webui-router/src/inventory.test.ts deleted file mode 100644 index 6fa9cc0b..00000000 --- a/packages/webui-router/src/inventory.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { strict as assert } from 'node:assert'; -import { describe, test } from 'node:test'; - -import { - clearInventoryBit, - encodeInventoryHex, - parseInventoryHex, -} from './inventory.js'; - -describe('inventory helpers', () => { - test('parseInventoryHex round-trips with encodeInventoryHex', () => { - const hex = '2000000000000000000000000000000000000000000000000001000000000000'; - const bytes = parseInventoryHex(hex); - assert.equal(encodeInventoryHex(bytes), hex); - }); - - test('parseInventoryHex handles empty string', () => { - const bytes = parseInventoryHex(''); - assert.equal(bytes.length, 0); - assert.equal(encodeInventoryHex(bytes), ''); - }); - - test('clearInventoryBit clears the correct bit for a component', () => { - // 'section-page' hashes to bit 200 → byte 25, mask 0x01. - // Start with byte 25 = 0x01 (bit set). - const inv = new Uint8Array(32); - inv[25] = 0x01; - clearInventoryBit(inv, 'section-page'); - assert.equal(inv[25], 0x00, 'bit for section-page should be cleared'); - }); - - test('clearInventoryBit does not disturb other bits', () => { - // 'routes-app' hashes to bit 5 → byte 0, mask 0x20 (0b00100000). - // Set byte 0 to 0xff so all bits are set. - const inv = new Uint8Array(32); - inv[0] = 0xff; - clearInventoryBit(inv, 'routes-app'); - assert.equal(inv[0], 0xff & ~0x20, 'only bit 5 should be cleared'); - }); - - test('clearInventoryBit is safe when byte index is out of range', () => { - const inv = new Uint8Array(1); // too small for most components - // Should not throw — just no-ops - clearInventoryBit(inv, 'section-page'); - assert.equal(inv[0], 0x00); - }); - - // Cross-check: FNV-1a bit positions must match the Rust implementation - // in crates/webui-handler/src/route_handler.rs. - test('FNV-1a bit positions match server (cross-check)', () => { - // Known vectors computed from the Rust implementation: - // section-page → bit 200 (byte 25, mask 0x01) - // topic-page → bit 192 (byte 24, mask 0x01) - // routes-app → bit 5 (byte 0, mask 0x20) - // user-detail → bit 252 (byte 31, mask 0x10) - // home-page → bit 186 (byte 23, mask 0x04) - const cases: Array<[string, number, number]> = [ - ['section-page', 25, 0x01], - ['topic-page', 24, 0x01], - ['routes-app', 0, 0x20], - ['user-detail', 31, 0x10], - ['home-page', 23, 0x04], - ]; - - for (const [name, byteIdx, mask] of cases) { - // Set the expected bit, then clear it — verifies the hash lands correctly. - const inv = new Uint8Array(32); - inv[byteIdx] = mask; - clearInventoryBit(inv, name); - assert.equal( - inv[byteIdx], 0x00, - `clearInventoryBit('${name}') should clear byte ${byteIdx} mask 0x${mask.toString(16)}`, - ); - } - }); -}); diff --git a/packages/webui-router/src/inventory.ts b/packages/webui-router/src/inventory.ts deleted file mode 100644 index 94327b1e..00000000 --- a/packages/webui-router/src/inventory.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -/** - * Component inventory bitmask helpers. - * - * The inventory is a 256-bit mask (32 bytes, hex-encoded) that tracks which - * component templates the client has already loaded. The server checks this - * via the `X-WebUI-Inventory` header to avoid re-sending known templates. - * - * Bit positions are derived from an FNV-1a hash of the component tag name. - * This implementation **must** stay in sync with the Rust version in - * `crates/webui-handler/src/route_handler.rs`. - */ - -/** FNV-1a hash mod 256 — deterministic bit position for a component name. */ -function componentBitPosition(name: string): number { - let hash = 0x811c9dc5; - for (let i = 0; i < name.length; i++) { - hash ^= name.charCodeAt(i); - hash = Math.imul(hash, 0x01000193) >>> 0; - } - return hash % 256; -} - -export function parseInventoryHex(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length >> 1); - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); - } - return bytes; -} - -export function encodeInventoryHex(inv: Uint8Array): string { - let hex = ''; - for (const b of inv) { - hex += (b < 16 ? '0' : '') + b.toString(16); - } - return hex; -} - -export function clearInventoryBit(inv: Uint8Array, name: string): void { - const bit = componentBitPosition(name); - const byteIdx = bit >> 3; - if (byteIdx < inv.length) { - inv[byteIdx] &= ~(1 << (bit & 7)); - } -} diff --git a/packages/webui-router/src/router.test.ts b/packages/webui-router/src/router.test.ts index 01df2aac..e2741687 100644 --- a/packages/webui-router/src/router.test.ts +++ b/packages/webui-router/src/router.test.ts @@ -6,7 +6,6 @@ import './browser-shim.js'; import { strict as assert } from 'node:assert'; import { describe, test, beforeEach, afterEach } from 'node:test'; -import { encodeInventoryHex } from './inventory.js'; import { WebUIRouter } from './router.js'; // ── Test-only type access ──────────────────────────────────────── @@ -38,41 +37,41 @@ function globals(): TemplateRegistry { return globalThis as unknown as TemplateRegistry; } -/** Build a 32-byte inventory with specific bits set via component names. */ +// Assign deterministic indices for test components +const testIndex: Record = {}; +let nextIdx = 0; +function ensureIndex(name: string): number { + if (!(name in testIndex)) testIndex[name] = nextIdx++; + return testIndex[name]; +} + +/** Build an inventory hex with specific components marked as loaded. */ function inventoryWith(...names: string[]): string { - function bitPosition(name: string): number { - let hash = 0x811c9dc5; - for (let i = 0; i < name.length; i++) { - hash ^= name.charCodeAt(i); - hash = Math.imul(hash, 0x01000193) >>> 0; - } - return hash % 256; - } - const inv = new Uint8Array(32); + for (const n of names) ensureIndex(n); + const byteCount = Math.max(1, Math.ceil(nextIdx / 8)); + const inv = new Uint8Array(byteCount); for (const n of names) { - const bit = bitPosition(n); - inv[bit >> 3] |= 1 << (bit & 7); + const idx = testIndex[n]; + inv[idx >> 3] |= 1 << (idx & 7); + } + // Encode as hex + let hex = ''; + for (const b of inv) { + hex += (b < 16 ? '0' : '') + b.toString(16); } - return encodeInventoryHex(inv); + return hex; } -/** Check if a bit is set for a component name in an inventory hex string. */ +/** Check if a component's bit is set in an inventory hex string. */ function hasBit(hex: string, name: string): boolean { - function bitPosition(n: string): number { - let hash = 0x811c9dc5; - for (let i = 0; i < n.length; i++) { - hash ^= n.charCodeAt(i); - hash = Math.imul(hash, 0x01000193) >>> 0; - } - return hash % 256; - } + const idx = testIndex[name]; + if (idx === undefined) return false; const bytes = new Uint8Array(hex.length >> 1); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); } - const bit = bitPosition(name); - const byteIdx = bit >> 3; - return byteIdx < bytes.length && (bytes[byteIdx] & (1 << (bit & 7))) !== 0; + const byteIdx = idx >> 3; + return byteIdx < bytes.length && (bytes[byteIdx] & (1 << (idx & 7))) !== 0; } describe('WebUIRouter', () => { @@ -91,29 +90,8 @@ describe('WebUIRouter', () => { } }); - describe('releaseTemplates', () => { - test('releases specific templates and clears inventory bits', () => { - const router = new WebUIRouter(); - const registry = globals().__webui_templates!; - registry['page-a'] = { h: '
A
' }; - registry['page-b'] = { h: '
B
' }; - registry['page-c'] = { h: '
C
' }; - - internals(router).inventory = inventoryWith('page-a', 'page-b', 'page-c'); - - router.releaseTemplates(['page-a', 'page-c']); - - assert.equal(registry['page-a'], undefined, 'page-a should be deleted'); - assert.ok(registry['page-b'], 'page-b should be retained'); - assert.equal(registry['page-c'], undefined, 'page-c should be deleted'); - - const inv = internals(router).inventory; - assert.equal(hasBit(inv, 'page-a'), false, 'inventory bit for page-a should be cleared'); - assert.equal(hasBit(inv, 'page-b'), true, 'inventory bit for page-b should remain'); - assert.equal(hasBit(inv, 'page-c'), false, 'inventory bit for page-c should be cleared'); - }); - - test('releases all non-active templates when called with no args', () => { + describe('gc', () => { + test('clears all templates and resets inventory', () => { const router = new WebUIRouter(); const registry = globals().__webui_templates!; registry['shell'] = { h: '
Shell
' }; @@ -122,79 +100,19 @@ describe('WebUIRouter', () => { const ri = internals(router); ri.inventory = inventoryWith('shell', 'page-x', 'page-y'); - ri.activeChain = [{ component: 'shell', path: '/', params: {} }]; - - router.releaseTemplates(); - - assert.ok(registry['shell'], 'active component should be retained'); - assert.equal(registry['page-x'], undefined, 'inactive page-x should be released'); - assert.equal(registry['page-y'], undefined, 'inactive page-y should be released'); - }); - - test('skips active components even when explicitly requested', () => { - const router = new WebUIRouter(); - const registry = globals().__webui_templates!; - registry['active-comp'] = { h: '
active
' }; - registry['inactive-comp'] = { h: '
inactive
' }; - const ri = internals(router); - ri.inventory = inventoryWith('active-comp', 'inactive-comp'); - ri.activeChain = [{ component: 'active-comp', path: '/', params: {} }]; + router.gc(); - router.releaseTemplates(['active-comp', 'inactive-comp']); - - assert.ok(registry['active-comp'], 'active component must not be released'); - assert.equal(registry['inactive-comp'], undefined, 'inactive component should be released'); - assert.equal(hasBit(ri.inventory, 'active-comp'), true, 'active bit must remain set'); + assert.equal(registry['shell'], undefined, 'shell should be cleared'); + assert.equal(registry['page-x'], undefined, 'page-x should be cleared'); + assert.equal(registry['page-y'], undefined, 'page-y should be cleared'); + assert.equal(ri.inventory, '', 'inventory should be reset to empty'); }); test('is a no-op when no templates are registered', () => { const router = new WebUIRouter(); globals().__webui_templates = undefined; - - // Should not throw - router.releaseTemplates(); - router.releaseTemplates(['anything']); - }); - - test('is a no-op when all requested tags are active', () => { - const router = new WebUIRouter(); - const registry = globals().__webui_templates!; - registry['only-one'] = { h: '
' }; - - const origInventory = inventoryWith('only-one'); - const ri = internals(router); - ri.inventory = origInventory; - ri.activeChain = [{ component: 'only-one', path: '/', params: {} }]; - - router.releaseTemplates(['only-one']); - - assert.ok(registry['only-one'], 'should not release'); - assert.equal(ri.inventory, origInventory, 'inventory should be unchanged'); - }); - - test('handles nested active chain correctly', () => { - const router = new WebUIRouter(); - const registry = globals().__webui_templates!; - registry['app-shell'] = { h: '
shell
' }; - registry['section-page'] = { h: '
section
' }; - registry['topic-page'] = { h: '
topic
' }; - registry['lesson-page'] = { h: '
lesson
' }; - - const ri = internals(router); - ri.inventory = inventoryWith('app-shell', 'section-page', 'topic-page', 'lesson-page'); - ri.activeChain = [ - { component: 'app-shell', path: '/', params: {} }, - { component: 'section-page', path: 'sections/:id', params: { id: '1' } }, - { component: 'topic-page', path: 'topics/:tid', params: { tid: 'react' } }, - ]; - - router.releaseTemplates(); - - assert.ok(registry['app-shell'], 'active shell retained'); - assert.ok(registry['section-page'], 'active section retained'); - assert.ok(registry['topic-page'], 'active topic retained'); - assert.equal(registry['lesson-page'], undefined, 'inactive lesson released'); + router.gc(); // should not throw }); }); diff --git a/packages/webui-router/src/router.ts b/packages/webui-router/src/router.ts index 12d9a5d1..9b572d7c 100644 --- a/packages/webui-router/src/router.ts +++ b/packages/webui-router/src/router.ts @@ -14,7 +14,6 @@ * templates, instantiates the component, and mounts it into the route. */ -import { clearInventoryBit, encodeInventoryHex, parseInventoryHex } from './inventory.js'; import { buildNavigationTarget, prependBasePath } from './navigation-path.js'; import type { RouterConfig, NavigationEvent } from './types.js'; import type { NavigationTarget } from './navigation-path.js'; @@ -120,7 +119,7 @@ export class WebUIRouter { private started = false; private cleanupFns: Array<() => void> = []; private isInitialNavigation = true; - /** Hex string tracking which component templates are loaded. */ + /** Comma-separated list tracking which component templates are loaded. */ private inventory = ''; /** CSP nonce read from `` — used for dynamic script creation. */ private nonce = ''; @@ -191,35 +190,20 @@ export class WebUIRouter { } /** - * Release cached templates to free memory. Removes entries from - * `window.__webui_templates` and clears their inventory bits so the - * server will re-send them on the next navigation that needs them. - * - * The framework's `templateCache` is a `WeakMap` keyed by the same - * meta objects, so those entries become GC-eligible automatically. - * - * @param tags - Component tag names to release (e.g. `['section-page']`). - * Omit to release all non-active templates. + * Garbage-collect all cached templates to free memory. Clears every entry + * from `window.__webui_templates` and resets the inventory so the server + * re-sends needed templates on the next navigation. */ - releaseTemplates(tags?: string[]): void { + gc(): void { const registry = window.__webui_templates; - if (!registry) return; - - const activeSet = new Set(this.activeChain.map(e => e.component)); - const toRelease = tags - ? tags.filter(t => !activeSet.has(t)) - : Object.keys(registry).filter(t => !activeSet.has(t)); - - if (toRelease.length === 0) return; - - // Parse inventory hex → bytes, clear bits, re-encode - const inv = parseInventoryHex(this.inventory); - for (const tag of toRelease) { - delete registry[tag]; - clearInventoryBit(inv, tag); + if (registry) { + for (const tag of Object.keys(registry)) { + delete registry[tag]; + } } - this.inventory = encodeInventoryHex(inv); + this.inventory = ''; } + /** Tear down. */ destroy(): void { this.loaderPromises.clear();