From 0904a4efaa05c9fe0375644665a28cac4e944832 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 19 Aug 2026 00:49:31 +0530 Subject: [PATCH] fix(csr): render control blocks created by a client rerender {#if}, {#each} and their {:else}/{:else if}/{:empty} branches worked on the server and after hydration, but a block nested inside another block stayed empty once the outer block rerendered. Adding a row to a list produced the row's markup with its inner block markers in place and nothing between them, for the life of the page. Two causes, both on the client-created path only: reactive() registers an effect; effects run when renderAll sweeps the list. A state change runs just the affected effects rather than sweeping, so an effect registered during that rerender was queued and never invoked. setupControlBlock now returns its runner and the creating block invokes it immediately. The first reactive pass is skipped so hydration does not discard server-rendered DOM. A block created by a rerender has no server DOM, so skipping its only pass left it permanently empty. firstRun is now keyed off outerLocals, which is set only on the client-created path. Verified in a browser as well as in tests: adding a group to a list now renders the new row's nested {:else}, and the existing rows' nested loops survive the rerender. Co-Authored-By: Claude Opus 5 --- packages/csr/src/reactive-runtime.ts | 26 ++++++++++++++-- packages/csr/test/reactive.test.ts | 44 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 331556c3..8fffecae 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -1993,7 +1993,17 @@ export const REACTIVE_RUNTIME = String.raw` var eachDefinition = block.hasAttribute("data-wrn-each") ? decodeControlDefinition(block.getAttribute("data-wrn-each")) : null; - var firstRun = true; + /* + * Skip the first reactive pass only when hydrating server DOM. + * + * A block that arrived with the server HTML is already rendered, so + * redrawing on the first pass would discard it. A block created later by + * an outer block's rerender has no server DOM — outerLocals is how it + * receives its enclosing loop's scope, and is only ever set on that path. + * Skipping its first pass leaves it permanently empty, because its + * dependencies never change again to trigger a second one. + */ + var firstRun = !outerLocals; function controlRead(name) { return Object.prototype.hasOwnProperty.call(inherited, name) ? inherited[name] : readScope(name); @@ -2029,12 +2039,22 @@ export const REACTIVE_RUNTIME = String.raw` if (node.matches && node.matches("[data-wrn-if],[data-wrn-each]")) controls.push(node); if (node.querySelectorAll) Array.prototype.push.apply(controls, node.querySelectorAll("[data-wrn-if],[data-wrn-each]")); controls.forEach(function (nested) { - if (!nested.__wrnexusControl) setupControlBlock(nested, locals || inherited); + if (nested.__wrnexusControl) return; + /* + * Run the new block now rather than waiting for a sweep. + * + * reactive() only registers an effect; effects execute when + * renderAll sweeps the list. A state change runs just the affected + * effects, so a block registered during that rerender is queued and + * never invoked -- it would stay empty for the life of the page. + */ + var runNested = setupControlBlock(nested, locals || inherited); + if (runNested) runNested(); }); }); } - reactive(function () { + return reactive(function () { if (ifDefinition) { var selected = null; for (var branchIndex = 0; branchIndex < ifDefinition.length; branchIndex++) { diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index 8532ee0d..ca126b60 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -1631,3 +1631,47 @@ test("splitter announces its new size for the component to re-emit", () => { ); expect(seen).toEqual([60]); }); + +test("control blocks created by a client rerender render their own content", () => { + // A nested block that arrives with the server HTML is hydrated: its first + // reactive pass must NOT redraw, or it would throw away server DOM. A nested + // block created later by an outer rerender has no server DOM, so skipping its + // first pass leaves it permanently empty — its dependencies never change + // again to trigger a second one. + const inner = Buffer.from( + JSON.stringify([ + { cond: "g.rows.length > 0", body: '

HAS

' }, + { cond: null, body: '

NONE

' }, + ]), + ).toString("base64"); + const outer = Buffer.from( + JSON.stringify({ + list: "groups", + item: "g", + body: + `
{g.name}` + + `` + + `
`, + empty: "", + }), + ).toString("base64"); + + const win = mount( + `
` + + `` + + `` + + `
g1` + + `

HAS

` + + `
` + + `` + + `
`, + ); + + win.document.querySelector("button")!.click(); + + // The outer each rerendered: the new group's heading is present. + expect(win.document.querySelector(".group span")?.textContent).toBe("g2"); + // The nested if inside that new row must have rendered its else branch. + expect(win.document.querySelector(".no-rows")?.textContent).toBe("NONE"); + expect(win.document.querySelector(".has-rows")).toBeNull(); +});