From f1993852044769691dc77c0471dd488b8ad3cfe2 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 19 Aug 2026 00:22:00 +0530 Subject: [PATCH] Make if and each blocks reactive on the client --- bun.lock | 4 +- docs/WRN-LANGUAGE-SPEC-1.0.md | 4 + packages/compiler/package.json | 2 +- packages/compiler/src/codegen.ts | 123 ++++++++++++++++++- packages/compiler/test/compiler.test.ts | 17 +++ packages/csr/package.json | 2 +- packages/csr/src/reactive-runtime.ts | 156 +++++++++++++++++++++--- packages/csr/test/reactive.test.ts | 49 ++++++++ 8 files changed, 330 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index 72e233f2..eca4ec5a 100644 --- a/bun.lock +++ b/bun.lock @@ -300,7 +300,7 @@ }, "packages/compiler": { "name": "@wrnexus/compiler", - "version": "0.8.11", + "version": "0.8.12", "dependencies": { "@wrnexus/csr": "workspace:*", "@wrnexus/store": "workspace:*", @@ -322,7 +322,7 @@ }, "packages/csr": { "name": "@wrnexus/csr", - "version": "0.8.22", + "version": "0.8.23", "dependencies": { "@wrnexus/core": "workspace:*", }, diff --git a/docs/WRN-LANGUAGE-SPEC-1.0.md b/docs/WRN-LANGUAGE-SPEC-1.0.md index d6be43b4..7c50fa80 100644 --- a/docs/WRN-LANGUAGE-SPEC-1.0.md +++ b/docs/WRN-LANGUAGE-SPEC-1.0.md @@ -166,6 +166,10 @@ Supported view features include: - `{#each items as item, index key item.id}`, optional keys, and optional `{:empty}` branches - comments and scoped styles +`{#if}` and `{#each}` are rendered on the server for the initial response and +remain reactive after hydration. Browser state changes switch conditional +branches and rerender loop rows, including the `{:empty}` branch. + Output is escaped by default. Explicit raw HTML APIs must be treated as security boundaries. diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 676f094f..3b7dbc01 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.8.11", + "version": "0.8.12", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 464ba1cb..0f1e81bf 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -593,7 +593,22 @@ function renderNode( // templateEscape, swapped for its real `${…}` code after escaping. if (node.type === "each" || node.type === "if") { loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node)); - return `\x00WRNEACH${loops.length - 1}\x00`; + const definition = + node.type === "each" + ? { + list: node.list, + item: node.item, + index: node.index, + key: node.key, + body: renderClientControlTemplate(node.body), + empty: renderClientControlTemplate(node.empty), + } + : node.branches.map((branch) => ({ + cond: branch.cond, + body: renderClientControlTemplate(branch.body), + })); + const attribute = node.type === "each" ? "data-wrn-each" : "data-wrn-if"; + return `\x00WRNEACH${loops.length - 1}\x00`; } if (node.tag === "Static" || node.tag === "Dynamic") { @@ -2037,6 +2052,87 @@ function compileAttrValue(raw: string, ctx: CompCtx): string { return out + escLit(attrEscape(raw.slice(last))); } +/** + * Serialize a control-block body as inert browser-side template markup. + * Values deliberately remain as mustaches: the CSR runtime evaluates them + * against the component scope (and `{#each}` locals) when it materializes the + * template. The string is base64 encoded before it is placed in HTML. + */ +function renderClientControlTemplate(nodes: ViewNode[]): string { + const render = (node: ViewNode): string => { + if (node.type === "text") { + return node.value.replace(/\{([^{}]+)\}/g, (whole, rawExpression: string) => { + const expression = rawExpression.trim(); + return expression.startsWith("t:") + ? `` + : `${whole}`; + }); + } + if (node.type === "each") { + return ``; + } + if (node.type === "if") { + return ``; + } + + const componentTag = isComponentTag(node.tag); + let bindIndex = 0; + const attrs = node.attrs + .map((attribute) => { + const name = attribute.event + ? componentTag + ? componentEventAttribute(attribute.name) + : eventAttribute(attribute.name) + : attribute.name; + if (attribute.boolean) return ` ${name}`; + if (attribute.name.startsWith("class:")) { + const expression = unwrapDirectiveExpression(attribute.value); + return ` data-wrn-class-${bindIndex++}="${attrEscape( + JSON.stringify([attribute.name.slice("class:".length), expression]), + )}"`; + } + if (attribute.name === "data-show") { + return ` data-show="${attrEscape(unwrapDirectiveExpression(attribute.value))}"`; + } + const rendered = ` ${name}="${attrEscape(attribute.value)}"`; + return attribute.value.includes("{") + ? `${rendered} data-wrn-bind-${bindIndex++}="${attrEscape( + JSON.stringify([name, attribute.value]), + )}"` + : rendered; + }) + .join(""); + const children = node.children.map(render).join(""); + if (node.tag === "Static") return children; + if (componentTag) + return `
${children}
`; + if (VOID_ELEMENTS.has(node.tag.toLowerCase())) return `<${node.tag}${attrs}>`; + return `<${node.tag}${attrs}>${children}`; + }; + + return nodes.map(render).join(""); +} + +function encodeClientControl(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64"); +} + function renderComponentIfNode(node: IfNode, ctx: CompCtx): string { let expression = "``"; @@ -2051,7 +2147,14 @@ function renderComponentIfNode(node: IfNode, ctx: CompCtx): string { : `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`; } - return "${" + expression + "}"; + const definition = encodeClientControl( + node.branches.map((branch) => ({ + cond: branch.cond, + body: renderClientControlTemplate(branch.body), + })), + ); + + return `${"${" + expression + "}"}`; } function renderComponentEachNode(node: EachNode, ctx: CompCtx): string { @@ -2067,7 +2170,7 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string { const body = node.body.map((child) => renderComponentNode(child, childCtx)).join(""); const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join(""); - return ( + const serverBody = "${(() => { const __wl = Array.isArray(" + list + ") ? (" + @@ -2080,8 +2183,18 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string { body + '`).join("") : `' + empty + - "`; })()}" - ); + "`; })()}"; + + const definition = encodeClientControl({ + list: node.list, + item: node.item, + index: node.index, + key: node.key, + body: renderClientControlTemplate(node.body), + empty: renderClientControlTemplate(node.empty), + }); + + return `${serverBody}`; } function serverLoopLocalsAttribute(ctx: CompCtx): string { diff --git a/packages/compiler/test/compiler.test.ts b/packages/compiler/test/compiler.test.ts index d425ac3f..5daeda1d 100644 --- a/packages/compiler/test/compiler.test.ts +++ b/packages/compiler/test/compiler.test.ts @@ -1053,6 +1053,23 @@ component Banner { expect(output).toContain("Visible"); expect(output).toContain("Hidden"); }); + +test("if and each blocks emit browser control metadata while preserving SSR", () => { + const output = generate( + parse(`component ClientBlocks { + state open = false + state items = ["a"] + view { + {#if open}

Open

{:else}

Closed

{/if} + {#each items as item}{item}{:empty}Empty{/each} + } + }`), + ); + + expect(output).toContain("data-wrn-if="); + expect(output).toContain("data-wrn-each="); + expect(output).toContain("Array.isArray(items)"); +}); test("component array props support each blocks", () => { const output = generate( parse(` diff --git a/packages/csr/package.json b/packages/csr/package.json index e0580153..3455e623 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.8.22", + "version": "0.8.23", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 4a328e58..331556c3 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -1611,24 +1611,21 @@ export const REACTIVE_RUNTIME = String.raw` if ( attribute.name === "data-text" ) { - try { - var textValue = - itemEval(attribute.value); - - node.textContent = - textValue == null - ? "" - : String(textValue); - } catch (error) { - console.error( - "[wrnexus] data-for text binding failed for '" + - attribute.value + - "'", - error, - ); - - node.textContent = ""; - } + (function (textNode, textExpression) { + var runText = reactive(function () { + try { + var textValue = itemEval(textExpression); + textNode.textContent = textValue == null ? "" : String(textValue); + } catch (error) { + console.error( + "[wrnexus] data-for text binding failed for '" + textExpression + "'", + error, + ); + textNode.textContent = ""; + } + }); + runText(); + })(node, attribute.value); return; } @@ -1954,6 +1951,129 @@ export const REACTIVE_RUNTIME = String.raw` } } + // Compiled if/each blocks keep their SSR result in the custom + // element and carry an inert, base64-encoded template for later browser + // updates. The first reactive pass only subscribes to dependencies, so + // hydration does not throw away server DOM. Subsequent state changes + // materialize the appropriate branch/rows and hydrate their bindings. + function decodeControlDefinition(value) { + try { + var binary = window.atob(value || ""); + var bytes = new Uint8Array(binary.length); + for (var index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return JSON.parse(new TextDecoder("utf-8").decode(bytes)); + } catch (error) { + reportDiagnostic("WRN-CONTROL-DECODE", "Failed to decode a client control block.", el, error); + return null; + } + } + + function setupControlBlock(block, outerLocals) { + if (!block || block.__wrnexusControl || (!outerLocals && !owns(block))) return; + block.__wrnexusControl = true; + var inherited = outerLocals || decodeLoopLocals(block); + var rangeEnd = null; + if (block.tagName && block.tagName.toLowerCase() === "template") { + var depth = 0; + for (var sibling = block.nextSibling; sibling; sibling = sibling.nextSibling) { + if (sibling.nodeType !== 1 || sibling.tagName.toLowerCase() !== "template") continue; + if (sibling.hasAttribute("data-wrn-if") || sibling.hasAttribute("data-wrn-each")) depth++; + if (sibling.hasAttribute("data-wrn-control-end")) { + if (depth === 0) { rangeEnd = sibling; break; } + depth--; + } + } + if (!rangeEnd) return; + } + var ifDefinition = block.hasAttribute("data-wrn-if") + ? decodeControlDefinition(block.getAttribute("data-wrn-if")) + : null; + var eachDefinition = block.hasAttribute("data-wrn-each") + ? decodeControlDefinition(block.getAttribute("data-wrn-each")) + : null; + var firstRun = true; + + function controlRead(name) { + return Object.prototype.hasOwnProperty.call(inherited, name) ? inherited[name] : readScope(name); + } + + function controlEval(expression, locals) { + return evaluateExpression(expression, function (name) { + return locals && Object.prototype.hasOwnProperty.call(locals, name) + ? locals[name] + : controlRead(name); + }); + } + + function clearControlContent() { + if (!rangeEnd) { block.innerHTML = ""; return; } + while (block.nextSibling && block.nextSibling !== rangeEnd) { + block.parentNode.removeChild(block.nextSibling); + } + } + + function appendControlContent(markup, locals) { + var template = document.createElement("template"); + template.innerHTML = markup || ""; + var fragment = template.content; + var elements = Array.prototype.slice.call(fragment.childNodes).filter(function (node) { + return node.nodeType === 1; + }); + if (rangeEnd) block.parentNode.insertBefore(fragment, rangeEnd); + else block.appendChild(fragment); + elements.forEach(function (node) { hydrateItem(node, locals || inherited); }); + elements.forEach(function (node) { + var controls = []; + 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); + }); + }); + } + + reactive(function () { + if (ifDefinition) { + var selected = null; + for (var branchIndex = 0; branchIndex < ifDefinition.length; branchIndex++) { + var branch = ifDefinition[branchIndex]; + if (branch.cond === null || !!controlEval(branch.cond, inherited)) { + selected = branch; + break; + } + } + if (firstRun) { firstRun = false; return; } + clearControlContent(); + appendControlContent(selected ? selected.body : "", inherited); + return; + } + + if (!eachDefinition) return; + var list = controlEval(eachDefinition.list, inherited); + if (!Array.isArray(list)) list = []; + if (firstRun) { firstRun = false; return; } + clearControlContent(); + if (list.length === 0) { + appendControlContent(eachDefinition.empty || "", inherited); + return; + } + for (var itemIndex = 0; itemIndex < list.length; itemIndex++) { + var rowLocals = {}; + Object.keys(inherited).forEach(function (name) { rowLocals[name] = inherited[name]; }); + rowLocals[eachDefinition.item] = list[itemIndex]; + if (eachDefinition.index) rowLocals[eachDefinition.index] = itemIndex; + appendControlContent(eachDefinition.body || "", rowLocals); + } + }); + } + + Array.prototype.slice.call(el.querySelectorAll("[data-wrn-if],[data-wrn-each]")).forEach(function (block) { + if (block.parentElement && block.parentElement.closest("[data-wrn-if],[data-wrn-each]")) return; + setupControlBlock(block, null); + }); + /* * Set up one [data-for] template. Extracted from an inline forEach so it * can recurse: hydrateItem calls it for every loop nested inside a rendered diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index 121324d9..8532ee0d 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -117,6 +117,55 @@ test("@event (data-on-click) mutates a signal and re-renders", () => { expect(btn.textContent).toBe("2"); }); +test("compiled if blocks switch branches after hydration", () => { + const definition = Buffer.from( + JSON.stringify([ + { cond: "open", body: '

Open {count}

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

Closed

' }, + ]), + ).toString("base64"); + const win = mount( + `
` + + `` + + `` + + `

Closed

` + + `
`, + ); + + win.document.querySelector("button")!.click(); + expect(win.document.querySelector(".closed")).toBeNull(); + expect(win.document.querySelector(".open")?.textContent).toBe("Open 2"); + win.document.querySelectorAll("button")[1]!.click(); + expect(win.document.querySelector(".open")?.textContent).toBe("Open 3"); +}); + +test("compiled each blocks rerender rows and their empty branch", () => { + const definition = Buffer.from( + JSON.stringify({ + list: "items", + item: "item", + index: "index", + body: '

{index}:{item}

', + empty: '

Empty

', + }), + ).toString("base64"); + const win = mount( + `
` + + `` + + `` + + `

0:a

` + + `
`, + ); + + win.document.querySelectorAll("button")[0]!.click(); + expect(Array.from(win.document.querySelectorAll(".row")).map((node) => node.textContent)).toEqual( + ["0:b", "1:c"], + ); + win.document.querySelectorAll("button")[1]!.click(); + expect(win.document.querySelector(".row")).toBeNull(); + expect(win.document.querySelector(".empty")?.textContent).toBe("Empty"); +}); + test("component functions support formatted multiline assignments and ternaries", () => { const behavior = Buffer.from( JSON.stringify({