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)};`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user