143 lines
6.2 KiB
TypeScript
143 lines
6.2 KiB
TypeScript
import { test, expect } from "bun:test";
|
|
import { Window } from "happy-dom";
|
|
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
|
|
|
restoreGlobalsAfterAll([
|
|
"window",
|
|
"document",
|
|
"location",
|
|
"NodeFilter",
|
|
"MutationObserver",
|
|
"CustomEvent",
|
|
]);
|
|
|
|
function mount(html: string): Window {
|
|
const win = new Window() as unknown as Window & Record<string, unknown>;
|
|
win.document.body.innerHTML = `<div id="app">${html}</div>`;
|
|
const g = globalThis as Record<string, unknown>;
|
|
g.window = win;
|
|
g.document = win.document;
|
|
g.location = win.location;
|
|
g.NodeFilter = (win as unknown as { NodeFilter: unknown }).NodeFilter;
|
|
g.MutationObserver = (win as unknown as { MutationObserver: unknown }).MutationObserver;
|
|
g.CustomEvent = (win as unknown as { CustomEvent: unknown }).CustomEvent;
|
|
(0, eval)(REACTIVE_RUNTIME);
|
|
return win;
|
|
}
|
|
|
|
// A client-rendered `data-for` passed its loop locals to hydration in memory but
|
|
// never wrote the `data-wrn-loop-locals` marker the SSR path writes. Anything
|
|
// that resolves locals by READING the DOM -- notably a component's
|
|
// `data-wrn-out-*` output binding, which calls decodeLoopLocals(componentRoot)
|
|
// -- therefore found nothing and silently dropped the value, with no console
|
|
// error. A plain DOM `@click` kept working, because it receives locals through
|
|
// the hydration closure instead, which is what made this look arbitrary.
|
|
test("a client-rendered for-loop item carries its loop locals in the DOM", () => {
|
|
const win = mount(
|
|
`<div data-scope="items: [{code: 'small'}, {code: 'large'}]">
|
|
<div data-for="item in items" data-component="Card" data-wrn-out-action="pick(item.code)"></div>
|
|
</div>`,
|
|
);
|
|
|
|
const mounts = win.document.querySelectorAll("[data-component='Card']");
|
|
expect(mounts.length).toBe(2);
|
|
|
|
const decoded = Array.from(mounts).map((node) => {
|
|
const raw = (node as unknown as Element).getAttribute("data-wrn-loop-locals");
|
|
expect(raw).toBeTruthy();
|
|
return JSON.parse(Buffer.from(String(raw), "base64").toString("utf8"));
|
|
});
|
|
|
|
// The marker must carry the real item, so an output binding naming `item`
|
|
// resolves it rather than silently dropping the call.
|
|
expect(decoded[0].item).toEqual({ code: "small" });
|
|
expect(decoded[1].item).toEqual({ code: "large" });
|
|
});
|
|
|
|
// The keyed path builds its clones separately, so it needs its own coverage --
|
|
// a fix applied to only one of the two loop paths leaves half the bug in place.
|
|
test("a keyed for-loop item carries its loop locals too", () => {
|
|
const win = mount(
|
|
`<div data-scope="rows: [{id: 1, code: 'a'}, {id: 2, code: 'b'}]">
|
|
<div data-for="row in rows key row.id" data-component="Card" data-wrn-out-action="pick(row.code)"></div>
|
|
</div>`,
|
|
);
|
|
|
|
const decoded = Array.from(win.document.querySelectorAll("[data-component='Card']"), (node) => {
|
|
const raw = (node as unknown as Element).getAttribute("data-wrn-loop-locals");
|
|
expect(raw).toBeTruthy();
|
|
return JSON.parse(Buffer.from(String(raw), "base64").toString("utf8"));
|
|
});
|
|
|
|
expect(decoded.map((d) => d.row.code)).toEqual(["a", "b"]);
|
|
});
|
|
|
|
// The encoder goes through UTF-8 before base64, as the server's does. btoa on a
|
|
// raw JS string throws on any character above U+00FF, which would take out the
|
|
// whole loop for an ordinary non-ASCII label.
|
|
test("loop locals survive non-ASCII values", () => {
|
|
const win = mount(
|
|
`<div data-scope="rows: [{label: 'Ünïcode — 日本語'}]">
|
|
<div data-for="row in rows" data-component="Card" data-wrn-out-action="pick(row.label)"></div>
|
|
</div>`,
|
|
);
|
|
|
|
const raw = win.document
|
|
.querySelector("[data-component='Card']")!
|
|
.getAttribute("data-wrn-loop-locals");
|
|
expect(raw).toBeTruthy();
|
|
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);
|
|
});
|