From 2d0df4efc9328979207ba3460975ed4e65c0446a Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sat, 22 Aug 2026 11:31:11 +0530 Subject: [PATCH] fix(csr): write loop locals onto client-rendered for-loop items A client `data-for` passed its loop locals to hydration in memory but never wrote the `data-wrn-loop-locals` attribute 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 call, with no console error. A plain DOM handler kept working, because it receives locals through the hydration closure instead, which is what made the failure look arbitrary: the same loop variable resolved for `@click` and vanished for a component output. Both loop paths write the marker now, keyed and non-keyed, so the DOM is the single source of truth. Encoding goes through UTF-8 before base64 as the server's does; `btoa` on a raw string throws above U+00FF, which would take the whole loop down for an ordinary non-ASCII label. Co-Authored-By: Claude Opus 5 --- packages/csr/src/reactive-runtime.ts | 40 ++++++++++ packages/csr/test/for-loop-locals.test.ts | 92 +++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 packages/csr/test/for-loop-locals.test.ts diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 4a50ed30..a5edde61 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -980,6 +980,43 @@ export const REACTIVE_RUNTIME = String.raw` renderers[index](); } } + /* + * Write loop locals onto a client-rendered item, mirroring the + * data-wrn-loop-locals attribute the server emits for an each block. + * + * Client data-for used to pass locals to hydration in memory only, so + * anything that resolves them by READING the DOM -- a component output + * binding calls decodeLoopLocals(componentRoot) -- found nothing and + * silently dropped the value. A plain DOM handler kept working, because it + * receives locals through the hydration closure, which is what made the + * failure look arbitrary. The DOM is the single source of truth for + * locals; both paths write it now. + */ + function writeLoopLocals(node, locals) { + if (!node || node.nodeType !== 1 || !locals) return; + + try { + var json = JSON.stringify(locals); + if (json === undefined) return; + + var bytes = new TextEncoder().encode(json); + var binary = ""; + + for (var index = 0; index < bytes.length; index++) { + binary += String.fromCharCode(bytes[index]); + } + + node.setAttribute("data-wrn-loop-locals", window.btoa(binary)); + } catch (error) { + /* + * A value that will not serialise (a cycle, a DOM node) must not take + * the whole loop down -- the item still renders, and a handler naming + * that local fails on its own terms rather than silently. + */ + console.error("[wrnexus] failed to encode loop locals", error); + } + } + function decodeLoopLocals(node) { if (!node) { return {}; @@ -2216,6 +2253,8 @@ export const REACTIVE_RUNTIME = String.raw` itemIndex; } + writeLoopLocals(clone, locals); + hydrateItem( clone, locals, @@ -2292,6 +2331,7 @@ export const REACTIVE_RUNTIME = String.raw` } var keyedClone = template.cloneNode(true); + writeLoopLocals(keyedClone, keyedLocals); hydrateItem(keyedClone, keyedLocals); record = { node: keyedClone, diff --git a/packages/csr/test/for-loop-locals.test.ts b/packages/csr/test/for-loop-locals.test.ts new file mode 100644 index 00000000..19286570 --- /dev/null +++ b/packages/csr/test/for-loop-locals.test.ts @@ -0,0 +1,92 @@ +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 — 日本語"); +});