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:
2026-08-22 11:31:11 +05:30
co-authored by Claude Opus 5
parent 20783699ac
commit 2d0df4efc9
2 changed files with 132 additions and 0 deletions
+40
View File
@@ -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,