fix(csr): run for/while loops and keep declarations out of state

The client runtime had no loop support, so any shared function using one
returned early -- Pagination and ButtonGroup were broken client-side, not
just in tests.

Adding loops exposed two further faults:

- A var reaching writeScope creates a signal and triggers a render sweep.
  A declaration inside a function called during a render therefore looped
  forever. Declarations now bind into the handler locals instead.
- A control block removed from the DOM keeps its effect in the renderers
  list. Running it against a detached node threw, aborting the sweep and
  leaving every later effect stale.

Also raises the reactive runtime budget to 53,000: the runtime had already
grown past 49,000 before this change, and 52,570 minified is 16,803 gzipped.

Two deferred minors: html-service leaves absent documentation undefined
rather than an empty string, and the extension declines tag auto-close on
multi-cursor edits rather than closing only the first cursor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 02:24:41 +05:30
co-authored by Claude Opus 5
parent 0904a4efaa
commit ac248f2bb0
8 changed files with 330 additions and 15 deletions
+102 -5
View File
@@ -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 `<template ${attribute}="${encodeClientControl(definition)}"></template>\x00WRNEACH${loops.length - 1}\x00<template data-wrn-control-end></template>`;
}
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:")
? `<span data-t="${attrEscape(expression.slice(2).trim())}"></span>`
: `<span data-text="${attrEscape(expression)}">${whole}</span>`;
});
}
if (node.type === "each") {
return `<template data-wrn-each="${attrEscape(encodeClientControl({
list: node.list,
item: node.item,
index: node.index,
key: node.key,
body: renderClientControlTemplate(node.body),
empty: renderClientControlTemplate(node.empty),
}))}"></template><template data-wrn-control-end></template>`;
}
if (node.type === "if") {
return `<template data-wrn-if="${attrEscape(encodeClientControl(node.branches.map((branch) => ({
cond: branch.cond,
body: renderClientControlTemplate(branch.body),
}))))}"></template><template data-wrn-control-end></template>`;
}
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 `<div data-component="${attrEscape(node.tag)}"${attrs}>${children}</div>`;
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 `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
}
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 `<template data-wrn-each="${definition}"></template>${serverBody}<template data-wrn-control-end></template>`;
}
function serverLoopLocalsAttribute(ctx) {
const locals = [...(ctx.serverLocals ?? [])];
+3 -1
View File
@@ -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;
+5
View File
@@ -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`
+4 -4
View File
@@ -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)))