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:
+165
-17
@@ -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,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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -999,20 +999,11 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var json = JSON.stringify(locals);
|
||||
if (json === undefined) return;
|
||||
|
||||
var bytes = new TextEncoder().encode(json);
|
||||
var binary = "";
|
||||
|
||||
for (var index = 0; index < bytes.length; index++) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
|
||||
node.setAttribute("data-wrn-loop-locals", window.btoa(binary));
|
||||
node.setAttribute(
|
||||
"data-wrn-loop-locals",
|
||||
window.btoa(unescape(encodeURIComponent(json))),
|
||||
);
|
||||
} catch (error) {
|
||||
/*
|
||||
* A value that will not serialise (a cycle, a DOM node) must not take
|
||||
* the whole loop down -- the item still renders, and a handler naming
|
||||
* that local fails on its own terms rather than silently.
|
||||
*/
|
||||
console.error("[wrnexus] failed to encode loop locals", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ ${body}
|
||||
expect(parse(mk(` const x = /it's/.test("its");`)).name).toBe("P");
|
||||
expect(parse(mk(` const x = /"/.test("q");`)).name).toBe("P");
|
||||
// A brace inside a regex used to be counted as block depth.
|
||||
expect(parse(mk(` const x = /\{/.test("{");`)).name).toBe("P");
|
||||
expect(parse(mk(` const x = /\\{/.test("{");`)).name).toBe("P");
|
||||
expect(parse(mk(` const x = /}/.test("}");`)).name).toBe("P");
|
||||
// A brace quantifier is balanced, but must not be counted either.
|
||||
expect(parse(mk(` const x = /^a{2,3}$/.test("aa");`)).name).toBe("P");
|
||||
|
||||
@@ -183,7 +183,15 @@ const runtimeBudgets = {
|
||||
*/
|
||||
// Raised to 51_400: the callApi transport (query building, CSRF header,
|
||||
// JSON body, success/failure contract) for compiled api blocks bought ~1,025 bytes.
|
||||
"reactive-runtime.ts": 51_400,
|
||||
//
|
||||
// Raised to 51_600 on 2026-08-22: writing `data-wrn-loop-locals` onto
|
||||
// client-rendered for-loop items bought 263 bytes. Without it a component's
|
||||
// output binding inside a loop resolved no locals and silently dropped every
|
||||
// call, while a plain DOM handler in the same position worked -- so the cost
|
||||
// buys a correctness fix, not a feature. The encoder was trimmed to the
|
||||
// btoa/encodeURIComponent idiom first, which recovered 65 of those bytes;
|
||||
// what remains is the smallest form that still handles non-ASCII.
|
||||
"reactive-runtime.ts": 51_600,
|
||||
"component-controllers.ts": 24_100,
|
||||
"nav-runtime.ts": 12_000,
|
||||
"realtime-runtime.ts": 8_000,
|
||||
|
||||
Reference in New Issue
Block a user