feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.44",
|
||||
"version": "0.8.74",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -73,9 +73,13 @@ function stripControllerSections(source: string): string {
|
||||
const CONTROLLER_LOADER = String.raw`
|
||||
var componentControllerSelector = "[data-wrn-anchored],[data-wrn-dialog],[data-wrn-roving],[data-wrn-scrollspy],[data-wrn-splitter],[data-wrn-navbar],[data-wrn-preferences],[data-wrn-select],[data-wrn-pin-input]";
|
||||
var componentControllerPromise = null;
|
||||
var componentControllerUrl = (document.currentScript && document.currentScript.src)
|
||||
? new URL("./controllers.js", document.currentScript.src).href
|
||||
: "/__wrnexus/controllers.js";
|
||||
var reactiveScript = document.currentScript || document.querySelector('script[src*="/__wrnexus/reactive.js"]');
|
||||
var componentControllerUrl = "/__wrnexus/controllers.js";
|
||||
if (reactiveScript && reactiveScript.src) {
|
||||
var controllerUrl = new URL("./controllers.js", reactiveScript.src);
|
||||
controllerUrl.search = new URL(reactiveScript.src).search;
|
||||
componentControllerUrl = controllerUrl.href;
|
||||
}
|
||||
function needsComponentControllers(root) {
|
||||
var host = root || document;
|
||||
return !!((host.matches && host.matches(componentControllerSelector)) ||
|
||||
@@ -166,6 +170,7 @@ ${sections}
|
||||
setupRovingFocus();
|
||||
setupScrollspy();
|
||||
setupSplitters();
|
||||
startDocumentWatch();
|
||||
window.__wrnexusComponentControllers = { hydrate: hydrate, reposition: repositionAnchored };
|
||||
hydrate(document);
|
||||
})();
|
||||
|
||||
@@ -285,6 +285,11 @@ export const NAV_RUNTIME = String.raw`
|
||||
return loaded;
|
||||
}
|
||||
|
||||
// Track the original document and scripts explicitly scheduled by navigation.
|
||||
// Imported page script nodes are inert; seeing them in the swapped DOM does
|
||||
// not mean their code has run.
|
||||
var loadedNavigationScripts = loadedScriptPaths();
|
||||
|
||||
function runtimeIds(root) {
|
||||
var ids = {};
|
||||
if (!root || !root.querySelectorAll) return ids;
|
||||
@@ -330,7 +335,7 @@ export const NAV_RUNTIME = String.raw`
|
||||
* currently loaded.
|
||||
*/
|
||||
function ensureScripts(doc) {
|
||||
var loaded = loadedScriptPaths();
|
||||
var loaded = loadedNavigationScripts;
|
||||
|
||||
doc
|
||||
.querySelectorAll("script[src]")
|
||||
@@ -683,6 +688,7 @@ export const NAV_RUNTIME = String.raw`
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
"x-wrnexus-nav": "1",
|
||||
"x-wrnexus-behaviors": "registry-v2",
|
||||
...(isPrefetch ? { "x-wrnexus-prefetch": "1" } : {}),
|
||||
accept: "text/html",
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -90,3 +90,53 @@ test("loop locals survive non-ASCII values", () => {
|
||||
const decoded = JSON.parse(Buffer.from(String(raw), "base64").toString("utf8"));
|
||||
expect(decoded.row.label).toBe("Ünïcode — 日本語");
|
||||
});
|
||||
|
||||
test("replaced loop rows release their native effects and stop observing state", () => {
|
||||
const win = mount(
|
||||
`<div id="owner" data-scope="items: [1], value: 0"><p class="row" data-for="item in items" data-wrn-bind-title='["title","{value}"]'>{item}</p></div>`,
|
||||
);
|
||||
const owner = win.document.querySelector("#owner") as any;
|
||||
const removed: any[] = [];
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const row = win.document.querySelector(".row") as any;
|
||||
removed.push(row);
|
||||
owner.__wrnexusScopeApi.set("items", [i + 1]);
|
||||
owner.__wrnexusScopeApi.set("value", i);
|
||||
expect(win.document.querySelector(".row")!.getAttribute("title")).toBe(String(i));
|
||||
expect(row.getAttribute("title")).toBe(String(i - 1));
|
||||
expect(row.__wrnexusReactiveDisposers?.size ?? 0).toBe(0);
|
||||
}
|
||||
expect(removed.every((row) => !row.isConnected)).toBe(true);
|
||||
});
|
||||
|
||||
test("removing nested rows disposes their loop markers and descendant effects", () => {
|
||||
const win = mount(
|
||||
`<div id="owner" data-scope="groups: [{items:[1,2]}], value: 0"><section class="group" data-for="group in groups"><p class="row" data-for="item in group.items" data-wrn-bind-title='["title","{value}"]'>{item}</p></section></div>`,
|
||||
);
|
||||
const owner = win.document.querySelector("#owner") as any;
|
||||
const group = win.document.querySelector(".group") as any;
|
||||
const rows = Array.from(group.querySelectorAll(".row")) as any[];
|
||||
owner.__wrnexusScopeApi.set("groups", []);
|
||||
owner.__wrnexusScopeApi.set("value", 4);
|
||||
expect(rows.map((row) => row.getAttribute("title"))).toEqual(["0", "0"]);
|
||||
const walker = win.document.createTreeWalker(group, (win as any).NodeFilter.SHOW_ALL);
|
||||
let node: any = group;
|
||||
do {
|
||||
expect(node.__wrnexusReactiveDisposers?.size ?? 0).toBe(0);
|
||||
} while ((node = walker.nextNode()) !== null);
|
||||
});
|
||||
|
||||
test("moving a mounted scope keeps its listeners and state alive", async () => {
|
||||
const win = mount(
|
||||
`<div id="owner" data-scope="count: 0"><button data-on-click="count++">Increment</button><span data-text="count"></span></div>`,
|
||||
);
|
||||
const owner = win.document.querySelector("#owner") as any;
|
||||
const button = owner.querySelector("button");
|
||||
button.click();
|
||||
expect(owner.querySelector("span").textContent).toBe("1");
|
||||
win.document.body.appendChild(owner);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
button.click();
|
||||
expect(owner.querySelector("span").textContent).toBe("2");
|
||||
expect(owner.__wrnexusScopeApi.get("count")).toBe(2);
|
||||
});
|
||||
@@ -58,6 +58,20 @@ beforeEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
test("loads a new page script even when its inert tag is copied inside app content", async () => {
|
||||
install('<div id="app"><a href="/report" id="lnk">Report</a></div>', (window) => {
|
||||
window.happyDOM.settings.disableJavaScriptFileLoading = true;
|
||||
});
|
||||
nextHtml =
|
||||
'<html><body><div id="app"><button>Download</button><script type="module" src="/reports.js?v=1"></script></div></body></html>';
|
||||
win.document.getElementById("lnk").click();
|
||||
await flush();
|
||||
expect(win.document.body.querySelectorAll('script[src="/reports.js?v=1"]')).toHaveLength(2);
|
||||
win.__wrnexusNavigate("/report?environment=live");
|
||||
await flush();
|
||||
expect(win.document.body.querySelectorAll('script[src="/reports.js?v=1"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("intercepts an internal link click and swaps #app in place", async () => {
|
||||
install(`<div id="app"><h1>Home</h1><a href="/about" id="lnk">About</a></div>`);
|
||||
nextHtml =
|
||||
@@ -84,10 +98,12 @@ test("fragment history navigation preserves the page without fetching", async ()
|
||||
test("navigation supplies TrustedHTML to the document parser", async () => {
|
||||
let parses = 0;
|
||||
install('<div id="app"><a href="/next" id="lnk">Next</a></div>', (window) => {
|
||||
window.trustedTypes = { createPolicy: (_name: string, rules: any) => ({
|
||||
createHTML: (value: string) => ({ trusted: true, value: rules.createHTML(value) }),
|
||||
createScriptURL: rules.createScriptURL,
|
||||
}) };
|
||||
window.trustedTypes = {
|
||||
createPolicy: (_name: string, rules: any) => ({
|
||||
createHTML: (value: string) => ({ trusted: true, value: rules.createHTML(value) }),
|
||||
createScriptURL: rules.createScriptURL,
|
||||
}),
|
||||
};
|
||||
const Parser = window.DOMParser;
|
||||
(globalThis as any).DOMParser = class {
|
||||
parseFromString(value: any, type: string) {
|
||||
@@ -105,13 +121,17 @@ test("navigation supplies TrustedHTML to the document parser", async () => {
|
||||
});
|
||||
|
||||
test("navigation replaces document control registries before hydration", async () => {
|
||||
install('<div id="app"><a href="/next" id="lnk">Next</a></div><script type="application/json" data-wrn-controls id="old">[]</script>');
|
||||
install(
|
||||
'<div id="app"><a href="/next" id="lnk">Next</a></div><script type="application/json" data-wrn-controls id="old">[]</script>',
|
||||
);
|
||||
const importNode = win.document.importNode.bind(win.document);
|
||||
win.document.importNode = (node: any, deep: boolean) => {
|
||||
if (node.nodeName === "SCRIPT") throw new Error("Script text must not be cloned under Trusted Types");
|
||||
if (node.nodeName === "SCRIPT")
|
||||
throw new Error("Script text must not be cloned under Trusted Types");
|
||||
return importNode(node, deep);
|
||||
};
|
||||
nextHtml = '<html><body><div id="app">Next</div><script type="application/json" data-wrn-controls id="new">[[{"cond":null,"body":"Ready"}]]</script><script data-wrn-controls id="executable">window.bad = true</script></body></html>';
|
||||
nextHtml =
|
||||
'<html><body><div id="app">Next</div><script type="application/json" data-wrn-controls id="new">[[{"cond":null,"body":"Ready"}]]</script><script data-wrn-controls id="executable">window.bad = true</script></body></html>';
|
||||
win.document.getElementById("lnk").click();
|
||||
await flush();
|
||||
expect(win.document.getElementById("old")).toBeNull();
|
||||
|
||||
@@ -1,9 +1,87 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { Window, type HTMLButtonElement as HappyButtonElement } 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";
|
||||
import {
|
||||
runWithRequestContext,
|
||||
encodeControlTemplate,
|
||||
renderControlTemplates,
|
||||
} from "@wrnexus/core";
|
||||
|
||||
test("controlled select survives option reconciliation and uses the latest value", async () => {
|
||||
const win = mount(
|
||||
`<div data-scope="choice: 'field'"><select data-wrn-bind-0='["value","{choice}"]'><option value="">Choose</option><option value="field">Field</option><option value="form">Form</option></select><button data-on-click="choice = 'form'">Change</button></div>`,
|
||||
);
|
||||
const select = win.document.querySelector("select")!;
|
||||
// Native option reconciliation can reset selection after the value binding.
|
||||
select.innerHTML =
|
||||
'<option value="">Choose</option><option value="field">Field</option><option value="form">Form</option>';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(select.value).toBe("field");
|
||||
select.innerHTML =
|
||||
'<option value="">Choose</option><option value="field">Field</option><option value="form">Form</option>';
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(select.value).toBe("field");
|
||||
win.document.querySelector("button")!.click();
|
||||
select.value = "";
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(select.value).toBe("form");
|
||||
});
|
||||
test("an unchanged conditional branch preserves input identity and focus", () => {
|
||||
const definition = Buffer.from(
|
||||
JSON.stringify([{ cond: "count >= 0", body: '<input name="answer" />' }]),
|
||||
).toString("base64");
|
||||
const win = mount(
|
||||
`<div data-scope="count: 0"><button data-on-click="count++">Update</button><template data-wrn-if="${definition}"></template><input name="answer" /><template data-wrn-control-end></template></div>`,
|
||||
);
|
||||
const input = win.document.querySelector("input")!;
|
||||
input.value = "Typing";
|
||||
input.focus();
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.document.querySelector("input") === input).toBe(true);
|
||||
expect(win.document.activeElement === input).toBe(true);
|
||||
expect(input.value).toBe("Typing");
|
||||
});
|
||||
|
||||
test("compacted nested component controls materialize immediately and keep loop reactivity", () => {
|
||||
runWithRequestContext({} as never, () => {
|
||||
const rows = encodeControlTemplate({
|
||||
list: "items",
|
||||
item: "item",
|
||||
index: "index",
|
||||
body: '<p class="row">{index}:{item}</p>',
|
||||
empty: '<p class="empty">Empty</p>',
|
||||
});
|
||||
const inner = encodeControlTemplate([
|
||||
{
|
||||
cond: "active",
|
||||
body: `<template data-wrn-each="${rows}"></template><p>Stale SSR row</p><template data-wrn-control-end></template>`,
|
||||
},
|
||||
]);
|
||||
const child = `<div data-scope="active: true, items: ['a','b']"><button data-on-click="items = []">Clear</button><template data-wrn-if="${inner}"></template><p>Stale SSR branch</p><template data-wrn-control-end></template></div>`;
|
||||
const outer = encodeControlTemplate([
|
||||
{ cond: "open", body: child },
|
||||
{ cond: null, body: "<i>Closed</i>" },
|
||||
]);
|
||||
const win = mount(
|
||||
renderControlTemplates() +
|
||||
`<div data-scope="open: false"><button data-on-click="open = !open">Toggle</button><template data-wrn-if="${outer}"></template><i>Closed</i><template data-wrn-control-end></template></div>`,
|
||||
);
|
||||
win.document.querySelector("button")!.click();
|
||||
expect([...win.document.querySelectorAll(".row")].map((node) => node.textContent)).toEqual([
|
||||
"0:a",
|
||||
"1:b",
|
||||
]);
|
||||
expect(win.document.querySelector("#app")!.textContent).not.toContain("Stale SSR");
|
||||
win.document.querySelectorAll("button")[1]!.click();
|
||||
expect(win.document.querySelector(".row")).toBeNull();
|
||||
expect(win.document.querySelector(".empty")?.textContent).toBe("Empty");
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.document.querySelector("i")?.textContent).toBe("Closed");
|
||||
});
|
||||
});
|
||||
|
||||
// Fresh DOM per test, with the runtime's globals bound.
|
||||
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window {
|
||||
@@ -26,6 +104,18 @@ function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Wind
|
||||
(globalThis as Record<string, unknown>).CustomEvent = (
|
||||
win as unknown as { CustomEvent: unknown }
|
||||
).CustomEvent;
|
||||
(globalThis as Record<string, unknown>).HTMLOptionElement = (
|
||||
win as unknown as { HTMLOptionElement: unknown }
|
||||
).HTMLOptionElement;
|
||||
(globalThis as Record<string, unknown>).HTMLInputElement = (
|
||||
win as unknown as { HTMLInputElement: unknown }
|
||||
).HTMLInputElement;
|
||||
(globalThis as Record<string, unknown>).HTMLTextAreaElement = (
|
||||
win as unknown as { HTMLTextAreaElement: unknown }
|
||||
).HTMLTextAreaElement;
|
||||
(globalThis as Record<string, unknown>).HTMLSelectElement = (
|
||||
win as unknown as { HTMLSelectElement: unknown }
|
||||
).HTMLSelectElement;
|
||||
(0, eval)(runtime);
|
||||
if (controllers) (0, eval)(controllers);
|
||||
// Hydrate deterministically (auto-init waits on DOMContentLoaded, which the
|
||||
@@ -35,10 +125,22 @@ function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Wind
|
||||
return win as unknown as Window;
|
||||
}
|
||||
|
||||
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "MutationObserver"];
|
||||
const REPLACED_GLOBALS = [
|
||||
"window",
|
||||
"document",
|
||||
"location",
|
||||
"fetch",
|
||||
"MutationObserver",
|
||||
"HTMLOptionElement",
|
||||
"HTMLInputElement",
|
||||
"HTMLTextAreaElement",
|
||||
"HTMLSelectElement",
|
||||
];
|
||||
|
||||
test("optional properties and nullish fallbacks hydrate without crashing", () => {
|
||||
const win = mount(`<div data-scope="records: null, row: { count: 0 }, items: ['ok']"><b data-text="records?.rows?.length ?? 0"></b><i data-text="row?.count ?? 5"></i><em data-text="items?.[0] ?? 'empty'"></em><u data-text="!records?.rows?.length"></u></div>`);
|
||||
const win = mount(
|
||||
`<div data-scope="records: null, row: { count: 0 }, items: ['ok']"><b data-text="records?.rows?.length ?? 0"></b><i data-text="row?.count ?? 5"></i><em data-text="items?.[0] ?? 'empty'"></em><u data-text="!records?.rows?.length"></u></div>`,
|
||||
);
|
||||
expect(win.document.querySelector("b")?.textContent).toBe("0");
|
||||
expect(win.document.querySelector("i")?.textContent).toBe("0");
|
||||
expect(win.document.querySelector("em")?.textContent).toBe("ok");
|
||||
@@ -135,6 +237,24 @@ test("@event (data-on-click) mutates a signal and re-renders", () => {
|
||||
expect(btn.textContent).toBe("2");
|
||||
});
|
||||
|
||||
test("new conditional content preserves nested component field state", async () => {
|
||||
const definition = Buffer.from(
|
||||
JSON.stringify([
|
||||
{
|
||||
cond: "open",
|
||||
body: '<div data-scope="label: "Evidence", name: "evidenceReference""><label data-text="label">Evidence</label><input name="{name}" data-wrn-bind-0="["name","{name}"]"></div>',
|
||||
},
|
||||
]),
|
||||
).toString("base64");
|
||||
const win = mount(
|
||||
`<div data-scope="open: false"><button data-on-click="open = true">Open</button><template data-wrn-if="${definition}"></template><template data-wrn-control-end></template></div>`,
|
||||
);
|
||||
win.document.querySelector("button")!.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(win.document.querySelector("label")?.textContent).toBe("Evidence");
|
||||
expect(win.document.querySelector("input")?.name).toBe("evidenceReference");
|
||||
});
|
||||
|
||||
test("compiled if blocks switch branches after hydration", () => {
|
||||
const definition = Buffer.from(
|
||||
JSON.stringify([
|
||||
@@ -159,11 +279,19 @@ test("compiled if blocks switch branches after hydration", () => {
|
||||
|
||||
test("registry controls retain nested markup and work after the registry leaves the document", () => {
|
||||
const definitions = [
|
||||
[{ cond: "open", body: '<template data-wrn-if="@registry:1"></template><b>Nested</b><template data-wrn-control-end></template>' }, { cond: null, body: '<i>Closed</i>' }],
|
||||
[{ cond: null, body: '<b>Nested</b>' }],
|
||||
[
|
||||
{
|
||||
cond: "open",
|
||||
body: '<template data-wrn-if="@registry:1"></template><b>Nested</b><template data-wrn-control-end></template>',
|
||||
},
|
||||
{ cond: null, body: "<i>Closed</i>" },
|
||||
],
|
||||
[{ cond: null, body: "<b>Nested</b>" }],
|
||||
];
|
||||
const win = mount(`<script type="application/json" data-wrn-controls id="registry">${JSON.stringify(definitions)}</script>` +
|
||||
'<div data-scope="open: false"><button data-on-click="open = !open">Toggle</button><template data-wrn-if="@registry:0"></template><i>Closed</i><template data-wrn-control-end></template></div>');
|
||||
const win = mount(
|
||||
`<script type="application/json" data-wrn-controls id="registry">${JSON.stringify(definitions)}</script>` +
|
||||
'<div data-scope="open: false"><button data-on-click="open = !open">Toggle</button><template data-wrn-if="@registry:0"></template><i>Closed</i><template data-wrn-control-end></template></div>',
|
||||
);
|
||||
win.document.getElementById("registry")!.remove();
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.document.querySelector("b")?.textContent).toBe("Nested");
|
||||
@@ -294,6 +422,44 @@ test("nested scopes don't clobber each other (regression)", () => {
|
||||
expect(b!.textContent).toBe("B10"); // unchanged
|
||||
});
|
||||
|
||||
test("a delayed parent does not replace a nested select's scope API", async () => {
|
||||
let resolveModule: (module: object) => void = () => {};
|
||||
const globals = globalThis as Record<string, unknown>;
|
||||
globals.__wrnSelectParentModule = new Promise<object>((resolve) => {
|
||||
resolveModule = resolve;
|
||||
});
|
||||
try {
|
||||
const runtime = REACTIVE_RUNTIME.replace(
|
||||
"promise = import(url)",
|
||||
"promise = globalThis.__wrnSelectParentModule",
|
||||
);
|
||||
const win = mount(
|
||||
`<div id="parent" data-scope="value: 'parent'" data-wrn-client-module="/select-parent-delayed.mjs"><div id="child" data-scope="value: 'personal'"><select data-wrn-select><option value="personal">Personal</option></select></div></div>`,
|
||||
runtime,
|
||||
);
|
||||
const child = win.document.querySelector("#child") as unknown as {
|
||||
__wrnexusScopeApi: { get(name: string): unknown };
|
||||
};
|
||||
const select = win.document.querySelector("select") as unknown as {
|
||||
__wrnexusScopeApi: { get(name: string): unknown };
|
||||
};
|
||||
expect(select.__wrnexusScopeApi.get("value")).toBe("personal");
|
||||
resolveModule({});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(
|
||||
(
|
||||
win.document.querySelector("#parent") as unknown as {
|
||||
__wrnexusScopeApi: { get(name: string): unknown };
|
||||
}
|
||||
).__wrnexusScopeApi.get("value"),
|
||||
).toBe("parent");
|
||||
expect(select.__wrnexusScopeApi).toBe(child.__wrnexusScopeApi);
|
||||
expect(select.__wrnexusScopeApi.get("value")).toBe("personal");
|
||||
} finally {
|
||||
delete globals.__wrnSelectParentModule;
|
||||
}
|
||||
});
|
||||
|
||||
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>`,
|
||||
@@ -786,6 +952,16 @@ test("slot content is hydrated by the parent scope, not the component it lands i
|
||||
expect(win.document.querySelector("i")?.getAttribute("data-show")).toBe("true");
|
||||
});
|
||||
|
||||
test("forwarded slots keep the original author through wrapper and modal scopes", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="label: 'Audit hours', hours: 168"><b id="original">{hours}</b><div data-scope="label: 'wrapper', hours: 1"><div data-scope="label: 'modal', hours: 2"><wrn-slot data-wrn-slot><section><wrn-slot data-wrn-slot><span id="label">{label}</span><input data-wrn-bind-0='["value","{hours}"]' /><button data-on-click="hours++">Change</button></wrn-slot></section></wrn-slot></div></div></div>`,
|
||||
);
|
||||
expect(win.document.querySelector("#label")?.textContent).toBe("Audit hours");
|
||||
expect(win.document.querySelector("input")?.value).toBe("168");
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.document.querySelector("#original")?.textContent).toBe("169");
|
||||
});
|
||||
|
||||
test("slot event handlers write to the parent scope", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="count: 0">` +
|
||||
@@ -1914,15 +2090,506 @@ test("a @change binding still fires for a bubbling event inside a component", ()
|
||||
expect(win.document.querySelector("#out")?.textContent).toBe("changed");
|
||||
});
|
||||
|
||||
|
||||
test("appending keyed notifications does not detach existing rows", () => {
|
||||
const win = mount(`<div data-scope="rows: [{id: 1, name: 'first'}]"><ul><li data-for="row in rows key row.id">{row.name}</li></ul><button data-on-click="rows = [...rows, {id: 2, name: 'second'}]">add</button></div>`);
|
||||
const list = win.document.querySelector("ul")!;
|
||||
const first = list.querySelector("li");
|
||||
const observer = new win.MutationObserver(() => {});
|
||||
observer.observe(list, {childList: true});
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(list.querySelectorAll("li").length).toBe(2);
|
||||
expect(observer.takeRecords().flatMap(record => Array.from(record.removedNodes))).not.toContain(first);
|
||||
observer.disconnect();
|
||||
const win = mount(
|
||||
`<div data-scope="rows: [{id: 1, name: 'first'}]"><ul><li data-for="row in rows key row.id">{row.name}</li></ul><button data-on-click="rows = [...rows, {id: 2, name: 'second'}]">add</button></div>`,
|
||||
);
|
||||
const list = win.document.querySelector("ul")!;
|
||||
const first = list.querySelector("li");
|
||||
const observer = new win.MutationObserver(() => {});
|
||||
observer.observe(list, { childList: true });
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(list.querySelectorAll("li").length).toBe(2);
|
||||
expect(observer.takeRecords().flatMap((record) => Array.from(record.removedNodes))).not.toContain(
|
||||
first,
|
||||
);
|
||||
observer.disconnect();
|
||||
});
|
||||
|
||||
test("literal server-rendered JSON survives hydration inside component scopes", () => {
|
||||
const json = '{"ok":true,"data":{"message":"Test event","sample":"{count}"}}';
|
||||
const win = mount(
|
||||
`<section data-scope="count: 2"><pre data-wrn-literal><code>${json}</code></pre><b>{count}</b></section>`,
|
||||
);
|
||||
expect(win.document.querySelector("pre")?.textContent).toBe(json);
|
||||
expect(win.document.querySelector("b")?.textContent).toBe("2");
|
||||
});
|
||||
|
||||
test("nested structured prop bindings preserve attachment arrays across slot ownership", async () => {
|
||||
const files = [{ nodeId: "file", filename: "report.pdf" }];
|
||||
const encoded = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64");
|
||||
const win = mount(
|
||||
`<div data-scope="data: {}" data-wrn-scope="${encoded({ data: { files } })}"><div data-scope="data: {}" data-wrn-scope="${encoded({ data: { files } })}" data-wrn-prop-bind-0='["data","{data}"]'><div data-scope="title: 'Card'"><div data-wrn-slot><div data-scope="files: []" data-wrn-scope="${encoded({ files })}" data-wrn-prop-bind-0='["files","{data.files}"]'><b id="files" data-text="JSON.stringify(files)">${JSON.stringify(files)}</b><span data-text="files[0]?.filename">report.pdf</span></div></div></div></div></div>`,
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(win.document.querySelector("#files")?.textContent).toBe(JSON.stringify(files));
|
||||
expect(win.document.querySelector("span")?.textContent).toBe("report.pdf");
|
||||
});
|
||||
|
||||
test("dynamic child props receive parent translations and subsequent changes", async () => {
|
||||
const body =
|
||||
'<div data-scope="label: "Input"" data-wrn-prop-bind-0="["label","{messages.evidence}"]"><label data-text="label">Input</label></div>';
|
||||
const definition = Buffer.from(JSON.stringify([{ cond: "open", body }])).toString("base64");
|
||||
const win = mount(
|
||||
`<div data-scope='open: false, messages: {evidence: "Evidence"}'><button data-on-click="open = true">Open</button><button data-on-click="messages = {evidence: 'Updated'}">Change</button><template data-wrn-if="${definition}"></template><template data-wrn-control-end></template></div>`,
|
||||
);
|
||||
win.document.querySelector("button")!.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(win.document.querySelector("label")?.textContent).toBe("Evidence");
|
||||
win.document.querySelectorAll("button")[1]!.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(win.document.querySelector("label")?.textContent).toBe("Updated");
|
||||
});
|
||||
|
||||
test("child props wait for an asynchronous module and retain the latest parent values", async () => {
|
||||
let resolveModule: (module: object) => void = () => {};
|
||||
const globals = globalThis as Record<string, unknown>;
|
||||
globals.__wrnDelayedTestModule = new Promise<object>((resolve) => {
|
||||
resolveModule = resolve;
|
||||
});
|
||||
try {
|
||||
const runtime = REACTIVE_RUNTIME.replace(
|
||||
"promise = import(url)",
|
||||
"promise = globalThis.__wrnDelayedTestModule",
|
||||
);
|
||||
expect(runtime).not.toBe(REACTIVE_RUNTIME);
|
||||
const win = mount(
|
||||
'<div id="parent" data-scope="value: 1"><div id="child" data-scope="value: 0" data-wrn-client-module="/delayed.mjs" data-wrn-prop-bind-0=\'["value","{value}"]\'><b data-text="value">0</b></div></div>',
|
||||
runtime,
|
||||
);
|
||||
const parent = win.document.querySelector("#parent") as any,
|
||||
child = win.document.querySelector("#child") as any;
|
||||
parent.__wrnexusScopeApi.set("value", 7);
|
||||
expect(child.__wrnexusScopeApi).toBeUndefined();
|
||||
resolveModule({});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(child.__wrnexusScopeApi.get("value")).toBe(7);
|
||||
expect(child.querySelector("b").textContent).toBe("7");
|
||||
parent.__wrnexusScopeApi.set("value", 9);
|
||||
expect(child.querySelector("b").textContent).toBe("9");
|
||||
} finally {
|
||||
delete globals.__wrnDelayedTestModule;
|
||||
}
|
||||
});
|
||||
|
||||
test("child prop bindings preserve inline objects and mixed text", () => {
|
||||
const win = mount(
|
||||
'<div id="parent" data-scope="answer: null, first: \'One\', second: \'Two\'"><div id="child" data-scope="value: null, label: \'\'" data-wrn-prop-bind-0=\'["value","{answer||{}}"]\' data-wrn-prop-bind-1=\'["label","{first} of {second}"]\'><b data-text="label"></b></div></div>',
|
||||
);
|
||||
const parent = win.document.querySelector("#parent") as any,
|
||||
child = win.document.querySelector("#child") as any;
|
||||
expect(child.__wrnexusScopeApi.get("value")).toEqual({});
|
||||
expect(child.querySelector("b").textContent).toBe("One of Two");
|
||||
parent.__wrnexusScopeApi.set("answer", { line1: "10 Test Street", country: "India" });
|
||||
expect(child.__wrnexusScopeApi.get("value")).toEqual({
|
||||
line1: "10 Test Street",
|
||||
country: "India",
|
||||
});
|
||||
parent.__wrnexusScopeApi.set("first", "Updated");
|
||||
expect(child.querySelector("b").textContent).toBe("Updated of Two");
|
||||
});
|
||||
|
||||
test("child props retain arrays produced by inline object callbacks", () => {
|
||||
const win = mount(
|
||||
'<div id="parent" data-scope="items: [\'a\']"><div id="child" data-scope="options: []" data-wrn-prop-bind-0=\'["options","{items.map(item=>({value:item,label:item}))}"]\'></div></div>',
|
||||
);
|
||||
const parent = win.document.querySelector("#parent") as any,
|
||||
child = win.document.querySelector("#child") as any;
|
||||
expect(child.__wrnexusScopeApi.get("options")).toEqual([{ value: "a", label: "a" }]);
|
||||
parent.__wrnexusScopeApi.set("items", ["b", "c"]);
|
||||
expect(child.__wrnexusScopeApi.get("options")).toEqual([
|
||||
{ value: "b", label: "b" },
|
||||
{ value: "c", label: "c" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("loop hydration preserves structured child prop expressions", () => {
|
||||
const body =
|
||||
'<div data-scope="value: {}" data-wrn-prop-bind-0=\'["value","{answers[item.id]||{}}"]\'><b data-text="value.line1"></b></div>';
|
||||
const definition = Buffer.from(
|
||||
JSON.stringify({ list: "items", item: "item", body, empty: "" }),
|
||||
).toString("base64");
|
||||
const win = mount(
|
||||
`<div id="parent" data-scope="items: [], answers: {}"><template data-wrn-each="${definition}"></template><template data-wrn-control-end></template></div>`,
|
||||
);
|
||||
const parent = win.document.querySelector("#parent") as any;
|
||||
parent.__wrnexusScopeApi.set("answers", { address: { line1: "10 Test Street" } });
|
||||
parent.__wrnexusScopeApi.set("items", [{ id: "address" }]);
|
||||
expect(win.document.querySelector("b")?.textContent).toBe("10 Test Street");
|
||||
parent.__wrnexusScopeApi.set("answers", { address: { line1: "Updated" } });
|
||||
expect(win.document.querySelector("b")?.textContent).toBe("Updated");
|
||||
});
|
||||
test("new async component controls render pending props on their first hydration", async () => {
|
||||
const globals = globalThis as Record<string, unknown>;
|
||||
globals.__wrnPendingRows = Promise.resolve({ bindClientScope: () => ({}) });
|
||||
try {
|
||||
const rows = Buffer.from(
|
||||
JSON.stringify({ list: "fields", item: "field", body: "<b>{field.label}</b>", empty: "" }),
|
||||
).toString("base64");
|
||||
const child = `<div class="child" data-scope="fields: []" data-wrn-client-module="/pending.mjs" data-wrn-prop-bind-0='["fields","{fields}"]'><template data-wrn-each="${rows}"></template><template data-wrn-control-end></template></div>`;
|
||||
const branches = Buffer.from(JSON.stringify([{ cond: "open", body: child }])).toString(
|
||||
"base64",
|
||||
);
|
||||
const runtime = REACTIVE_RUNTIME.replace(
|
||||
"promise = import(url)",
|
||||
"promise = globalThis.__wrnPendingRows",
|
||||
);
|
||||
const win = mount(
|
||||
`<div id="parent" data-scope="open: false, fields: [{label:'Section heading'}]"><template data-wrn-if="${branches}"></template><template data-wrn-control-end></template></div>`,
|
||||
runtime,
|
||||
);
|
||||
(win.document.querySelector("#parent") as any).__wrnexusScopeApi.set("open", true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(win.document.querySelector(".child b")?.textContent).toBe("Section heading");
|
||||
} finally {
|
||||
delete globals.__wrnPendingRows;
|
||||
}
|
||||
});
|
||||
|
||||
test("async nested controls restore structured field props after navigation", async () => {
|
||||
const globals = globalThis as Record<string, unknown>;
|
||||
globals.__wrnRestoreModule = Promise.resolve({
|
||||
bindClientScope: (ctx: any) => ({
|
||||
currentName: () => ctx.props.value?.name || "",
|
||||
currentConsent: () => !!ctx.props.value?.consent,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const runtime = REACTIVE_RUNTIME.replace(
|
||||
"promise = import(url)",
|
||||
"promise = globalThis.__wrnRestoreModule",
|
||||
);
|
||||
const input =
|
||||
'<div class="input" data-scope="value: \'\'" data-wrn-client-module="/input.mjs" data-wrn-prop-bind-0=\'["value","{currentName()}"]\'><input data-wrn-bind-0=\'["value","{value}"]\' /></div>';
|
||||
const signature = `<div class="signature" data-scope="value: {}" data-wrn-client-module="/signature.mjs" data-wrn-prop-bind-0='["value","{answers[field.id] || {}}"]'>${input}</div>`;
|
||||
const fields = Buffer.from(
|
||||
JSON.stringify({ list: "fields", item: "field", body: signature, empty: "" }),
|
||||
).toString("base64");
|
||||
const branch = Buffer.from(
|
||||
JSON.stringify([
|
||||
{
|
||||
cond: "open",
|
||||
body: `<template data-wrn-each="${fields}"></template><template data-wrn-control-end></template>`,
|
||||
},
|
||||
]),
|
||||
).toString("base64");
|
||||
const win = mount(
|
||||
`<div id="parent" data-scope="fields: [], answers: {}, open: false"><template data-wrn-if="${branch}"></template><template data-wrn-control-end></template></div>`,
|
||||
runtime,
|
||||
);
|
||||
const parent = win.document.querySelector("#parent") as any;
|
||||
parent.__wrnexusScopeApi.set("answers", {
|
||||
signature: { name: "Browser Signer", consent: true },
|
||||
});
|
||||
parent.__wrnexusScopeApi.set("fields", [{ id: "signature" }]);
|
||||
parent.__wrnexusScopeApi.set("open", true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(win.document.querySelector("input")?.value).toBe("Browser Signer");
|
||||
parent.__wrnexusScopeApi.set("open", false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
parent.__wrnexusScopeApi.set("open", true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(win.document.querySelector("input")?.value).toBe("Browser Signer");
|
||||
} finally {
|
||||
delete globals.__wrnRestoreModule;
|
||||
}
|
||||
});
|
||||
|
||||
test("nested page conditions restore structured field props after navigation", () => {
|
||||
const signature =
|
||||
'<div class="signature" data-scope="field: {}, value: {}" data-wrn-prop-bind-0=\'["field","{field}"]\' data-wrn-prop-bind-1=\'["value","{answers[field.id]||{}}"]\'><b data-text="value.name"></b></div>';
|
||||
const fields = Buffer.from(
|
||||
JSON.stringify({ list: "page.fields", item: "field", body: signature, empty: "" }),
|
||||
).toString("base64");
|
||||
const branch = Buffer.from(
|
||||
JSON.stringify([
|
||||
{
|
||||
cond: "index === pageIndex",
|
||||
body: `<template data-wrn-each="${fields}"></template><template data-wrn-control-end></template>`,
|
||||
},
|
||||
]),
|
||||
).toString("base64");
|
||||
const pages = Buffer.from(
|
||||
JSON.stringify({
|
||||
list: "pages",
|
||||
item: "page",
|
||||
index: "index",
|
||||
body: `<template data-wrn-if="${branch}"></template><template data-wrn-control-end></template>`,
|
||||
empty: "",
|
||||
}),
|
||||
).toString("base64");
|
||||
const win = mount(
|
||||
`<div id="parent" data-scope="pages: [], answers: {}, pageIndex: 0"><template data-wrn-each="${pages}"></template><template data-wrn-control-end></template></div>`,
|
||||
);
|
||||
const parent = win.document.querySelector("#parent") as any;
|
||||
parent.__wrnexusScopeApi.set("answers", { signature: { name: "Browser Signer", consent: true } });
|
||||
parent.__wrnexusScopeApi.set("pages", [
|
||||
{ id: "first", fields: [{ id: "signature" }] },
|
||||
{ id: "last", fields: [] },
|
||||
]);
|
||||
expect(win.document.querySelector(".signature b")?.textContent).toBe("Browser Signer");
|
||||
parent.__wrnexusScopeApi.set("pageIndex", 1);
|
||||
expect(win.document.querySelector(".signature")).toBeNull();
|
||||
parent.__wrnexusScopeApi.set("pageIndex", 0);
|
||||
expect(win.document.querySelector(".signature b")?.textContent).toBe("Browser Signer");
|
||||
});
|
||||
|
||||
test("compiled helpers react to prop changes, including controlled multiple selections", async () => {
|
||||
const globals = globalThis as Record<string, unknown>;
|
||||
globals.__wrnCompiledPropsModule = Promise.resolve({
|
||||
bindClientScope: (ctx: any) => ({
|
||||
selectedA: () => ctx.props.values.includes("a"),
|
||||
selectedB: () => ctx.props.values.includes("b"),
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const runtime = REACTIVE_RUNTIME.replace(
|
||||
"promise = import(url)",
|
||||
"promise = globalThis.__wrnCompiledPropsModule",
|
||||
);
|
||||
const win = mount(
|
||||
'<div id="parent" data-scope="values: [\'a\']"><div id="child" data-scope="values: []" data-wrn-client-module="/props.mjs" data-wrn-prop-bind-0=\'["values","{values}"]\'><select multiple><option value="a" data-wrn-bind-0=\'["selected","{selectedA()}"]\'>A</option><option value="b" data-wrn-bind-0=\'["selected","{selectedB()}"]\'>B</option></select></div></div>',
|
||||
runtime,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const parent = win.document.querySelector("#parent") as any;
|
||||
const values = () =>
|
||||
Array.from(win.document.querySelector("select")!.selectedOptions).map(
|
||||
(option) => option.value,
|
||||
);
|
||||
expect(values()).toEqual(["a"]);
|
||||
parent.__wrnexusScopeApi.set("values", ["b"]);
|
||||
expect(values()).toEqual(["b"]);
|
||||
parent.__wrnexusScopeApi.set("values", ["a", "b"]);
|
||||
expect(values()).toEqual(["a", "b"]);
|
||||
} finally {
|
||||
delete globals.__wrnCompiledPropsModule;
|
||||
}
|
||||
});
|
||||
|
||||
test("client control rows expose loop locals for child component bindings", () => {
|
||||
runWithRequestContext({} as never, () => {
|
||||
const rows = encodeControlTemplate({
|
||||
list: "items",
|
||||
item: "item",
|
||||
body: '<div class="row"><span data-text="item.id"></span></div>',
|
||||
empty: "",
|
||||
});
|
||||
const win = mount(
|
||||
renderControlTemplates() +
|
||||
`<div data-scope="items: []"><button data-on-click="items = [{ id: 'new-node' }]">Add</button><template data-wrn-each="${rows}"></template><template data-wrn-control-end></template></div>`,
|
||||
);
|
||||
win.document.querySelector("button")!.click();
|
||||
const row = win.document.querySelector(".row")!;
|
||||
expect(row.textContent).toBe("new-node");
|
||||
expect(JSON.parse(win.atob(row.getAttribute("data-wrn-loop-locals")!)).item.id).toBe(
|
||||
"new-node",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("components inserted by a conditional bind outputs in their parent scope", () => {
|
||||
const child =
|
||||
'<div data-scope="count: 0" data-wrn-hydration="Control:test"><div data-wrn-events="confirm" data-wrn-out-confirm="saved = payload.value"><button data-on-click="output.confirm({value: 7})">Confirm</button></div></div>';
|
||||
const definition = Buffer.from(JSON.stringify([{ cond: "open", body: child }])).toString(
|
||||
"base64",
|
||||
);
|
||||
const win = mount(
|
||||
`<div data-scope="open: false, saved: 0"><button id="toggle" data-on-click="open = !open">Toggle</button><span id="saved">{saved}</span><template data-wrn-if="${definition}"></template><template data-wrn-control-end></template></div>`,
|
||||
);
|
||||
win.document.querySelector<HappyButtonElement>("#toggle")!.click();
|
||||
win.document.querySelectorAll("button")[1]!.click();
|
||||
expect(win.document.querySelector("#saved")!.textContent).toBe("7");
|
||||
win.document.querySelector<HappyButtonElement>("#toggle")!.click();
|
||||
win.document.querySelector<HappyButtonElement>("#toggle")!.click();
|
||||
const target = win.document.querySelector("[data-wrn-events]") as any;
|
||||
expect(target.__wrnexusOutputHandlers.confirm.size).toBe(1);
|
||||
});
|
||||
|
||||
test("forwarded component events use their binding node's nested loop locals", () => {
|
||||
const outer = Buffer.from(JSON.stringify({ group: "outer" })).toString("base64");
|
||||
const inner = Buffer.from(JSON.stringify({ group: "outer", row: { id: "nested-row" } })).toString(
|
||||
"base64",
|
||||
);
|
||||
const win = mount(
|
||||
`<div data-scope="saved: ''"><span id="saved" data-text="saved"></span><div data-scope="unused: 0" data-wrn-loop-locals="${outer}"><span data-wrn-events="click,confirm"><button data-wrn-events="click,confirm" data-wrn-loop-locals="${inner}" data-wrn-out-click="saved = row.id" data-wrn-out-confirm="saved = row.id">Save</button></span></div></div>`,
|
||||
);
|
||||
const button = win.document.querySelector("button") as any;
|
||||
button.click();
|
||||
expect(win.document.querySelector("#saved")?.textContent).toBe("nested-row");
|
||||
const parent = win.document.querySelector("[data-text]")?.parentElement as any;
|
||||
parent.__wrnexusScopeApi.set("saved", "");
|
||||
for (const handler of button.__wrnexusOutputHandlers.confirm) handler({});
|
||||
expect(win.document.querySelector("#saved")?.textContent).toBe("nested-row");
|
||||
});
|
||||
|
||||
test("forwarded events created during async child hydration bind in the parent", async () => {
|
||||
const globals = globalThis as Record<string, unknown>;
|
||||
globals.__wrnAsyncControlModule = Promise.resolve({});
|
||||
try {
|
||||
const body = '<button data-wrn-events="click" data-wrn-out-click="saved = 7">Save</button>';
|
||||
const definition = Buffer.from(JSON.stringify([{ cond: "active", body }])).toString("base64");
|
||||
const runtime = REACTIVE_RUNTIME.replace(
|
||||
"promise = import(url)",
|
||||
"promise = globalThis.__wrnAsyncControlModule",
|
||||
);
|
||||
const win = mount(
|
||||
`<div data-scope="saved: 0"><b data-text="saved"></b><div data-scope="active: true" data-wrn-client-module="/async-control.mjs"><template data-wrn-if="${definition}" data-wrn-control-client></template><template data-wrn-control-end></template></div></div>`,
|
||||
runtime,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.document.querySelector("b")?.textContent).toBe("7");
|
||||
} finally {
|
||||
delete globals.__wrnAsyncControlModule;
|
||||
}
|
||||
});
|
||||
|
||||
test("an unhandled nested output cannot overwrite a same-named parent output", () => {
|
||||
const win = mount(
|
||||
'<div data-scope="saved: 0"><b data-text="saved"></b><div data-scope="unused: 0"><div data-wrn-events="change" data-wrn-out-change="saved = payload.value"><button id="own" data-on-click="output.change({value: 7})">Own</button><div data-scope="unused: 0"><div data-wrn-events="change"><button id="nested" data-on-click="output.change({value: 99})">Nested</button></div></div></div></div></div>',
|
||||
);
|
||||
win.document.querySelector<HappyButtonElement>("#own")!.click();
|
||||
expect(win.document.querySelector("b")?.textContent).toBe("7");
|
||||
win.document.querySelector<HappyButtonElement>("#nested")!.click();
|
||||
expect(win.document.querySelector("b")?.textContent).toBe("7");
|
||||
});
|
||||
|
||||
test("async repeated field components render nested conditional rows after dynamic insertion", async () => {
|
||||
const globals = globalThis as Record<string, unknown>;
|
||||
globals.__wrnNestedModules = {
|
||||
"/nested-fields.mjs": Promise.resolve({
|
||||
bindClientScope: () => ({ visible: (field: any) => !!field }),
|
||||
}),
|
||||
"/nested-repeat.mjs": Promise.resolve({
|
||||
bindClientScope: (ctx: any) => ({
|
||||
currentRows: () => ctx.state.rows || [],
|
||||
add: () => {
|
||||
ctx.state.rows = [{ id: 0, values: {} }];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64");
|
||||
try {
|
||||
const innerIf = encode([
|
||||
{ cond: "visible(field)", body: '<label class="entry-label">{field.label}</label>' },
|
||||
]);
|
||||
const innerRows = encode({
|
||||
list: "fields",
|
||||
item: "field",
|
||||
body: `<template data-wrn-if="${innerIf}"></template><template data-wrn-control-end></template>`,
|
||||
empty: "",
|
||||
});
|
||||
const inner = `<div class="entry-fields" data-scope="fields: []" data-wrn-client-module="/nested-fields.mjs" data-wrn-prop-bind-0='["fields","{field.fields}"]'><template data-wrn-each="${innerRows}"></template><template data-wrn-control-end></template></div>`;
|
||||
const entries = encode({ list: "currentRows()", item: "row", body: inner, empty: "" });
|
||||
const repeat = `<div class="repeat" data-scope="field: {}, rows: null" data-wrn-client-module="/nested-repeat.mjs" data-wrn-prop-bind-0='["field","{field}"]'><template data-wrn-each="${entries}"></template><template data-wrn-control-end></template><button class="add-entry" data-on-click="add()">Add</button></div>`;
|
||||
const fields = encode({ list: "fields", item: "field", body: repeat, empty: "" });
|
||||
const root = `<div data-scope="fields: []" data-wrn-client-module="/nested-fields.mjs" data-wrn-prop-bind-0='["fields","{fields}"]'><template data-wrn-each="${fields}"></template><template data-wrn-control-end></template></div>`;
|
||||
const branch = encode([{ cond: "open", body: root }]);
|
||||
const runtime = REACTIVE_RUNTIME.replace(
|
||||
"promise = import(url)",
|
||||
"promise = globalThis.__wrnNestedModules[url]",
|
||||
);
|
||||
const win = mount(
|
||||
`<div id="parent" data-scope="open: false, fields: []"><template data-wrn-if="${branch}"></template><template data-wrn-control-end></template></div>`,
|
||||
runtime,
|
||||
);
|
||||
const parent = win.document.querySelector("#parent") as any;
|
||||
parent.__wrnexusScopeApi.set("fields", [
|
||||
{
|
||||
id: "group",
|
||||
fields: [
|
||||
{ id: "age", label: "Group age" },
|
||||
{ id: "name", label: "Group member" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
parent.__wrnexusScopeApi.set("open", true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
win.document.querySelector<HappyButtonElement>(".add-entry")!.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(
|
||||
Array.from(win.document.querySelectorAll(".entry-label")).map((label) => label.textContent),
|
||||
).toEqual(["Group age", "Group member"]);
|
||||
} finally {
|
||||
delete globals.__wrnNestedModules;
|
||||
}
|
||||
});
|
||||
|
||||
test("request-registry behavior references hydrate isolated component state", () => {
|
||||
const behavior = {
|
||||
functions: "function increment(){count++}",
|
||||
outputs: [],
|
||||
computed: [],
|
||||
effects: [],
|
||||
lifecycle: {},
|
||||
watches: [],
|
||||
};
|
||||
const markup =
|
||||
'<div data-scope="count: 0" data-wrn-behavior="@behaviors:0"><button data-on-click="increment()">Increment</button><b data-text="count"></b></div>';
|
||||
const win = mount(
|
||||
'<script type="application/json" data-wrn-controls id="behaviors">' +
|
||||
JSON.stringify([behavior]) +
|
||||
"</script>" +
|
||||
markup +
|
||||
markup,
|
||||
);
|
||||
const buttons = win.document.querySelectorAll("button");
|
||||
buttons[0]!.click();
|
||||
expect([...win.document.querySelectorAll("b")].map((node) => node.textContent)).toEqual([
|
||||
"1",
|
||||
"0",
|
||||
]);
|
||||
buttons[1]!.click();
|
||||
buttons[1]!.click();
|
||||
expect([...win.document.querySelectorAll("b")].map((node) => node.textContent)).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("split controllers observe drawers opened after initial hydration", async () => {
|
||||
const win = new Window({ settings: { enableJavaScriptEvaluation: true } });
|
||||
win.document.body.innerHTML = `<div data-scope="open: false"><button id="opener" data-on-click="open=true">Open</button><div data-wrn-dialog data-open="false" data-wrn-bind-0="["data-open","{open ? 'true' : 'false'}"]"><section role="dialog" aria-modal="true"><button id="closer" data-on-click="open=false">Close</button></section></div></div>`;
|
||||
win.eval(getReactiveRuntime(true));
|
||||
win.eval(getComponentControllerRuntime(true));
|
||||
(win as unknown as { __wrnexusHydrateScopes: (node: unknown) => void }).__wrnexusHydrateScopes(
|
||||
win.document,
|
||||
);
|
||||
(win.document.querySelector("#opener") as HappyButtonElement).focus();
|
||||
(win.document.querySelector("#opener") as HappyButtonElement).click();
|
||||
for (let attempt = 0; attempt < 30 && win.document.activeElement?.id !== "closer"; attempt++)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(win.document.activeElement?.id).toBe("closer");
|
||||
expect(win.document.body.style.overflow).toBe("hidden");
|
||||
(win.document.querySelector("#closer") as HappyButtonElement).click();
|
||||
for (let attempt = 0; attempt < 30 && win.document.activeElement?.id !== "opener"; attempt++)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(win.document.querySelector("[data-wrn-dialog]")?.getAttribute("data-open")).toBe("false");
|
||||
expect(win.document.activeElement?.id).toBe("opener");
|
||||
expect(win.document.body.style.overflow).toBe("");
|
||||
expect(getReactiveRuntime()).toContain(
|
||||
"controllerUrl.search = new URL(reactiveScript.src).search",
|
||||
);
|
||||
});
|
||||
|
||||
test("shared scope and loop metadata preserves per-instance state and row values", () => {
|
||||
const values = [{ label: "start", count: 0 }, { entry: { label: "Selected row" } }];
|
||||
const node =
|
||||
'<div data-scope="" data-wrn-scope="@metadata:0"><button class="choose" data-wrn-loop-locals="@metadata:1" data-on-click="label = entry.label">Choose</button><button class="add" data-on-click="count++">Add</button><span data-text="label"></span><b data-text="count"></b></div>';
|
||||
const win = mount(
|
||||
'<script type="application/json" data-wrn-controls id="metadata">' +
|
||||
JSON.stringify(values) +
|
||||
"</script>" +
|
||||
node +
|
||||
node,
|
||||
);
|
||||
(win.document.querySelector(".choose") as HappyButtonElement).click();
|
||||
(win.document.querySelector(".add") as HappyButtonElement).click();
|
||||
expect([...win.document.querySelectorAll("span")].map((el) => el.textContent)).toEqual([
|
||||
"Selected row",
|
||||
"start",
|
||||
]);
|
||||
expect([...win.document.querySelectorAll("b")].map((el) => el.textContent)).toEqual(["1", "0"]);
|
||||
});
|
||||
Reference in new issue
Block a user