chore: fit the loop-locals fix within the production gate

Three things the gate caught that the test suite could not.

The runtime size budget: writing `data-wrn-loop-locals` on client-rendered
loop items pushed reactive-runtime.ts to 51,603 against a 51,400 budget that
had only 125 bytes of headroom. Trimmed the encoder to the
btoa/encodeURIComponent idiom, recovering 65 bytes and leaving the smallest
form that still handles non-ASCII, then raised the budget to 51,600 with the
reason recorded in the file's own convention -- the remaining 263 bytes buy a
correctness fix, not a feature.

The VS Code extension bundles its own copy of the compiler, so the syntax and
compiler fixes made it stale. Rebuilt.

And a bug in the new test: `\{` inside a template literal is an unnecessary
escape, so the "brace inside a regex" case was testing an unescaped brace.
`\{` tests the case it was written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 12:08:07 +05:30
co-authored by Claude Opus 5
parent 2d0df4efc9
commit 2299a0726d
6 changed files with 256 additions and 36 deletions
+165 -17
View File
@@ -1,6 +1,6 @@
"use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
// WRN editor compiler source hash: 3dd8f4ba41eb3b4086c63e530a5daa985c1591e76e23bdd0fc47c87eefe5a4a6
// WRN editor compiler source hash: 0ce7440feda082e0baf1ad3a8f2a5c309f308b755bd074fa8cab6de16efb675e
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3
const __nodeRequire = require;
@@ -1291,15 +1291,15 @@ function bakeLoopAttr(raw, typed = false) {
return out + escLit(attrEscape(raw.slice(last)));
}
/** Render one loop-body node to template-literal source (nested loops inline). */
function renderLoopBody(node) {
function renderLoopBody(node, locals = []) {
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);
const attrs = node.attrs
@@ -1316,7 +1316,13 @@ function renderLoopBody(node) {
return escLit(` ${name}="`) + bakeLoopAttr(attr.value, componentTag) + escLit(`"`);
})
.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")
@@ -1360,26 +1366,37 @@ function renderLoopBody(node) {
if (island)
return escLit(island);
return (escLit(`<div data-component="${attrEscape(node.tag)}"`) +
attrs +
openAttrs +
escLit(">") +
inner +
escLit("</div>"));
}
if (syntax_1.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) {
const entries = locals.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ");
return ` data-wrn-loop-locals="\${__wrnexusEncodeLoopLocals({ ${entries} })}"`;
}
/**
* Compile a `{#each list as item}` block to a `${}` template-literal interpolation
* 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) {
function compileEachExpr(node, outerLocals = []) {
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 +
") ? (" +
@@ -1399,11 +1416,11 @@ function compileEachExpr(node) {
* 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) {
function compileIfExpr(node, locals = []) {
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 + "}";
@@ -2282,6 +2299,18 @@ function generateInner(ast) {
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)};`);
}
if (hasServerApis) {
@@ -7477,6 +7506,88 @@ exports.isIdentPart = isIdentPart;
* reimplementing it a second hand-rolled scanner is how apostrophes in
* prose used to swallow braces.
*/
/**
* Identifiers that can precede a `/` without ending an expression, so the `/`
* opens a regex rather than dividing.
*/
const REGEX_PRECEDING_KEYWORDS = new Set([
"return",
"typeof",
"instanceof",
"in",
"of",
"new",
"delete",
"void",
"do",
"else",
"yield",
"await",
"case",
]);
/**
* Decide whether the `/` at `i` opens a regex literal or is a division sign.
*
* Scans backwards for the last significant character. `a / b` divides; `(/a/)`,
* `= /a/` and `return /a/` do not. Erring towards division is the safe
* direction -- mistaking division for a regex would swallow everything to the
* next `/` and lose any braces in between.
*/
function opensRegex(src, i) {
let j = i - 1;
while (j >= 0 && (src[j] === " " || src[j] === "\t" || src[j] === "\r" || src[j] === "\n"))
j--;
if (j < 0)
return true;
const prev = src[j];
if (/[A-Za-z0-9_$]/.test(prev)) {
// An identifier ends an expression, so `/` divides -- unless it is a
// keyword that cannot end one, like `return`.
let k = j;
while (k >= 0 && /[A-Za-z0-9_$]/.test(src[k]))
k--;
return REGEX_PRECEDING_KEYWORDS.has(src.slice(k + 1, j + 1));
}
// `)` and `]` close an expression, `.` continues one, and a quote ends a
// literal; anything else leaves us in a position where a regex may start.
return (prev !== ")" && prev !== "]" && prev !== "." && prev !== '"' && prev !== "'" && prev !== "`");
}
/**
* Scan a regex literal starting at `i`, returning the index just past its
* closing `/` and flags, or null when this is not in fact a regex.
*
* A regex literal cannot span a newline, so an unterminated one is treated as
* "not a regex" rather than swallowing the rest of the file. That is what keeps
* a bare URL in view text (`https://example.com/a//b`) intact.
*/
function skipRegex(src, i) {
let j = i + 1;
let inClass = false;
while (j < src.length) {
const c = src[j];
if (c === "\n")
return null;
if (c === "\\") {
j += 2;
continue;
}
if (inClass) {
if (c === "]")
inClass = false;
}
else if (c === "[") {
inClass = true;
}
else if (c === "/") {
j++;
while (j < src.length && /[a-z]/.test(src[j]))
j++;
return j;
}
j++;
}
return null;
}
function skipLiteralOrComment(src, i, atLineStart) {
const c = src[i];
if (c === "/" && src[i + 1] === "*") {
@@ -7500,6 +7611,13 @@ function skipLiteralOrComment(src, i, atLineStart) {
}
return src.length;
}
// A regex literal is neither a string nor a brace pair, but it can contain
// both. Without this, a quote inside one opened a phantom string that
// swallowed every brace to the next quote, and a lone `{`/`}` miscounted
// block depth.
if (c === "/" && opensRegex(src, i)) {
return skipRegex(src, i);
}
return null;
}
class Lexer {
@@ -7508,8 +7626,13 @@ class Lexer {
constructor(src) {
this.src = src;
}
/** Skip whitespace and `// line comments`. */
skipTrivia() {
/**
* Skip whitespace and `// line comments`, but NOT block comments.
*
* Kept separate from `skipTrivia` so `startsWithBlockComment` can still see a
* block comment that `skipTrivia` would otherwise consume.
*/
skipWhitespaceAndLineComments() {
const { src } = this;
while (this.pos < src.length) {
const c = src[this.pos];
@@ -7525,9 +7648,34 @@ class Lexer {
break;
}
}
/** True when the next non-trivia characters open a block comment. */
/**
* Skip whitespace, line comments and block comments.
*
* Block comments used to be skipped only inside a braced body, so one written
* between two members failed with a bare "Unexpected character '/'" -- the
* same comment parsed or did not depending on where it sat.
*/
skipTrivia() {
const { src } = this;
while (this.pos < src.length) {
this.skipWhitespaceAndLineComments();
if (src[this.pos] === "/" && src[this.pos + 1] === "*") {
const close = src.indexOf("*/", this.pos + 2);
this.pos = close === -1 ? src.length : close + 2;
continue;
}
break;
}
}
/**
* True when the next non-trivia characters open a block comment.
*
* Deliberately skips only whitespace and line comments: `props {}` refuses
* block comments with an explained error, and that check must run before
* `skipTrivia` would swallow the comment and drop a declaration silently.
*/
startsWithBlockComment() {
this.skipTrivia();
this.skipWhitespaceAndLineComments();
return this.src[this.pos] === "/" && this.src[this.pos + 1] === "*";
}
/** Read and consume the next structural token. */
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 59ff54353ed4f50aa53f3731e232726d55549936b2edf8958beb407cc4fdf9d2
// WRN editor extension source hash: e2d214e98eb953756974d4b731530ed432c2f9dca6f0aaee975453b2eec84d75
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
+76 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// WRN editor language server source hash: e55270eb33d7722a82d13d3a79097fea77eca69612aeba104e9cbdd8fc67e8c6
// WRN editor language server source hash: cea070c2eebb5339c7b48f4200d34e51f2adb16eba4d6d3f92e5040081c5b079
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
// @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
@@ -169671,6 +169671,64 @@ var isWs = (c) => c === " " || c === "\t" || c === `
` || c === "\r";
var isIdentStart = (c) => /[A-Za-z_]/.test(c);
var isIdentPart = (c) => /[A-Za-z0-9_]/.test(c);
var REGEX_PRECEDING_KEYWORDS = new Set([
"return",
"typeof",
"instanceof",
"in",
"of",
"new",
"delete",
"void",
"do",
"else",
"yield",
"await",
"case"
]);
function opensRegex(src, i) {
let j = i - 1;
while (j >= 0 && (src[j] === " " || src[j] === "\t" || src[j] === "\r" || src[j] === `
`))
j--;
if (j < 0)
return true;
const prev = src[j];
if (/[A-Za-z0-9_$]/.test(prev)) {
let k = j;
while (k >= 0 && /[A-Za-z0-9_$]/.test(src[k]))
k--;
return REGEX_PRECEDING_KEYWORDS.has(src.slice(k + 1, j + 1));
}
return prev !== ")" && prev !== "]" && prev !== "." && prev !== '"' && prev !== "'" && prev !== "`";
}
function skipRegex(src, i) {
let j = i + 1;
let inClass = false;
while (j < src.length) {
const c = src[j];
if (c === `
`)
return null;
if (c === "\\") {
j += 2;
continue;
}
if (inClass) {
if (c === "]")
inClass = false;
} else if (c === "[") {
inClass = true;
} else if (c === "/") {
j++;
while (j < src.length && /[a-z]/.test(src[j]))
j++;
return j;
}
j++;
}
return null;
}
function skipLiteralOrComment(src, i, atLineStart) {
const c = src[i];
if (c === "/" && src[i + 1] === "*") {
@@ -169695,6 +169753,9 @@ function skipLiteralOrComment(src, i, atLineStart) {
}
return src.length;
}
if (c === "/" && opensRegex(src, i)) {
return skipRegex(src, i);
}
return null;
}
@@ -169704,7 +169765,7 @@ class Lexer {
constructor(src) {
this.src = src;
}
skipTrivia() {
skipWhitespaceAndLineComments() {
const { src } = this;
while (this.pos < src.length) {
const c = src[this.pos];
@@ -169721,8 +169782,20 @@ class Lexer {
break;
}
}
skipTrivia() {
const { src } = this;
while (this.pos < src.length) {
this.skipWhitespaceAndLineComments();
if (src[this.pos] === "/" && src[this.pos + 1] === "*") {
const close = src.indexOf("*/", this.pos + 2);
this.pos = close === -1 ? src.length : close + 2;
continue;
}
break;
}
}
startsWithBlockComment() {
this.skipTrivia();
this.skipWhitespaceAndLineComments();
return this.src[this.pos] === "/" && this.src[this.pos + 1] === "*";
}
next() {