ForEach Conditional Hydration
Design Log #121 — forEach Conditional Hydration
Written for AI agents. See Log Methodology Note below for details.
Background
In jay-html, j-if/j-else conditions inside a forEach render correctly during SSR but fail to toggle after hydration. The condition update is silently ignored.
Related Design Logs
- #93 — Client hydration
- #99 — Hydration coordinate alignment bugs
- #100 — Hydrate conditional creation fallback
- #106 — Hydrate dynamic elements with Kindergarten
Problem
Given this template:
<div class="item" forEach="items" trackBy="_id">
<span class="name">{name}</span>
<span class="badge" if="isActive">Active</span>
<span class="badge-off" if="!isActive">Inactive</span>
<button ref="toggleButton">Toggle</button>
</div>
Initial SSR render is correct — items with isActive=true show "Active", others show "Inactive". But clicking "Toggle" does nothing. The condition never updates.
Root Cause
hydrateConditional needs a KindergartenGroup (set via _setGroup) to insert/remove DOM nodes when the condition changes. This group is assigned by adoptDynamicElement, which creates a Kindergarten and iterates children, calling _setGroup on dynamic ones.
But forEach items bypass adoptDynamicElement. The compiler generates either:
- Flat array (no item adoption needed):
() => [hydrateConditional(...), hydrateConditional(...)] adoptElement(item has dynamic attrs/ref):() => [adoptElement("", attrs, [hydrateConditional(...)])]
Neither hydrateForEach's flat-array handling (hydrate.ts:462-476) nor adoptElement (hydrate.ts:218-220) creates a Kindergarten or calls _setGroup. The conditional's group stays null, and updates bail out at if (!group) return; (hydrate.ts:360).
For comparison, regular elements with conditional children use adoptDynamicElement (compiler detects hasInteractiveChildren at line 963 and switches at line 1039).
Reproduction
Test 10c (packages/jay-stack/dev-server/test/10c-nested-conditional/) now includes a toggle button and interactive phase. SSR disabled mode passes (element target handles conditionals correctly). SSR modes fail on interactivity — the toggle has no effect.
Design
Approach: Generate adoptDynamicElement for forEach items with interactive children
The compiler already generates adoptDynamicElement with STATIC sentinels for regular elements with interactive children (jay-html-compiler-hydrate.ts lines 1039-1092). Apply the same pattern to forEach item adoption.
File: packages/compiler/compiler-jay-html/lib/jay-target/jay-html-compiler-hydrate.ts
In the forEach adopt body generation (lines 296-334):
- Compute
itemHasInteractiveChildren— same check as line 963-972: any direct child is an interactive conditional or a nested forEach - When true, generate
adoptDynamicElement("", attrs, [STATIC, hydrateConditional(...), hydrateConditional(...), STATIC])— the item wrapper creates a Kindergarten and calls_setGroupon dynamic children - When
needsItemAdoptionwithout interactive children — keepadoptElement(existing path) - When neither — keep flat array (existing path)
When both itemHasInteractiveChildren and needsItemAdoption are true, adoptDynamicElement handles everything (it calls adoptBase internally, which handles dynamic attrs and refs).
Generated Code Example
Before (broken):
hydrateForEach(vs => vs.items, '_id',
() => [
adoptText('0/0', vs1 => vs1.name),
hydrateConditional(vs1 => vs1.isActive, () => adoptElement('0/1', {}), ...),
hydrateConditional(vs1 => !vs1.isActive, () => adoptElement('0/2', {}), ...),
],
(item, id) => { ... },
)
After (fixed):
hydrateForEach(vs => vs.items, '_id',
() => [
adoptDynamicElement("", {}, [
STATIC,
hydrateConditional(vs1 => vs1.isActive, () => adoptElement('0/1', {}), ...),
hydrateConditional(vs1 => !vs1.isActive, () => adoptElement('0/2', {}), ...),
STATIC,
]),
],
(item, id) => { ... },
)
The STATIC sentinels mark the <span class="name"> and <button> positions so the Kindergarten can compute correct insertion offsets.
Implementation Plan
- In the forEach adopt body generation, add
itemHasInteractiveChildrencheck - When true, build children array with
STATIC/ dynamic child classification (same pattern as lines 1039-1078) - Generate
adoptDynamicElementcall wrapping the children - Add
Import.adoptDynamicElementandImport.STATICto imports - Update expected-hydrate.ts and expected-ssr.html fixtures for test 10c
- Run tests
Verification
cd packages/jay-stack/dev-server && yarn vitest run test/hydration.test.ts -t "10c"— all pass including SSR interactivitycd packages/jay-stack/dev-server && yarn vitest run— full suite passescd packages/compiler/compiler-jay-html && yarn vitest run— all pass
Follow-up Fix: Conditional ref with dynamic text
Problem
DL#121 fixed conditionals on elements with static text (<span if="cond">Active</span>). A remaining bug affected elements with both if, ref, AND dynamic text — e.g., <button if="!inStock" ref="choiceButton">{name}</button> inside a forEach.
Discovered in the golf project's product card quick-option buttons.
Two bugs
Bug 1 — Adopt path: renderHydrateElementContent at the textFragment && !hasDynamicAttrs branch (line ~1199) emitted adoptText("S16/0", accessor, ref). adoptText adopts the text node, not the element — the button was never properly adopted. The ref happened to work accidentally because adoptText peeks the parent element and calls ref.set(element), but the element's attributes and children weren't tracked.
Bug 2 — Create path: The hydrateConditional create callback (line ~245) was built manually without the ref argument: e('button', {class: '...'}, [dt(...)]). When the condition toggled false→true at runtime, the newly created button had no ref wired, so click handlers didn't fire. This was the user-visible failure.
Fix
File: packages/compiler/compiler-jay-html/lib/jay-target/jay-html-compiler-hydrate.ts
Adopt path: When element has
textFragmentANDrefName, emitadoptElement("coord", {}, [adoptText("coord", accessor)], ref())instead ofadoptText("coord", accessor, ref()). TheadoptTextwithout ref is fine as a child — it just tracks the text content. The ref is wired to the element viaadoptElement.Create path: Added
renderElementRef(element, createRenderContext)to build the ref argument, appended as 4th arg toe():e('button', {attrs}, [children], refChoiceButton()).
Test
Added test 10e-conditional-ref-with-text — forEach with two conditional buttons (if="!inStock" / if="inStock"), both with ref="choiceButton" and dynamic text {name}. The interactivity test clicks the button twice: first to toggle the condition (creates a new button via the create callback), then clicks the NEW button to verify the ref is wired.
Test Results
- dev-server hydration: 634 passed
- compiler-jay-html: 633 passed, 4 skipped
- 3 existing fixtures updated (basics/refs, conditions-with-refs, duplicate-ref-different-branches)
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.