fix(compiler): expose {#each} locals to event handlers on pages
A handler expression is emitted as text and evaluated when the event fires, so
any loop variable it names has to travel with the element. Components emitted
`data-wrn-loop-locals` for this; pages did not. The same view worked inside a
component and threw ReferenceError inside a page -- with a green build and green
tests, since nothing renders the page in a browser during a build.
The CSR runtime already resolves locals generically via
closest("[data-wrn-loop-locals]"), so only codegen needed to change.
The marker is emitted only on elements that actually bind an event, and the
encoder only when a marker was produced -- but it MUST be emitted whenever one
is, or the render throws on an undefined function instead of the handler
throwing on an undefined variable, which is strictly worse. Covered by its own
test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(`<div data-component="${attrEscape(node.tag)}"`) +
|
||||
attrs +
|
||||
openAttrs +
|
||||
escLit(">") +
|
||||
inner +
|
||||
escLit("</div>")
|
||||
@@ -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, unknown>): 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)};`,
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
<button @click="buyPack(pack.code)">{pack.code}</button>
|
||||
{/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}
|
||||
<button @click="pick(i)">{pack.code}</button>
|
||||
{/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}
|
||||
<span>{pack.code}</span>
|
||||
{/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 { <span>{packs.length}</span> }
|
||||
}`),
|
||||
);
|
||||
expect(code).not.toContain("__wrnexusEncodeLoopLocals");
|
||||
});
|
||||
Reference in New Issue
Block a user