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; win.document.body.innerHTML = `
${html}
`; const g = globalThis as Record; 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( `
`, ); 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( `
`, ); 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( `
`, ); 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( `

{item}

`, ); 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( `

{item}

`, ); 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( `
`, ); 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); });