diff --git a/ai/plans/archive/hydrate-each-external-state.md b/ai/plans/archive/hydrate-each-external-state.md new file mode 100644 index 000000000..bf3ce045f --- /dev/null +++ b/ai/plans/archive/hydrate-each-external-state.md @@ -0,0 +1,168 @@ +# Each-Hydrate Reactivity for External-State Bindings + +## Goal + +Fix a silent reactivity loss inside hydrated `{#each}` blocks: per-item bindings whose helpers close over external state (a component method that reads `state.x`, an attribute expression like `class="{classMap getItemClasses item}"`) never registered Reactions when items arrived from props and never mutated. State mutations the helper depended on had nothing to invalidate; the SSR'd DOM stayed stale forever. The docs site `inpage-menu` reproduced this on every page that hydrated with menu items from props — scroll never updated the active class. + +The fix had to land without reintroducing the ~425ms hydrate regression on 1000-item lists that the prior `hydration-perf-pass` had paid down by making `each.hydrate` deliberately lazy. + +## Design / Implementation + +### Where the bug actually lived + +`each.hydrate` (`packages/renderer/src/engines/native/blocks/each.js`) was a one-line opt-out from the framework's "wire per-binding Reactions on hydrate" invariant that every other block honored: + +```js +hydrate({ node, lookupExpression, self }) { + lookupExpression(node.over); // dep on the items collection + self.hasHydrated = true; +} +``` + +The reasoning, per the perf pass: per-item Reactions are expensive (item count × bindings per item), so defer wiring until the items signal first fires. When that fires, `update` calls `adoptServerItems` which walks the per-item DOM and wires Reactions in place via `hydrateInnerContent` with `skipFirstWrite: true`. + +The bug was that the perf optimization assumed per-item bindings only depend on item-local data. When a binding closed over external state (state signal accessed via a closure inside a helper), the items signal could be the wrong dep — items might never mutate. With no Reaction wired, the external signal had no subscriber. The bindings stayed stuck on whatever the server rendered. + +### The classifier + +`packages/renderer/src/engines/native/blocks/each-content-classifier.js` walks the each block's content AST once per AST identity (cached on a module-level `WeakMap`) and decides whether everything resolves locally. Identifier extraction is a small static-syntactic walk: + +- Strip string literals (`'...'`, `"..."`, `` `...` ``) +- Walk character-by-character tracking brace depth +- For each identifier-like token: + - Skip if preceded by `.` (property access — only the head matters) + - Skip if followed by `:` while inside `{...}` (object literal key) + - Otherwise classify against `iteration-vars ∪ pure-helper-registry ∪ reserved-names` + +`PURE_HELPERS` is `Object.keys(TemplateHelpers)` — framework-shipped helpers don't read user signals. User-registered helpers and component instance methods fall into the "external" bucket via the same path as state signals: statically indistinguishable, conservative bail. + +Recursive walk handles nested blocks. For nested `each → if → each`, the inner each's iteration vars accumulate on top of the outer's. Conservative bails: +- `each` without explicit `as` (item keys spread into local scope; statically indistinguishable from external names) +- `template`/`snippet`/`rerender`/`async`/`guard` invocations (cross-AST or dynamic; not traced) +- Unknown node types + +### `each.hydrate` consults the classifier + +```js +hydrate({ node, data, scope, region, renderAST, lookupExpression, hydrateInnerContent, self, isSVG }) { + lookupExpression(node.over); + self.hasHydrated = true; + if (isEachContentSelfContained(node)) { return; } // lazy path preserved + const { items, collectionType } = resolveItems(node, lookupExpression); + if (items.length === 0) { return; } + const adopted = adoptServerItems({ ... }); + if (adopted) { self.hasHydrated = false; } // already wired; update() shouldn't try again +} +``` + +Self-contained → existing lazy hydrate. Anything else → `adoptServerItems` eagerly so per-item Reactions register their external deps now. + +### Where the work runs + +`each.hydrate` only fires on the SSR-then-hydrate path. The 90% of users who runtime-compile in the browser without SSR pay zero analyzer cost. SSR users pay one walk per unique each-content AST shape (cached). The AST stays clean — no per-node hydration metadata, just a binding-layer cache keyed by AST identity. + +## Supporting changes + +### PR #176 — test infrastructure (`Test: Make Hydration Tests Actually Hydrate`) + +Three latent bugs surfaced once the test setup was corrected to actually hydrate: + +- **`ssrAndHydrate` used `wrapper.innerHTML = html`.** `innerHTML`'s fragment parser does NOT process `