perf: scoped context cache invalidation & directive dispatch optimizations#282
perf: scoped context cache invalidation & directive dispatch optimizations#282evertonfraga wants to merge 4 commits into
Conversation
…sed loops The loops/reconciliation benchmark accessed `list.parentElement.__ctx` and `list.children`, but since the v1.15 marker-based loop refactor the loop element is removed from the DOM (replaced by comment markers) and clones are managed as siblings of the parent `state` element. `list.parentElement` was therefore null, so all 96 tests threw `Cannot read properties of null (reading '__ctx')` against v1.16.1 and the benchmark produced zero data. Point state mutations and assertions at the `state` element (the context host) instead. Drop the now-unused `list` destructure in test bodies; setupDOM still returns it for callers that want the template element. No production code changed.
Three targeted optimizations in the init/processTree hot path: 1. _matchDirective: use a single Map.get instead of has+get (eliminates double hash lookup on every exact match), and add a charCodeAt(0) gate so plain-HTML attributes (id, href, data-*, aria-*) skip the 4-way startsWith pattern loop entirely. 2. processElement: early-return for zero-directive elements (the vast majority on any page), skip sort() for ≤1 match, and use an index loop instead of for..of iterator allocation. 3. processTree: replace root.contains(node) — an O(depth) ancestor walk per element — with the O(1) node.isConnected check, gated on root.isConnected to preserve correct processing of DocumentFragment roots.
The keyed reconciliation update branch (loops.js:412) mutates __raw via Object.assign, bypassing the proxy set trap. Unlike its three sibling paths (filter at :187, delta-append at :297, key-eval at :379), it did NOT invalidate __collectKeysCache. This meant the reused child context could serve stale cached keys on re-render. Currently masked by the global _ctxGeneration counter (any other reactive write bumps it), but this becomes a live correctness bug when scoped invalidation (R1) is implemented. Also: single Map.get instead of double lookup, add _hasGlobals flag to globals.js (for future context.js optimization).
Replace the single global _ctxGeneration counter with per-context _gen stamps. _collectKeys now validates its cache against the self + ancestor chain's generations — unrelated context writes no longer blow the cache. Before: any reactive write anywhere bumped a global counter, forcing EVERY context's _collectKeys to recompute on next evaluate(). Verified penalty: 5.8x slower evals (0.54µs → 3.13µs) when an unrelated region updated concurrently. After: eval + unrelated write = 0.544µs (ratio 0.92x of warm baseline). The cache is only invalidated when the context itself or one of its actual ancestors mutates. Global _ctxGeneration is retained for backward compat with the `delete __collectKeysCache` paths in loops.js (which explicitly bust the cache when bypassing the proxy setter). Correctness verified: self-write invalidates, parent-write invalidates child, unrelated-write preserves cache. All 1914 unit tests pass.
Review: PR#282 — scoped context cache invalidation & directive dispatch optimizationsFull review of the four commits, plus unit + e2e runs on the branch and a merge dry-run against current Test results on this branch
The e2e failures are not caused by this PR's source changes. Two pieces of evidence:
So the e2e signal here is stale-base noise — but that leads directly to the biggest problem. 1. 🚫 Blocker: branch is based on stale main and does not mergeMerge-base is
A dry-run merge of this branch into current main conflicts in The good news: main's 2. 🐛 Confirmed bug: keyed-reuse raw write doesn't bump
|
Summary
Runtime performance optimizations targeting the two hottest paths in No.JS: expression evaluation (context key collection) and directive dispatch (DOM tree walking).
Changes
Context cache invalidation — 5.8× eval speedup (
src/context.js)The
_collectKeyscache was invalidated on every reactive mutation anywhere in the app (global generation counter). This meant unrelated context writes blew caches for all contexts — catastrophic for pages with many independent reactive scopes.Fix: Each context now tracks its own
_genstamp. Cache validation walks the ancestor chain comparing per-context stamps (O(depth) integer compares, depth typically 1–5) instead of relying on a single global counter. Unrelated mutations no longer invalidate unrelated caches.Directive dispatch fast-path (
src/registry.js)has()+get()double-lookup with a singleMap.get()for exact directive matchesc/o/s/b) to skip the wildcard pattern loop entirely for plain HTML attributes (id,href,data-*,aria-*, etc.) — the overwhelmingly common casefor..ofwith indexedforloops in hot pathsDOM walk guard (
src/registry.js—processTree)root.contains(node)(O(depth) per node) withnode.isConnected(O(1)) for live documentsDocumentFragmentrootsLoop keyed-reuse cache fix (
src/directives/loops.js)When reusing keyed elements via
Object.assign(bypassing the proxy setter), the_collectKeyscache was not invalidated. Added explicit cache deletion on the reuse path.Minor
_hasGlobalsfast guard to skipfor..inover empty_globalsin evaluate (common case — no plugins)Testing