diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 07e46978..25d7d38d 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -448,17 +448,17 @@ function bakeLoopAttr(raw: string, typed = false): string { } /** Render one loop-body node to template-literal source (nested loops inline). */ -function renderLoopBody(node: ViewNode): string { +function renderLoopBody(node: ViewNode, locals: string[] = []): string { if (node.type === "text") { return bakeLoopText(node.value); } if (node.type === "each") { - return compileEachExpr(node); + return compileEachExpr(node, locals); } if (node.type === "if") { - return compileIfExpr(node); + return compileIfExpr(node, locals); } const componentTag = isComponentTag(node.tag); @@ -480,7 +480,16 @@ function renderLoopBody(node: ViewNode): string { }) .join(""); - const inner = node.children.map(renderLoopBody).join(""); + // A handler expression is emitted as text and evaluated at event time, so any + // `{#each}` variable it names has to travel with the element. The runtime + // resolves them with closest("[data-wrn-loop-locals]"). Only elements that + // actually bind an event need it -- marking every node would bloat the HTML. + const localsAttr = + locals.length > 0 && node.attrs.some((attr) => attr.event) ? loopLocalsAttr(locals) : ""; + + const inner = node.children.map((child) => renderLoopBody(child, locals)).join(""); + + const openAttrs = attrs + localsAttr; if (node.tag === "Static") return inner; if (node.tag === "Dynamic") @@ -536,7 +545,7 @@ function renderLoopBody(node: ViewNode): string { if (island) return escLit(island); return ( escLit(`
") + inner + escLit("
") @@ -544,10 +553,20 @@ function renderLoopBody(node: ViewNode): string { } if (VOID_ELEMENTS.has(node.tag.toLowerCase())) { - return escLit(`<${node.tag}`) + attrs + escLit(">"); + return escLit(`<${node.tag}`) + openAttrs + escLit(">"); } - return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(``); + return escLit(`<${node.tag}`) + openAttrs + escLit(">") + inner + escLit(``); +} + +/** + * Build the ` data-wrn-loop-locals="..."` attribute for a page-rendered loop + * body. Deliberately not passed through escLit: the `${...}` must stay live so + * the values are encoded at render time. + */ +function loopLocalsAttr(locals: string[]): string { + const entries = locals.map((name) => `${JSON.stringify(name)}: ${name}`).join(", "); + return ` data-wrn-loop-locals="\${__wrnexusEncodeLoopLocals({ ${entries} })}"`; } /** @@ -555,11 +574,13 @@ function renderLoopBody(node: ViewNode): string { * that iterates the (server-evaluated) list and joins the per-item body. `list` is a * JS expression evaluated where `ssr` data bindings are in scope as raw named values. */ -function compileEachExpr(node: EachNode): string { +function compileEachExpr(node: EachNode, outerLocals: string[] = []): string { const item = node.item; const index = node.index ?? "__wi"; - const body = node.body.map(renderLoopBody).join(""); - const empty = node.empty.map(renderLoopBody).join(""); + // A nested loop can reference the outer loop's variables too. + const locals = [...outerLocals, item, index]; + const body = node.body.map((child) => renderLoopBody(child, locals)).join(""); + const empty = node.empty.map((child) => renderLoopBody(child, outerLocals)).join(""); return ( "${(() => { const __wl = Array.isArray(" + node.list + @@ -582,11 +603,11 @@ function compileEachExpr(node: EachNode): string { * that renders the first truthy branch's body (or the `{:else}` body, or "" when neither). * Conditions are JS expressions evaluated in the surrounding server scope. */ -function compileIfExpr(node: IfNode): string { +function compileIfExpr(node: IfNode, locals: string[] = []): string { let expr = "``"; // no matching branch → empty string for (let k = node.branches.length - 1; k >= 0; k--) { const b = node.branches[k]!; - const bodySrc = "`" + b.body.map(renderLoopBody).join("") + "`"; + const bodySrc = "`" + b.body.map((child) => renderLoopBody(child, locals)).join("") + "`"; expr = b.cond === null ? bodySrc : "(" + b.cond + ") ? " + bodySrc + " : " + expr; } return "${" + expr + "}"; @@ -1644,6 +1665,18 @@ function generateInner(ast: PageAst): string { if (needsRuntimeHelpers) { out.push(`import { buildApiRequest as __wrnexusBuildApiRequest } from "@wrnexus/core";`); out.push(ssrRuntimeSource()); + // Loop bodies that bind an event carry their {#each} locals in an encoded + // attribute. Emitted only when the view actually produced one, so a page + // without handlers in a loop keeps the smaller prelude -- but it MUST be + // emitted whenever the marker is, or the render throws on an undefined + // function instead of the handler throwing on an undefined variable. + if (body.includes("__wrnexusEncodeLoopLocals(")) { + out.push(`function __wrnexusEncodeLoopLocals(value: Record): string { + const json = JSON.stringify(value); + const buffer = (globalThis as { Buffer?: { from(i: string, e: string): { toString(e: string): string } } }).Buffer; + return buffer ? buffer.from(json, "utf8").toString("base64") : btoa(unescape(encodeURIComponent(json))); +}`); + } out.push( `const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`, ); diff --git a/packages/compiler/test/page-loop-locals.test.ts b/packages/compiler/test/page-loop-locals.test.ts new file mode 100644 index 00000000..f4c28c84 --- /dev/null +++ b/packages/compiler/test/page-loop-locals.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from "bun:test"; +import { generate, parse } from "../src/index.ts"; + +// A `{#each}` inside a COMPONENT emitted `data-wrn-loop-locals`, so a handler +// could reference the loop variable. The same view inside a PAGE emitted the +// handler text verbatim with no locals marker, so the loop variable was not +// defined at click time and the handler threw ReferenceError -- with a green +// build and green tests, because nothing renders the page in a browser during +// a build. The runtime resolves locals with closest("[data-wrn-loop-locals]"), +// so emitting the marker is all that is required. +const view = ` + {#each packs as pack} + + {/each} +`; + +const body = ` + state packs = [{ code: "small" }] + functions { client async function buyPack(c) { console.log(c) } } + view {${view}} +`; + +test("a page {#each} exposes loop locals to an event handler", () => { + const code = generate(parse(`page P {${body}}`)); + expect(code).toContain("data-wrn-loop-locals"); + // The item and the implicit index both have to travel, since a handler may + // reference either. + expect(code).toContain('"pack": pack'); +}); + +test("a component {#each} still exposes loop locals to an event handler", () => { + const code = generate(parse(`component C {${body}}`)); + expect(code).toContain("data-wrn-loop-locals"); + expect(code).toContain('"pack": pack'); +}); + +test("a named loop index is exposed to a page event handler", () => { + const code = generate( + parse(`page P { + state packs = [{ code: "small" }] + functions { client async function pick(c) { console.log(c) } } + view { + {#each packs as pack, i} + + {/each} + } +}`), + ); + expect(code).toContain('"i": i'); +}); + +// Only elements that actually bind an event need the marker; adding it to every +// element in a loop body would bloat the HTML for no benefit. +test("a page loop body element with no handler gets no locals marker", () => { + const code = generate( + parse(`page P { + state packs = [{ code: "small" }] + view { + {#each packs as pack} + {pack.code} + {/each} + } +}`), + ); + expect(code).not.toContain("data-wrn-loop-locals"); +}); + +// Emitting the marker without defining its encoder would turn a ReferenceError +// on the loop variable into a ReferenceError on the encoder -- at render time +// rather than click time, so strictly worse. +test("a page that emits loop locals also defines the encoder", () => { + const code = generate(parse(`page P {${body}}`)); + expect(code).toContain("data-wrn-loop-locals"); + expect(code).toContain("function __wrnexusEncodeLoopLocals"); +}); + +test("a page with no loop-local markers does not define the encoder", () => { + const code = generate( + parse(`page P { + state packs = [{ code: "small" }] + view { {packs.length} } +}`), + ); + expect(code).not.toContain("__wrnexusEncodeLoopLocals"); +});