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 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 00:49:31 +05:30
co-authored by Claude Opus 5
parent 5b11b937bb
commit 0904a4efaa
2 changed files with 67 additions and 3 deletions
+23 -3
View File
@@ -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++) {
+44
View File
@@ -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: '<p class="has-rows">HAS</p>' },
{ cond: null, body: '<p class="no-rows">NONE</p>' },
]),
).toString("base64");
const outer = Buffer.from(
JSON.stringify({
list: "groups",
item: "g",
body:
`<section class="group"><span data-text="g.name">{g.name}</span>` +
`<template data-wrn-if="${inner}"></template><template data-wrn-control-end></template>` +
`</section>`,
empty: "",
}),
).toString("base64");
const win = mount(
`<div data-scope="groups: [{ name: 'g1', rows: ['a'] }]">` +
`<button data-on-click="groups = [{ name: 'g2', rows: [] }]">swap</button>` +
`<template data-wrn-each="${outer}"></template>` +
`<section class="group"><span data-text="g.name">g1</span>` +
`<template data-wrn-if="${inner}"></template><p class="has-rows">HAS</p>` +
`<template data-wrn-control-end></template></section>` +
`<template data-wrn-control-end></template>` +
`</div>`,
);
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();
});