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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<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 — 日本語");
|
||||
});
|
||||
Reference in New Issue
Block a user