Compare commits

...
5 Commits
Author SHA1 Message Date
ClintchizandClaude Opus 5 e898929193 fix(react): mount visible islands and load rebuilt code after HMR
Quality / quality (ubuntu-latest) (push) Failing after 9m55s
Quality / quality (windows-latest) (push) Canceled after 0s
Two faults found by driving the island demo in a real browser. Both were
silent: the markup, every asset, and all 48 island tests were correct
either way.

An island renders nothing until it mounts, so its placeholder is
zero-height, and IntersectionObserver does not treat a zero-area target
consistently -- client:visible islands mounted on one load and not the
next. Visibility for those is now decided from the element's own rect,
driven by scroll and resize; a placeholder with real size still uses the
observer. The strategy had no test at all, which is why this shipped.

After an island source edit the browser kept running the old code. The
rebuild worked and the file was refetched, but the loader imports a URL
that does not change, and the browser caches modules by URL. Remounts now
carry a generation the dev loader folds into the request.

Verified in the browser: mounts with start={3} as a number, clicks reach
React (3 -> 5), and an edit to Counter.tsx now shows the new text and
stays interactive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 13:32:05 +05:30
ClintchizandClaude Opus 5 18a1c40118 fix(dev-server): recycle the server once hot rebuilds pile up
The dev server got slower the longer it ran. Measured on the example app:
30 .wrn edits grew RSS from 117 MB to 137 MB and never gave it back, while
30 CSS edits cost nothing -- so the leak is exactly one retained module
identity per rebuild, not caches or file handles.

That is inherent to reloading a module in-process. Bun caches modules by
path, so a rebuild has to be given a new identity to be picked up at all,
and Bun has no API to unload the old one. At roughly 0.66 MB a rebuild, a
long editing session is several hundred megabytes of garbage that cannot
be collected.

The process now recycles itself past a rebuild threshold, exiting with the
RESTART_EXIT_CODE the CLI supervisor already respawns on; browsers
reconnect because the HMR client already retries. It waits for a quiet
period first so a live request is never cut off, and the threshold (300
rebuilds, about 200 MB) sits well above a normal session. Set
WRNEXUS_DEV_RECYCLE_AFTER to tune it, or 0 to switch it off.

Also bounds browserArtifactPaths and islandArtifactPaths, which are keyed
by content hash and so gained an entry per rebuild that was never read
again. Small next to the module leak, but unbounded is unbounded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:30:54 +05:30
ClintchizandClaude Opus 5 a20f143acb 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 <noreply@anthropic.com>
2026-08-19 10:14:18 +05:30
ClintchizandClaude Opus 5 ac248f2bb0 fix(csr): run for/while loops and keep declarations out of state
The client runtime had no loop support, so any shared function using one
returned early -- Pagination and ButtonGroup were broken client-side, not
just in tests.

