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 — 日本語"); });