SlowForEachItem Adopt Element Wrapping
Design Log #115 — slowForEachItem Adopt Element Wrapping
Written for AI agents. See Log Methodology Note below for details.
Background
slowForEachItem returns a BaseJayElement which has a single dom reference. The runtime assumes the adopt callback returns one element:
slowForEachItem<ViewState, Item>(
accessor: (vs: ViewState) => Item[],
index: number,
trackBy: string,
adopt: () => BaseJayElement<Item>, // single element
): BaseJayElement<ViewState>
When a slow forEach item has a single dynamic child, this works — the callback returns one element (an adoptElement, adoptDynamicElement, childCompHydrate, etc.). But when a slow forEach item contains multiple children needing adoption, the current hydrate compiler breaks.
Related
- DL#106 Bug H — nested slowForEach coordinate stripping (fixed)
- DL#99 Bug F — nested slowForEach coordinate concatenation (fixed)
- Test fixture 10a — nested slow forEach with interactive content (currently failing)
Problem
Template:
<div class="category" forEach="categories" trackBy="_id">
<h2>{name}</h2>
<div class="item" forEach="items" trackBy="_id">
<span class="label">{label}</span>
<span class="count">{count}</span>
<!-- fast+interactive -->
</div>
</div>
After slow rendering, each category has multiple inner slow forEach items. The hydrate compiler generates:
slowForEachItem(
(vs) => vs.categories, 0, 'c1',
() => // arrow function returns ONLY i1
slowForEachItem(
(vs1) => vs1.items, 0, 'i1',
() => adoptText('1', ...),
),
slowForEachItem( // i2 becomes stray 5th argument
(vs1) => vs1.items, 1, 'i2',
() => adoptText('1', ...),
),
),
The () => expr1, expr2 arrow function only returns expr1. The expr2 (slowForEachItem for i2) is evaluated (JS evaluates all arguments) but its result is discarded — it becomes a 5th argument to the outer slowForEachItem which only accepts 4.
Root cause: The hydrate compiler's slowForEach handler calls renderHydrateElementContent (processes children only), then wraps the result in () => ${childContent}. When childContent has multiple fragments joined by ,, the single-expression arrow function can't return all of them.
The deeper issue: slowForEachItem's callback should return a BaseJayElement representing the item's root DOM element — but no such element is adopted. The <div class="category"> element is never wrapped in adoptElement.
Design
Always adopt the slow forEach item's root element
The hydrate compiler should wrap slowForEachItem content in adoptElement or adoptDynamicElement for the item's root DOM element. This mirrors how hydrateForEach items get their root element adopted.
Target output:
slowForEachItem(
(vs) => vs.categories, 0, 'c1',
() => adoptDynamicElement('', {}, [
STATIC, // <h2>
slowForEachItem(
(vs1) => vs1.items, 0, 'i1',
() => adoptElement('', {}, [
adoptText('1', (vs2) => vs2.count),
]),
),
slowForEachItem(
(vs1) => vs1.items, 1, 'i2',
() => adoptElement('', {}, [
adoptText('1', (vs2) => vs2.count),
]),
),
]),
),
The '' coordinate resolves to coordinateBase.join('/') itself — after forItem('c1'), that's 'c1', matching the DOM element's jay-coordinate="c1".
Changes needed
1. SSR compiler: force jay-coordinate on slow forEach roots
Slow forEach items already have jay-coordinate-base from assignCoordinates (e.g., c1). But the SSR compiler skips emitting jay-coordinate when the element has no dynamic content or refs. With the adoption wrapping, the hydrate code needs the coordinate to find the element.
Fix: in renderServerElement's slowForEach handler, pass { isRoot: true } to renderServerElementContent to force coordinate emission. This is safe for fully-static slow forEach items (no hydrate code tries to resolve them).
2. Hydrate compiler: wrap slowForEachItem content in adoptElement
In renderHydrateElement's slowForEach handler, instead of:
const childContent = renderHydrateElementContent(element, itemContext, ...);
// ... wrap in: () => ${childContent}
Generate an adoptElement/adoptDynamicElement call that:
- Uses coordinate
''(stripped jayTrackBy = empty = coordinateBase itself) - Has the children from
renderHydrateElementContentas its child array - Uses
adoptDynamicElementwhen children include dynamic groups (slowForEachItem, hydrateForEach, hydrateConditional),adoptElementotherwise
To avoid recursion (renderHydrateElement → detects slowForEach → loops), the adoption wrapping is done directly in the slowForEach handler rather than calling back into renderHydrateElement.
3. Handle fully-static slow forEach items
When childContent is empty (fully static slow forEach item, no dynamic descendants), skip the wrapping — return empty as before. No adoptElement needed.
When childContent has a single fragment (one dynamic child), still wrap in adoptElement for consistency and correctness.
Implementation Plan
Phase 1: SSR — force coordinate on slow forEach roots
In renderServerElement's slowForEach handler, pass { isRoot: true } to both renderServerElementContent calls (the non-indented and indented paths).
Phase 2: Hydrate — wrap slowForEachItem callback in adoptElement
In renderHydrateElement's slowForEach handler:
- After
renderHydrateElementContent, check if content is non-empty - Determine if children include dynamic groups (need
adoptDynamicElementvsadoptElement) - Generate the adoption wrapper with coordinate
'' - Wrap in the
slowForEachItem(...)call as before
Phase 3: Verify
- Run 10a test (nested slow forEach with interactive content) — should pass
- Run all hydration tests — no regressions
- Run compiler tests — no regressions
- Test golf page — no hydration warnings
Verification Criteria
- 10a fixture: "no hydration warnings" passes in all 3 SSR modes
- 10a fixture: "DOM is correct after hydration" verifies count values render correctly
- 10d fixture: interactivity test toggles
isActiveconditional inside slow forEach — verifies Kindergarten works - All existing hydration tests (1–9a, 10b–10d) continue passing
- Golf page: no
adoptBase coordinate not foundwarnings
Implementation Results
Approach: targeted wrapping based on child count
Instead of always using forceAdopt (which generated unnecessary adoptElement('', {}, []) for fully static items), the fix processes children individually and decides:
- 0 non-empty children → skip entirely (fully static, as before)
- 1 non-empty child → callback returns it directly (no wrapper needed)
- 2+ non-empty children → wrap in
adoptElementoradoptDynamicElement
For the multi-child case, the wrapper type depends on whether children include dynamic groups:
- Has interactive conditional, forEach, or nested slowForEach →
adoptDynamicElementwithSTATICsentinels (Kindergarten for DOM positioning) - Otherwise → plain
adoptElement
SSR change
renderServerElement's slowForEach handler passes { isRoot: true } to renderServerElementContent, forcing jay-coordinate emission on slow forEach item root elements. This is needed so the hydrate wrapper's adoptElement('', ...) can resolve the element.
Files changed
jay-html-compiler.ts(hydrate path): slowForEach handler processes children individually viarenderHydrateNode, counts non-empty results, wraps inadoptElement/adoptDynamicElementwhen multiple children existjay-html-compiler.ts(SSR path): slowForEach handler passes{ isRoot: true }to force coordinate emission- 2 SSR compiler test fixtures updated (slow forEach roots now emit
jay-coordinate) - 10a fixture: added
countfield atphase: fast+interactiveto exercise nested slow forEach with dynamic content - 10d fixture: added toggle button +
withInteractivehandler + interactivity test that togglesisActiveconditional
Test results
- 458/458 hydration tests pass (including 10a nested slow forEach + 10d interactivity toggle)
- 616/616 compiler-jay-html tests pass
- 68/68 packages build successfully
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.