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(`${node.tag}>`);
+ return escLit(`<${node.tag}`) + openAttrs + escLit(">") + inner + escLit(`${node.tag}>`);
+}
+
+/**
+ * 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");
+});