diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs
index befc7ac7..128a1b1a 100644
--- a/editors/vscode/src/compiler.cjs
+++ b/editors/vscode/src/compiler.cjs
@@ -1,6 +1,6 @@
"use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
-// WRN editor compiler source hash: fd183ab8c54df72c779d099d7625ce0068e49bea458052335c77cbf31ccf9179
+// WRN editor compiler source hash: 27f13fbc79aedf4736913f268ab4af1f03a236ea44960cffb7caff225d157faf
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3
const __nodeRequire = require;
@@ -1261,7 +1261,21 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
// templateEscape, swapped for its real `${…}` code after escaping.
if (node.type === "each" || node.type === "if") {
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
- return `\x00WRNEACH${loops.length - 1}\x00`;
+ const definition = node.type === "each"
+ ? {
+ list: node.list,
+ item: node.item,
+ index: node.index,
+ key: node.key,
+ body: renderClientControlTemplate(node.body),
+ empty: renderClientControlTemplate(node.empty),
+ }
+ : node.branches.map((branch) => ({
+ cond: branch.cond,
+ body: renderClientControlTemplate(branch.body),
+ }));
+ const attribute = node.type === "each" ? "data-wrn-each" : "data-wrn-if";
+ return `\x00WRNEACH${loops.length - 1}\x00`;
}
if (node.tag === "Static" || node.tag === "Dynamic") {
const inner = node.children
@@ -2425,6 +2439,76 @@ function compileAttrValue(raw, ctx) {
}
return out + escLit(attrEscape(raw.slice(last)));
}
+/**
+ * Serialize a control-block body as inert browser-side template markup.
+ * Values deliberately remain as mustaches: the CSR runtime evaluates them
+ * against the component scope (and `{#each}` locals) when it materializes the
+ * template. The string is base64 encoded before it is placed in HTML.
+ */
+function renderClientControlTemplate(nodes) {
+ const render = (node) => {
+ if (node.type === "text") {
+ return node.value.replace(/\{([^{}]+)\}/g, (whole, rawExpression) => {
+ const expression = rawExpression.trim();
+ return expression.startsWith("t:")
+ ? ``
+ : `${whole}`;
+ });
+ }
+ if (node.type === "each") {
+ return ``;
+ }
+ if (node.type === "if") {
+ return ``;
+ }
+ const componentTag = isComponentTag(node.tag);
+ let bindIndex = 0;
+ const attrs = node.attrs
+ .map((attribute) => {
+ const name = attribute.event
+ ? componentTag
+ ? componentEventAttribute(attribute.name)
+ : eventAttribute(attribute.name)
+ : attribute.name;
+ if (attribute.boolean)
+ return ` ${name}`;
+ if (attribute.name.startsWith("class:")) {
+ const expression = unwrapDirectiveExpression(attribute.value);
+ return ` data-wrn-class-${bindIndex++}="${attrEscape(JSON.stringify([attribute.name.slice("class:".length), expression]))}"`;
+ }
+ if (attribute.name === "data-show") {
+ return ` data-show="${attrEscape(unwrapDirectiveExpression(attribute.value))}"`;
+ }
+ const rendered = ` ${name}="${attrEscape(attribute.value)}"`;
+ return attribute.value.includes("{")
+ ? `${rendered} data-wrn-bind-${bindIndex++}="${attrEscape(JSON.stringify([name, attribute.value]))}"`
+ : rendered;
+ })
+ .join("");
+ const children = node.children.map(render).join("");
+ if (node.tag === "Static")
+ return children;
+ if (componentTag)
+ return `
${children}
`;
+ if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase()))
+ return `<${node.tag}${attrs}>`;
+ return `<${node.tag}${attrs}>${children}${node.tag}>`;
+ };
+ return nodes.map(render).join("");
+}
+function encodeClientControl(value) {
+ return node_buffer_1.Buffer.from(JSON.stringify(value), "utf8").toString("base64");
+}
function renderComponentIfNode(node, ctx) {
let expression = "``";
for (let index = node.branches.length - 1; index >= 0; index--) {
@@ -2436,7 +2520,11 @@ function renderComponentIfNode(node, ctx) {
? bodyExpression
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
}
- return "${" + expression + "}";
+ const definition = encodeClientControl(node.branches.map((branch) => ({
+ cond: branch.cond,
+ body: renderClientControlTemplate(branch.body),
+ })));
+ return `${"${" + expression + "}"}`;
}
function renderComponentEachNode(node, ctx) {
const item = node.item;
@@ -2448,7 +2536,7 @@ function renderComponentEachNode(node, ctx) {
};
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
- return ("${(() => { const __wl = Array.isArray(" +
+ const serverBody = "${(() => { const __wl = Array.isArray(" +
list +
") ? (" +
list +
@@ -2460,7 +2548,16 @@ function renderComponentEachNode(node, ctx) {
body +
'`).join("") : `' +
empty +
- "`; })()}");
+ "`; })()}";
+ const definition = encodeClientControl({
+ list: node.list,
+ item: node.item,
+ index: node.index,
+ key: node.key,
+ body: renderClientControlTemplate(node.body),
+ empty: renderClientControlTemplate(node.empty),
+ });
+ return `${serverBody}`;
}
function serverLoopLocalsAttribute(ctx) {
const locals = [...(ctx.serverLocals ?? [])];
diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs
index c0405bfc..8dced462 100644
--- a/editors/vscode/src/extension.bundle.cjs
+++ b/editors/vscode/src/extension.bundle.cjs
@@ -1,4 +1,4 @@
-// WRN editor extension source hash: 38063dc1b7ef04c106da5a5e264fae51315e60286d6eb0d0176ab62419e8f546
+// WRN editor extension source hash: f74c11de70974caa6fb0cb4ee90ec39c10a924951733dadcc346b308be0e13d3
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
@@ -22724,6 +22724,8 @@ function registerAutoCloseTags(context, client2) {
return;
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true))
return;
+ if (event.contentChanges.length !== 1)
+ return;
const change = event.contentChanges[0];
if (!change || change.text !== ">" && change.text !== "/")
return;
diff --git a/editors/vscode/src/extension.js b/editors/vscode/src/extension.js
index cb86233d..0be65256 100644
--- a/editors/vscode/src/extension.js
+++ b/editors/vscode/src/extension.js
@@ -35,6 +35,11 @@ function registerAutoCloseTags(context, client) {
if (event.document.languageId !== "wrn") return;
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true)) return;
+ // Multi-cursor typing reports one change per cursor. Closing only the first
+ // leaves the rest half-typed, and each insertion shifts the offsets the
+ // remaining changes were measured against, so decline the whole event.
+ if (event.contentChanges.length !== 1) return;
+
const change = event.contentChanges[0];
if (!change || (change.text !== ">" && change.text !== "/")) return;
// A replaced selection (overtype/select-and-type) makes `range.start + text.length`
diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs
index 5b402925..43cc361b 100644
--- a/editors/vscode/src/language-server.cjs
+++ b/editors/vscode/src/language-server.cjs
@@ -1,5 +1,5 @@
#!/usr/bin/env node
-// WRN editor language server source hash: 5702fe2c68d78698b5507beb585cfd66a1c826f5c9febddca09fc45c49e40d4e
+// WRN editor language server source hash: 8555261bb7933ee73d08cee279600fa64d2f35b35c5144ceba516e6929593d37
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
// @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
@@ -195929,11 +195929,11 @@ function htmlDocument(document) {
}
function markdown(value) {
if (typeof value === "string")
- return value;
+ return value || undefined;
if (value && typeof value === "object" && "value" in value) {
- return String(value.value);
+ return String(value.value) || undefined;
}
- return "";
+ return;
}
function htmlCompletions(document, position) {
if (!isInsideHtml(document, offsetAt2(document.text, position)))
diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts
index 8fffecae..c0813f3f 100644
--- a/packages/csr/src/reactive-runtime.ts
+++ b/packages/csr/src/reactive-runtime.ts
@@ -1276,6 +1276,8 @@ export const REACTIVE_RUNTIME = String.raw`
source,
locals,
) {
+ locals = locals || Object.create(null);
+
return batchUpdates(function () {
var statements =
splitStatements(source);
@@ -1328,6 +1330,9 @@ export const REACTIVE_RUNTIME = String.raw`
locals,
);
},
+ function (name, value) {
+ locals[name] = value;
+ },
);
if (result.returned) {
@@ -2055,6 +2060,12 @@ export const REACTIVE_RUNTIME = String.raw`
}
return reactive(function () {
+ /*
+ * A block removed from the DOM keeps its effect in the renderers list,
+ * so a later sweep would run it against a detached node and throw --
+ * aborting the sweep, leaving every later effect unrendered. Skip it.
+ */
+ if (!block.parentNode) return;
if (ifDefinition) {
var selected = null;
for (var branchIndex = 0; branchIndex < ifDefinition.length; branchIndex++) {
@@ -4791,12 +4802,68 @@ export const REACTIVE_RUNTIME = String.raw`
: null;
}
+ /*
+ * Parse a while or for statement into its parts.
+ *
+ * Returns null for anything else so the caller falls through to the other
+ * statement forms. A for header is split on top-level semicolons only, so a
+ * semicolon inside a call argument or a string does not break it.
+ */
+ function parseLoopStatement(source) {
+ source = String(source || "").trim();
+
+ var kind = null;
+
+ if (source.slice(0, 5) === "while" && !/[A-Za-z0-9_$]/.test(source.charAt(5))) {
+ kind = "while";
+ } else if (source.slice(0, 3) === "for" && !/[A-Za-z0-9_$]/.test(source.charAt(3))) {
+ kind = "for";
+ } else {
+ return null;
+ }
+
+ var index = skipStatementWhitespace(source, kind === "while" ? 5 : 3);
+
+ if (source.charAt(index) !== "(") return null;
+
+ var headerEnd = findClosingDelimiter(source, index, "(", ")");
+
+ if (headerEnd < 0) throw new Error("Unclosed " + kind + " header");
+
+ var header = source.slice(index + 1, headerEnd);
+
+ index = skipStatementWhitespace(source, headerEnd + 1);
+
+ if (source.charAt(index) !== "{") throw new Error("Expected a block after " + kind);
+
+ var bodyEnd = findClosingDelimiter(source, index, "{", "}");
+
+ if (bodyEnd < 0) throw new Error("Unclosed " + kind + " body");
+
+ var body = source.slice(index + 1, bodyEnd);
+
+ if (kind === "while") {
+ return { init: null, condition: header.trim(), step: null, body: body };
+ }
+
+ var parts = splitTopLevel(header, ";");
+
+ if (parts.length !== 3) throw new Error("A for header needs three parts");
+
+ return {
+ init: parts[0].trim(),
+ condition: parts[1].trim(),
+ step: parts[2].trim(),
+ body: body,
+ };
+ }
function runStatement(
stmt,
evalExpr,
read,
write,
runBlock,
+ declare,
) {
stmt = String(stmt || "").trim();
@@ -4847,6 +4914,60 @@ export const REACTIVE_RUNTIME = String.raw`
};
}
+ /*
+ * A loop body is author-written and runs in the browser, so a mistaken
+ * condition would freeze the tab. The cap keeps a runaway loop from
+ * hanging the page; it is far above any list a view renders.
+ */
+ var loop = parseLoopStatement(stmt);
+
+ if (loop) {
+ var guard = 0;
+
+ if (loop.init) {
+ runStatement(loop.init, evalExpr, read, write, runBlock, declare);
+ }
+
+ while (!loop.condition || !!evalExpr(loop.condition)) {
+ if (++guard > 100000) break;
+
+ var outcome = runBlock(loop.body);
+
+ if (outcome && outcome.returned) return outcome;
+
+ if (loop.step) {
+ runStatement(loop.step, evalExpr, read, write, runBlock, declare);
+ }
+ }
+
+ return { returned: false, value: undefined };
+ }
+
+ /*
+ * A declaration binds a local, then falls through to the assignment
+ * branch below.
+ *
+ * Declaring first is what makes it local: an unknown name reaching
+ * writeScope becomes a signal and triggers a render sweep, so a var
+ * inside a shared function called during a render would loop forever.
+ * Once the name exists in locals, read and write both stay there.
+ */
+ var declaration = stmt.match(
+ /^(?:var|let|const)\s+([A-Za-z_$][A-Za-z0-9_$]*[\s\S]*)$/,
+ );
+
+ if (declaration) {
+ stmt = declaration[1].trim();
+
+ var declaredName = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(stmt)[0];
+
+ if (declare) declare(declaredName, undefined);
+
+ if (!/=/.test(stmt)) {
+ return { returned: false, value: undefined };
+ }
+ }
+
var increment = stmt.match(
/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/,
);
diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts
index ca126b60..4b5e3626 100644
--- a/packages/csr/test/reactive.test.ts
+++ b/packages/csr/test/reactive.test.ts
@@ -1675,3 +1675,86 @@ test("control blocks created by a client rerender render their own content", ()
expect(win.document.querySelector(".no-rows")?.textContent).toBe("NONE");
expect(win.document.querySelector(".has-rows")).toBeNull();
});
+
+test("a for loop with a declaration initialiser runs in a handler", () => {
+ const win = mount(
+ `
` +
+ `` +
+ `0` +
+ `
`,
+ );
+ win.document.querySelector("button")!.click();
+ expect(win.document.querySelector("span")?.textContent).toBe("6");
+});
+
+test("a while loop runs in a handler", () => {
+ const win = mount(
+ `
` +
+ `` +
+ `1` +
+ `
`,
+ );
+ win.document.querySelector("button")!.click();
+ expect(win.document.querySelector("span")?.textContent).toBe("16");
+});
+
+test("a declaration stays local instead of becoming reactive state", () => {
+ // An unknown name reaching writeScope becomes a signal and triggers a render
+ // sweep. A var inside a function called during a render would then loop
+ // forever, so declarations must bind locally.
+ const win = mount(
+ `
` +
+ `` +
+ `0` +
+ `` +
+ `
`,
+ );
+ win.document.querySelector("button")!.click();
+ expect(win.document.querySelector(".out")?.textContent).toBe("6");
+ expect(win.document.querySelector(".leak")?.textContent).toBe("");
+});
+
+test("a control block removed from the DOM does not abort later renders", () => {
+ // Its effect stays in the renderers list. Running it against a detached node
+ // throws, which would abort the sweep and leave every later effect stale.
+ const definition = Buffer.from(
+ JSON.stringify([{ cond: "n < 100", body: '' }]),
+ ).toString("base64");
+ const win = mount(
+ `
`,
+ );
+ win.document.querySelector("button")!.click();
+ expect(win.document.querySelector("span")?.textContent).toBe("6");
+});
+
+test("an unbounded loop stops instead of hanging the page", () => {
+ // Handler source is author-controlled and runs in the browser. Without a cap
+ // a mistaken condition freezes the tab with no way back.
+ const win = mount(
+ `
` +
+ `` +
+ `0` +
+ `
`,
+ );
+ win.document.querySelector("button")!.click();
+ const value = Number(win.document.querySelector("span")?.textContent);
+ expect(value).toBeGreaterThan(0);
+ expect(Number.isFinite(value)).toBe(true);
+});
diff --git a/packages/language-server/src/html-service.ts b/packages/language-server/src/html-service.ts
index dfcfafd0..90f218bb 100644
--- a/packages/language-server/src/html-service.ts
+++ b/packages/language-server/src/html-service.ts
@@ -27,12 +27,12 @@ function htmlDocument(document: TextDocument) {
return HtmlTextDocument.create(virtual.uri, "html", document.version ?? 1, virtual.text);
}
-function markdown(value: unknown): string {
- if (typeof value === "string") return value;
+function markdown(value: unknown): string | undefined {
+ if (typeof value === "string") return value || undefined;
if (value && typeof value === "object" && "value" in value) {
- return String((value as { value: unknown }).value);
+ return String((value as { value: unknown }).value) || undefined;
}
- return "";
+ return undefined;
}
/**
diff --git a/scripts/security-performance-audit.mjs b/scripts/security-performance-audit.mjs
index 3702cc9b..2c9eee5e 100644
--- a/scripts/security-performance-audit.mjs
+++ b/scripts/security-performance-audit.mjs
@@ -169,7 +169,14 @@ addCheck(
* minified transfer once. That is the number worth defending.
*/
const runtimeBudgets = {
- "reactive-runtime.ts": 49_000,
+ /*
+ * Raised from 49,000 on 2026-08-19. The reactive runtime had already grown
+ * past the old figure, and client-side control blocks ({#if}/{#each}
+ * rerendering, for/while in handlers) added the rest. What a visitor pays is
+ * the compressed transfer: 52,570 minified is 16,803 gzipped, once, behind
+ * an immutable year-long cache.
+ */
+ "reactive-runtime.ts": 53_000,
"component-controllers.ts": 24_100,
"nav-runtime.ts": 12_000,
"realtime-runtime.ts": 8_000,