Adding loops exposed two further faults:

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 02:24:41 +05:30
ClintchizandClaude Opus 5 0904a4efaa fix(csr): render control blocks created by a client rerender
{#if}, {#each} and their {:else}/{:else if}/{:empty} branches worked on
the server and after hydration, but a block nested inside another block
stayed empty once the outer block rerendered. Adding a row to a list
produced the row's markup with its inner block markers in place and
nothing between them, for the life of the page.

Two causes, both on the client-created path only:

reactive() registers an effect; effects run when renderAll sweeps the
list. A state change runs just the affected effects rather than sweeping,
so an effect registered during that rerender was queued and never
invoked. setupControlBlock now returns its runner and the creating block
invokes it immediately.

The first reactive pass is skipped so hydration does not discard
server-rendered DOM. A block created by a rerender has no server DOM, so
skipping its only pass left it permanently empty. firstRun is now keyed
off outerLocals, which is set only on the client-created path.

Verified in a browser as well as in tests: adding a group to a list now
renders the new row's nested {:else}, and the existing rows' nested loops
survive the rerender.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:49:31 +05:30
27 changed files with 1430 additions and 407 deletions
+83
View File
@@ -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 };
+102 -5
View File
@@ -1,6 +1,6 @@
"use strict"; "use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly. // Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
// WRN editor compiler source hash: fd183ab8c54df72c779d099d7625ce0068e49bea458052335c77cbf31ccf9179 // WRN editor compiler source hash: 27f13fbc79aedf4736913f268ab4af1f03a236ea44960cffb7caff225d157faf
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8 // WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3 // Generated with TypeScript: 6.0.3
const __nodeRequire = require; const __nodeRequire = require;
@@ -1261,7 +1261,21 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
// templateEscape, swapped for its real `${…}` code after escaping. // templateEscape, swapped for its real `${…}` code after escaping.
if (node.type === "each" || node.type === "if") { if (node.type === "each" || node.type === "if") {
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node)); loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
return `\x00WRNEACH${loops.length - 1}\x00`; const definition = node.type === "each"
? {
list: node.list,
item: node.item,
index: node.index,
key: node.key,
body: renderClientControlTemplate(node.body),
empty: renderClientControlTemplate(node.empty),
}
: node.branches.map((branch) => ({
cond: branch.cond,
body: renderClientControlTemplate(branch.body),
}));
const attribute = node.type === "each" ? "data-wrn-each" : "data-wrn-if";
return `<template ${attribute}="${encodeClientControl(definition)}"></template>\x00WRNEACH${loops.length - 1}\x00<template data-wrn-control-end></template>`;
} }
if (node.tag === "Static" || node.tag === "Dynamic") { if (node.tag === "Static" || node.tag === "Dynamic") {
const inner = node.children const inner = node.children
@@ -2425,6 +2439,76 @@ function compileAttrValue(raw, ctx) {
} }
return out + escLit(attrEscape(raw.slice(last))); return out + escLit(attrEscape(raw.slice(last)));
} }
/**
* Serialize a control-block body as inert browser-side template markup.
* Values deliberately remain as mustaches: the CSR runtime evaluates them
* against the component scope (and `{#each}` locals) when it materializes the
* template. The string is base64 encoded before it is placed in HTML.
*/
function renderClientControlTemplate(nodes) {
const render = (node) => {
if (node.type === "text") {
return node.value.replace(/\{([^{}]+)\}/g, (whole, rawExpression) => {
const expression = rawExpression.trim();
return expression.startsWith("t:")
? `<span data-t="${attrEscape(expression.slice(2).trim())}"></span>`
: `<span data-text="${attrEscape(expression)}">${whole}</span>`;
});
}
if (node.type === "each") {
return `<template data-wrn-each="${attrEscape(encodeClientControl({
list: node.list,
item: node.item,
index: node.index,
key: node.key,
body: renderClientControlTemplate(node.body),
empty: renderClientControlTemplate(node.empty),
}))}"></template><template data-wrn-control-end></template>`;
}
if (node.type === "if") {
return `<template data-wrn-if="${attrEscape(encodeClientControl(node.branches.map((branch) => ({
cond: branch.cond,
body: renderClientControlTemplate(branch.body),
}))))}"></template><template data-wrn-control-end></template>`;
}
const componentTag = isComponentTag(node.tag);
let bindIndex = 0;
const attrs = node.attrs
.map((attribute) => {
const name = attribute.event
? componentTag
? componentEventAttribute(attribute.name)
: eventAttribute(attribute.name)
: attribute.name;
if (attribute.boolean)
return ` ${name}`;
if (attribute.name.startsWith("class:")) {
const expression = unwrapDirectiveExpression(attribute.value);
return ` data-wrn-class-${bindIndex++}="${attrEscape(JSON.stringify([attribute.name.slice("class:".length), expression]))}"`;
}
if (attribute.name === "data-show") {
return ` data-show="${attrEscape(unwrapDirectiveExpression(attribute.value))}"`;
}
const rendered = ` ${name}="${attrEscape(attribute.value)}"`;
return attribute.value.includes("{")
? `${rendered} data-wrn-bind-${bindIndex++}="${attrEscape(JSON.stringify([name, attribute.value]))}"`
: rendered;
})
.join("");
const children = node.children.map(render).join("");
if (node.tag === "Static")
return children;
if (componentTag)
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${children}</div>`;
if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase()))
return `<${node.tag}${attrs}>`;
return `<${node.tag}${attrs}>${children}</${node.tag}>`;
};
return nodes.map(render).join("");
}
function encodeClientControl(value) {
return node_buffer_1.Buffer.from(JSON.stringify(value), "utf8").toString("base64");
}
function renderComponentIfNode(node, ctx) { function renderComponentIfNode(node, ctx) {
let expression = "``"; let expression = "``";
for (let index = node.branches.length - 1; index >= 0; index--) { for (let index = node.branches.length - 1; index >= 0; index--) {
@@ -2436,7 +2520,11 @@ function renderComponentIfNode(node, ctx) {
? bodyExpression ? bodyExpression
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`; : `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
} }
return "${" + expression + "}"; const definition = encodeClientControl(node.branches.map((branch) => ({
cond: branch.cond,
body: renderClientControlTemplate(branch.body),
})));
return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
} }
function renderComponentEachNode(node, ctx) { function renderComponentEachNode(node, ctx) {
const item = node.item; const item = node.item;
@@ -2448,7 +2536,7 @@ function renderComponentEachNode(node, ctx) {
}; };
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join(""); const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join(""); const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
return ("${(() => { const __wl = Array.isArray(" + const serverBody = "${(() => { const __wl = Array.isArray(" +
list + list +
") ? (" + ") ? (" +
list + list +
@@ -2460,7 +2548,16 @@ function renderComponentEachNode(node, ctx) {
body + body +
'`).join("") : `' + '`).join("") : `' +
empty + empty +
"`; })()}"); "`; })()}";
const definition = encodeClientControl({
list: node.list,
item: node.item,
index: node.index,
key: node.key,
body: renderClientControlTemplate(node.body),
empty: renderClientControlTemplate(node.empty),
});
return `<template data-wrn-each="${definition}"></template>${serverBody}<template data-wrn-control-end></template>`;
} }
function serverLoopLocalsAttribute(ctx) { function serverLoopLocalsAttribute(ctx) {
const locals = [...(ctx.serverLocals ?? [])]; const locals = [...(ctx.serverLocals ?? [])];
+54 -37
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 38063dc1b7ef04c106da5a5e264fae51315e60286d6eb0d0176ab62419e8f546 // WRN editor extension source hash: 63bce75e2686c7586a3a08b8811ebc681d2265fdfe54e984630614e3dcef21f5
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict"; "use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); 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 // editors/vscode/src/extension.js
var path = require("node:path"); var path = require("node:path");
var vscode = require("vscode"); var vscode = require("vscode");
var { LanguageClient, TransportKind } = require_main5(); var { LanguageClient, TransportKind } = require_main5();
var { registerAutoCloseTags } = require_auto_close_tags();
var WRN_LANGUAGE_ID = "wrn"; var WRN_LANGUAGE_ID = "wrn";
var client; var client;
async function recoverWrnLanguage(document) { async function recoverWrnLanguage(document) {
@@ -22718,42 +22771,6 @@ async function recoverWrnLanguage(document) {
console.warn("[wrnexus] unable to recover .wrn language association:", error instanceof Error ? error.message : String(error)); 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;
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) { async function activate(context) {
for (const document of vscode.workspace.textDocuments) for (const document of vscode.workspace.textDocuments)
recoverWrnLanguage(document); recoverWrnLanguage(document);
+1 -45
View File
@@ -4,6 +4,7 @@
const path = require("node:path"); const path = require("node:path");
const vscode = require("vscode"); const vscode = require("vscode");
const { LanguageClient, TransportKind } = require("vscode-languageclient/node"); const { LanguageClient, TransportKind } = require("vscode-languageclient/node");
const { registerAutoCloseTags } = require("./auto-close-tags.js");
const WRN_LANGUAGE_ID = "wrn"; const WRN_LANGUAGE_ID = "wrn";
/** @type {LanguageClient | undefined} */ /** @type {LanguageClient | undefined} */
@@ -23,51 +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;
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 */ /** @param {vscode.ExtensionContext} context */
async function activate(context) { async function activate(context) {
for (const document of vscode.workspace.textDocuments) void recoverWrnLanguage(document); for (const document of vscode.workspace.textDocuments) void recoverWrnLanguage(document);
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
// WRN editor language server source hash: 5702fe2c68d78698b5507beb585cfd66a1c826f5c9febddca09fc45c49e40d4e // WRN editor language server source hash: 8555261bb7933ee73d08cee279600fa64d2f35b35c5144ceba516e6929593d37
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
// @bun @bun-cjs // @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {var __create = Object.create; (function(exports, require, module, __filename, __dirname) {var __create = Object.create;
@@ -195929,11 +195929,11 @@ function htmlDocument(document) {
} }
function markdown(value) { function markdown(value) {
if (typeof value === "string") if (typeof value === "string")
return value; return value || undefined;
if (value && typeof value === "object" && "value" in value) { if (value && typeof value === "object" && "value" in value) {
return String(value.value); return String(value.value) || undefined;
} }
return ""; return;
} }
function htmlCompletions(document, position) { function htmlCompletions(document, position) {
if (!isInsideHtml(document, offsetAt2(document.text, position))) if (!isInsideHtml(document, offsetAt2(document.text, position)))
+178
View File
@@ -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</div>" });
await run.fire();
assert.equal(run.inserted.length, 1);
assert.equal(run.inserted[0].value, "$0</div>");
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</div>",
});
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</p>",
});
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</div>" : "$0</span>"),
});
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</br>" : 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</div>" });
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);
});
+39 -46
View File
@@ -2,54 +2,47 @@
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert"); const assert = require("node:assert");
const Module = require("node:module"); const { installVsCodeHost } = require("./vscode-host.js");
// Mock the vscode module for unit tests const restoreHost = installVsCodeHost({
const originalLoad = Module._load; Position: class Position {
Module._load = function load(request, parent, isMain) { constructor(line, character) {
if (request === "vscode") { this.line = line;
return { this.character = character;
Position: class Position { }
constructor(line, character) { },
this.line = line; Range: class Range {
this.character = character; constructor(start, end) {
} this.start = start;
}, this.end = end;
Range: class Range { }
constructor(start, end) { },
this.start = start; CompletionItem: class CompletionItem {
this.end = end; constructor(label, kind) {
} this.label = label;
}, this.kind = kind;
CompletionItem: class CompletionItem { }
constructor(label, kind) { },
this.label = label; CompletionItemKind: {
this.kind = kind; Event: 23,
} Property: 10,
}, Function: 12,
CompletionItemKind: { Keyword: 14,
Event: 23, Variable: 13,
Property: 10, },
Function: 12, SnippetString: class SnippetString {
Keyword: 14, constructor(text) {
Variable: 13, this.value = text;
}, }
SnippetString: class SnippetString { },
constructor(text) { MarkdownString: class MarkdownString {
this.value = text; constructor(text) {
} this.value = text;
}, }
MarkdownString: class MarkdownString { },
constructor(text) { });
this.value = text;
}
},
};
}
return originalLoad.call(this, request, parent, isMain);
};
const { isInsideViewBlock, provideCompletionItems } = require("../src/completion.js"); const { isInsideViewBlock, provideCompletionItems } = require("../src/completion.js");
Module._load = originalLoad; restoreHost();
const PAGE = `page Home { const PAGE = `page Home {
view { view {
+3 -7
View File
@@ -2,17 +2,13 @@
const assert = require("node:assert"); const assert = require("node:assert");
const { test } = require("node:test"); 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 // These extraction helpers are pure, but their module also registers VS Code
// providers at runtime. Supply a minimal host shim for unit tests. // providers at runtime. Supply a minimal host shim for unit tests.
const originalLoad = Module._load; const restoreHost = installVsCodeHost({});
Module._load = function load(request, parent, isMain) {
if (request === "vscode") return {};
return originalLoad.call(this, request, parent, isMain);
};
const { extractRouteParams, extractStates } = require("../src/completion"); const { extractRouteParams, extractStates } = require("../src/completion");
Module._load = originalLoad; restoreHost();
test("extracts dynamic route params from filename", () => { test("extracts dynamic route params from filename", () => {
const document = { const document = {
+18 -24
View File
@@ -2,30 +2,24 @@
const assert = require("node:assert"); const assert = require("node:assert");
const { test } = require("node:test"); const { test } = require("node:test");
const Module = require("node:module"); const { installVsCodeHost } = require("./vscode-host.js");
const originalLoad = Module._load; const restoreHost = installVsCodeHost({
Module._load = function load(request, parent, isMain) { Diagnostic: class Diagnostic {
if (request === "vscode") { constructor(range, message, severity) {
return { this.range = range;
Diagnostic: class Diagnostic { this.message = message;
constructor(range, message, severity) { this.severity = severity;
this.range = range; }
this.message = message; },
this.severity = severity; DiagnosticSeverity: { Error: 0, Warning: 1 },
} Range: class Range {
}, constructor(start, end) {
DiagnosticSeverity: { Error: 0, Warning: 1 }, this.start = start;
Range: class Range { this.end = end;
constructor(start, end) { }
this.start = start; },
this.end = end; });
}
},
};
}
return originalLoad.call(this, request, parent, isMain);
};
const { const {
findTopLevelDeclaration, findTopLevelDeclaration,
maskLeadingTrivia, maskLeadingTrivia,
@@ -34,7 +28,7 @@ const {
validateLayoutUsage, validateLayoutUsage,
validateRootMembers, validateRootMembers,
} = require("../src/diagnostics"); } = require("../src/diagnostics");
Module._load = originalLoad; restoreHost();
function mockDocument() { function mockDocument() {
return { return {
+39
View File
@@ -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 };
+8
View File
@@ -127,6 +127,14 @@ export function getComponentControllerRuntime(development = false): string {
var emitPinInputEvent = bridge.emitPinInputEvent; var emitPinInputEvent = bridge.emitPinInputEvent;
var parseScopeDecl = bridge.parseScopeDecl; var parseScopeDecl = bridge.parseScopeDecl;
var warnOnce = bridge.warn || function () {}; 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} ${sections}
function hydrate(root) { function hydrate(root) {
var host = root || document; var host = root || document;
+268 -203
View File
@@ -26,58 +26,78 @@ export const REACTIVE_RUNTIME = String.raw`
var behaviorObserver; var behaviorObserver;
var clientModuleCache = new Map(); 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 * 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 * would let any expression reach every global on the page (and would make a
* typo silently resolve to some unrelated window property) -- these lists * typo silently resolve to some unrelated window property) -- these lists
* say exactly what client code may reach. * say exactly what client code may reach.
* *
* dialogGlobals must be bound to window or the browser throws * Prototype-less so a name like "toString" or "constructor" is a miss
* "Illegal invocation" when they are called detached. * rather than a hit on Object.prototype.
*/ */
var dialogGlobals = { function nameSet(names) {
alert: 1, var set = Object.create(null);
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,
};
// Language builtins. Wrapped in thunks so referencing one that a given names.split(" ").forEach(function (name) {
// engine lacks cannot throw at table-definition time. set[name] = 1;
var jsGlobals = { });
Object: function () { return Object; },
Boolean: function () { return Boolean; }, return set;
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; }, * Called with window as the receiver. Detached, the browser throws
Error: function () { return Error; }, * "Illegal invocation" for these.
Symbol: function () { return typeof Symbol === "undefined" ? undefined : Symbol; }, */
BigInt: function () { return typeof BigInt === "undefined" ? undefined : BigInt; }, var boundWindowGlobals = nameSet(
Intl: function () { return typeof Intl === "undefined" ? undefined : Intl; }, "alert confirm prompt fetch print open scrollTo scrollBy matchMedia" +
parseInt: function () { return parseInt; }, " getComputedStyle structuredClone queueMicrotask btoa atob" +
parseFloat: function () { return parseFloat; }, " setTimeout clearTimeout setInterval clearInterval" +
isNaN: function () { return isNaN; }, " requestAnimationFrame cancelAnimationFrame",
isFinite: function () { return isFinite; }, );
encodeURIComponent: function () { return encodeURIComponent; },
decodeURIComponent: function () { return decodeURIComponent; }, /*
encodeURI: function () { return encodeURI; }, * Language builtins and other realm globals, read off globalThis. Naming
decodeURI: function () { return decodeURI; }, * them rather than referencing them directly means one an engine lacks
NaN: function () { return NaN; }, * resolves to undefined instead of throwing where the table is defined.
Infinity: function () { return Infinity; }, */
undefined: function () { return undefined; }, 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. * toast(...) -- raise a notification from any client expression.
@@ -154,27 +174,12 @@ export const REACTIVE_RUNTIME = String.raw`
if (!window.toast) window.toast = toastApi; if (!window.toast) window.toast = toastApi;
// Read straight off window, no binding needed (objects, not functions). // Read straight off window, no binding needed (objects, not functions).
var windowGlobals = { var windowGlobals = nameSet(
localStorage: 1, "localStorage sessionStorage screen performance crypto CustomEvent Event" +
sessionStorage: 1, " FormData URLSearchParams AbortController Notification" +
screen: 1, " IntersectionObserver ResizeObserver MutationObserver devicePixelRatio" +
performance: 1, " innerWidth innerHeight scrollX scrollY location history navigator",
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,
};
function reportDiagnostic(code, message, element, detail) { function reportDiagnostic(code, message, element, detail) {
var payload = { var payload = {
@@ -1071,7 +1076,7 @@ export const REACTIVE_RUNTIME = String.raw`
var serverProxy = new Proxy({}, { var serverProxy = new Proxy({}, {
get: function (_target, property) { get: function (_target, property) {
return function () { 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 === "server") return serverProxy;
if (name === "props") return propsProxy; if (name === "props") return propsProxy;
if (name === "refs") return refsProxy; 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") { if (name === "$emit") {
return function (eventName, detail) { return function (eventName, detail) {
return dispatchComponentEvent(componentEventTarget, eventName, detail); return dispatchComponentEvent(componentEventTarget, eventName, detail);
@@ -1120,26 +1125,10 @@ export const REACTIVE_RUNTIME = String.raw`
if (name === "window") return window; if (name === "window") return window;
if (name === "document") return document; if (name === "document") return document;
if (name === "console") return console; 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 (name === "$route" || name === "route") {
if (currentRenderer) routeValue.subscribe(currentRenderer); if (currentRenderer) routeValue.subscribe(currentRenderer);
return routeValue.get(); 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. * Ordinary browser and language globals.
* *
@@ -1156,11 +1145,11 @@ export const REACTIVE_RUNTIME = String.raw`
* lacks one of these does not break the rest. * lacks one of these does not break the rest.
*/ */
if (name === "toast") return toastApi; if (name === "toast") return toastApi;
if (dialogGlobals[name] && typeof window[name] === "function") { if (boundWindowGlobals[name] && typeof window[name] === "function") {
return window[name].bind(window); return window[name].bind(window);
} }
if (jsGlobals[name]) { if (ambientGlobals[name]) {
var builtin = jsGlobals[name](); var builtin = globalThis[name];
if (builtin !== undefined) return builtin; if (builtin !== undefined) return builtin;
} }
if (windowGlobals[name]) { if (windowGlobals[name]) {
@@ -1174,7 +1163,7 @@ export const REACTIVE_RUNTIME = String.raw`
} }
function readScope(name) { function readScope(name) {
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) { if (hasOwn(computedDefinitions, name)) {
if (computing.has(name)) { if (computing.has(name)) {
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el); reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
return undefined; return undefined;
@@ -1191,18 +1180,18 @@ export const REACTIVE_RUNTIME = String.raw`
if (currentRenderer) sig.subscribe(currentRenderer); if (currentRenderer) sig.subscribe(currentRenderer);
return sig.get(); return sig.get();
} }
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) { if (hasOwn(behaviorFunctions, name)) {
return behaviorFunctions[name]; return behaviorFunctions[name];
} }
return readGlobal(name); return readGlobal(name);
} }
function peekScope(name) { function peekScope(name) {
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) { if (hasOwn(computedDefinitions, name)) {
return readScope(name); return readScope(name);
} }
if (signals[name]) return signals[name].get(); if (signals[name]) return signals[name].get();
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) { if (hasOwn(behaviorFunctions, name)) {
return behaviorFunctions[name]; return behaviorFunctions[name];
} }
return readGlobal(name); return readGlobal(name);
@@ -1265,7 +1254,7 @@ export const REACTIVE_RUNTIME = String.raw`
function evalExpr(expr, locals) { function evalExpr(expr, locals) {
return evaluateExpression(expr, function (name) { return evaluateExpression(expr, function (name) {
if (locals && Object.prototype.hasOwnProperty.call(locals, name)) { if (locals && hasOwn(locals, name)) {
return locals[name]; return locals[name];
} }
return readScope(name); return readScope(name);
@@ -1276,6 +1265,8 @@ export const REACTIVE_RUNTIME = String.raw`
source, source,
locals, locals,
) { ) {
locals = locals || Object.create(null);
return batchUpdates(function () { return batchUpdates(function () {
var statements = var statements =
splitStatements(source); splitStatements(source);
@@ -1297,8 +1288,7 @@ export const REACTIVE_RUNTIME = String.raw`
function (name) { function (name) {
if ( if (
locals && locals &&
Object.prototype hasOwn(
.hasOwnProperty.call(
locals, locals,
name, name,
) )
@@ -1311,8 +1301,7 @@ export const REACTIVE_RUNTIME = String.raw`
function (name, value) { function (name, value) {
if ( if (
locals && locals &&
Object.prototype hasOwn(
.hasOwnProperty.call(
locals, locals,
name, name,
) )
@@ -1328,6 +1317,9 @@ export const REACTIVE_RUNTIME = String.raw`
locals, locals,
); );
}, },
function (name, value) {
locals[name] = value;
},
); );
if (result.returned) { if (result.returned) {
@@ -1501,19 +1493,13 @@ export const REACTIVE_RUNTIME = String.raw`
} }
return type + ":" + String(value); 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( function hydrateItem(
root, root,
locals, locals,
) { ) {
function localRead(name) { function localRead(name) {
if ( if (
Object.prototype.hasOwnProperty.call( hasOwn(
locals, locals,
name, name,
) )
@@ -1597,7 +1583,7 @@ export const REACTIVE_RUNTIME = String.raw`
if (node !== root && insideNestedLoop(node)) return; if (node !== root && insideNestedLoop(node)) return;
var attributes = var attributes =
Array.prototype.slice.call( toArray(
node.attributes, node.attributes,
); );
@@ -1639,28 +1625,12 @@ export const REACTIVE_RUNTIME = String.raw`
"data-wrn-class-", "data-wrn-class-",
) === 0 ) === 0
) { ) {
var classBinding; var classBinding = pairBinding(attribute.value);
try { if (!classBinding) return;
classBinding = JSON.parse(
attribute.value,
);
} catch (_) {
return;
}
if ( var className = classBinding[0];
!classBinding || var classExpression = classBinding[1];
classBinding.length !== 2
) {
return;
}
var className =
classBinding[0];
var classExpression =
classBinding[1];
var classEnabled = false; var classEnabled = false;
@@ -1690,28 +1660,13 @@ export const REACTIVE_RUNTIME = String.raw`
) === 0 ) === 0
) { ) {
node.removeAttribute(attribute.name); node.removeAttribute(attribute.name);
var binding;
try { var binding = pairBinding(attribute.value);
binding = JSON.parse(
attribute.value,
);
} catch (_) {
return;
}
if ( if (!binding) return;
!binding ||
binding.length !== 2
) {
return;
}
var attributeName = var attributeName = binding[0];
binding[0]; var attributeTemplate = binding[1];
var attributeTemplate =
binding[1];
/* /*
* Reactive, not resolved once. The expression can read component * Reactive, not resolved once. The expression can read component
@@ -1779,7 +1734,7 @@ export const REACTIVE_RUNTIME = String.raw`
eventLocals.event = event; eventLocals.event = event;
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 { try {
runStmt( runStmt(
@@ -1931,8 +1886,7 @@ export const REACTIVE_RUNTIME = String.raw`
// Hand every nested loop its own renderer, with this item in scope. // Hand every nested loop its own renderer, with this item in scope.
if (root.querySelectorAll) { if (root.querySelectorAll) {
Array.prototype.slice toArray(root.querySelectorAll("[data-for]"))
.call(root.querySelectorAll("[data-for]"))
.forEach(function (nested) { .forEach(function (nested) {
// Only the outermost nested templates: deeper ones are set up by // Only the outermost nested templates: deeper ones are set up by
// their own parent when it renders. // their own parent when it renders.
@@ -1993,15 +1947,25 @@ export const REACTIVE_RUNTIME = String.raw`
var eachDefinition = block.hasAttribute("data-wrn-each") var eachDefinition = block.hasAttribute("data-wrn-each")
? decodeControlDefinition(block.getAttribute("data-wrn-each")) ? decodeControlDefinition(block.getAttribute("data-wrn-each"))
: null; : null;
var firstRun = true; /*
* Skip the first reactive pass only when hydrating server DOM.
*
* A block that arrived with the server HTML is already rendered, so
* redrawing on the first pass would discard it. A block created later by
* an outer block's rerender has no server DOM — outerLocals is how it
* receives its enclosing loop's scope, and is only ever set on that path.
* Skipping its first pass leaves it permanently empty, because its
* dependencies never change again to trigger a second one.
*/
var firstRun = !outerLocals;
function controlRead(name) { 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) { function controlEval(expression, locals) {
return evaluateExpression(expression, function (name) { return evaluateExpression(expression, function (name) {
return locals && Object.prototype.hasOwnProperty.call(locals, name) return locals && hasOwn(locals, name)
? locals[name] ? locals[name]
: controlRead(name); : controlRead(name);
}); });
@@ -2018,7 +1982,7 @@ export const REACTIVE_RUNTIME = String.raw`
var template = document.createElement("template"); var template = document.createElement("template");
template.innerHTML = markup || ""; template.innerHTML = markup || "";
var fragment = template.content; 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; return node.nodeType === 1;
}); });
if (rangeEnd) block.parentNode.insertBefore(fragment, rangeEnd); if (rangeEnd) block.parentNode.insertBefore(fragment, rangeEnd);
@@ -2029,12 +1993,28 @@ export const REACTIVE_RUNTIME = String.raw`
if (node.matches && node.matches("[data-wrn-if],[data-wrn-each]")) controls.push(node); if (node.matches && node.matches("[data-wrn-if],[data-wrn-each]")) controls.push(node);
if (node.querySelectorAll) Array.prototype.push.apply(controls, node.querySelectorAll("[data-wrn-if],[data-wrn-each]")); if (node.querySelectorAll) Array.prototype.push.apply(controls, node.querySelectorAll("[data-wrn-if],[data-wrn-each]"));
controls.forEach(function (nested) { controls.forEach(function (nested) {
if (!nested.__wrnexusControl) setupControlBlock(nested, locals || inherited); if (nested.__wrnexusControl) return;
/*
* Run the new block now rather than waiting for a sweep.
*
* reactive() only registers an effect; effects execute when
* renderAll sweeps the list. A state change runs just the affected
* effects, so a block registered during that rerender is queued and
* never invoked -- it would stay empty for the life of the page.
*/
var runNested = setupControlBlock(nested, locals || inherited);
if (runNested) runNested();
}); });
}); });
} }
reactive(function () { return reactive(function () {
/*
* A block removed from the DOM keeps its effect in the renderers list,
* so a later sweep would run it against a detached node and throw --
* aborting the sweep, leaving every later effect unrendered. Skip it.
*/
if (!block.parentNode) return;
if (ifDefinition) { if (ifDefinition) {
var selected = null; var selected = null;
for (var branchIndex = 0; branchIndex < ifDefinition.length; branchIndex++) { for (var branchIndex = 0; branchIndex < ifDefinition.length; branchIndex++) {
@@ -2069,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; if (block.parentElement && block.parentElement.closest("[data-wrn-if],[data-wrn-each]")) return;
setupControlBlock(block, null); setupControlBlock(block, null);
}); });
@@ -2101,7 +2081,7 @@ export const REACTIVE_RUNTIME = String.raw`
} }
function loopRead(name) { function loopRead(name) {
if (Object.prototype.hasOwnProperty.call(inherited, name)) { if (hasOwn(inherited, name)) {
return inherited[name]; return inherited[name];
} }
return readScope(name); return readScope(name);
@@ -2274,7 +2254,7 @@ export const REACTIVE_RUNTIME = String.raw`
rawKey = evaluateExpression( rawKey = evaluateExpression(
keyExpression, keyExpression,
function (name) { function (name) {
return Object.prototype.hasOwnProperty.call(keyedLocals, name) return hasOwn(keyedLocals, name)
? keyedLocals[name] ? keyedLocals[name]
: loopRead(name); : loopRead(name);
}, },
@@ -2347,8 +2327,7 @@ export const REACTIVE_RUNTIME = String.raw`
} }
Array.prototype.slice toArray(el.querySelectorAll("[data-for]"))
.call(el.querySelectorAll("[data-for]"))
.forEach(function (tpl) { .forEach(function (tpl) {
// Only top-level templates here; nested ones are connected by the item // Only top-level templates here; nested ones are connected by the item
// that contains them, once it has values to give them. // that contains them, once it has values to give them.
@@ -2444,24 +2423,18 @@ export const REACTIVE_RUNTIME = String.raw`
// Conditional class bindings emitted as: // Conditional class bindings emitted as:
// data-wrn-class-*='["class-name","expression"]' // data-wrn-class-*='["class-name","expression"]'
var classBindNodes = [el].concat( var classBindNodes = [el].concat(
Array.prototype.slice.call(el.querySelectorAll("*")), toArray(el.querySelectorAll("*")),
); );
classBindNodes.forEach(function (node) { classBindNodes.forEach(function (node) {
if (!owns(node)) return; 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; if (marker.name.indexOf("data-wrn-class-") !== 0) return;
var binding; var binding = pairBinding(marker.value);
try { if (!binding) return;
binding = JSON.parse(marker.value);
} catch (e) {
return;
}
if (!binding || binding.length !== 2) return;
var className = binding[0]; var className = binding[0];
var expression = binding[1]; var expression = binding[1];
@@ -2490,7 +2463,7 @@ export const REACTIVE_RUNTIME = String.raw`
// [attributeName, originalTemplate], preserving an SSR value while allowing // [attributeName, originalTemplate], preserving an SSR value while allowing
// state changes to update type, aria-*, class, href, and other attributes. // state changes to update type, aria-*, class, href, and other attributes.
var bindNodes = [el].concat( var bindNodes = [el].concat(
Array.prototype.slice.call( toArray(
el.querySelectorAll("*"), el.querySelectorAll("*"),
), ),
); );
@@ -2498,8 +2471,7 @@ export const REACTIVE_RUNTIME = String.raw`
bindNodes.forEach(function (node) { bindNodes.forEach(function (node) {
if (!owns(node)) return; if (!owns(node)) return;
Array.prototype.slice toArray(node.attributes)
.call(node.attributes)
.forEach(function (marker) { .forEach(function (marker) {
if ( if (
marker.name.indexOf( marker.name.indexOf(
@@ -2510,22 +2482,10 @@ export const REACTIVE_RUNTIME = String.raw`
} }
node.removeAttribute(marker.name); node.removeAttribute(marker.name);
var binding;
try { var binding = pairBinding(marker.value);
binding = JSON.parse(
marker.value,
);
} catch (error) {
return;
}
if ( if (!binding) return;
!binding ||
binding.length !== 2
) {
return;
}
var name = binding[0]; var name = binding[0];
var template = binding[1]; var template = binding[1];
@@ -2617,10 +2577,10 @@ export const REACTIVE_RUNTIME = String.raw`
} }
// Event handlers on elements, window, and document. // 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) { nodes.forEach(function (node) {
if (!owns(node)) return; 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; if (attr.name.indexOf("data-on-") !== 0) return;
var rawName = attr.name.slice("data-on-".length); var rawName = attr.name.slice("data-on-".length);
@@ -2647,7 +2607,7 @@ export const REACTIVE_RUNTIME = String.raw`
locals.event = event; locals.event = event;
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 { try {
runStmt( runStmt(
@@ -2696,8 +2656,7 @@ export const REACTIVE_RUNTIME = String.raw`
// function only exists out here. The compiler emits these as data-wrn-out-* so the // 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 // two cases stay distinguishable, and this scope claims every one that
// sits on a component it directly mounts. // sits on a component it directly mounts.
Array.prototype.slice toArray(el.querySelectorAll("[data-wrn-events]"))
.call(el.querySelectorAll("[data-wrn-events]"))
.forEach(function (node) { .forEach(function (node) {
var componentRoot = closestScope(node); var componentRoot = closestScope(node);
if (!componentRoot || componentRoot === el) return; if (!componentRoot || componentRoot === el) return;
@@ -2714,7 +2673,7 @@ export const REACTIVE_RUNTIME = String.raw`
node.__wrnexusOutputHandlers || node.__wrnexusOutputHandlers ||
(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; if (attr.name.indexOf("data-wrn-out-") !== 0) return;
var outName = attr.name.slice("data-wrn-out-".length); var outName = attr.name.slice("data-wrn-out-".length);
@@ -2752,7 +2711,7 @@ export const REACTIVE_RUNTIME = String.raw`
locals.event = event; locals.event = event;
locals.$event = event; locals.$event = event;
locals.payload = locals.payload =
event && Object.prototype.hasOwnProperty.call(event, "detail") event && hasOwn(event, "detail")
? event.detail ? event.detail
: undefined; : undefined;
try { try {
@@ -2774,16 +2733,14 @@ export const REACTIVE_RUNTIME = String.raw`
// Prop expressions belong to the parent that mounted the component. The // Prop expressions belong to the parent that mounted the component. The
// server forwards these markers onto the rendered child root; evaluate // server forwards these markers onto the rendered child root; evaluate
// them here and write changes into the child's prop signals. // them here and write changes into the child's prop signals.
Array.prototype.slice toArray(el.querySelectorAll("*"))
.call(el.querySelectorAll("*"))
.filter(isScopeRoot) .filter(isScopeRoot)
.forEach(function (node) { .forEach(function (node) {
if (!node.parentNode || ownerScope(node.parentNode) !== el) return; 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; if (attr.name.indexOf("data-wrn-prop-bind-") !== 0) return;
var binding; var binding = pairBinding(attr.value);
try { binding = JSON.parse(attr.value); } catch (_) { return; } if (!binding) return;
if (!binding || binding.length !== 2) return;
var propName = binding[0]; var propName = binding[0];
var template = binding[1]; var template = binding[1];
reactive(function () { reactive(function () {
@@ -3137,8 +3094,7 @@ export const REACTIVE_RUNTIME = String.raw`
var host = root && root.querySelectorAll ? root : document; var host = root && root.querySelectorAll ? root : document;
anchoredWriting = true; anchoredWriting = true;
try { try {
Array.prototype.slice toArray(host.querySelectorAll(ANCHORED_SELECTOR))
.call(host.querySelectorAll(ANCHORED_SELECTOR))
.forEach(clampAnchored); .forEach(clampAnchored);
} finally { } finally {
// Released on a timer, not requestAnimationFrame. rAF does not fire in // Released on a timer, not requestAnimationFrame. rAF does not fire in
@@ -3672,8 +3628,7 @@ export const REACTIVE_RUNTIME = String.raw`
// therefore no client-side binding to retain; consume its compiler markers // therefore no client-side binding to retain; consume its compiler markers
// separately from component hydration. // separately from component hydration.
if (host === document || host === document.documentElement) { if (host === document || host === document.documentElement) {
Array.prototype.slice toArray(document.documentElement.attributes)
.call(document.documentElement.attributes)
.forEach(function (attribute) { .forEach(function (attribute) {
if (attribute.name.indexOf("data-wrn-bind-") === 0) { if (attribute.name.indexOf("data-wrn-bind-") === 0) {
document.documentElement.removeAttribute(attribute.name); document.documentElement.removeAttribute(attribute.name);
@@ -4315,7 +4270,7 @@ export const REACTIVE_RUNTIME = String.raw`
function emitPinInputEvent(root, name, extra) { function emitPinInputEvent(root, name, extra) {
var hidden = root.querySelector("[data-pin-value]"); 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 value = hidden ? hidden.value : "";
var detail = { var detail = {
component: "PinInput", component: "PinInput",
@@ -4337,7 +4292,7 @@ export const REACTIVE_RUNTIME = String.raw`
/*__WRNEXUS_CONTROLLERS_PIN_START__*/ /*__WRNEXUS_CONTROLLERS_PIN_START__*/
function setupPinInputController(root) { function setupPinInputController(root) {
if (!root || root.__wrnexusPinInputController) return; 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 hidden = root.querySelector("[data-pin-value]");
var clearButton = root.querySelector("[data-pin-clear]"); var clearButton = root.querySelector("[data-pin-clear]");
var patternSource = root.getAttribute("data-pattern") || "[0-9]"; var patternSource = root.getAttribute("data-pattern") || "[0-9]";
@@ -4771,12 +4726,68 @@ export const REACTIVE_RUNTIME = String.raw`
: null; : null;
} }
/*
* Parse a while or for statement into its parts.
*
* Returns null for anything else so the caller falls through to the other
* statement forms. A for header is split on top-level semicolons only, so a
* semicolon inside a call argument or a string does not break it.
*/
function parseLoopStatement(source) {
source = String(source || "").trim();
var kind = null;
if (source.slice(0, 5) === "while" && !/[A-Za-z0-9_$]/.test(source.charAt(5))) {
kind = "while";
} else if (source.slice(0, 3) === "for" && !/[A-Za-z0-9_$]/.test(source.charAt(3))) {
kind = "for";
} else {
return null;
}
var index = skipStatementWhitespace(source, kind === "while" ? 5 : 3);
if (source.charAt(index) !== "(") return null;
var headerEnd = findClosingDelimiter(source, index, "(", ")");
if (headerEnd < 0) throw new Error("Unclosed " + kind + " header");
var header = source.slice(index + 1, headerEnd);
index = skipStatementWhitespace(source, headerEnd + 1);
if (source.charAt(index) !== "{") throw new Error("Expected a block after " + kind);
var bodyEnd = findClosingDelimiter(source, index, "{", "}");
if (bodyEnd < 0) throw new Error("Unclosed " + kind + " body");
var body = source.slice(index + 1, bodyEnd);
if (kind === "while") {
return { init: null, condition: header.trim(), step: null, body: body };
}
var parts = splitTopLevel(header, ";");
if (parts.length !== 3) throw new Error("A for header needs three parts");
return {
init: parts[0].trim(),
condition: parts[1].trim(),
step: parts[2].trim(),
body: body,
};
}
function runStatement( function runStatement(
stmt, stmt,
evalExpr, evalExpr,
read, read,
write, write,
runBlock, runBlock,
declare,
) { ) {
stmt = String(stmt || "").trim(); stmt = String(stmt || "").trim();
@@ -4827,6 +4838,60 @@ export const REACTIVE_RUNTIME = String.raw`
}; };
} }
/*
* A loop body is author-written and runs in the browser, so a mistaken
* condition would freeze the tab. The cap keeps a runaway loop from
* hanging the page; it is far above any list a view renders.
*/
var loop = parseLoopStatement(stmt);
if (loop) {
var guard = 0;
if (loop.init) {
runStatement(loop.init, evalExpr, read, write, runBlock, declare);
}
while (!loop.condition || !!evalExpr(loop.condition)) {
if (++guard > 100000) break;
var outcome = runBlock(loop.body);
if (outcome && outcome.returned) return outcome;
if (loop.step) {
runStatement(loop.step, evalExpr, read, write, runBlock, declare);
}
}
return { returned: false, value: undefined };
}
/*
* A declaration binds a local, then falls through to the assignment
* branch below.
*
* Declaring first is what makes it local: an unknown name reaching
* writeScope becomes a signal and triggers a render sweep, so a var
* inside a shared function called during a render would loop forever.
* Once the name exists in locals, read and write both stay there.
*/
var declaration = stmt.match(
/^(?:var|let|const)\s+([A-Za-z_$][A-Za-z0-9_$]*[\s\S]*)$/,
);
if (declaration) {
stmt = declaration[1].trim();
var declaredName = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(stmt)[0];
if (declare) declare(declaredName, undefined);
if (!/=/.test(stmt)) {
return { returned: false, value: undefined };
}
}
var increment = stmt.match( var increment = stmt.match(
/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/, /^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/,
); );
@@ -5819,7 +5884,7 @@ export const REACTIVE_RUNTIME = String.raw`
host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) { host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) {
if (element.__wrnDynamicMounted) return; if (element.__wrnDynamicMounted) return;
element.__wrnDynamicMounted = true; 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"); return candidate.hasAttribute("data-component-case");
}).map(function (candidate) { }).map(function (candidate) {
var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || "")); var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || ""));
+28
View File
@@ -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<string, unknown>(
names.map((name) => [name, (globalThis as Record<string, unknown>)[name]]),
);
afterAll(() => {
for (const [name, value] of captured) {
if (value === undefined) {
delete (globalThis as Record<string, unknown>)[name];
} else {
(globalThis as Record<string, unknown>)[name] = value;
}
}
});
}
+15 -10
View File
@@ -1,6 +1,7 @@
import { test, expect, beforeEach } from "bun:test"; import { test, expect, beforeEach } from "bun:test";
import { Window } from "happy-dom"; import { Window } from "happy-dom";
import { NAV_RUNTIME } from "../src/nav-runtime.ts"; import { NAV_RUNTIME } from "../src/nav-runtime.ts";
import { restoreGlobalsAfterAll } from "./global-restore.ts";
let win: any; let win: any;
let fetchCalls: { url: string; opts: 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 flush = () => new Promise((r) => setTimeout(r, 0));
const REPLACED_GLOBALS = [
"window",
"document",
"history",
"location",
"DOMParser",
"CustomEvent",
"Event",
"fetch",
];
restoreGlobalsAfterAll(REPLACED_GLOBALS);
beforeEach(() => { beforeEach(() => {
const g = globalThis as any; const g = globalThis as any;
for (const k of [ for (const k of REPLACED_GLOBALS) {
"window",
"document",
"history",
"location",
"DOMParser",
"CustomEvent",
"Event",
"fetch",
]) {
delete g[k]; delete g[k];
} }
}); });
+155 -5
View File
@@ -3,6 +3,7 @@ import { Window } from "happy-dom";
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts"; import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts"; import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
import { mountHtml } from "@wrnexus/test"; import { mountHtml } from "@wrnexus/test";
import { restoreGlobalsAfterAll } from "./global-restore.ts";
// Fresh DOM per test, with the runtime's globals bound. // Fresh DOM per test, with the runtime's globals bound.
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window { 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; return win as unknown as Window;
} }
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "MutationObserver"];
restoreGlobalsAfterAll(REPLACED_GLOBALS);
beforeEach(() => { beforeEach(() => {
delete (globalThis as Record<string, unknown>).window; for (const name of REPLACED_GLOBALS) {
delete (globalThis as Record<string, unknown>).document; delete (globalThis as Record<string, unknown>)[name];
delete (globalThis as Record<string, unknown>).location; }
delete (globalThis as Record<string, unknown>).fetch;
delete (globalThis as Record<string, unknown>).MutationObserver;
}); });
test("split runtime hydrates a controller only from the controller asset", () => { test("split runtime hydrates a controller only from the controller asset", () => {
@@ -1631,3 +1634,150 @@ test("splitter announces its new size for the component to re-emit", () => {
); );
expect(seen).toEqual([60]); expect(seen).toEqual([60]);
}); });
test("control blocks created by a client rerender render their own content", () => {
// A nested block that arrives with the server HTML is hydrated: its first
// reactive pass must NOT redraw, or it would throw away server DOM. A nested
// block created later by an outer rerender has no server DOM, so skipping its
// first pass leaves it permanently empty — its dependencies never change
// again to trigger a second one.
const inner = Buffer.from(
JSON.stringify([
{ cond: "g.rows.length > 0", body: '<p class="has-rows">HAS</p>' },
{ cond: null, body: '<p class="no-rows">NONE</p>' },
]),
).toString("base64");
const outer = Buffer.from(
JSON.stringify({
list: "groups",
item: "g",
body:
`<section class="group"><span data-text="g.name">{g.name}</span>` +
`<template data-wrn-if="${inner}"></template><template data-wrn-control-end></template>` +
`</section>`,
empty: "",
}),
).toString("base64");
const win = mount(
`<div data-scope="groups: [{ name: 'g1', rows: ['a'] }]">` +
`<button data-on-click="groups = [{ name: 'g2', rows: [] }]">swap</button>` +
`<template data-wrn-each="${outer}"></template>` +
`<section class="group"><span data-text="g.name">g1</span>` +
`<template data-wrn-if="${inner}"></template><p class="has-rows">HAS</p>` +
`<template data-wrn-control-end></template></section>` +
`<template data-wrn-control-end></template>` +
`</div>`,
);
win.document.querySelector("button")!.click();
// The outer each rerendered: the new group's heading is present.
expect(win.document.querySelector(".group span")?.textContent).toBe("g2");
// The nested if inside that new row must have rendered its else branch.
expect(win.document.querySelector(".no-rows")?.textContent).toBe("NONE");
expect(win.document.querySelector(".has-rows")).toBeNull();
});
test("a for loop with a declaration initialiser runs in a handler", () => {
const win = mount(
`<div data-scope="total: 0">` +
`<button data-on-click="for (var i = 1; i <= 3; i += 1) { total = total + i }">go</button>` +
`<span data-text="total">0</span>` +
`</div>`,
);
win.document.querySelector("button")!.click();
expect(win.document.querySelector("span")?.textContent).toBe("6");
});
test("a while loop runs in a handler", () => {
const win = mount(
`<div data-scope="n: 1">` +
`<button data-on-click="while (n < 10) { n = n * 2 }">go</button>` +
`<span data-text="n">1</span>` +
`</div>`,
);
win.document.querySelector("button")!.click();
expect(win.document.querySelector("span")?.textContent).toBe("16");
});
test("a declaration stays local instead of becoming reactive state", () => {
// An unknown name reaching writeScope becomes a signal and triggers a render
// sweep. A var inside a function called during a render would then loop
// forever, so declarations must bind locally.
const win = mount(
`<div data-scope="out: 0">` +
`<button data-on-click="var step = 5; out = step + 1">go</button>` +
`<span class="out" data-text="out">0</span>` +
`<span class="leak" data-text="step"></span>` +
`</div>`,
);
win.document.querySelector("button")!.click();
expect(win.document.querySelector(".out")?.textContent).toBe("6");
expect(win.document.querySelector(".leak")?.textContent).toBe("");
});
test("a control block removed from the DOM does not abort later renders", () => {
// Its effect stays in the renderers list. Running it against a detached node
// throws, which would abort the sweep and leave every later effect stale.
const definition = Buffer.from(
JSON.stringify([{ cond: "n < 100", body: '<i class="gone"></i>' }]),
).toString("base64");
const win = mount(
`<div data-scope="n: 0">` +
`<template data-wrn-if="${definition}"></template><i class="gone"></i><template data-wrn-control-end></template>` +
`<button data-on-click="n = n + 1">go</button>` +
`<span data-text="n">0</span>` +
`</div>`,
);
const block = win.document.querySelector("[data-wrn-if]")!;
block.parentNode!.removeChild(block);
win.document.querySelector("button")!.click();
expect(win.document.querySelector("span")?.textContent).toBe("1");
});
test("a declaration statement assigns into scope", () => {
const win = mount(
`<div data-scope="out: 0">` +
`<button data-on-click="var step = 5; out = step + 1">go</button>` +
`<span data-text="out">0</span>` +
`</div>`,
);
win.document.querySelector("button")!.click();
expect(win.document.querySelector("span")?.textContent).toBe("6");
});
test("an unbounded loop stops instead of hanging the page", () => {
// Handler source is author-controlled and runs in the browser. Without a cap
// a mistaken condition freezes the tab with no way back.
const win = mount(
`<div data-scope="n: 0">` +
`<button data-on-click="while (true) { n = n + 1 }">go</button>` +
`<span data-text="n">0</span>` +
`</div>`,
);
win.document.querySelector("button")!.click();
const value = Number(win.document.querySelector("span")?.textContent);
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(
`<div data-scope="rows: [{&quot;id&quot;:1},{&quot;id&quot;:2}], selected: 1">` +
`<button data-on-click="selected = 2">pick</button>` +
`<ul><li data-for="row in rows" data-wrn-class-active='${binding}'></li></ul>` +
`</div>`,
);
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);
});
+6 -1
View File
@@ -1,6 +1,7 @@
import { test, expect, beforeEach } from "bun:test"; import { test, expect, beforeEach } from "bun:test";
import { Window } from "happy-dom"; import { Window } from "happy-dom";
import { REALTIME_RUNTIME } from "../src/realtime-runtime.ts"; 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. */ /* A fake WebSocket that records instances + sent frames and lets tests drive events. */
let sockets: FakeWS[]; let sockets: FakeWS[];
@@ -45,8 +46,12 @@ function boot(bodyHtml: string) {
return win as unknown as Window; return win as unknown as Window;
} }
const REPLACED_GLOBALS = ["window", "document", "location", "WebSocket"];
restoreGlobalsAfterAll(REPLACED_GLOBALS);
beforeEach(() => { beforeEach(() => {
for (const k of ["window", "document", "location", "WebSocket"]) { for (const k of REPLACED_GLOBALS) {
delete (globalThis as Record<string, unknown>)[k]; delete (globalThis as Record<string, unknown>)[k];
} }
}); });
+40
View File
@@ -48,6 +48,22 @@ import {
wrnBrowserArtifactUrlAsync, wrnBrowserArtifactUrlAsync,
} from "./pipeline.ts"; } from "./pipeline.ts";
import { createRpcHandler } from "@wrnexus/ssr/rpc"; import { createRpcHandler } from "@wrnexus/ssr/rpc";
import { createRecycleMonitor } from "./recycle.ts";
import { RESTART_EXIT_CODE } from "./restart.ts";
/*
* Recycle after this many hot rebuilds. Each retains roughly 0.66 MB that Bun
* cannot release, so 300 caps the leak near 200 MB -- far more than a normal
* session reaches, and far less than what makes the server crawl. Set
* WRNEXUS_DEV_RECYCLE_AFTER to tune it, or to 0 to never recycle.
*/
const RECYCLE_REBUILD_THRESHOLD = (() => {
const configured = Number(process.env.WRNEXUS_DEV_RECYCLE_AFTER);
return Number.isFinite(configured) && configured >= 0 ? configured : 300;
})();
/** Quiet period required first, so a recycle never interrupts a live request. */
const RECYCLE_IDLE_MS = 10_000;
const RECYCLE_CHECK_MS = 5_000;
import { createHandlers, type WsData } from "./runtime.ts"; import { createHandlers, type WsData } from "./runtime.ts";
import { createDevAssetServer } from "./assets.ts"; import { createDevAssetServer } from "./assets.ts";
import { pluginAssetsFromContributions } from "./plugin-assets.ts"; import { pluginAssetsFromContributions } from "./plugin-assets.ts";
@@ -588,6 +604,22 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
validateCsrf: validateRpcCsrf, validateCsrf: validateRpcCsrf,
}); });
/*
* Hot rebuilds retain their predecessors (see recycle.ts). Only dev reloads
* modules, so only dev needs to recycle.
*/
const recycle =
hmr && mode === "development" && RECYCLE_REBUILD_THRESHOLD > 0
? createRecycleMonitor({
threshold: RECYCLE_REBUILD_THRESHOLD,
idleMs: RECYCLE_IDLE_MS,
onRecycle(reason) {
console.log(`[wrnexus] ${reason}`);
process.exit(RESTART_EXIT_CODE);
},
})
: null;
const server = Bun.serve<WsData>({ const server = Bun.serve<WsData>({
port, port,
hostname, hostname,
@@ -595,12 +627,19 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
maxRequestBodySize: 10 * 1024 * 1024, maxRequestBodySize: 10 * 1024 * 1024,
...(opts.tls ? { tls: opts.tls } : {}), ...(opts.tls ? { tls: opts.tls } : {}),
fetch(request, server) { fetch(request, server) {
recycle?.recordRequest(Date.now());
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request); if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request);
return handlers.fetch(request, server); return handlers.fetch(request, server);
}, },
websocket: handlers.websocket, websocket: handlers.websocket,
}); });
if (recycle) {
// unref so a pending check never keeps the process alive on its own.
const timer = setInterval(() => recycle.tick(Date.now()), RECYCLE_CHECK_MS);
timer.unref?.();
}
try { try {
await pluginRunner.hook("configureServer", { await pluginRunner.hook("configureServer", {
server, server,
@@ -626,6 +665,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
for (const file of files) { for (const file of files) {
invalidateModule(isAbsolute(file) ? file : resolve(appDir, file)); invalidateModule(isAbsolute(file) ? file : resolve(appDir, file));
recycle?.recordRebuild();
} }
if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss(); if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss();
+23 -4
View File
@@ -58,9 +58,28 @@ export function runMiddleware(
*/ */
const moduleCache = new Map<string, Promise<Record<string, unknown>>>(); const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
const moduleVersions = new Map<string, number>(); const moduleVersions = new Map<string, number>();
/*
* Artifact URLs carry a content hash, so a rebuild registers a new key and the
* previous one is never requested again -- left alone these grow for the life
* of the dev server. Bounded rather than cleared on rebuild because a page
* already mid-load may still ask for the URL it was served.
*/
const ARTIFACT_PATH_LIMIT = 512;
const browserArtifactPaths = new Map<string, string>(); const browserArtifactPaths = new Map<string, string>();
const islandArtifactPaths = new Map<string, string>(); const islandArtifactPaths = new Map<string, string>();
function rememberArtifact(paths: Map<string, string>, pathname: string, artifact: string): void {
// Re-insert so a key still in use is treated as recent.
paths.delete(pathname);
paths.set(pathname, artifact);
while (paths.size > ARTIFACT_PATH_LIMIT) {
const oldest = paths.keys().next();
if (oldest.done) break;
paths.delete(oldest.value);
}
}
type ImportMode = "legacy" | "compatible" | "explicit"; type ImportMode = "legacy" | "compatible" | "explicit";
interface CompileImportOptions { interface CompileImportOptions {
mode: ImportMode; mode: ImportMode;
@@ -622,7 +641,7 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
); );
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8"); writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8"); writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
browserArtifactPaths.set(browserPath, artifacts.browser); rememberArtifact(browserArtifactPaths, browserPath, artifacts.browser);
compileMetrics.compilations++; compileMetrics.compilations++;
return artifacts; return artifacts;
})().finally(() => asyncCompileInProgress.delete(key)); })().finally(() => asyncCompileInProgress.delete(key));
@@ -673,7 +692,7 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
.map(([, path]) => path); .map(([, path]) => path);
if (requiredArtifacts.every((path) => statSync(path).isFile())) { if (requiredArtifacts.every((path) => statSync(path).isFile())) {
compileMetrics.hits++; compileMetrics.hits++;
browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser); rememberArtifact(browserArtifactPaths, `/__wrnexus/client/${stem}.mjs`, artifacts.browser);
// A cached .wrn still needs its island bundles: the .tsx may have changed // A cached .wrn still needs its island bundles: the .tsx may have changed
// since, and after a restart with a warm cache nothing else would build them. // since, and after a restart with a warm cache nothing else would build them.
let cachedIslands: Array<{ name: string; sourcePath: string }> = []; let cachedIslands: Array<{ name: string; sourcePath: string }> = [];
@@ -715,7 +734,7 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
rewriteArtifactImports(targets.browser, result.ast, file, "browser"), rewriteArtifactImports(targets.browser, result.ast, file, "browser"),
"utf8", "utf8",
); );
browserArtifactPaths.set(browserPath, artifacts.browser); rememberArtifact(browserArtifactPaths, browserPath, artifacts.browser);
writeFileSync( writeFileSync(
artifacts.server, artifacts.server,
rewriteArtifactImports(targets.server, result.ast, file, "server"), rewriteArtifactImports(targets.server, result.ast, file, "server"),
@@ -775,7 +794,7 @@ export function serveWrnBrowserArtifact(pathname: string): Response | null {
/** Registers a built island asset for serving under `/__wrnexus/island/`. */ /** Registers a built island asset for serving under `/__wrnexus/island/`. */
export function registerIslandArtifact(pathname: string, artifact: string): void { export function registerIslandArtifact(pathname: string, artifact: string): void {
islandArtifactPaths.set(pathname, artifact); rememberArtifact(islandArtifactPaths, pathname, artifact);
} }
/** Serves a built island bundle, chunk, or the island mount runtime. */ /** Serves a built island bundle, chunk, or the island mount runtime. */
+69
View File
@@ -0,0 +1,69 @@
/**
* Recycle the dev server once hot rebuilds have piled up.
*
* Every rebuild of a `.wrn` file has to be given a new module identity,
* because Bun caches modules by path and would otherwise serve the old one.
* Bun has no API to unload a module, so each rebuild retains its predecessor
* for the life of the process -- measured at roughly 0.66 MB per rebuild,
* while edits that mint no new module (CSS) cost nothing. Over a long session
* that is the difference between a fast dev server and a stuck one.
*
* The process therefore recycles itself: the child exits with
* RESTART_EXIT_CODE and the CLI supervisor respawns it. Browsers reconnect on
* their own because the HMR client already retries.
*
* Recycling is deferred until the server has been idle for a moment, so it
* never interrupts a request in flight. The cost is that in-memory state
* (realtime rooms, warmed caches) resets at that point, which is why the
* threshold is high enough that an ordinary editing session never reaches it.
*/
export interface RecycleMonitorOptions {
/** Retained rebuilds tolerated before a recycle is armed. */
threshold: number;
/** Quiet period required before recycling, in milliseconds. */
idleMs: number;
onRecycle: (reason: string) => void;
}
export interface RecycleMonitor {
/** Count one rebuild that retained a module version. */
recordRebuild(): void;
/** Note that a request was served, at `now`. */
recordRequest(now: number): void;
/** Recycle if the threshold is passed and the server has gone quiet. */
tick(now: number): void;
retained(): number;
}
export function createRecycleMonitor(options: RecycleMonitorOptions): RecycleMonitor {
let rebuilds = 0;
let lastRequestAt: number | null = null;
let recycled = false;
return {
recordRebuild() {
rebuilds++;
},
recordRequest(now: number) {
lastRequestAt = now;
},
tick(now: number) {
if (recycled) return;
if (rebuilds < options.threshold) return;
// A server that has served nothing is idle by definition.
if (lastRequestAt !== null && now - lastRequestAt < options.idleMs) return;
recycled = true;
options.onRecycle(
`${rebuilds} hot rebuilds retained; restarting to release the memory they hold`,
);
},
retained() {
return rebuilds;
},
};
}
+81
View File
@@ -0,0 +1,81 @@
import { test, expect } from "bun:test";
import { createRecycleMonitor } from "../src/recycle.ts";
/** Fresh monitor with a small threshold so tests stay readable. */
function monitor(overrides: Partial<Parameters<typeof createRecycleMonitor>[0]> = {}) {
const recycled: string[] = [];
const control = createRecycleMonitor({
threshold: 3,
idleMs: 1000,
onRecycle: (reason) => recycled.push(reason),
...overrides,
});
return { control, recycled };
}
test("stays quiet below the rebuild threshold", () => {
const { control, recycled } = monitor();
control.recordRebuild();
control.recordRebuild();
control.tick(10_000);
expect(recycled).toEqual([]);
});
test("recycles once rebuilds pass the threshold and the server goes idle", () => {
const { control, recycled } = monitor();
for (let i = 0; i < 3; i++) control.recordRebuild();
control.recordRequest(0);
control.tick(1_500);
expect(recycled.length).toBe(1);
});
test("waits for the idle gap rather than cutting off active work", () => {
// Recycling mid-request would drop it. The gap is the whole point.
const { control, recycled } = monitor();
for (let i = 0; i < 3; i++) control.recordRebuild();
control.recordRequest(0);
control.tick(500);
expect(recycled).toEqual([]);
control.recordRequest(900);
control.tick(1_400);
expect(recycled).toEqual([]);
control.tick(2_000);
expect(recycled.length).toBe(1);
});
test("recycles only once even if it keeps being ticked", () => {
const { control, recycled } = monitor();
for (let i = 0; i < 5; i++) control.recordRebuild();
control.recordRequest(0);
control.tick(5_000);
control.tick(6_000);
control.tick(7_000);
expect(recycled.length).toBe(1);
});
test("a server that never served a request can still recycle", () => {
const { control, recycled } = monitor();
for (let i = 0; i < 3; i++) control.recordRebuild();
control.tick(9_999);
expect(recycled.length).toBe(1);
});
test("reports how many rebuilds are being retained", () => {
const { control } = monitor();
control.recordRebuild();
control.recordRebuild();
expect(control.retained()).toBe(2);
});
+4 -4
View File
@@ -27,12 +27,12 @@ function htmlDocument(document: TextDocument) {
return HtmlTextDocument.create(virtual.uri, "html", document.version ?? 1, virtual.text); return HtmlTextDocument.create(virtual.uri, "html", document.version ?? 1, virtual.text);
} }
function markdown(value: unknown): string { function markdown(value: unknown): string | undefined {
if (typeof value === "string") return value; if (typeof value === "string") return value || undefined;
if (value && typeof value === "object" && "value" in value) { if (value && typeof value === "object" && "value" in value) {
return String((value as { value: unknown }).value); return String((value as { value: unknown }).value) || undefined;
} }
return ""; return undefined;
} }
/** /**
+68 -4
View File
@@ -3,7 +3,14 @@ import { createRoot, type Root } from "react-dom/client";
import { IslandErrorBoundary } from "./error-boundary.tsx"; import { IslandErrorBoundary } from "./error-boundary.tsx";
export interface MountOptions { export interface MountOptions {
loader: (name: string) => Promise<{ default: ComponentType<any> }>; /**
* Resolve an island module by name.
*
* `generation` counts remounts. A rebuilt island keeps its URL and the
* browser caches a module by URL, so a dev loader must fold this into the
* request or the page keeps running the code it first imported.
*/
loader: (name: string, generation: number) => Promise<{ default: ComponentType<any> }>;
development?: boolean; development?: boolean;
/** /**
* Re-render islands that are already mounted instead of skipping them. * Re-render islands that are already mounted instead of skipping them.
@@ -16,6 +23,7 @@ export interface MountOptions {
} }
const roots = new Map<Element, Root>(); const roots = new Map<Element, Root>();
let generation = 0;
export function islandRootCount(): number { export function islandRootCount(): number {
return roots.size; return roots.size;
@@ -32,8 +40,40 @@ function readProps(element: Element): Record<string, unknown> {
} }
} }
function whenReady(element: Element, strategy: string): Promise<void> { function rectOf(element: Element): DOMRect | null {
if (strategy === "visible" && typeof IntersectionObserver !== "undefined") { const measure = (element as HTMLElement).getBoundingClientRect;
return typeof measure === "function" ? (element as HTMLElement).getBoundingClientRect() : null;
}
function inViewport(element: Element): boolean {
const rect = rectOf(element);
if (!rect) return false;
const height = window.innerHeight || document.documentElement?.clientHeight || 0;
const width = window.innerWidth || document.documentElement?.clientWidth || 0;
return rect.top <= height && rect.bottom >= 0 && rect.left <= width && rect.right >= 0;
}
/**
* Resolve once the island's placeholder has come into view.
*
* An island renders nothing until it mounts, so its placeholder is usually
* zero-height -- and IntersectionObserver does not treat a zero-area target
* consistently. When it declines to report one, the island never mounts at
* all, which is silent: the markup and every asset are present and correct.
* Those are driven from the element's own rect instead; a placeholder with
* real size (an SSR fallback, or a reserved min-height) still uses the
* observer, which is cheaper and needs no scroll listener.
*/
function whenVisible(element: Element): Promise<void> {
if (typeof window === "undefined") return Promise.resolve();
if (inViewport(element)) return Promise.resolve();
const rect = rectOf(element);
const hasArea = !!rect && rect.width > 0 && rect.height > 0;
if (hasArea && typeof IntersectionObserver !== "undefined") {
return new Promise((resolve) => { return new Promise((resolve) => {
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) { if (entries.some((entry) => entry.isIntersecting)) {
@@ -44,6 +84,26 @@ function whenReady(element: Element, strategy: string): Promise<void> {
observer.observe(element); observer.observe(element);
}); });
} }
return new Promise((resolve) => {
const check = () => {
if (!inViewport(element)) return;
cleanup();
resolve();
};
const cleanup = () => {
window.removeEventListener("scroll", check, true);
window.removeEventListener("resize", check);
};
// Capture phase so a scrolling container, not just the page, wakes it.
window.addEventListener("scroll", check, true);
window.addEventListener("resize", check);
});
}
function whenReady(element: Element, strategy: string): Promise<void> {
if (strategy === "visible") return whenVisible(element);
if (strategy === "idle" && typeof requestIdleCallback !== "undefined") { if (strategy === "idle" && typeof requestIdleCallback !== "undefined") {
return new Promise((resolve) => requestIdleCallback(() => resolve())); return new Promise((resolve) => requestIdleCallback(() => resolve()));
} }
@@ -65,7 +125,7 @@ async function mountOne(element: Element, options: MountOptions): Promise<void>
let Component: ComponentType<any>; let Component: ComponentType<any>;
try { try {
Component = (await options.loader(name)).default; Component = (await options.loader(name, generation)).default;
} catch (error) { } catch (error) {
console.error(`[wrnexus] failed to load island bundle for '${name}'`, error); console.error(`[wrnexus] failed to load island bundle for '${name}'`, error);
return; return;
@@ -118,6 +178,10 @@ export function unmountIslands(root: ParentNode): void {
* a runtime and is out of scope. * a runtime and is out of scope.
*/ */
export async function remountIslands(root: ParentNode, options: MountOptions): Promise<void> { export async function remountIslands(root: ParentNode, options: MountOptions): Promise<void> {
// A remount only happens after a rebuild, so the modules on the other side
// of the loader have changed.
generation++;
// Every mounted container is swapped for a bare clone before remounting. // Every mounted container is swapped for a bare clone before remounting.
// //
// Re-rendering the existing root is not enough: HMR wipes the container's // Re-rendering the existing root is not enough: HMR wipes the container's
+5 -2
View File
@@ -8,8 +8,11 @@
export function getIslandRuntime(development = false): string { export function getIslandRuntime(development = false): string {
return ` return `
(function () { (function () {
function loader(name) { function loader(name, generation) {
return import("/__wrnexus/island/" + encodeURIComponent(name) + ".js"); var url = "/__wrnexus/island/" + encodeURIComponent(name) + ".js";
// A rebuilt island keeps its URL, and the browser caches modules by URL,
// so a remount has to ask for a URL it has not imported before.
return import(generation ? url + "?v=" + generation : url);
} }
function boot() { function boot() {
+23
View File
@@ -92,3 +92,26 @@ test("remount re-renders in place instead of creating a second root", async () =
expect(islandRootCount()).toBe(1); expect(islandRootCount()).toBe(1);
expect(window.document.body.textContent).toContain("v1"); expect(window.document.body.textContent).toContain("v1");
}); });
test("a remount asks the loader for a newer generation than the mount did", async () => {
// The rebuilt island keeps its URL. Without a changing generation the dev
// loader re-imports the cached module and the page keeps the old code --
// silently, because the island still mounts and still works.
const window = domWith(marker);
const generations: number[] = [];
const loader = async (_name: string, generation: number) => {
generations.push(generation);
return { default: () => createElement("span", null, `gen ${generation}`) };
};
await act(async () => {
await mountIslands(host(window), { loader });
});
await act(async () => {
await remountIslands(host(window), { loader });
});
expect(generations.length).toBe(2);
expect(generations[1]).toBeGreaterThan(generations[0]!);
expect(window.document.body.textContent).toContain(`gen ${generations[1]}`);
});
@@ -128,3 +128,91 @@ test("malformed props JSON falls back to empty props instead of throwing", async
expect(window.document.body.textContent).toContain("none"); expect(window.document.body.textContent).toContain("none");
expect(islandRootCount()).toBe(1); expect(islandRootCount()).toBe(1);
}); });
/**
* Stand in for the browser's IntersectionObserver, reporting only targets that
* actually have area.
*
* That is the case the real one is inconsistent about: an island placeholder
* is empty until it mounts, so it is zero-height, and an engine that declines
* to report it leaves the island unmounted forever.
*/
function installAreaOnlyObserver(window: Window, onObserve?: () => void) {
const observed: Element[] = [];
(globalThis as any).IntersectionObserver = class {
constructor(private callback: (entries: { isIntersecting: boolean }[]) => void) {}
observe(element: Element) {
observed.push(element);
onObserve?.();
const rect = (element as unknown as HTMLElement).getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) this.callback([{ isIntersecting: true }]);
}
disconnect() {}
};
return observed;
}
/** Place the island marker at a given position with a given size. */
function positionIsland(window: Window, top: number, height: number) {
const element = window.document.querySelector("[data-wrn-island]") as unknown as HTMLElement;
element.getBoundingClientRect = () =>
({ top, bottom: top + height, left: 0, right: 800, width: 800, height }) as DOMRect;
return element;
}
test("mounts a visible island whose placeholder has no height", async () => {
const window = domWith(marker("{}", "visible"));
installAreaOnlyObserver(window);
positionIsland(window, 40, 0);
await act(async () => {
await mountIslands(host(window), { loader });
});
expect(islandRootCount()).toBe(1);
delete (globalThis as any).IntersectionObserver;
});
test("a visible island below the fold waits, then mounts once scrolled to", async () => {
const window = domWith(marker("{}", "visible"));
installAreaOnlyObserver(window);
positionIsland(window, 5000, 0);
let settled = false;
// Started outside act: it stays pending until the scroll, and the render it
// then performs is what act needs to wrap.
const mounting = mountIslands(host(window), { loader }).then(() => {
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(settled).toBe(false);
expect(islandRootCount()).toBe(0);
positionIsland(window, 100, 0);
await act(async () => {
window.dispatchEvent(new window.Event("scroll"));
await mounting;
});
expect(settled).toBe(true);
expect(islandRootCount()).toBe(1);
delete (globalThis as any).IntersectionObserver;
});
test("a placeholder with real size still goes through the observer", async () => {
const window = domWith(marker("{}", "visible"));
let observedCount = 0;
installAreaOnlyObserver(window, () => {
observedCount++;
});
positionIsland(window, 5000, 300);
await act(async () => {
await mountIslands(host(window), { loader });
});
expect(observedCount).toBe(1);
expect(islandRootCount()).toBe(1);
delete (globalThis as any).IntersectionObserver;
});
+15 -5
View File
@@ -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'); 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 () => { test("carousel autoplay timers are available in the browser reactive runtime", () => {
const { getReactiveRuntime } = await import("../../csr/src/index.ts"); // Resolve them through the runtime rather than asserting on its source: the
const runtime = getReactiveRuntime(); // timers only have to be reachable from a client expression, and a substring
expect(runtime).toContain('name === "setInterval"'); // check goes stale the moment the lookup is written differently.
expect(runtime).toContain('name === "clearInterval"'); const dom = mountHtml(
`<div data-scope="started: 0, stopped: 0">` +
`<button data-on-click="started = setInterval; stopped = clearInterval">go</button>` +
`<span class="started" data-text="started"></span>` +
`<span class="stopped" data-text="stopped"></span>` +
`</div>`,
);
(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 () => { test("carousel snap controls scroll and multiple slides stop at the last full group", async () => {
+13 -1
View File
@@ -169,7 +169,19 @@ addCheck(
* minified transfer once. That is the number worth defending. * minified transfer once. That is the number worth defending.
*/ */
const runtimeBudgets = { const runtimeBudgets = {
"reactive-runtime.ts": 49_000, /*
* 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": 50_500,
"component-controllers.ts": 24_100, "component-controllers.ts": 24_100,
"nav-runtime.ts": 12_000, "nav-runtime.ts": 12_000,
"realtime-runtime.ts": 8_000, "realtime-runtime.ts": 8_000,