Files
WRNexusJS/packages/csr/test/reactive.test.ts
T
ClintchizandClaude Opus 5 3441b96362 fix(csr): deliver focus and blur outputs from inside a component
A parent writing @focus on a component tag never heard that component's
own input or button take focus. The runtime bound every output-named DOM
fallback listener in the bubble phase, and focus and blur do not bubble,
so the event fired on the descendant and stopped there. Nothing errored --
the binding simply did nothing.

That made a whole class of declared outputs undeliverable: button.focus,
button.blur, TextLink.focus, TextLink.blur, WysiwygEditor.focus and
WysiwygEditor.blur all advertised events they could never send.

The ui ratchet for outputs nothing emits excluded natively-named outputs
on the grounds that a native event reaches the root anyway. That holds for
click and change, which bubble, and was wrong for focus and blur. Binding
those two in the capture phase makes the exclusion honest rather than
convenient; the ratchet's comment now says so.

Also documents WysiwygEditor as the chrome shell it is: it emits none of
its four outputs itself, it forwards whatever the slotted control raises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:31:33 +05:30

1838 lines
74 KiB
TypeScript

import { test, expect, beforeEach } from "bun:test";
import { Window } from "happy-dom";
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
import { mountHtml } from "@wrnexus/test";
import { restoreGlobalsAfterAll } from "./global-restore.ts";
// Fresh DOM per test, with the runtime's globals bound.
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `<div id="app">${html}</div>`;
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
(globalThis as Record<string, unknown>).location = win.location;
(globalThis as Record<string, unknown>).NodeFilter = (
win as unknown as { NodeFilter: unknown }
).NodeFilter;
(globalThis as Record<string, unknown>).MutationObserver = (
win as unknown as { MutationObserver: unknown }
).MutationObserver;
/*
* Bind the window CustomEvent too. Without it the runtime constructs events
* from the host global, and a listener registered through happy-dom never
* matches them, so anything dispatched looks silently lost.
*/
(globalThis as Record<string, unknown>).CustomEvent = (
win as unknown as { CustomEvent: unknown }
).CustomEvent;
(0, eval)(runtime);
if (controllers) (0, eval)(controllers);
// Hydrate deterministically (auto-init waits on DOMContentLoaded, which the
// test window may not fire). setupScope is idempotent, so this is safe.
const w = win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void };
w.__wrnexusHydrateScopes?.(win.document);
return win as unknown as Window;
}
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "MutationObserver"];
restoreGlobalsAfterAll(REPLACED_GLOBALS);
beforeEach(() => {
for (const name of REPLACED_GLOBALS) {
delete (globalThis as Record<string, unknown>)[name];
}
});
test("split runtime hydrates a controller only from the controller asset", () => {
const core = getReactiveRuntime(true);
const controllers = getComponentControllerRuntime(true);
expect(core).not.toContain("function setupRovingFocus");
expect(controllers).toContain("function setupRovingFocus");
const win = mount(
`<div data-wrn-roving="horizontal"><button data-wrn-roving-item>One</button><button data-wrn-roving-item>Two</button></div>`,
core,
controllers,
);
const buttons = win.document.querySelectorAll("button");
(buttons[0] as unknown as HTMLButtonElement).focus();
buttons[0]!.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
expect(win.document.activeElement).toBe(buttons[1]);
});
test("a page without controller markers does not request the controller asset", () => {
const win = mount(
`<main data-scope="count: 1"><span>{count}</span></main>`,
getReactiveRuntime(true),
);
expect(win.document.querySelector('script[src$="controllers.js"]')).toBeNull();
});
test("hydrates {expr} mustaches from data-scope", () => {
const win = mount(`<div data-scope="count: 0"><span>{count}, {count * 2}</span></div>`);
expect(win.document.querySelector("span")!.textContent).toBe("0, 0");
});
test("mounts client-only templates before hydrating their scopes", () => {
const win = mount(
`<div data-wrn-client-root="client-page" aria-busy="true"></div>` +
`<template data-wrn-client-template="client-page"><main data-scope="count: 2"><b>{count}</b></main></template>`,
);
const runtime = win as unknown as {
__wrnexusMountClientRoots?: (root: unknown) => void;
__wrnexusHydrateScopes?: (root: unknown) => void;
};
runtime.__wrnexusMountClientRoots?.(win.document);
runtime.__wrnexusHydrateScopes?.(win.document);
expect(win.document.querySelector("template")).toBeNull();
expect(win.document.querySelector("main b")?.textContent).toBe("2");
});
test("orchestrates named client loads and renders the success template", async () => {
(globalThis as Record<string, unknown>).fetch = () =>
Promise.resolve(Response.json({ data: { name: "Ada" } }));
const win = mount(
`<section data-wrn-async="users" data-wrn-async-retries="0" aria-busy="true">` +
`<div data-wrn-async-content>Loading</div>` +
`<template data-wrn-async-loading>Loading</template>` +
`<template data-wrn-async-success><strong>{users.name}</strong></template>` +
`<template data-wrn-async-error><b>{error.message}</b></template></section>`,
);
const runtime = win as unknown as { __wrnexusHydrateAsyncBoundaries?: (root: unknown) => void };
runtime.__wrnexusHydrateAsyncBoundaries?.(win.document);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(win.document.querySelector("section")?.getAttribute("data-wrn-async-state")).toBe(
"success",
);
expect(win.document.querySelector("strong")?.textContent).toBe("Ada");
});
test("@event (data-on-click) mutates a signal and re-renders", () => {
const win = mount(
`<div data-scope="count: 0"><button data-on-click="count++">{count}</button></div>`,
);
const btn = win.document.querySelector("button")!;
expect(btn.textContent).toBe("0");
btn.click();
btn.click();
expect(btn.textContent).toBe("2");
});
test("compiled if blocks switch branches after hydration", () => {
const definition = Buffer.from(
JSON.stringify([
{ cond: "open", body: '<p class="open">Open <span data-text="count">{count}</span></p>' },
{ cond: null, body: '<p class="closed">Closed</p>' },
]),
).toString("base64");
const win = mount(
`<div data-scope="open: false, count: 2">` +
`<button data-on-click="open = !open">toggle</button>` +
`<button data-on-click="count++">increment</button>` +
`<template data-wrn-if="${definition}"></template><p class="closed">Closed</p><template data-wrn-control-end></template>` +
`</div>`,
);
win.document.querySelector("button")!.click();
expect(win.document.querySelector(".closed")).toBeNull();
expect(win.document.querySelector(".open")?.textContent).toBe("Open 2");
win.document.querySelectorAll("button")[1]!.click();
expect(win.document.querySelector(".open")?.textContent).toBe("Open 3");
});
test("compiled each blocks rerender rows and their empty branch", () => {
const definition = Buffer.from(
JSON.stringify({
list: "items",
item: "item",
index: "index",
body: '<p class="row">{index}:{item}</p>',
empty: '<p class="empty">Empty</p>',
}),
).toString("base64");
const win = mount(
`<div data-scope="items: ['a']">` +
`<button data-on-click="items = ['b', 'c']">more</button>` +
`<button data-on-click="items = []">clear</button>` +
`<template data-wrn-each="${definition}"></template><p class="row">0:a</p><template data-wrn-control-end></template>` +
`</div>`,
);
win.document.querySelectorAll("button")[0]!.click();
expect(Array.from(win.document.querySelectorAll(".row")).map((node) => node.textContent)).toEqual(
["0:b", "1:c"],
);
win.document.querySelectorAll("button")[1]!.click();
expect(win.document.querySelector(".row")).toBeNull();
expect(win.document.querySelector(".empty")?.textContent).toBe("Empty");
});
test("component functions support formatted multiline assignments and ternaries", () => {
const behavior = Buffer.from(
JSON.stringify({
functions: `
function increment(event, nextValue) {
nextValue =
Number(count) +
(event.shiftKey ? 10 : 1)
count = nextValue
}
function normalize(useMinimum) {
count = useMinimum
? 0
: Number(count)
}
`,
watches: [],
lifecycle: {},
}),
).toString("base64");
const win = mount(
`<div data-scope="count: 0" data-wrn-behavior="${behavior}">
<button id="increment" data-on-click="increment(event)">{count}</button>
<button id="normalize" data-on-click="normalize(true)">normalize</button>
</div>`,
);
const increment = win.document.getElementById("increment") as unknown as HTMLElement;
increment.click();
expect(increment.textContent).toBe("1");
const MouseEventConstructor = (win as unknown as { MouseEvent: typeof MouseEvent }).MouseEvent;
increment.dispatchEvent(
new MouseEventConstructor("click", { shiftKey: true }) as unknown as Event,
);
expect(increment.textContent).toBe("11");
(win.document.getElementById("normalize") as unknown as HTMLElement).click();
expect(increment.textContent).toBe("0");
});
test("component functions evaluate template literals without unsafe eval", () => {
const behavior = Buffer.from(
JSON.stringify({
functions: `function increment() {
count++
message = \`Count is now \${count}.\`
}`,
watches: [],
lifecycle: {},
}),
).toString("base64");
const win = mount(
`<div data-scope="count: 0, message: 'ready'" data-wrn-behavior="${behavior}">` +
`<button data-on-click="increment()">{message}</button></div>`,
);
const button = win.document.querySelector("button")!;
button.click();
expect(button.textContent).toBe("Count is now 1.");
});
test("declared component events emit through the generic $emit function", () => {
const win = mount(
`<div data-scope="" data-wrn-events="complete">
<button data-on-click="$emit('complete', { value: '4829' })">Complete</button>
</div>`,
);
const root = win.document.querySelector("[data-wrn-events]")!;
let detail: Record<string, unknown> | undefined;
root.addEventListener("complete", (event) => {
detail = (event as unknown as CustomEvent).detail;
});
win.document.querySelector("button")!.click();
expect(detail).toEqual({ value: "4829" });
});
test("nested scopes don't clobber each other (regression)", () => {
// An empty outer scope must not touch inner scopes' values.
const win = mount(
`<div data-scope="">
<div data-scope="count: 0"><button data-on-click="count++">A{count}</button></div>
<div data-scope="count: 10"><button data-on-click="count++">B{count}</button></div>
</div>`,
);
const [a, b] = Array.from(win.document.querySelectorAll("button"));
expect(a!.textContent).toBe("A0");
expect(b!.textContent).toBe("B10");
a!.click();
expect(a!.textContent).toBe("A1");
expect(b!.textContent).toBe("B10"); // unchanged
});
test("data-text binds an element's textContent to an expression", () => {
const win = mount(
`<div data-scope="n: 3"><strong data-text="n * 3">?</strong><button data-on-click="n = 5">x</button></div>`,
);
expect(win.document.querySelector("strong")!.textContent).toBe("9");
win.document.querySelector("button")!.click();
expect(win.document.querySelector("strong")!.textContent).toBe("15");
});
test("string scope values bind via data-text", () => {
const win = mount(`<div data-scope="msg: 'hi'"><strong data-text="msg">?</strong></div>`);
expect(win.document.querySelector("strong")!.textContent).toBe("hi");
});
test("data-for renders a list of objects and reacts to array changes", () => {
const win = mount(
`<div data-scope="todos: [{text: 'a'}, {text: 'b'}]">
<ul><li data-for="t in todos" data-text="t.text"></li></ul>
<button id="add" data-on-click="todos = todos.concat([{text: 'c'}])">add</button>
<button id="clear" data-on-click="todos = []">clear</button>
</div>`,
);
const items = () => Array.from(win.document.querySelectorAll("li"), (li) => li.textContent);
expect(items()).toEqual(["a", "b"]);
(win.document.getElementById("add") as unknown as HTMLElement).click();
expect(items()).toEqual(["a", "b", "c"]);
(win.document.getElementById("clear") as unknown as HTMLElement).click();
expect(items()).toEqual([]);
});
test("data-for exposes item + index, mustaches and member access", () => {
const win = mount(
`<div data-scope="rows: [{name: 'x'}, {name: 'y'}]">
<ul><li data-for="r, i in rows">{i}:{r.name}</li></ul>
</div>`,
);
expect(Array.from(win.document.querySelectorAll("li"), (li) => li.textContent)).toEqual([
"0:x",
"1:y",
]);
});
test("keyed data-for preserves DOM identity when items reorder", () => {
const win = mount(
`<div data-scope="rows: [{id: 1, name: 'a'}, {id: 2, name: 'b'}]">
<ul><li data-for="row in rows key row.id">{row.name}</li></ul>
<button data-on-click="rows = rows.slice().reverse()">reverse</button>
</div>`,
);
const before = Array.from(win.document.querySelectorAll("li"));
expect(before.map((node) => node.textContent)).toEqual(["a", "b"]);
(win.document.querySelector("button") as unknown as HTMLElement).click();
const after = Array.from(win.document.querySelectorAll("li"));
expect(after.map((node) => node.textContent)).toEqual(["b", "a"]);
expect(after[0]).toBe(before[1]);
expect(after[1]).toBe(before[0]);
});
test("expression evaluator: member access, ternary, comparison, calls", () => {
const win = mount(
`<div data-scope="user: {name: 'Ada', age: 36}, items: [1, 2, 3]">
<span id="a" data-text="user.name"></span>
<span id="b" data-text="user.age > 30 ? 'senior' : 'junior'"></span>
<span id="c" data-text="items.length"></span>
</div>`,
);
expect(win.document.getElementById("a")!.textContent).toBe("Ada");
expect(win.document.getElementById("b")!.textContent).toBe("senior");
expect(win.document.getElementById("c")!.textContent).toBe("3");
});
test("expression evaluator exposes safe primitive conversion globals", () => {
const win = mount(
`<div data-scope="count: 12">
<span data-text="String(count)"></span>
</div>`,
);
expect(win.document.querySelector("span")!.textContent).toBe("12");
});
test("logical expressions consume their right-hand side when the result short-circuits", () => {
const win = mount(
`<div data-scope="leftFalse: false, leftTrue: true">
<span id="and" data-text="leftFalse && 123"></span>
<span id="or" data-text="leftTrue || 456"></span>
</div>`,
);
expect(win.document.getElementById("and")!.textContent).toBe("false");
expect(win.document.getElementById("or")!.textContent).toBe("true");
});
test("expression evaluator supports flatMap callbacks", () => {
const win = mount(
`<div data-scope="groups: [{items: [1, 2]}, {items: [3]}]">
<span data-text="groups.flatMap((group) => group.items)"></span>
</div>`,
);
expect(win.document.querySelector("span")!.textContent).toBe("1,2,3");
});
test("data-show toggles visibility without destroying interactive state", () => {
const win = mount(
`<div data-scope="tab: 0">
<button data-on-click="tab = 1">go</button>
<section id="a" data-show="tab === 0">A</section>
<section id="b" data-show="tab === 1">B</section>
</div>`,
);
const a = win.document.getElementById("a") as unknown as HTMLElement;
const b = win.document.getElementById("b") as unknown as HTMLElement;
expect(a.style.display).toBe("");
expect(b.style.display).toBe("none");
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(a.style.display).toBe("none");
expect(b.style.display).toBe("");
});
test("dynamic components remove inactive cases from the live DOM", async () => {
const win = mount(
`<div data-scope="active: 'Admin'">
<button data-on-click="active = active === 'Admin' ? 'Guest' : 'Admin'">switch</button>
<div data-wrn-dynamic-component="Admin" data-wrn-bind-0='["data-wrn-dynamic-component","{active}"]'>
<section data-component-case="Admin">Administrator secret</section>
<section data-component-case="Guest">Guest dashboard</section>
</div>
</div>`,
);
win.document.dispatchEvent(new win.Event("DOMContentLoaded"));
expect(win.document.querySelector('[data-component-case="Admin"]')?.textContent).toContain(
"Administrator",
);
expect(win.document.querySelector('[data-component-case="Guest"]')).toBeNull();
(win.document.querySelector("button") as unknown as HTMLElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(win.document.querySelector('[data-component-case="Admin"]')).toBeNull();
expect(win.document.querySelector('[data-component-case="Guest"]')?.textContent).toContain(
"Guest",
);
});
test("reactive data attributes preserve explicit boolean strings", () => {
const win = mount(
`<div data-scope="ready: true">
<section
id="carousel-layout"
data-show="true"
data-wrn-bind-0='["data-show","{ready}"]'
>Slides</section>
</div>`,
);
expect(win.document.getElementById("carousel-layout")!.getAttribute("data-show")).toBe("true");
});
test("reactive attribute bindings update input and accessibility attributes", () => {
const win = mount(
`<div data-scope="show: false">
<input id="password" type="password" data-wrn-bind-0='["type","{show ? &#39;text&#39; : &#39;password&#39;}"]'>
<button data-on-click="show = !show" aria-label="Show password"
data-wrn-bind-0='["aria-label","{show ? &#39;Hide password&#39; : &#39;Show password&#39;}"]'>Toggle</button>
</div>`,
);
const input = win.document.getElementById("password") as unknown as HTMLInputElement;
const button = win.document.querySelector("button") as unknown as HTMLElement;
expect(input.type).toBe("password");
expect(button.getAttribute("aria-label")).toBe("Show password");
button.click();
expect(input.type).toBe("text");
expect(button.getAttribute("aria-label")).toBe("Hide password");
});
test("parent state updates a mounted child's reactive prop", async () => {
const win = mount(
`<div data-scope="n: 1">` +
`<button data-on-click="n++">increment</button>` +
`<div data-scope="value: 1" data-wrn-prop-bind-0='["value","{n}"]'>` +
`<span id="child-value" data-text="value">1</span>` +
`</div></div>`,
);
await Promise.resolve();
expect(win.document.querySelector("#child-value")?.textContent).toBe("1");
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(win.document.querySelector("#child-value")?.textContent).toBe("2");
});
test("independent signals in one scope update correctly (dependency tracking)", () => {
const win = mount(
`<div data-scope="a: 0, b: 100">
<span id="ta" data-text="a"></span>
<span id="tb" data-text="b"></span>
<button id="ba" data-on-click="a++">A</button>
<button id="bb" data-on-click="b++">B</button>
</div>`,
);
const ta = () => win.document.getElementById("ta")!.textContent;
const tb = () => win.document.getElementById("tb")!.textContent;
const click = (id: string) => (win.document.getElementById(id) as unknown as HTMLElement).click();
expect([ta(), tb()]).toEqual(["0", "100"]);
click("ba");
click("ba");
expect([ta(), tb()]).toEqual(["2", "100"]); // b untouched by a's changes
click("bb");
expect([ta(), tb()]).toEqual(["2", "101"]);
});
test("data-for preserves arrays of objects from encoded component scope", () => {
const items = [
{
question: "One",
answer: "First",
},
{
question: "Two",
answer: "Second",
},
{
question: "Three",
answer: "Third",
},
{
question: "Four",
answer: "Fourth",
},
];
const encoded = Buffer.from(
JSON.stringify({
items,
}),
"utf8",
).toString("base64");
const dom = mountHtml(`
<div data-wrn-scope="${encoded}">
<article data-for="item, index in items">
<span>{index + 1}</span>
<strong>{item.question}</strong>
</article>
</div>
`);
expect(dom.querySelectorAll("article")).toHaveLength(4);
expect(dom.querySelectorAll("strong").map((element) => element.textContent)).toEqual([
"One",
"Two",
"Three",
"Four",
]);
});
test("$emit inside component functions does not depend on a global browser event", () => {
const behavior = Buffer.from(
JSON.stringify({
functions: `
function showOverlay(sourceEvent) {
visible = true
$emit("open", { source: sourceEvent.type })
}
`,
watches: [],
lifecycle: {},
}),
).toString("base64");
delete (globalThis as Record<string, unknown>).event;
const win = mount(
`<div data-scope="visible: false" data-wrn-events="open" data-wrn-behavior="${behavior}">
<button data-on-click="showOverlay(event)">Open overlay</button>
<span>{visible}</span>
</div>`,
);
const root = win.document.querySelector("[data-wrn-events]")!;
let detail: Record<string, unknown> | undefined;
root.addEventListener("open", (event) => {
detail = (event as unknown as CustomEvent).detail;
});
win.document.querySelector("button")!.click();
expect(win.document.querySelector("span")!.textContent).toBe("true");
expect(detail).toEqual({ source: "click" });
});
test("template literals work inside browser API call arguments", () => {
const behavior = Buffer.from(
JSON.stringify({
functions: `function save() {
window.localStorage.setItem(\`prefs:\${count}\`, JSON.stringify(count))
message = \`Saved \${count}\`
}`,
watches: [],
lifecycle: {},
}),
).toString("base64");
const win = mount(
`<div data-scope="count: 3, message: 'ready'" data-wrn-behavior="${behavior}">` +
`<button data-on-click="save()">{message}</button></div>`,
);
win.document.querySelector("button")!.click();
expect(win.localStorage.getItem("prefs:3")).toBe("3");
expect(win.document.querySelector("button")!.textContent).toBe("Saved 3");
});
test("hydration consumes private scope, behavior, and binding metadata", () => {
const behavior = Buffer.from(
JSON.stringify({
functions: "function toggle() { active = !active }",
watches: [],
lifecycle: {},
}),
).toString("base64");
const scope = Buffer.from(JSON.stringify({ active: false })).toString("base64");
const win = mount(
`<div id="scope" data-scope="active: false" data-wrn-scope="${scope}" data-wrn-behavior="${behavior}">` +
`<button id="toggle" data-wrn-bind-0='["aria-expanded","{active}"]' data-on-click="toggle()">Toggle</button>` +
`</div>`,
);
const root = win.document.getElementById("scope")!;
const button = win.document.getElementById("toggle")!;
expect(root.hasAttribute("data-scope")).toBe(false);
expect(root.hasAttribute("data-wrn-scope")).toBe(false);
expect(root.hasAttribute("data-wrn-behavior")).toBe(false);
expect(button.hasAttribute("data-wrn-bind-0")).toBe(false);
expect(button.getAttribute("aria-expanded")).toBe("false");
(button as unknown as { click(): void }).click();
expect(button.getAttribute("aria-expanded")).toBe("true");
});
test("hydration removes server-resolved document binding metadata", () => {
const win = mount(`<main>Document content</main>`);
const root = win.document.documentElement;
root.setAttribute("lang", "en");
root.setAttribute("data-theme", "dark");
root.setAttribute("data-wrn-bind-0", '["lang","{language}"]');
root.setAttribute("data-wrn-bind-1", '["data-theme","{theme}"]');
const runtime = win as unknown as {
__wrnexusHydrateScopes?: (root: unknown) => void;
};
runtime.__wrnexusHydrateScopes?.(win.document);
expect(root.getAttribute("lang")).toBe("en");
expect(root.getAttribute("data-theme")).toBe("dark");
expect(root.hasAttribute("data-wrn-bind-0")).toBe(false);
expect(root.hasAttribute("data-wrn-bind-1")).toBe(false);
});
test("compiled client functions are not overwritten by fallback behavior parsing", () => {
const behavior = Buffer.from(
JSON.stringify({
functions: `function save() { message = \`fallback\` }`,
watches: [],
lifecycle: {},
}),
).toString("base64");
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML =
`<div id="scope" data-scope="message: 'ready'" data-wrn-behavior="${behavior}">` +
`<button data-on-click="save()">{message}</button></div>`;
const scope = win.document.getElementById("scope") as unknown as HTMLElement & {
__wrnexusClientModule?: unknown;
};
scope.__wrnexusClientModule = {
bindClientScope(context: { state: Record<string, unknown> }) {
return {
save() {
context.state.message = "native";
},
};
},
};
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
(globalThis as Record<string, unknown>).location = win.location;
(globalThis as Record<string, unknown>).NodeFilter = (
win as unknown as { NodeFilter: unknown }
).NodeFilter;
(globalThis as Record<string, unknown>).MutationObserver = (
win as unknown as { MutationObserver: unknown }
).MutationObserver;
(0, eval)(REACTIVE_RUNTIME);
(win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }).__wrnexusHydrateScopes?.(
win.document,
);
win.document.querySelector("button")!.click();
expect(win.document.querySelector("button")!.textContent).toBe("native");
});
test("$route updates after client navigation", () => {
const win = new Window({ url: "http://localhost/" }) as unknown as Window &
Record<string, unknown>;
win.document.body.innerHTML =
`<span hidden data-wrn-route-params='{"id":"42"}'></span>` +
`<div data-scope=""><strong>{$route.pathname}</strong><em>{$route.params.id}</em></div>`;
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
(globalThis as Record<string, unknown>).location = win.location;
(globalThis as Record<string, unknown>).NodeFilter = (
win as unknown as { NodeFilter: unknown }
).NodeFilter;
(globalThis as Record<string, unknown>).MutationObserver = (
win as unknown as { MutationObserver: unknown }
).MutationObserver;
(0, eval)(REACTIVE_RUNTIME);
(win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }).__wrnexusHydrateScopes?.(
win.document,
);
expect(win.document.querySelector("strong")!.textContent).toBe("/");
expect(win.document.querySelector("em")!.textContent).toBe("42");
expect(win.document.querySelector("[data-wrn-route-params]")).toBeNull();
win.history.pushState({}, "", "/settings?tab=chat");
win.dispatchEvent(
new (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent(
"wrnexus:navigated",
) as unknown as Parameters<Window["dispatchEvent"]>[0],
);
expect(win.document.querySelector("strong")!.textContent).toBe("/settings");
});
test("cache invalidation refetches matching client Async boundaries", async () => {
let requests = 0;
(globalThis as Record<string, unknown>).fetch = () => {
requests++;
return Promise.resolve(Response.json({ data: { value: requests } }));
};
const win = mount(
`<section data-wrn-async="uploads" data-wrn-async-tags="uploads" data-wrn-async-retries="0">` +
`<div data-wrn-async-content></div>` +
`<template data-wrn-async-loading>Loading</template>` +
`<template data-wrn-async-success data-wrn-async-alias="uploads"><b>{uploads.value}</b></template>` +
`<template data-wrn-async-error data-wrn-async-alias="error">{error.message}</template>` +
`</section>`,
);
const runtime = win as unknown as { __wrnexusHydrateAsyncBoundaries?: (root: unknown) => void };
runtime.__wrnexusHydrateAsyncBoundaries?.(win.document);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(win.document.querySelector("b")?.textContent).toBe("1");
win.dispatchEvent(
new (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent(
"wrnexus:cache:invalidate",
{ detail: { tags: ["uploads"] } },
) as unknown as Parameters<Window["dispatchEvent"]>[0],
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(requests).toBe(2);
expect(win.document.querySelector("b")?.textContent).toBe("2");
});
// --- parent-scope ownership across a component boundary --------------------
// Slot content and `@output` handlers on a component tag are authored in the
// PARENT (page) source, but SSR splices them inside the child component's
// [data-scope] root. Without an owner-chain rule the child scope claims them,
// so the page's own functions and state are invisible to markup the page
// itself wrote.
test("slot content is hydrated by the parent scope, not the component it lands in", () => {
const win = mount(
`<div data-scope="pageValue: 'page', pageFlag: true">` +
`<div data-scope="pageValue: 'child', pageFlag: false" data-wrn-hydration="Modal:x">` +
`<div data-wrn-events="confirm" class="view">` +
`<wrn-slot data-wrn-slot=""><b>{pageValue}</b><i data-show="pageFlag">shown</i></wrn-slot>` +
`</div></div></div>`,
);
expect(win.document.querySelector("b")?.textContent).toBe("page");
expect(win.document.querySelector("i")?.getAttribute("data-show")).toBe("true");
});
test("slot event handlers write to the parent scope", () => {
const win = mount(
`<div data-scope="count: 0">` +
`<span id="out">{count}</span>` +
`<div data-scope="count: 99" data-wrn-hydration="Modal:x">` +
`<div data-wrn-events="confirm">` +
`<wrn-slot data-wrn-slot=""><button data-on-click="count++">go</button></wrn-slot>` +
`</div></div></div>`,
);
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(win.document.querySelector("#out")?.textContent).toBe("1");
});
test("component output handlers run in the parent scope", () => {
const win = mount(
`<div data-scope="saved: ''">` +
`<span id="out">{saved}</span>` +
`<div data-scope="saved: 'child'" data-wrn-hydration="Modal:x">` +
`<div data-wrn-events="confirm" data-wrn-out-confirm="saved = 'yes'"></div>` +
`</div></div>`,
);
const target = win.document.querySelector("[data-wrn-events]") as unknown as {
__wrnexusOutputHandlers?: Record<string, Set<(payload: unknown) => unknown>>;
};
const handlers = target.__wrnexusOutputHandlers?.confirm;
expect(handlers && handlers.size).toBe(1);
handlers!.forEach((handler) => handler({}));
expect(win.document.querySelector("#out")?.textContent).toBe("yes");
});
test("a camelCase output reaches a parent binding despite attribute lowercasing", () => {
/*
* A parent writes @sizeChange; HTML lowercases attribute names, so the
* handler registers under "sizechange" while the component emits
* "sizeChange". The lookup used to miss and fall through to a DOM dispatch,
* so the binding was never invoked and nothing reported an error. Every
* camelCase output in the library was undeliverable -- LayoutSplitter's
* sizeChange, DataTable's pageChange and rowClick, Map's markerClick and
* twelve more. Verified in a browser before and after the fix.
*/
const win = mount(
`<div data-scope="saved: ''">` +
`<span id="out">{saved}</span>` +
`<div data-scope="n: 0" data-wrn-hydration="LayoutSplitter:x">` +
`<div data-wrn-events="sizechange" data-wrn-out-sizechange="saved = 'yes'">` +
`<button data-on-click="output.sizeChange({ size: 45 })">go</button>` +
`</div></div></div>`,
);
const target = win.document.querySelector("[data-wrn-events]") as unknown as {
__wrnexusOutputHandlers?: Record<string, Set<(payload: unknown) => unknown>>;
};
// The registry is keyed as the DOM gave it: lowercased, not as authored.
expect(Object.keys(target.__wrnexusOutputHandlers ?? {})).toContain("sizechange");
expect(target.__wrnexusOutputHandlers?.sizeChange).toBeUndefined();
// Emitting through the real output proxy, under the camelCase name the
// component actually writes, must still reach the parent.
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(win.document.querySelector("#out")?.textContent).toBe("yes");
});
test("development runtime allows an output with no parent binding", () => {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `<div data-scope="" data-wrn-events="complete"><button data-on-click="output.complete({})">go</button></div>`;
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
(globalThis as Record<string, unknown>).location = win.location;
(globalThis as Record<string, unknown>).NodeFilter = (
win as unknown as { NodeFilter: unknown }
).NodeFilter;
(globalThis as Record<string, unknown>).MutationObserver = (
win as unknown as { MutationObserver: unknown }
).MutationObserver;
(globalThis as Record<string, unknown>).CustomEvent = (
win as unknown as { CustomEvent: unknown }
).CustomEvent;
const warnings: unknown[][] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => warnings.push(args);
try {
(0, eval)(getReactiveRuntime(true));
(
win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }
).__wrnexusHydrateScopes?.(win.document);
(win.document.querySelector("button") as unknown as HTMLElement).click();
(win.document.querySelector("button") as unknown as HTMLElement).click();
} finally {
console.warn = originalWarn;
}
expect(warnings).toHaveLength(0);
});
test("an unbound native-named output does not re-enter its DOM handler", () => {
const win = mount(
`<div data-scope="" data-wrn-events="click">` +
`<button data-on-click="output.click()">go</button>` +
`</div>`,
);
const root = win.document.querySelector("[data-wrn-events]") as unknown as HTMLElement;
let outputs = 0;
root.addEventListener("click", (event) => {
if ((event as Event & { __wrnexusComponentOutput?: boolean }).__wrnexusComponentOutput) {
outputs += 1;
}
});
expect(() =>
(win.document.querySelector("button") as unknown as HTMLElement).click(),
).not.toThrow();
expect(outputs).toBe(1);
});
test("component server calls send the CSRF cookie in the RPC header", async () => {
const win = mount(
`<div data-scope="" data-wrn-component="Home">` +
`<button data-on-click="server.handleClick()">go</button>` +
`</div>`,
);
Object.defineProperty(win.document, "cookie", {
configurable: true,
value: "wrn-csrf=rpc%20token",
});
let request: RequestInit | undefined;
const globals = globalThis as Record<string, unknown>;
const originalFetch = globals.fetch;
globals.fetch = async (_input: RequestInfo | URL, init?: RequestInit) => {
request = init;
return Response.json({ ok: true });
};
try {
(win.document.querySelector("button") as unknown as HTMLElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
} finally {
globals.fetch = originalFetch;
}
expect(new Headers(request?.headers).get("x-csrf-token")).toBe("rpc token");
});
test("component server calls use the nearest hydration component identity", async () => {
const win = mount(
`<div data-scope="" data-wrn-hydration="CButton:abc123">` +
`<div data-wrn-events="click"><button data-on-click="server.handleClick()">go</button></div>` +
`</div>`,
);
let payload: { component?: string } | undefined;
const globals = globalThis as Record<string, unknown>;
const originalFetch = globals.fetch;
globals.fetch = async (_input: RequestInfo | URL, init?: RequestInit) => {
payload = JSON.parse(String(init?.body));
return Response.json({ ok: true });
};
try {
(win.document.querySelector("button") as unknown as HTMLElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
} finally {
globals.fetch = originalFetch;
}
expect(payload?.component).toBe("CButton");
});
test("a parent scope does not inherit a child component RPC identity", async () => {
const win = mount(
`<div data-scope="" data-wrn-hydration="Home:page123">` +
`<div data-scope="" data-wrn-hydration="CButton:button123">` +
`<div data-wrn-events="click"></div></div>` +
`<button id="parent-action" data-on-click="server.handleClick()">go</button>` +
`</div>`,
);
let payload: { component?: string } | undefined;
const globals = globalThis as Record<string, unknown>;
const originalFetch = globals.fetch;
globals.fetch = async (_input: RequestInfo | URL, init?: RequestInit) => {
payload = JSON.parse(String(init?.body));
return Response.json({ ok: true });
};
try {
(win.document.querySelector("#parent-action") as unknown as HTMLElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
} finally {
globals.fetch = originalFetch;
}
expect(payload?.component).toBe("Home");
});
test("component server calls prefer the rendered CSRF token over stale cookies", async () => {
const win = mount(
`<meta name="wrnexus-csrf" content="current-token">` +
`<div data-scope="" data-wrn-component="Home">` +
`<button data-on-click="server.handleClick()">go</button>` +
`</div>`,
);
Object.defineProperty(win.document, "cookie", {
configurable: true,
value: "wrn-csrf=stale-token",
});
let request: RequestInit | undefined;
const globals = globalThis as Record<string, unknown>;
const originalFetch = globals.fetch;
globals.fetch = async (_input: RequestInfo | URL, init?: RequestInit) => {
request = init;
return Response.json({ ok: true });
};
try {
(win.document.querySelector("button") as unknown as HTMLElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
} finally {
globals.fetch = originalFetch;
}
expect(new Headers(request?.headers).get("x-csrf-token")).toBe("current-token");
});
test("development runtime warns when a component binding names a missing function", () => {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML =
`<div data-scope="">` +
`<div data-scope="" data-wrn-hydration="Child:x">` +
`<div data-wrn-events="save" data-wrn-out-save="missingSave(payload)"></div>` +
`</div></div>`;
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
(globalThis as Record<string, unknown>).location = win.location;
(globalThis as Record<string, unknown>).NodeFilter = (
win as unknown as { NodeFilter: unknown }
).NodeFilter;
(globalThis as Record<string, unknown>).MutationObserver = (
win as unknown as { MutationObserver: unknown }
).MutationObserver;
(globalThis as Record<string, unknown>).CustomEvent = (
win as unknown as { CustomEvent: unknown }
).CustomEvent;
const warnings: unknown[][] = [];
const originalWarn = console.warn;
const originalError = console.error;
console.warn = (...args: unknown[]) => warnings.push(args);
console.error = () => {};
try {
(0, eval)(getReactiveRuntime(true));
(
win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }
).__wrnexusHydrateScopes?.(win.document);
const target = win.document.querySelector("[data-wrn-events]") as unknown as {
__wrnexusOutputHandlers?: Record<string, Set<(payload: unknown) => unknown>>;
};
target.__wrnexusOutputHandlers?.save?.forEach((handler) => handler({ id: 1 }));
target.__wrnexusOutputHandlers?.save?.forEach((handler) => handler({ id: 2 }));
} finally {
console.warn = originalWarn;
console.error = originalError;
}
expect(warnings).toHaveLength(1);
expect(String(warnings[0]?.[0])).toContain("WRN-DEV-BINDING-MISSING");
expect(String(warnings[0]?.[0])).toContain("missingSave");
});
test("development runtime warns for referenced theme tokens absent from rendered CSS", () => {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.head.innerHTML =
`<style>:root { --wrn-present-test: red; } .probe { ` +
`color: var(--wrn-present-test); background: var(--wrn-missing-test); }</style>`;
win.document.body.innerHTML = `<div class="probe" data-scope=""></div>`;
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
(globalThis as Record<string, unknown>).location = win.location;
(globalThis as Record<string, unknown>).NodeFilter = (
win as unknown as { NodeFilter: unknown }
).NodeFilter;
(globalThis as Record<string, unknown>).MutationObserver = (
win as unknown as { MutationObserver: unknown }
).MutationObserver;
(globalThis as Record<string, unknown>).CustomEvent = (
win as unknown as { CustomEvent: unknown }
).CustomEvent;
const warnings: unknown[][] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => warnings.push(args);
try {
(0, eval)(getReactiveRuntime(true));
(
win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }
).__wrnexusHydrateScopes?.(win.document);
} finally {
console.warn = originalWarn;
}
expect(warnings).toHaveLength(1);
expect(String(warnings[0]?.[0])).toContain("WRN-DEV-THEME-TOKEN-MISSING");
expect(String(warnings[0]?.[0])).toContain("--wrn-missing-test");
});
test("development runtime accepts component-local and inline wrn variables", () => {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.head.innerHTML =
`<style>.component { --wrn-component-local: red; color: var(--wrn-component-local); ` +
`background: var(--wrn-inline-local); }</style>`;
win.document.body.innerHTML = `<div class="component" style="--wrn-inline-local: blue" data-scope=""></div>`;
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
(globalThis as Record<string, unknown>).location = win.location;
(globalThis as Record<string, unknown>).NodeFilter = (
win as unknown as { NodeFilter: unknown }
).NodeFilter;
(globalThis as Record<string, unknown>).MutationObserver = (
win as unknown as { MutationObserver: unknown }
).MutationObserver;
(globalThis as Record<string, unknown>).CustomEvent = (
win as unknown as { CustomEvent: unknown }
).CustomEvent;
const warnings: unknown[][] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => warnings.push(args);
try {
(0, eval)(getReactiveRuntime(true));
(
win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }
).__wrnexusHydrateScopes?.(win.document);
} finally {
console.warn = originalWarn;
}
expect(warnings).toHaveLength(0);
});
test("production runtime strips development diagnostics", () => {
const production = getReactiveRuntime();
expect(production).not.toContain("WRN-DEV-");
expect(production).not.toContain("warnOnce");
expect(REACTIVE_RUNTIME).toContain("__WRNEXUS_DEV_START__");
});
// --- browser globals + regex literals in client expressions ----------------
// Client functions and inline handlers are interpreted by the runtime's own
// eval-free expression engine (so a strict CSP needs no unsafe-eval). Anything
// the engine cannot resolve silently becomes undefined, so an author calling
// an ordinary browser API gets a confusing "not a function" instead of the
// behavior they wrote.
test("client expressions can call browser globals like alert", () => {
const win = mount(
`<div data-scope="msg: 'hi'"><button data-on-click="alert(msg)">go</button></div>`,
) as unknown as Window & {
alert?: (message: string) => void;
};
const seen: string[] = [];
win.alert = (message: string) => seen.push(String(message));
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(seen).toEqual(["hi"]);
});
test("client expressions resolve JS builtins", () => {
const win = mount(
`<div data-scope="raw: ' 42 ', out: 0, keys: ''">` +
`<button data-on-click="out = parseInt(raw, 10); keys = Object.keys({ a: 1, b: 2 }).join('-')">go</button>` +
`<span id="n">{out}</span><span id="k">{keys}</span>` +
`</div>`,
);
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(win.document.querySelector("#n")?.textContent).toBe("42");
expect(win.document.querySelector("#k")?.textContent).toBe("a-b");
});
test("client expressions support regex literals", () => {
const win = mount(
`<div data-scope="email: 'dev@wrnexus.io', ok: ''">` +
// String.raw so the backslashes reach the runtime: in a plain template
// literal `\s` collapses to `s` and the test would pass against a
// pattern the author never wrote.
String.raw`<button data-on-click="ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? 'yes' : 'no'">go</button>` +
`<span id="out">{ok}</span></div>`,
);
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(win.document.querySelector("#out")?.textContent).toBe("yes");
});
test("a regex literal with a division sign nearby still parses as division", () => {
const win = mount(
`<div data-scope="a: 10, b: 4, out: 0"><button data-on-click="out = a / b">go</button><span id="out">{out}</span></div>`,
);
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(win.document.querySelector("#out")?.textContent).toBe("2.5");
});
test("a string literal that spells an operator stays a value", () => {
const win = mount(
`<div data-scope="parts: 'a.b.c', joined: '', first: ''">` +
`<button data-on-click="joined = ['x','y'].join('-'); first = parts.split('.')[0]">go</button>` +
`<span id="j">{joined}</span><span id="f">{first}</span></div>`,
);
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(win.document.querySelector("#j")?.textContent).toBe("x-y");
expect(win.document.querySelector("#f")?.textContent).toBe("a");
});
// --- toast() ---------------------------------------------------------------
// The runtime raises notifications through a window event and never imports
// the Toaster component, so an app can host toasts however it likes.
test("toast() dispatches a wrnexus:toast event when a toaster is mounted", () => {
const win = mount(
`<div data-toaster="true"></div>` +
`<div data-scope="who: 'Ada'"><button data-on-click="toast.success('hi ' + who, { title: 'Done' })">go</button></div>`,
) as unknown as Window & { addEventListener: Window["addEventListener"] };
const seen: Record<string, unknown>[] = [];
win.addEventListener("wrnexus:toast", ((event: { detail: Record<string, unknown> }) => {
seen.push(event.detail);
}) as unknown as Parameters<Window["addEventListener"]>[1]);
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(seen).toEqual([{ message: "hi Ada", title: "Done", tone: "success" }]);
});
test("toast() falls back to the console when no toaster is mounted", () => {
const win = mount(
`<div data-scope=""><button data-on-click="toast('orphan')">go</button></div>`,
) as unknown as Window & {
addEventListener: Window["addEventListener"];
};
const dispatched: unknown[] = [];
win.addEventListener("wrnexus:toast", ((event: unknown) =>
dispatched.push(event)) as unknown as Parameters<Window["addEventListener"]>[1]);
const original = console.info;
const logged: string[] = [];
console.info = (...args: unknown[]) => void logged.push(args.map(String).join(" "));
try {
(win.document.querySelector("button") as unknown as HTMLElement).click();
} finally {
console.info = original;
}
expect(dispatched).toEqual([]);
expect(logged.join("\n")).toContain("orphan");
});
test("toast is reachable as a real global for compiled client modules", () => {
const win = mount(`<div data-scope=""></div>`) as unknown as Window & {
toast?: unknown;
wrnToast?: unknown;
};
expect(typeof win.wrnToast).toBe("function");
expect(typeof win.toast).toBe("function");
});
test("data-show is evaluated inside a data-for row", () => {
const win = mount(
`<div data-scope="rows: [{ id: 1, label: 'a', extra: '' }, { id: 2, label: 'b', extra: 'yes' }]">` +
`<ul><li data-for="row in rows" data-key="row.id">` +
`<span class="label">{row.label}</span>` +
`<span class="extra" data-show="row.extra">{row.extra}</span>` +
`</li></ul></div>`,
);
const extras = [...win.document.querySelectorAll(".extra")] as unknown as HTMLElement[];
expect(extras.length).toBe(2);
// First row has no extra: hidden. Second row does: visible.
expect(extras[0]!.getAttribute("data-show")).toBe("false");
expect(extras[0]!.style.display).toBe("none");
expect(extras[1]!.getAttribute("data-show")).toBe("true");
expect(extras[1]!.style.display).not.toBe("none");
});
test("data-show in a data-for row can read component scope, not just the item", () => {
const win = mount(
`<div data-scope="showAll: false, rows: [{ id: 1 }]">` +
`<ul><li data-for="row in rows" data-key="row.id">` +
`<b data-show="showAll">always?</b>` +
`</li></ul></div>`,
);
const node = win.document.querySelector("b") as unknown as HTMLElement;
expect(node.getAttribute("data-show")).toBe("false");
expect(node.style.display).toBe("none");
});
// --- nested data-for -------------------------------------------------------
// A table is rows containing cells, so a loop inside a loop has to work. The
// inner template used to be left unexpanded (and its mustaches baked against
// the outer item, where the inner name is undefined), so nested rows rendered
// blank.
test("a data-for nested inside a data-for renders its rows", () => {
const win = mount(
`<div data-scope="rows: [{ id: 1, cells: [{ v: 'a1' }, { v: 'b1' }] }, { id: 2, cells: [{ v: 'a2' }, { v: 'b2' }] }]">` +
`<table><tbody>` +
`<tr data-for="row in rows" data-key="row.id">` +
`<td data-for="cell in row.cells" class="cell">{cell.v}</td>` +
`</tr>` +
`</tbody></table></div>`,
);
const cells = [...win.document.querySelectorAll(".cell")].map((n) => n.textContent);
expect(cells).toEqual(["a1", "b1", "a2", "b2"]);
});
test("a nested loop can read the enclosing item and component scope", () => {
const win = mount(
`<div data-scope="prefix: 'x', groups: [{ name: 'g1', items: ['p', 'q'] }]">` +
`<ul><li data-for="group in groups">` +
`<span data-for="item in group.items" class="leaf">{prefix}-{group.name}-{item}</span>` +
`</li></ul></div>`,
);
const leaves = [...win.document.querySelectorAll(".leaf")].map((n) => n.textContent);
expect(leaves).toEqual(["x-g1-p", "x-g1-q"]);
});
// A binding inside a loop row must react to state the row's list expression
// never touched. Effects registered during the initial render sweep used to be
// skipped entirely (forEach fixes its range up front), so they never ran, never
// subscribed, and stayed frozen at their first value for the life of the page.
test("a data-show inside a loop reacts to state the list expression never reads", () => {
const win = mount(
`<div data-scope="rows: [{ id: 1 }, { id: 2 }], showExtra: false">` +
`<div data-for="row in rows" data-key="row.id">` +
`<b class="extra" data-show="showExtra">detail</b>` +
`</div>` +
`<button data-on-click="showExtra = true">go</button>` +
`</div>`,
);
const hidden = [...win.document.querySelectorAll(".extra")] as unknown as HTMLElement[];
expect(hidden.length).toBe(2);
expect(hidden.every((n) => n.style.display === "none")).toBe(true);
(win.document.querySelector("button") as unknown as HTMLElement).click();
const shown = [...win.document.querySelectorAll(".extra")] as unknown as HTMLElement[];
expect(shown.every((n) => n.style.display !== "none")).toBe(true);
});
test("a checked binding inside a loop toggles with state", () => {
const win = mount(
`<div data-scope="rows: [{ id: 1 }, { id: 2 }], chosen: 2">` +
`<ul><li data-for="row in rows" data-key="row.id">` +
`<input class="box" type="checkbox" data-wrn-bind-0='["checked","{row.id === chosen}"]' />` +
`</li></ul>` +
`<button data-on-click="chosen = 1">pick first</button></div>`,
);
const boxes = () => [...win.document.querySelectorAll(".box")] as unknown as HTMLInputElement[];
expect(boxes().map((b) => b.checked)).toEqual([false, true]);
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(boxes().map((b) => b.checked)).toEqual([true, false]);
});
// Rows rebuilt AFTER hydration register their effects outside the initial
// sweep, so nothing ran them: data-show kept its raw expression and the
// element defaulted to visible. Re-sorting a table therefore drew a second
// copy of every header label.
test("data-show still resolves in rows rebuilt after the first render", () => {
const win = mount(
`<div data-scope="rows: [{ id: 1, on: true }, { id: 2, on: false }]">` +
`<div data-for="row in rows" data-key="row.id">` +
`<b class="yes" data-show="row.on">yes</b>` +
`</div>` +
`<button data-on-click="rows = [{ id: 1, on: false }, { id: 2, on: true }]">swap</button>` +
`</div>`,
);
const shown = () =>
([...win.document.querySelectorAll(".yes")] as unknown as HTMLElement[]).map(
(node) => node.style.display !== "none",
);
expect(shown()).toEqual([true, false]);
// New item objects mean brand new clones, hydrated outside the first sweep.
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(shown()).toEqual([false, true]);
});
// data-html is the one directive that does not escape, so it is a separate
// opt-in rather than a flag on data-text.
test("data-html renders markup while data-text escapes it", () => {
const win = mount(
`<div data-scope="markup: '<b>bold</b>'">` +
`<span id="raw" data-html="markup"></span>` +
`<span id="safe" data-text="markup"></span>` +
`</div>`,
);
expect(win.document.querySelector("#raw")?.innerHTML).toBe("<b>bold</b>");
expect(win.document.querySelector("#raw b")?.textContent).toBe("bold");
// data-text must still show the markup as literal characters.
expect(win.document.querySelector("#safe b")).toBeNull();
expect(win.document.querySelector("#safe")?.textContent).toBe("<b>bold</b>");
});
test("data-html applies inside a data-for row", () => {
const win = mount(
`<div data-scope="rows: [{ id: 1, cell: '<b>one</b>' }, { id: 2, cell: '<i>two</i>' }]">` +
`<ul><li data-for="row in rows" data-key="row.id">` +
`<span class="cell" data-html="row.cell"></span>` +
`</li></ul></div>`,
);
const cells = [...win.document.querySelectorAll(".cell")].map((n) => n.innerHTML);
expect(cells).toEqual(["<b>one</b>", "<i>two</i>"]);
});
// Statement bodies given to the expression engine -- lifecycle hooks, inline
// handlers -- are ordinary authored code and contain comments. The leading
// slash used to fall through to the regex-literal branch and kill the body
// with "Unclosed regular expression".
test("comments are ignored inside interpreted statements", () => {
const win = mount(
`<div data-scope="total: 0">` +
`<button data-on-click="// bump the counter&#10;total = total + 2 /* twice */">go</button>` +
`<span id="out">{total}</span></div>`,
);
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(win.document.querySelector("#out")?.textContent).toBe("2");
});
/*
* happy-dom builds its own KeyboardEvent, which is structurally distinct from
* the DOM lib Event that dispatchEvent is typed against. Same cast the window
* dispatches above use, kept in one place.
*/
function keydown(win: Window, key: string): Event {
const ctor = (win as unknown as { KeyboardEvent: new (type: string, init: unknown) => unknown })
.KeyboardEvent;
return new ctor("keydown", { key, bubbles: true }) as unknown as Event;
}
test("roving focus moves with arrow keys and wraps", () => {
const win = mount(
`<div data-wrn-roving="horizontal">
<button data-wrn-roving-item id="a">A</button>
<button data-wrn-roving-item id="b">B</button>
<button data-wrn-roving-item id="c">C</button>
</div>`,
);
const doc = win.document;
const a = doc.querySelector("#a") as unknown as HTMLElement;
const c = doc.querySelector("#c") as unknown as HTMLElement;
expect(a.getAttribute("tabindex")).toBe("0");
expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("-1");
a.focus();
a.dispatchEvent(keydown(win, "ArrowRight"));
expect(doc.activeElement!.id).toBe("b");
expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("0");
expect(a.getAttribute("tabindex")).toBe("-1");
(doc.querySelector("#b") as unknown as HTMLElement).dispatchEvent(keydown(win, "ArrowLeft"));
expect(doc.activeElement!.id).toBe("a");
a.dispatchEvent(keydown(win, "ArrowLeft"));
expect(doc.activeElement!.id).toBe("c");
c.dispatchEvent(keydown(win, "ArrowRight"));
expect(doc.activeElement!.id).toBe("a");
});
test("roving focus honours Home and End and skips disabled items", () => {
const win = mount(
`<div data-wrn-roving="vertical">
<button data-wrn-roving-item id="a">A</button>
<button data-wrn-roving-item id="b" disabled>B</button>
<button data-wrn-roving-item id="c">C</button>
</div>`,
);
const doc = win.document;
const a = doc.querySelector("#a") as unknown as HTMLElement;
a.focus();
a.dispatchEvent(keydown(win, "ArrowDown"));
expect(doc.activeElement!.id).toBe("c");
(doc.querySelector("#c") as unknown as HTMLElement).dispatchEvent(keydown(win, "Home"));
expect(doc.activeElement!.id).toBe("a");
a.dispatchEvent(keydown(win, "End"));
expect(doc.activeElement!.id).toBe("c");
});
test("horizontal roving ignores vertical arrows so the page still scrolls", () => {
const win = mount(
`<div data-wrn-roving="horizontal">
<button data-wrn-roving-item id="a">A</button>
<button data-wrn-roving-item id="b">B</button>
</div>`,
);
const a = win.document.querySelector("#a") as unknown as HTMLElement;
a.focus();
a.dispatchEvent(keydown(win, "ArrowDown"));
expect(win.document.activeElement!.id).toBe("a");
});
test("roving tabindex starts on the selected item, not the first", () => {
const win = mount(
`<div data-wrn-roving="horizontal">
<button data-wrn-roving-item id="a">A</button>
<button data-wrn-roving-item id="b" aria-selected="true">B</button>
</div>`,
);
expect(win.document.querySelector("#b")!.getAttribute("tabindex")).toBe("0");
expect(win.document.querySelector("#a")!.getAttribute("tabindex")).toBe("-1");
});
test("nested roving groups do not capture the outer group items", () => {
const win = mount(
`<div data-wrn-roving="horizontal" id="outer">
<button data-wrn-roving-item id="a">A</button>
<div data-wrn-roving="vertical" id="inner">
<button data-wrn-roving-item id="x">X</button>
<button data-wrn-roving-item id="y">Y</button>
</div>
<button data-wrn-roving-item id="b">B</button>
</div>`,
);
const doc = win.document;
const a = doc.querySelector("#a") as unknown as HTMLElement;
a.focus();
a.dispatchEvent(keydown(win, "ArrowRight"));
expect(doc.activeElement!.id).toBe("b");
});
test("opening a modal dialog traps Tab inside it", () => {
const win = mount(
`<div>
<button id="outside">Outside</button>
<div data-show="true">
<section role="dialog" aria-modal="true" tabindex="-1">
<button id="first">First</button>
<button id="last">Last</button>
</section>
</div>
</div>`,
);
const doc = win.document;
const last = doc.querySelector("#last") as unknown as HTMLElement;
last.focus();
last.dispatchEvent(keydown(win, "Tab"));
expect(doc.activeElement!.id).toBe("first");
});
test("a hidden dialog does not trap Tab", () => {
const win = mount(
`<div>
<button id="outside">Outside</button>
<div data-show="false">
<section role="dialog" aria-modal="true" tabindex="-1">
<button id="first">First</button>
</section>
</div>
</div>`,
);
const doc = win.document;
const outside = doc.querySelector("#outside") as unknown as HTMLElement;
outside.focus();
outside.dispatchEvent(keydown(win, "Tab"));
expect(doc.activeElement!.id).toBe("outside");
});
test("an empty or false roving attribute opts the group out entirely", () => {
const win = mount(
`<div data-wrn-roving="">
<button data-wrn-roving-item="false" id="a">A</button>
<button data-wrn-roving-item="false" id="b">B</button>
</div>`,
);
const doc = win.document;
const a = doc.querySelector("#a") as unknown as HTMLElement;
expect(a.getAttribute("tabindex")).toBeNull();
a.focus();
a.dispatchEvent(keydown(win, "ArrowRight"));
expect(doc.activeElement!.id).toBe("a");
});
test("json inside a textarea is left alone, not read as mustaches", () => {
const win = mount(
`<div data-scope="count: 7">
<textarea id="editor">{"id":"a-1","count":3}</textarea>
<span id="out">{count}</span>
</div>`,
);
const doc = win.document;
// The surrounding scope still interpolates normally...
expect(doc.querySelector("#out")!.textContent).toBe("7");
// ...but the textarea holds data, and an expression engine would have
// evaluated {"id":"a-1","count":3} away and left it empty.
expect(doc.querySelector("#editor")!.textContent).toBe('{"id":"a-1","count":3}');
});
test("a closed drawer does not hold the body scroll lock", () => {
const win = mount(
`<div data-ui-component="Drawer" data-open="false">
<div class="layer">
<section role="dialog" aria-modal="true" tabindex="-1">
<button id="inside">Inside</button>
</section>
</div>
</div>`,
);
const doc = win.document;
/*
* A drawer animates open, so its panel cannot be hidden with data-show --
* display:none is not transitionable. It publishes data-open instead. Reading
* only data-show made every closed drawer look open, which locked the body
* and left the page unscrollable.
*/
expect(doc.body.style.overflow).not.toBe("hidden");
const outside = doc.querySelector("#inside") as unknown as HTMLElement;
outside.focus();
outside.dispatchEvent(keydown(win, "Tab"));
// No trap either: focus is free to leave a closed dialog.
expect(doc.activeElement!.id).toBe("inside");
});
test("an open drawer locks the body scroll and traps Tab", () => {
const win = mount(
`<div data-ui-component="Drawer" data-open="true">
<section role="dialog" aria-modal="true" tabindex="-1">
<button id="first">First</button>
<button id="last">Last</button>
</section>
</div>`,
);
const doc = win.document;
expect(doc.body.style.overflow).toBe("hidden");
const last = doc.querySelector("#last") as unknown as HTMLElement;
last.focus();
last.dispatchEvent(keydown(win, "Tab"));
expect(doc.activeElement!.id).toBe("first");
});
test("splitter keyboard steps move the divider and clamp at the bounds", () => {
const win = mount(
`<div data-wrn-splitter="horizontal" data-wrn-splitter-min="20" data-wrn-splitter-step="10"
style="--wrn-split: 50%">
<div>left</div>
<div data-wrn-splitter-handle role="separator" tabindex="0"
aria-valuenow="50" aria-valuemin="20" aria-valuemax="80"></div>
<div>right</div>
</div>`,
);
const doc = win.document;
const root = doc.querySelector("[data-wrn-splitter]") as unknown as HTMLElement;
const handle = doc.querySelector("[data-wrn-splitter-handle]") as unknown as HTMLElement;
handle.dispatchEvent(keydown(win, "ArrowRight"));
expect(handle.getAttribute("aria-valuenow")).toBe("60");
expect(root.style.getPropertyValue("--wrn-split").trim()).toBe("60%");
handle.dispatchEvent(keydown(win, "ArrowLeft"));
expect(handle.getAttribute("aria-valuenow")).toBe("50");
// Home and End go to the bounds, not to 0 and 100: a pane dragged to
// nothing cannot be recovered with a pointer.
handle.dispatchEvent(keydown(win, "Home"));
expect(handle.getAttribute("aria-valuenow")).toBe("20");
handle.dispatchEvent(keydown(win, "End"));
expect(handle.getAttribute("aria-valuenow")).toBe("80");
// Already at the maximum; another step must not exceed it.
handle.dispatchEvent(keydown(win, "ArrowRight"));
expect(handle.getAttribute("aria-valuenow")).toBe("80");
});
test("a vertical splitter responds to up and down instead", () => {
const win = mount(
`<div data-wrn-splitter="vertical" data-wrn-splitter-min="25" data-wrn-splitter-step="5"
style="--wrn-split: 50%">
<div>top</div>
<div data-wrn-splitter-handle role="separator" tabindex="0"
aria-valuenow="50" aria-valuemin="25" aria-valuemax="75"></div>
<div>bottom</div>
</div>`,
);
const handle = win.document.querySelector("[data-wrn-splitter-handle]") as unknown as HTMLElement;
handle.dispatchEvent(keydown(win, "ArrowDown"));
expect(handle.getAttribute("aria-valuenow")).toBe("55");
// The other axis is left alone so the page can still scroll sideways.
handle.dispatchEvent(keydown(win, "ArrowRight"));
expect(handle.getAttribute("aria-valuenow")).toBe("55");
});
test("splitter announces its new size for the component to re-emit", () => {
const win = mount(
`<div data-wrn-splitter="horizontal" data-wrn-splitter-min="10" data-wrn-splitter-step="10"
style="--wrn-split: 50%">
<div>left</div>
<div data-wrn-splitter-handle role="separator" tabindex="0"
aria-valuenow="50" aria-valuemin="10" aria-valuemax="90"></div>
<div>right</div>
</div>`,
);
const doc = win.document;
const root = doc.querySelector("[data-wrn-splitter]") as unknown as HTMLElement;
const seen: number[] = [];
root.addEventListener("wrnexus:splitter:resize", (event) =>
seen.push((event as CustomEvent).detail.size),
);
(doc.querySelector("[data-wrn-splitter-handle]") as unknown as HTMLElement).dispatchEvent(
keydown(win, "ArrowRight"),
);
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);
});
test("a @focus binding on a component tag fires for a focusable element inside it", () => {
/*
* focus and blur do NOT bubble. The runtime bound every output-named DOM
* fallback listener in the bubble phase, so a parent writing @focus on a
* component tag never heard the component's own input or button take focus:
* the event fired on the descendant and stopped there. Nothing errored --
* the binding simply did nothing, which is why the library still declares
* focus/blur outputs on components that could never deliver them
* (button, TextLink, WysiwygEditor).
*
* The ui ratchet for "outputs nothing emits" excludes natively-named
* outputs on the stated grounds that a native event reaches the root
* anyway. That reasoning holds for click and change, which bubble, and
* fails for focus and blur, which do not.
*/
const win = mount(
`<div data-scope="seen: ''">` +
`<span id="out">{seen}</span>` +
`<div data-scope="n: 0" data-wrn-hydration="Field:x">` +
`<div data-wrn-events="focus" data-wrn-out-focus="seen = 'focused'">` +
`<button id="inner">press</button>` +
`</div></div></div>`,
);
const inner = win.document.querySelector("#inner") as unknown as {
dispatchEvent: (event: unknown) => boolean;
};
// A real focus event: fires on the descendant, does not bubble.
const FocusEventCtor = (win as unknown as { Event: typeof Event }).Event;
inner.dispatchEvent(new FocusEventCtor("focus", { bubbles: false }));
expect(win.document.querySelector("#out")?.textContent).toBe("focused");
});
test("a @change binding still fires for a bubbling event inside a component", () => {
// The companion case: change DOES bubble, and must keep working unchanged.
const win = mount(
`<div data-scope="seen: ''">` +
`<span id="out">{seen}</span>` +
`<div data-scope="n: 0" data-wrn-hydration="Field:y">` +
`<div data-wrn-events="change" data-wrn-out-change="seen = 'changed'">` +
`<input id="inner" />` +
`</div></div></div>`,
);
const inner = win.document.querySelector("#inner") as unknown as {
dispatchEvent: (event: unknown) => boolean;
};
const EventCtor = (win as unknown as { Event: typeof Event }).Event;
inner.dispatchEvent(new EventCtor("change", { bubbles: true }));
expect(win.document.querySelector("#out")?.textContent).toBe("changed");
});