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(); +});