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