From a20f143acbefb218f376cd8940390a16cbcaf1be Mon Sep 17 00:00:00 2001
From: Ajay Ghanwat
Date: Wed, 19 Aug 2026 10:14:18 +0530
Subject: [PATCH] fix: isolate test globals, close tags at every caret, trim
the runtime
Three pre-existing issues that the previous commit worked around rather
than solved.
Test global pollution. packages/csr's suites install a happy-dom window
over the real globals and delete them before each test. bun test runs one
file at a time, so those deletions outlived the file and later suites
failed with "fetch is not a function" -- 20 failures from `bun test` with
no argument. They now restore what they captured. The editor's Node tests
shim the vscode host by patching Module._load, which Bun's resolver does
not consult; the shim registers a virtual module under Bun instead, so the
same files pass under both runners.
Multi-cursor tag auto-close. The handler now closes the tag at every
caret. Positions come from the editor's selections rather than the change
ranges, which are in pre-edit coordinates and are short by the preceding
insertions once several carets share a line. One insertSnippet call
carries them all, since inserting sequentially would collapse the
selection to the first snippet. Carets wanting different closing tags are
declined rather than half-applied. Moved to its own module so it can be
tested without loading the language client.
Runtime size. Trimmed 2,414 bytes: the global lookup tables became one
prototype-safe scheme (a name like "toString" was previously a hit on
Object.prototype), shared hasOwn/toArray/pairBinding helpers replaced the
repeated chains, and dead code went. That was everything available without
dropping or deferring a feature -- 49,000 was not reachable, so the budget
is now 50,500, set just above the real figure so future growth trips it.
Two tests changed: one asserted on runtime source text and now asserts the
timers resolve; a new one covers reactive class bindings inside data-for,
which the enclosing loop effect tracks rather than each binding.
Co-Authored-By: Claude Opus 5
---
editors/vscode/src/auto-close-tags.js | 83 +++++
editors/vscode/src/extension.bundle.cjs | 93 +++---
editors/vscode/src/extension.js | 51 +--
editors/vscode/test/auto-close-tags.test.js | 178 ++++++++++
editors/vscode/test/completion-scope.test.js | 85 +++--
editors/vscode/test/completion.test.js | 10 +-
editors/vscode/test/diagnostics.test.js | 42 ++-
editors/vscode/test/vscode-host.js | 39 +++
packages/csr/src/index.ts | 8 +
packages/csr/src/reactive-runtime.ts | 324 +++++++------------
packages/csr/test/global-restore.ts | 28 ++
packages/csr/test/nav.test.ts | 25 +-
packages/csr/test/reactive.test.ts | 33 +-
packages/csr/test/realtime.test.ts | 7 +-
packages/ui/test/ui.test.ts | 20 +-
scripts/security-performance-audit.mjs | 17 +-
16 files changed, 650 insertions(+), 393 deletions(-)
create mode 100644 editors/vscode/src/auto-close-tags.js
create mode 100644 editors/vscode/test/auto-close-tags.test.js
create mode 100644 editors/vscode/test/vscode-host.js
create mode 100644 packages/csr/test/global-restore.ts
diff --git a/editors/vscode/src/auto-close-tags.js b/editors/vscode/src/auto-close-tags.js
new file mode 100644
index 00000000..754f9df2
--- /dev/null
+++ b/editors/vscode/src/auto-close-tags.js
@@ -0,0 +1,83 @@
+"use strict";
+
+const vscode = require("vscode");
+
+/**
+ * Auto-close tags as they are typed.
+ *
+ * LSP has no request for this, so the client watches document changes and asks
+ * the server whether the tag should close. The server owns the decision because
+ * void elements and already-closed tags must not be closed.
+ */
+function registerAutoCloseTags(context, client) {
+ const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
+ if (event.document.languageId !== "wrn") return;
+ if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true)) return;
+
+ const changes = event.contentChanges;
+ if (!changes.length) return;
+
+ const typed = changes[0].text;
+ if (typed !== ">" && typed !== "/") return;
+ // Every cursor must have typed the same trigger. A replaced selection
+ // (overtype, or select-and-type) is declined rather than guessed at.
+ if (!changes.every((change) => change.text === typed && change.rangeLength === 0)) return;
+
+ const editor = vscode.window.activeTextEditor;
+ if (!editor || editor.document !== event.document) return;
+
+ /*
+ * Positions come from the editor's selections, not from the changes.
+ *
+ * A change's `range` is in coordinates from before the whole event, so with
+ * several cursors on one line every range after the first is short by the
+ * insertions preceding it. The selections have already been adjusted for
+ * the edit, so they are where the carets actually are.
+ */
+ const positions = editor.selections.map((selection) => selection.active);
+ if (positions.length !== changes.length) return;
+ if (!editor.selections.every((selection) => selection.isEmpty)) return;
+
+ const documentVersion = event.document.version;
+ const snippets = await Promise.all(
+ positions.map((position) =>
+ client.sendRequest("wrn/tagComplete", {
+ textDocument: { uri: event.document.uri.toString() },
+ position: { line: position.line, character: position.character },
+ }),
+ ),
+ );
+
+ if (!snippets.every((snippet) => typeof snippet === "string" && snippet)) return;
+ /*
+ * One insertSnippet call carries one snippet, and it is the only form that
+ * keeps every caret: inserting sequentially would collapse the selection to
+ * the first snippet and invalidate the remaining positions. Cursors that
+ * want different closing tags are therefore declined rather than
+ * half-applied -- multi-cursor editing of matching lines, which is what
+ * this is for, produces one snippet for all of them.
+ */
+ if (!snippets.every((snippet) => snippet === snippets[0])) return;
+
+ // The user may have kept typing during the round-trip; re-validate everything the
+ // insertion depends on before touching the document, since a stale offset would
+ // silently corrupt it.
+ if (vscode.window.activeTextEditor !== editor) return;
+ if (editor.document !== event.document) return;
+ if (editor.document.version !== documentVersion) return;
+ if (editor.selections.length !== positions.length) return;
+ if (
+ !editor.selections.every(
+ (selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]),
+ )
+ ) {
+ return;
+ }
+
+ await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
+ });
+
+ context.subscriptions.push(listener);
+}
+
+module.exports = { registerAutoCloseTags };
diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs
index 8dced462..b7cf1aa8 100644
--- a/editors/vscode/src/extension.bundle.cjs
+++ b/editors/vscode/src/extension.bundle.cjs
@@ -1,4 +1,4 @@
-// WRN editor extension source hash: f74c11de70974caa6fb0cb4ee90ec39c10a924951733dadcc346b308be0e13d3
+// WRN editor extension source hash: 63bce75e2686c7586a3a08b8811ebc681d2265fdfe54e984630614e3dcef21f5
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
@@ -22701,10 +22701,63 @@ var require_main5 = __commonJS((exports2) => {
}
});
+// editors/vscode/src/auto-close-tags.js
+var require_auto_close_tags = __commonJS((exports2, module2) => {
+ var vscode = require("vscode");
+ function registerAutoCloseTags(context, client) {
+ const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
+ if (event.document.languageId !== "wrn")
+ return;
+ if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true))
+ return;
+ const changes = event.contentChanges;
+ if (!changes.length)
+ return;
+ const typed = changes[0].text;
+ if (typed !== ">" && typed !== "/")
+ return;
+ if (!changes.every((change) => change.text === typed && change.rangeLength === 0))
+ return;
+ const editor = vscode.window.activeTextEditor;
+ if (!editor || editor.document !== event.document)
+ return;
+ const positions = editor.selections.map((selection) => selection.active);
+ if (positions.length !== changes.length)
+ return;
+ if (!editor.selections.every((selection) => selection.isEmpty))
+ return;
+ const documentVersion = event.document.version;
+ const snippets = await Promise.all(positions.map((position) => client.sendRequest("wrn/tagComplete", {
+ textDocument: { uri: event.document.uri.toString() },
+ position: { line: position.line, character: position.character }
+ })));
+ if (!snippets.every((snippet) => typeof snippet === "string" && snippet))
+ return;
+ if (!snippets.every((snippet) => snippet === snippets[0]))
+ return;
+ if (vscode.window.activeTextEditor !== editor)
+ return;
+ if (editor.document !== event.document)
+ return;
+ if (editor.document.version !== documentVersion)
+ return;
+ if (editor.selections.length !== positions.length)
+ return;
+ if (!editor.selections.every((selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]))) {
+ return;
+ }
+ await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
+ });
+ context.subscriptions.push(listener);
+ }
+ module2.exports = { registerAutoCloseTags };
+});
+
// editors/vscode/src/extension.js
var path = require("node:path");
var vscode = require("vscode");
var { LanguageClient, TransportKind } = require_main5();
+var { registerAutoCloseTags } = require_auto_close_tags();
var WRN_LANGUAGE_ID = "wrn";
var client;
async function recoverWrnLanguage(document) {
@@ -22718,44 +22771,6 @@ async function recoverWrnLanguage(document) {
console.warn("[wrnexus] unable to recover .wrn language association:", error instanceof Error ? error.message : String(error));
}
}
-function registerAutoCloseTags(context, client2) {
- const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
- if (event.document.languageId !== "wrn")
- 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;
- if (change.rangeLength !== 0)
- return;
- const editor = vscode.window.activeTextEditor;
- if (!editor || editor.document !== event.document)
- return;
- const documentVersion = event.document.version;
- const position = change.range.start.translate(0, change.text.length);
- if (!editor.selection.isEmpty || !editor.selection.active.isEqual(position))
- return;
- const snippet = await client2.sendRequest("wrn/tagComplete", {
- textDocument: { uri: event.document.uri.toString() },
- position: { line: position.line, character: position.character }
- });
- if (typeof snippet !== "string" || !snippet)
- return;
- if (vscode.window.activeTextEditor !== editor)
- return;
- if (editor.document !== event.document)
- return;
- if (editor.document.version !== documentVersion)
- return;
- if (!editor.selection.isEmpty || !editor.selection.active.isEqual(position))
- return;
- await editor.insertSnippet(new vscode.SnippetString(snippet), position);
- });
- context.subscriptions.push(listener);
-}
async function activate(context) {
for (const document of vscode.workspace.textDocuments)
recoverWrnLanguage(document);
diff --git a/editors/vscode/src/extension.js b/editors/vscode/src/extension.js
index 0be65256..ff0a7e69 100644
--- a/editors/vscode/src/extension.js
+++ b/editors/vscode/src/extension.js
@@ -4,6 +4,7 @@
const path = require("node:path");
const vscode = require("vscode");
const { LanguageClient, TransportKind } = require("vscode-languageclient/node");
+const { registerAutoCloseTags } = require("./auto-close-tags.js");
const WRN_LANGUAGE_ID = "wrn";
/** @type {LanguageClient | undefined} */
@@ -23,56 +24,6 @@ async function recoverWrnLanguage(document) {
}
}
-/**
- * Auto-close tags as they are typed.
- *
- * LSP has no request for this, so the client watches document changes and asks
- * the server whether the tag should close. The server owns the decision because
- * void elements and already-closed tags must not be closed.
- */
-function registerAutoCloseTags(context, client) {
- const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
- 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`
- // an incorrect offset for both the query and the insertion; decline rather than guess.
- if (change.rangeLength !== 0) return;
-
- const editor = vscode.window.activeTextEditor;
- if (!editor || editor.document !== event.document) return;
-
- const documentVersion = event.document.version;
- const position = change.range.start.translate(0, change.text.length);
- if (!editor.selection.isEmpty || !editor.selection.active.isEqual(position)) return;
-
- const snippet = await client.sendRequest("wrn/tagComplete", {
- textDocument: { uri: event.document.uri.toString() },
- position: { line: position.line, character: position.character },
- });
- if (typeof snippet !== "string" || !snippet) return;
-
- // The user may have kept typing during the round-trip; re-validate everything the
- // insertion depends on before touching the document, since a stale offset would
- // silently corrupt it.
- if (vscode.window.activeTextEditor !== editor) return;
- if (editor.document !== event.document) return;
- if (editor.document.version !== documentVersion) return;
- if (!editor.selection.isEmpty || !editor.selection.active.isEqual(position)) return;
-
- await editor.insertSnippet(new vscode.SnippetString(snippet), position);
- });
-
- context.subscriptions.push(listener);
-}
-
/** @param {vscode.ExtensionContext} context */
async function activate(context) {
for (const document of vscode.workspace.textDocuments) void recoverWrnLanguage(document);
diff --git a/editors/vscode/test/auto-close-tags.test.js b/editors/vscode/test/auto-close-tags.test.js
new file mode 100644
index 00000000..65f5befc
--- /dev/null
+++ b/editors/vscode/test/auto-close-tags.test.js
@@ -0,0 +1,178 @@
+"use strict";
+
+const assert = require("node:assert");
+const { test } = require("node:test");
+const { installVsCodeHost } = require("./vscode-host.js");
+
+class Position {
+ constructor(line, character) {
+ this.line = line;
+ this.character = character;
+ }
+ translate(lineDelta, characterDelta) {
+ return new Position(this.line + lineDelta, this.character + characterDelta);
+ }
+ isEqual(other) {
+ return this.line === other.line && this.character === other.character;
+ }
+}
+
+class Selection {
+ constructor(active) {
+ this.active = active;
+ this.anchor = active;
+ this.isEmpty = true;
+ }
+}
+
+class SnippetString {
+ constructor(value) {
+ this.value = value;
+ }
+}
+
+let changeListener = null;
+const host = {
+ Position,
+ Selection,
+ SnippetString,
+ workspace: {
+ onDidChangeTextDocument(listener) {
+ changeListener = listener;
+ return { dispose() {} };
+ },
+ getConfiguration() {
+ return { get: (_key, fallback) => fallback };
+ },
+ },
+ window: { activeTextEditor: null },
+};
+
+const restoreHost = installVsCodeHost(host);
+const { registerAutoCloseTags } = require("../src/auto-close-tags.js");
+restoreHost();
+
+/**
+ * Drive the handler the way VS Code does: the document has already been
+ * updated and the carets moved by the time the change event fires.
+ */
+function scenario({ carets, snippetFor, typed = ">" }) {
+ const inserted = [];
+ const asked = [];
+ const document = { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } };
+ const editor = {
+ document,
+ selections: carets.map((caret) => new Selection(caret)),
+ insertSnippet(snippet, positions) {
+ inserted.push({ value: snippet.value, positions });
+ return Promise.resolve(true);
+ },
+ };
+ editor.selection = editor.selections[0];
+ host.window.activeTextEditor = editor;
+
+ const client = {
+ sendRequest(_method, params) {
+ asked.push(params.position);
+ return Promise.resolve(snippetFor(params.position));
+ },
+ };
+
+ registerAutoCloseTags({ subscriptions: [] }, client);
+
+ return {
+ inserted,
+ asked,
+ fire: () =>
+ changeListener({
+ document,
+ // Pre-edit coordinates, deliberately not usable as caret positions.
+ contentChanges: carets.map(() => ({
+ text: typed,
+ rangeLength: 0,
+ range: { start: new Position(0, 0) },
+ })),
+ }),
+ };
+}
+
+test("closes the tag at a single caret", async () => {
+ const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0" });
+ await run.fire();
+
+ assert.equal(run.inserted.length, 1);
+ assert.equal(run.inserted[0].value, "$0");
+ assert.deepEqual(
+ run.inserted[0].positions.map((p) => [p.line, p.character]),
+ [[1, 8]],
+ );
+});
+
+test("closes the tag at every caret in one insertion", async () => {
+ // One insertSnippet call is what keeps all the carets alive: inserting
+ // sequentially would collapse the selection to the first snippet.
+ const run = scenario({
+ carets: [new Position(1, 8), new Position(2, 8), new Position(3, 8)],
+ snippetFor: () => "$0",
+ });
+ await run.fire();
+
+ assert.equal(run.asked.length, 3);
+ assert.equal(run.inserted.length, 1);
+ assert.deepEqual(
+ run.inserted[0].positions.map((p) => [p.line, p.character]),
+ [
+ [1, 8],
+ [2, 8],
+ [3, 8],
+ ],
+ );
+});
+
+test("asks about each caret's own position rather than the change ranges", async () => {
+ // Every contentChange above reports (0, 0). Using those would query and
+ // insert at the wrong offsets once more than one caret is on a line.
+ const run = scenario({
+ carets: [new Position(4, 12), new Position(9, 3)],
+ snippetFor: () => "$0
",
+ });
+ await run.fire();
+
+ assert.deepEqual(
+ run.asked.map((p) => [p.line, p.character]),
+ [
+ [4, 12],
+ [9, 3],
+ ],
+ );
+});
+
+test("declines when the carets want different closing tags", async () => {
+ const run = scenario({
+ carets: [new Position(1, 8), new Position(2, 8)],
+ snippetFor: (position) => (position.line === 1 ? "$0" : "$0"),
+ });
+ await run.fire();
+
+ assert.equal(run.inserted.length, 0);
+});
+
+test("declines when any caret has no tag to close", async () => {
+ const run = scenario({
+ carets: [new Position(1, 8), new Position(2, 8)],
+ snippetFor: (position) => (position.line === 1 ? "$0" : null),
+ });
+ await run.fire();
+
+ assert.equal(run.inserted.length, 0);
+});
+
+test("declines a replaced selection", async () => {
+ const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0" });
+ await changeListener({
+ document: { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } },
+ contentChanges: [{ text: ">", rangeLength: 3, range: { start: new Position(1, 5) } }],
+ });
+
+ assert.equal(run.inserted.length, 0);
+});
diff --git a/editors/vscode/test/completion-scope.test.js b/editors/vscode/test/completion-scope.test.js
index bc453928..59bb3f6c 100644
--- a/editors/vscode/test/completion-scope.test.js
+++ b/editors/vscode/test/completion-scope.test.js
@@ -2,54 +2,47 @@
const test = require("node:test");
const assert = require("node:assert");
-const Module = require("node:module");
+const { installVsCodeHost } = require("./vscode-host.js");
-// Mock the vscode module for unit tests
-const originalLoad = Module._load;
-Module._load = function load(request, parent, isMain) {
- if (request === "vscode") {
- return {
- Position: class Position {
- constructor(line, character) {
- this.line = line;
- this.character = character;
- }
- },
- Range: class Range {
- constructor(start, end) {
- this.start = start;
- this.end = end;
- }
- },
- CompletionItem: class CompletionItem {
- constructor(label, kind) {
- this.label = label;
- this.kind = kind;
- }
- },
- CompletionItemKind: {
- Event: 23,
- Property: 10,
- Function: 12,
- Keyword: 14,
- Variable: 13,
- },
- SnippetString: class SnippetString {
- constructor(text) {
- this.value = text;
- }
- },
- MarkdownString: class MarkdownString {
- constructor(text) {
- this.value = text;
- }
- },
- };
- }
- return originalLoad.call(this, request, parent, isMain);
-};
+const restoreHost = installVsCodeHost({
+ Position: class Position {
+ constructor(line, character) {
+ this.line = line;
+ this.character = character;
+ }
+ },
+ Range: class Range {
+ constructor(start, end) {
+ this.start = start;
+ this.end = end;
+ }
+ },
+ CompletionItem: class CompletionItem {
+ constructor(label, kind) {
+ this.label = label;
+ this.kind = kind;
+ }
+ },
+ CompletionItemKind: {
+ Event: 23,
+ Property: 10,
+ Function: 12,
+ Keyword: 14,
+ Variable: 13,
+ },
+ SnippetString: class SnippetString {
+ constructor(text) {
+ this.value = text;
+ }
+ },
+ MarkdownString: class MarkdownString {
+ constructor(text) {
+ this.value = text;
+ }
+ },
+});
const { isInsideViewBlock, provideCompletionItems } = require("../src/completion.js");
-Module._load = originalLoad;
+restoreHost();
const PAGE = `page Home {
view {
diff --git a/editors/vscode/test/completion.test.js b/editors/vscode/test/completion.test.js
index 8986664a..b6fd73cb 100644
--- a/editors/vscode/test/completion.test.js
+++ b/editors/vscode/test/completion.test.js
@@ -2,17 +2,13 @@
const assert = require("node:assert");
const { test } = require("node:test");
-const Module = require("node:module");
+const { installVsCodeHost } = require("./vscode-host.js");
// These extraction helpers are pure, but their module also registers VS Code
// providers at runtime. Supply a minimal host shim for unit tests.
-const originalLoad = Module._load;
-Module._load = function load(request, parent, isMain) {
- if (request === "vscode") return {};
- return originalLoad.call(this, request, parent, isMain);
-};
+const restoreHost = installVsCodeHost({});
const { extractRouteParams, extractStates } = require("../src/completion");
-Module._load = originalLoad;
+restoreHost();
test("extracts dynamic route params from filename", () => {
const document = {
diff --git a/editors/vscode/test/diagnostics.test.js b/editors/vscode/test/diagnostics.test.js
index 64568625..dfae11a2 100644
--- a/editors/vscode/test/diagnostics.test.js
+++ b/editors/vscode/test/diagnostics.test.js
@@ -2,30 +2,24 @@
const assert = require("node:assert");
const { test } = require("node:test");
-const Module = require("node:module");
+const { installVsCodeHost } = require("./vscode-host.js");
-const originalLoad = Module._load;
-Module._load = function load(request, parent, isMain) {
- if (request === "vscode") {
- return {
- Diagnostic: class Diagnostic {
- constructor(range, message, severity) {
- this.range = range;
- this.message = message;
- this.severity = severity;
- }
- },
- DiagnosticSeverity: { Error: 0, Warning: 1 },
- Range: class Range {
- constructor(start, end) {
- this.start = start;
- this.end = end;
- }
- },
- };
- }
- return originalLoad.call(this, request, parent, isMain);
-};
+const restoreHost = installVsCodeHost({
+ Diagnostic: class Diagnostic {
+ constructor(range, message, severity) {
+ this.range = range;
+ this.message = message;
+ this.severity = severity;
+ }
+ },
+ DiagnosticSeverity: { Error: 0, Warning: 1 },
+ Range: class Range {
+ constructor(start, end) {
+ this.start = start;
+ this.end = end;
+ }
+ },
+});
const {
findTopLevelDeclaration,
maskLeadingTrivia,
@@ -34,7 +28,7 @@ const {
validateLayoutUsage,
validateRootMembers,
} = require("../src/diagnostics");
-Module._load = originalLoad;
+restoreHost();
function mockDocument() {
return {
diff --git a/editors/vscode/test/vscode-host.js b/editors/vscode/test/vscode-host.js
new file mode 100644
index 00000000..04e66741
--- /dev/null
+++ b/editors/vscode/test/vscode-host.js
@@ -0,0 +1,39 @@
+"use strict";
+
+/**
+ * Supply a stub `vscode` host so extension sources can be unit tested.
+ *
+ * These files run under `node --test` (see the package's test script), where
+ * patching `Module._load` is enough. A bare `bun test` from the repository
+ * root also picks them up by filename, and Bun resolves `require` through its
+ * own resolver without consulting `Module._load` -- so under Bun the same
+ * files failed with "Cannot find package 'vscode'". Registering a virtual
+ * module covers that case, leaving one shim that works under both runners.
+ *
+ * Returns a function restoring the original loader.
+ */
+function installVsCodeHost(stub) {
+ const Module = require("node:module");
+
+ if (typeof Bun !== "undefined") {
+ require("bun").plugin({
+ name: "vscode-host-stub",
+ setup(build) {
+ build.module("vscode", () => ({ exports: stub, loader: "object" }));
+ },
+ });
+ return () => {};
+ }
+
+ const originalLoad = Module._load;
+ Module._load = function load(request, parent, isMain) {
+ if (request === "vscode") return stub;
+ return originalLoad.call(this, request, parent, isMain);
+ };
+
+ return () => {
+ Module._load = originalLoad;
+ };
+}
+
+module.exports = { installVsCodeHost };
diff --git a/packages/csr/src/index.ts b/packages/csr/src/index.ts
index 2d494a89..4b2ea357 100644
--- a/packages/csr/src/index.ts
+++ b/packages/csr/src/index.ts
@@ -127,6 +127,14 @@ export function getComponentControllerRuntime(development = false): string {
var emitPinInputEvent = bridge.emitPinInputEvent;
var parseScopeDecl = bridge.parseScopeDecl;
var warnOnce = bridge.warn || function () {};
+ // The extracted sections use these the same way the core runtime does, and
+ // this bundle is a separate IIFE, so it needs its own copies.
+ function hasOwn(target, key) {
+ return Object.prototype.hasOwnProperty.call(target, key);
+ }
+ function toArray(value) {
+ return Array.prototype.slice.call(value);
+ }
${sections}
function hydrate(root) {
var host = root || document;
diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts
index c0813f3f..02510787 100644
--- a/packages/csr/src/reactive-runtime.ts
+++ b/packages/csr/src/reactive-runtime.ts
@@ -26,58 +26,78 @@ export const REACTIVE_RUNTIME = String.raw`
var behaviorObserver;
var clientModuleCache = new Map();
+ /*
+ * Two builtin chains the runtime reaches for constantly. Aliasing them is
+ * not only shorter: hasOwn keeps prototype keys from reading as data, and
+ * toArray is needed because a NodeList is not an Array.
+ */
+ function hasOwn(target, key) {
+ return Object.prototype.hasOwnProperty.call(target, key);
+ }
+
+ function toArray(value) {
+ return Array.prototype.slice.call(value);
+ }
+
+ /*
+ * data-wrn-class-* and data-wrn-bind-* both carry a JSON ["name","expression"]
+ * pair. Malformed markup yields null so every caller bails the same way
+ * rather than each repeating the parse and the shape check.
+ */
+ function pairBinding(value) {
+ var parsed;
+
+ try {
+ parsed = JSON.parse(value);
+ } catch (error) {
+ return null;
+ }
+
+ return parsed && parsed.length === 2 ? parsed : null;
+ }
+
/*
* Globals the expression engine resolves for client code. Kept as explicit
- * tables rather than falling through to window[name]: an implicit fallback
+ * lists rather than falling through to window[name]: an implicit fallback
* would let any expression reach every global on the page (and would make a
* typo silently resolve to some unrelated window property) -- these lists
* say exactly what client code may reach.
*
- * dialogGlobals must be bound to window or the browser throws
- * "Illegal invocation" when they are called detached.
+ * Prototype-less so a name like "toString" or "constructor" is a miss
+ * rather than a hit on Object.prototype.
*/
- var dialogGlobals = {
- alert: 1,
- confirm: 1,
- prompt: 1,
- fetch: 1,
- print: 1,
- open: 1,
- scrollTo: 1,
- scrollBy: 1,
- matchMedia: 1,
- getComputedStyle: 1,
- structuredClone: 1,
- queueMicrotask: 1,
- btoa: 1,
- atob: 1,
- };
+ function nameSet(names) {
+ var set = Object.create(null);
- // Language builtins. Wrapped in thunks so referencing one that a given
- // engine lacks cannot throw at table-definition time.
- var jsGlobals = {
- Object: function () { return Object; },
- Boolean: function () { return Boolean; },
- RegExp: function () { return RegExp; },
- Promise: function () { return typeof Promise === "undefined" ? undefined : Promise; },
- Set: function () { return typeof Set === "undefined" ? undefined : Set; },
- Map: function () { return typeof Map === "undefined" ? undefined : Map; },
- Error: function () { return Error; },
- Symbol: function () { return typeof Symbol === "undefined" ? undefined : Symbol; },
- BigInt: function () { return typeof BigInt === "undefined" ? undefined : BigInt; },
- Intl: function () { return typeof Intl === "undefined" ? undefined : Intl; },
- parseInt: function () { return parseInt; },
- parseFloat: function () { return parseFloat; },
- isNaN: function () { return isNaN; },
- isFinite: function () { return isFinite; },
- encodeURIComponent: function () { return encodeURIComponent; },
- decodeURIComponent: function () { return decodeURIComponent; },
- encodeURI: function () { return encodeURI; },
- decodeURI: function () { return decodeURI; },
- NaN: function () { return NaN; },
- Infinity: function () { return Infinity; },
- undefined: function () { return undefined; },
- };
+ names.split(" ").forEach(function (name) {
+ set[name] = 1;
+ });
+
+ return set;
+ }
+
+ /*
+ * Called with window as the receiver. Detached, the browser throws
+ * "Illegal invocation" for these.
+ */
+ var boundWindowGlobals = nameSet(
+ "alert confirm prompt fetch print open scrollTo scrollBy matchMedia" +
+ " getComputedStyle structuredClone queueMicrotask btoa atob" +
+ " setTimeout clearTimeout setInterval clearInterval" +
+ " requestAnimationFrame cancelAnimationFrame",
+ );
+
+ /*
+ * Language builtins and other realm globals, read off globalThis. Naming
+ * them rather than referencing them directly means one an engine lacks
+ * resolves to undefined instead of throwing where the table is defined.
+ */
+ var ambientGlobals = nameSet(
+ "Object Boolean RegExp Promise Set Map Error Symbol BigInt Intl parseInt" +
+ " parseFloat isNaN isFinite encodeURIComponent decodeURIComponent" +
+ " encodeURI decodeURI NaN Infinity undefined Array Number String Math" +
+ " JSON Date URL",
+ );
/*
* toast(...) -- raise a notification from any client expression.
@@ -154,27 +174,12 @@ export const REACTIVE_RUNTIME = String.raw`
if (!window.toast) window.toast = toastApi;
// Read straight off window, no binding needed (objects, not functions).
- var windowGlobals = {
- localStorage: 1,
- sessionStorage: 1,
- screen: 1,
- performance: 1,
- crypto: 1,
- CustomEvent: 1,
- Event: 1,
- FormData: 1,
- URLSearchParams: 1,
- AbortController: 1,
- Notification: 1,
- IntersectionObserver: 1,
- ResizeObserver: 1,
- MutationObserver: 1,
- devicePixelRatio: 1,
- innerWidth: 1,
- innerHeight: 1,
- scrollX: 1,
- scrollY: 1,
- };
+ var windowGlobals = nameSet(
+ "localStorage sessionStorage screen performance crypto CustomEvent Event" +
+ " FormData URLSearchParams AbortController Notification" +
+ " IntersectionObserver ResizeObserver MutationObserver devicePixelRatio" +
+ " innerWidth innerHeight scrollX scrollY location history navigator",
+ );
function reportDiagnostic(code, message, element, detail) {
var payload = {
@@ -1071,7 +1076,7 @@ export const REACTIVE_RUNTIME = String.raw`
var serverProxy = new Proxy({}, {
get: function (_target, property) {
return function () {
- return callServerFunction(componentRpcName, String(property), Array.prototype.slice.call(arguments));
+ return callServerFunction(componentRpcName, String(property), toArray(arguments));
};
},
});
@@ -1111,7 +1116,7 @@ export const REACTIVE_RUNTIME = String.raw`
if (name === "server") return serverProxy;
if (name === "props") return propsProxy;
if (name === "refs") return refsProxy;
- if (Object.prototype.hasOwnProperty.call(moduleBindings, name)) return moduleBindings[name];
+ if (hasOwn(moduleBindings, name)) return moduleBindings[name];
if (name === "$emit") {
return function (eventName, detail) {
return dispatchComponentEvent(componentEventTarget, eventName, detail);
@@ -1120,26 +1125,10 @@ export const REACTIVE_RUNTIME = String.raw`
if (name === "window") return window;
if (name === "document") return document;
if (name === "console") return console;
- if (name === "Array") return Array;
- if (name === "Number") return Number;
- if (name === "String") return String;
- if (name === "Math") return Math;
- if (name === "JSON") return JSON;
- if (name === "Date") return Date;
- if (name === "URL") return URL;
- if (name === "location") return window.location;
- if (name === "history") return window.history;
- if (name === "navigator") return window.navigator;
if (name === "$route" || name === "route") {
if (currentRenderer) routeValue.subscribe(currentRenderer);
return routeValue.get();
}
- if (name === "setTimeout") return window.setTimeout.bind(window);
- if (name === "clearTimeout") return window.clearTimeout.bind(window);
- if (name === "setInterval") return window.setInterval.bind(window);
- if (name === "clearInterval") return window.clearInterval.bind(window);
- if (name === "requestAnimationFrame") return window.requestAnimationFrame.bind(window);
- if (name === "cancelAnimationFrame") return window.cancelAnimationFrame.bind(window);
/*
* Ordinary browser and language globals.
*
@@ -1156,11 +1145,11 @@ export const REACTIVE_RUNTIME = String.raw`
* lacks one of these does not break the rest.
*/
if (name === "toast") return toastApi;
- if (dialogGlobals[name] && typeof window[name] === "function") {
+ if (boundWindowGlobals[name] && typeof window[name] === "function") {
return window[name].bind(window);
}
- if (jsGlobals[name]) {
- var builtin = jsGlobals[name]();
+ if (ambientGlobals[name]) {
+ var builtin = globalThis[name];
if (builtin !== undefined) return builtin;
}
if (windowGlobals[name]) {
@@ -1174,7 +1163,7 @@ export const REACTIVE_RUNTIME = String.raw`
}
function readScope(name) {
- if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
+ if (hasOwn(computedDefinitions, name)) {
if (computing.has(name)) {
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
return undefined;
@@ -1191,18 +1180,18 @@ export const REACTIVE_RUNTIME = String.raw`
if (currentRenderer) sig.subscribe(currentRenderer);
return sig.get();
}
- if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
+ if (hasOwn(behaviorFunctions, name)) {
return behaviorFunctions[name];
}
return readGlobal(name);
}
function peekScope(name) {
- if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
+ if (hasOwn(computedDefinitions, name)) {
return readScope(name);
}
if (signals[name]) return signals[name].get();
- if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
+ if (hasOwn(behaviorFunctions, name)) {
return behaviorFunctions[name];
}
return readGlobal(name);
@@ -1265,7 +1254,7 @@ export const REACTIVE_RUNTIME = String.raw`
function evalExpr(expr, locals) {
return evaluateExpression(expr, function (name) {
- if (locals && Object.prototype.hasOwnProperty.call(locals, name)) {
+ if (locals && hasOwn(locals, name)) {
return locals[name];
}
return readScope(name);
@@ -1299,8 +1288,7 @@ export const REACTIVE_RUNTIME = String.raw`
function (name) {
if (
locals &&
- Object.prototype
- .hasOwnProperty.call(
+ hasOwn(
locals,
name,
)
@@ -1313,8 +1301,7 @@ export const REACTIVE_RUNTIME = String.raw`
function (name, value) {
if (
locals &&
- Object.prototype
- .hasOwnProperty.call(
+ hasOwn(
locals,
name,
)
@@ -1506,19 +1493,13 @@ export const REACTIVE_RUNTIME = String.raw`
}
return type + ":" + String(value);
}
- function fillMustache(str, itemEval) {
- return str.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, d, s) {
- var e = (d || s).trim();
- try { return String(itemEval(e)); } catch (err) { return ""; }
- });
- }
function hydrateItem(
root,
locals,
) {
function localRead(name) {
if (
- Object.prototype.hasOwnProperty.call(
+ hasOwn(
locals,
name,
)
@@ -1602,7 +1583,7 @@ export const REACTIVE_RUNTIME = String.raw`
if (node !== root && insideNestedLoop(node)) return;
var attributes =
- Array.prototype.slice.call(
+ toArray(
node.attributes,
);
@@ -1644,28 +1625,12 @@ export const REACTIVE_RUNTIME = String.raw`
"data-wrn-class-",
) === 0
) {
- var classBinding;
+ var classBinding = pairBinding(attribute.value);
- try {
- classBinding = JSON.parse(
- attribute.value,
- );
- } catch (_) {
- return;
- }
+ if (!classBinding) return;
- if (
- !classBinding ||
- classBinding.length !== 2
- ) {
- return;
- }
-
- var className =
- classBinding[0];
-
- var classExpression =
- classBinding[1];
+ var className = classBinding[0];
+ var classExpression = classBinding[1];
var classEnabled = false;
@@ -1695,28 +1660,13 @@ export const REACTIVE_RUNTIME = String.raw`
) === 0
) {
node.removeAttribute(attribute.name);
- var binding;
- try {
- binding = JSON.parse(
- attribute.value,
- );
- } catch (_) {
- return;
- }
+ var binding = pairBinding(attribute.value);
- if (
- !binding ||
- binding.length !== 2
- ) {
- return;
- }
+ if (!binding) return;
- var attributeName =
- binding[0];
-
- var attributeTemplate =
- binding[1];
+ var attributeName = binding[0];
+ var attributeTemplate = binding[1];
/*
* Reactive, not resolved once. The expression can read component
@@ -1784,7 +1734,7 @@ export const REACTIVE_RUNTIME = String.raw`
eventLocals.event = event;
eventLocals.$event = event;
- eventLocals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
+ eventLocals.payload = event && hasOwn(event, "detail") ? event.detail : undefined;
try {
runStmt(
@@ -1936,8 +1886,7 @@ export const REACTIVE_RUNTIME = String.raw`
// Hand every nested loop its own renderer, with this item in scope.
if (root.querySelectorAll) {
- Array.prototype.slice
- .call(root.querySelectorAll("[data-for]"))
+ toArray(root.querySelectorAll("[data-for]"))
.forEach(function (nested) {
// Only the outermost nested templates: deeper ones are set up by
// their own parent when it renders.
@@ -2011,12 +1960,12 @@ export const REACTIVE_RUNTIME = String.raw`
var firstRun = !outerLocals;
function controlRead(name) {
- return Object.prototype.hasOwnProperty.call(inherited, name) ? inherited[name] : readScope(name);
+ return hasOwn(inherited, name) ? inherited[name] : readScope(name);
}
function controlEval(expression, locals) {
return evaluateExpression(expression, function (name) {
- return locals && Object.prototype.hasOwnProperty.call(locals, name)
+ return locals && hasOwn(locals, name)
? locals[name]
: controlRead(name);
});
@@ -2033,7 +1982,7 @@ export const REACTIVE_RUNTIME = String.raw`
var template = document.createElement("template");
template.innerHTML = markup || "";
var fragment = template.content;
- var elements = Array.prototype.slice.call(fragment.childNodes).filter(function (node) {
+ var elements = toArray(fragment.childNodes).filter(function (node) {
return node.nodeType === 1;
});
if (rangeEnd) block.parentNode.insertBefore(fragment, rangeEnd);
@@ -2100,7 +2049,7 @@ export const REACTIVE_RUNTIME = String.raw`
});
}
- Array.prototype.slice.call(el.querySelectorAll("[data-wrn-if],[data-wrn-each]")).forEach(function (block) {
+ toArray(el.querySelectorAll("[data-wrn-if],[data-wrn-each]")).forEach(function (block) {
if (block.parentElement && block.parentElement.closest("[data-wrn-if],[data-wrn-each]")) return;
setupControlBlock(block, null);
});
@@ -2132,7 +2081,7 @@ export const REACTIVE_RUNTIME = String.raw`
}
function loopRead(name) {
- if (Object.prototype.hasOwnProperty.call(inherited, name)) {
+ if (hasOwn(inherited, name)) {
return inherited[name];
}
return readScope(name);
@@ -2305,7 +2254,7 @@ export const REACTIVE_RUNTIME = String.raw`
rawKey = evaluateExpression(
keyExpression,
function (name) {
- return Object.prototype.hasOwnProperty.call(keyedLocals, name)
+ return hasOwn(keyedLocals, name)
? keyedLocals[name]
: loopRead(name);
},
@@ -2378,8 +2327,7 @@ export const REACTIVE_RUNTIME = String.raw`
}
- Array.prototype.slice
- .call(el.querySelectorAll("[data-for]"))
+ toArray(el.querySelectorAll("[data-for]"))
.forEach(function (tpl) {
// Only top-level templates here; nested ones are connected by the item
// that contains them, once it has values to give them.
@@ -2475,24 +2423,18 @@ export const REACTIVE_RUNTIME = String.raw`
// Conditional class bindings emitted as:
// data-wrn-class-*='["class-name","expression"]'
var classBindNodes = [el].concat(
- Array.prototype.slice.call(el.querySelectorAll("*")),
+ toArray(el.querySelectorAll("*")),
);
classBindNodes.forEach(function (node) {
if (!owns(node)) return;
- Array.prototype.slice.call(node.attributes).forEach(function (marker) {
+ toArray(node.attributes).forEach(function (marker) {
if (marker.name.indexOf("data-wrn-class-") !== 0) return;
- var binding;
+ var binding = pairBinding(marker.value);
- try {
- binding = JSON.parse(marker.value);
- } catch (e) {
- return;
- }
-
- if (!binding || binding.length !== 2) return;
+ if (!binding) return;
var className = binding[0];
var expression = binding[1];
@@ -2521,7 +2463,7 @@ export const REACTIVE_RUNTIME = String.raw`
// [attributeName, originalTemplate], preserving an SSR value while allowing
// state changes to update type, aria-*, class, href, and other attributes.
var bindNodes = [el].concat(
- Array.prototype.slice.call(
+ toArray(
el.querySelectorAll("*"),
),
);
@@ -2529,8 +2471,7 @@ export const REACTIVE_RUNTIME = String.raw`
bindNodes.forEach(function (node) {
if (!owns(node)) return;
- Array.prototype.slice
- .call(node.attributes)
+ toArray(node.attributes)
.forEach(function (marker) {
if (
marker.name.indexOf(
@@ -2541,22 +2482,10 @@ export const REACTIVE_RUNTIME = String.raw`
}
node.removeAttribute(marker.name);
- var binding;
- try {
- binding = JSON.parse(
- marker.value,
- );
- } catch (error) {
- return;
- }
+ var binding = pairBinding(marker.value);
- if (
- !binding ||
- binding.length !== 2
- ) {
- return;
- }
+ if (!binding) return;
var name = binding[0];
var template = binding[1];
@@ -2648,10 +2577,10 @@ export const REACTIVE_RUNTIME = String.raw`
}
// Event handlers on elements, window, and document.
- var nodes = [el].concat(Array.prototype.slice.call(el.querySelectorAll("*")));
+ var nodes = [el].concat(toArray(el.querySelectorAll("*")));
nodes.forEach(function (node) {
if (!owns(node)) return;
- Array.prototype.slice.call(node.attributes).forEach(function (attr) {
+ toArray(node.attributes).forEach(function (attr) {
if (attr.name.indexOf("data-on-") !== 0) return;
var rawName = attr.name.slice("data-on-".length);
@@ -2678,7 +2607,7 @@ export const REACTIVE_RUNTIME = String.raw`
locals.event = event;
locals.$event = event;
- locals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
+ locals.payload = event && hasOwn(event, "detail") ? event.detail : undefined;
try {
runStmt(
@@ -2727,8 +2656,7 @@ export const REACTIVE_RUNTIME = String.raw`
// function only exists out here. The compiler emits these as data-wrn-out-* so the
// two cases stay distinguishable, and this scope claims every one that
// sits on a component it directly mounts.
- Array.prototype.slice
- .call(el.querySelectorAll("[data-wrn-events]"))
+ toArray(el.querySelectorAll("[data-wrn-events]"))
.forEach(function (node) {
var componentRoot = closestScope(node);
if (!componentRoot || componentRoot === el) return;
@@ -2745,7 +2673,7 @@ export const REACTIVE_RUNTIME = String.raw`
node.__wrnexusOutputHandlers ||
(node.__wrnexusOutputHandlers = {});
- Array.prototype.slice.call(node.attributes).forEach(function (attr) {
+ toArray(node.attributes).forEach(function (attr) {
if (attr.name.indexOf("data-wrn-out-") !== 0) return;
var outName = attr.name.slice("data-wrn-out-".length);
@@ -2783,7 +2711,7 @@ export const REACTIVE_RUNTIME = String.raw`
locals.event = event;
locals.$event = event;
locals.payload =
- event && Object.prototype.hasOwnProperty.call(event, "detail")
+ event && hasOwn(event, "detail")
? event.detail
: undefined;
try {
@@ -2805,16 +2733,14 @@ export const REACTIVE_RUNTIME = String.raw`
// Prop expressions belong to the parent that mounted the component. The
// server forwards these markers onto the rendered child root; evaluate
// them here and write changes into the child's prop signals.
- Array.prototype.slice
- .call(el.querySelectorAll("*"))
+ toArray(el.querySelectorAll("*"))
.filter(isScopeRoot)
.forEach(function (node) {
if (!node.parentNode || ownerScope(node.parentNode) !== el) return;
- Array.prototype.slice.call(node.attributes).forEach(function (attr) {
+ toArray(node.attributes).forEach(function (attr) {
if (attr.name.indexOf("data-wrn-prop-bind-") !== 0) return;
- var binding;
- try { binding = JSON.parse(attr.value); } catch (_) { return; }
- if (!binding || binding.length !== 2) return;
+ var binding = pairBinding(attr.value);
+ if (!binding) return;
var propName = binding[0];
var template = binding[1];
reactive(function () {
@@ -3168,8 +3094,7 @@ export const REACTIVE_RUNTIME = String.raw`
var host = root && root.querySelectorAll ? root : document;
anchoredWriting = true;
try {
- Array.prototype.slice
- .call(host.querySelectorAll(ANCHORED_SELECTOR))
+ toArray(host.querySelectorAll(ANCHORED_SELECTOR))
.forEach(clampAnchored);
} finally {
// Released on a timer, not requestAnimationFrame. rAF does not fire in
@@ -3703,8 +3628,7 @@ export const REACTIVE_RUNTIME = String.raw`
// therefore no client-side binding to retain; consume its compiler markers
// separately from component hydration.
if (host === document || host === document.documentElement) {
- Array.prototype.slice
- .call(document.documentElement.attributes)
+ toArray(document.documentElement.attributes)
.forEach(function (attribute) {
if (attribute.name.indexOf("data-wrn-bind-") === 0) {
document.documentElement.removeAttribute(attribute.name);
@@ -4346,7 +4270,7 @@ export const REACTIVE_RUNTIME = String.raw`
function emitPinInputEvent(root, name, extra) {
var hidden = root.querySelector("[data-pin-value]");
- var cells = Array.prototype.slice.call(root.querySelectorAll("[data-pin-cell]"));
+ var cells = toArray(root.querySelectorAll("[data-pin-cell]"));
var value = hidden ? hidden.value : "";
var detail = {
component: "PinInput",
@@ -4368,7 +4292,7 @@ export const REACTIVE_RUNTIME = String.raw`
/*__WRNEXUS_CONTROLLERS_PIN_START__*/
function setupPinInputController(root) {
if (!root || root.__wrnexusPinInputController) return;
- var cells = Array.prototype.slice.call(root.querySelectorAll("[data-pin-cell]"));
+ var cells = toArray(root.querySelectorAll("[data-pin-cell]"));
var hidden = root.querySelector("[data-pin-value]");
var clearButton = root.querySelector("[data-pin-clear]");
var patternSource = root.getAttribute("data-pattern") || "[0-9]";
@@ -5960,7 +5884,7 @@ export const REACTIVE_RUNTIME = String.raw`
host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) {
if (element.__wrnDynamicMounted) return;
element.__wrnDynamicMounted = true;
- var cases = Array.prototype.slice.call(element.children).filter(function (candidate) {
+ var cases = toArray(element.children).filter(function (candidate) {
return candidate.hasAttribute("data-component-case");
}).map(function (candidate) {
var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || ""));
diff --git a/packages/csr/test/global-restore.ts b/packages/csr/test/global-restore.ts
new file mode 100644
index 00000000..0f3c684b
--- /dev/null
+++ b/packages/csr/test/global-restore.ts
@@ -0,0 +1,28 @@
+import { afterAll } from "bun:test";
+
+/**
+ * Restore globals a suite replaces, once the suite is done.
+ *
+ * These suites install a happy-dom window over the real globals and delete
+ * them before each test so every test starts clean. bun test loads and runs
+ * one file at a time rather than importing them all up front, so anything left
+ * deleted is still missing when the next suite runs -- which is how `bun test`
+ * with no argument came to fail unrelated files with "fetch is not a
+ * function". Names absent at capture time are deleted again rather than being
+ * restored as undefined, so a global that never existed does not gain a key.
+ */
+export function restoreGlobalsAfterAll(names: readonly string[]): void {
+ const captured = new Map(
+ names.map((name) => [name, (globalThis as Record)[name]]),
+ );
+
+ afterAll(() => {
+ for (const [name, value] of captured) {
+ if (value === undefined) {
+ delete (globalThis as Record)[name];
+ } else {
+ (globalThis as Record)[name] = value;
+ }
+ }
+ });
+}
diff --git a/packages/csr/test/nav.test.ts b/packages/csr/test/nav.test.ts
index 3cdceae8..453999bd 100644
--- a/packages/csr/test/nav.test.ts
+++ b/packages/csr/test/nav.test.ts
@@ -1,6 +1,7 @@
import { test, expect, beforeEach } from "bun:test";
import { Window } from "happy-dom";
import { NAV_RUNTIME } from "../src/nav-runtime.ts";
+import { restoreGlobalsAfterAll } from "./global-restore.ts";
let win: any;
let fetchCalls: { url: string; opts: any }[];
@@ -36,18 +37,22 @@ function install(bodyHtml: string): void {
const flush = () => new Promise((r) => setTimeout(r, 0));
+const REPLACED_GLOBALS = [
+ "window",
+ "document",
+ "history",
+ "location",
+ "DOMParser",
+ "CustomEvent",
+ "Event",
+ "fetch",
+];
+
+restoreGlobalsAfterAll(REPLACED_GLOBALS);
+
beforeEach(() => {
const g = globalThis as any;
- for (const k of [
- "window",
- "document",
- "history",
- "location",
- "DOMParser",
- "CustomEvent",
- "Event",
- "fetch",
- ]) {
+ for (const k of REPLACED_GLOBALS) {
delete g[k];
}
});
diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts
index 4b5e3626..b68ad915 100644
--- a/packages/csr/test/reactive.test.ts
+++ b/packages/csr/test/reactive.test.ts
@@ -3,6 +3,7 @@ import { Window } from "happy-dom";
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
import { mountHtml } from "@wrnexus/test";
+import { restoreGlobalsAfterAll } from "./global-restore.ts";
// Fresh DOM per test, with the runtime's globals bound.
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window {
@@ -34,12 +35,14 @@ function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Wind
return win as unknown as Window;
}
+const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "MutationObserver"];
+
+restoreGlobalsAfterAll(REPLACED_GLOBALS);
+
beforeEach(() => {
- delete (globalThis as Record).window;
- delete (globalThis as Record).document;
- delete (globalThis as Record).location;
- delete (globalThis as Record).fetch;
- delete (globalThis as Record).MutationObserver;
+ for (const name of REPLACED_GLOBALS) {
+ delete (globalThis as Record)[name];
+ }
});
test("split runtime hydrates a controller only from the controller asset", () => {
@@ -1758,3 +1761,23 @@ test("an unbounded loop stops instead of hanging the page", () => {
expect(value).toBeGreaterThan(0);
expect(Number.isFinite(value)).toBe(true);
});
+
+test("a class binding inside data-for follows state the row never mentions", () => {
+ // The row's own array is untouched, so nothing rebuilds the list. The
+ // binding has to be reactive in its own right to keep up.
+ const binding = JSON.stringify(["is-active", "selected === row.id"]);
+ const win = mount(
+ `
` +
+ `` +
+ `
` +
+ `
`,
+ );
+ const items = () => Array.from(win.document.querySelectorAll("li"));
+ expect(items()[0]?.classList.contains("is-active")).toBe(true);
+ expect(items()[1]?.classList.contains("is-active")).toBe(false);
+
+ win.document.querySelector("button")!.click();
+
+ expect(items()[0]?.classList.contains("is-active")).toBe(false);
+ expect(items()[1]?.classList.contains("is-active")).toBe(true);
+});
diff --git a/packages/csr/test/realtime.test.ts b/packages/csr/test/realtime.test.ts
index ef6aff68..e787b1fd 100644
--- a/packages/csr/test/realtime.test.ts
+++ b/packages/csr/test/realtime.test.ts
@@ -1,6 +1,7 @@
import { test, expect, beforeEach } from "bun:test";
import { Window } from "happy-dom";
import { REALTIME_RUNTIME } from "../src/realtime-runtime.ts";
+import { restoreGlobalsAfterAll } from "./global-restore.ts";
/* A fake WebSocket that records instances + sent frames and lets tests drive events. */
let sockets: FakeWS[];
@@ -45,8 +46,12 @@ function boot(bodyHtml: string) {
return win as unknown as Window;
}
+const REPLACED_GLOBALS = ["window", "document", "location", "WebSocket"];
+
+restoreGlobalsAfterAll(REPLACED_GLOBALS);
+
beforeEach(() => {
- for (const k of ["window", "document", "location", "WebSocket"]) {
+ for (const k of REPLACED_GLOBALS) {
delete (globalThis as Record)[k];
}
});
diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts
index 249331af..1eb9a857 100644
--- a/packages/ui/test/ui.test.ts
+++ b/packages/ui/test/ui.test.ts
@@ -1654,11 +1654,21 @@ test("carousel supports RTL, multiple slides, dragging, snap, and thumbnail layo
expect(css).toContain('.wrn-next--carousel[data-centered="true"] .wrn-next__carousel-track');
});
-test("carousel autoplay timers are available in the browser reactive runtime", async () => {
- const { getReactiveRuntime } = await import("../../csr/src/index.ts");
- const runtime = getReactiveRuntime();
- expect(runtime).toContain('name === "setInterval"');
- expect(runtime).toContain('name === "clearInterval"');
+test("carousel autoplay timers are available in the browser reactive runtime", () => {
+ // Resolve them through the runtime rather than asserting on its source: the
+ // timers only have to be reachable from a client expression, and a substring
+ // check goes stale the moment the lookup is written differently.
+ const dom = mountHtml(
+ `
` +
+ `` +
+ `` +
+ `` +
+ `
`,
+ );
+ (dom.querySelector("button") as HTMLButtonElement).click();
+
+ expect(dom.querySelector(".started")?.textContent).toContain("function");
+ expect(dom.querySelector(".stopped")?.textContent).toContain("function");
});
test("carousel snap controls scroll and multiple slides stop at the last full group", async () => {
diff --git a/scripts/security-performance-audit.mjs b/scripts/security-performance-audit.mjs
index 2c9eee5e..f70e8682 100644
--- a/scripts/security-performance-audit.mjs
+++ b/scripts/security-performance-audit.mjs
@@ -170,13 +170,18 @@ addCheck(
*/
const runtimeBudgets = {
/*
- * 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.
+ * Raised from 49,000 on 2026-08-19, to just above what the runtime actually
+ * minifies to rather than to a round number with room to drift.
+ *
+ * The runtime was already over 49,000 before client-side control blocks and
+ * for/while support were added. Trimming it afterwards -- prototype-safe
+ * global lookup tables, shared hasOwn/toArray/pairBinding helpers, dead code
+ * -- recovered 2,414 bytes, which was everything available without dropping
+ * or deferring a feature. What a visitor pays is the compressed transfer:
+ * 50,156 minified is ~16,000 gzipped, once, behind an immutable year-long
+ * cache.
*/
- "reactive-runtime.ts": 53_000,
+ "reactive-runtime.ts": 50_500,
"component-controllers.ts": 24_100,
"nav-runtime.ts": 12_000,
"realtime-runtime.ts": 8_000,