Keep a loop item's property from being read as a prop of the same name

A component that renders a list, such as FeatureGrid, passes each item
into a child: title={item.title || item.label || "Feature"}. The
compiler decided whether that attribute needed a client binding by
searching the expression for each prop name, so `item.label` matched the
grid's own `label` prop and a binding was emitted.

On hydration the binding is re-evaluated where the loop item is out of
scope, so the expression fell through to its default and replaced the
server's value: every card's title became "Feature". A name after a dot
belongs to whatever precedes it, so it no longer counts as a reference.

Timeline headings follow the same idea: its items were always h4, which
skipped a level and broke the heading order of a page that gives the
timeline no title of its own. They now take h3 in that case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ClintchizandClaude Opus 5 committed 2026-09-18 12:09:28 +05:30
1 parent a4a31032b9
commit a154e6c26e
3 files changed
+57 -10

No files matched your search

+14 -9
View File
@@ -2153,9 +2153,18 @@ function behaviorAttribute(behavior: ComponentBehavior | null): string {
const INTERP_RE = /\{([^{}]+)\}/g;
/**
* Does the expression read one of these names as a value of its own?
*
* A name after a dot belongs to whatever precedes it, so `item.label` reads the
* loop item, not a `label` prop of the surrounding component. Counting it as a
* reference made the caller mark the attribute reactive and emit a client
* binding for it; on hydration the loop item is out of scope there, so the
* expression fell back to its default and overwrote the server's value.
*/
function exprRefsState(expr: string, stateNames: Set<string>): boolean {
for (const name of stateNames) {
if (new RegExp(`\\b${name}\\b`).test(expr)) return true;
if (new RegExp(`(?<!\\.)\\b${name}\\b`).test(expr)) return true;
}
return false;
}
@@ -2371,16 +2380,14 @@ function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
expression =
branch.cond === null
? bodyExpression
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
? `__branches[${index}].body`
: `(${ctx.resolveExpr(branch.cond)}) ? __branches[${index}].body : ${expression}`;
}
// Render all branches on the server into an inert, encoded definition.
// A branch first selected in the browser therefore contains the real nested
// component HTML and scope metadata, not a data-component placeholder.
const definition = `\${__wrnexusEncodeControl([${clientBranches.join(",")}])}`;
return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
return '${(() => { const __branches = [' + clientBranches.join(",") + ']; return `<template data-wrn-if="${__wrnexusEncodeControl(__branches)}"></template>${' + expression + '}<template data-wrn-control-end></template>`; })()}';
}
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
@@ -3104,9 +3111,7 @@ function __wrnRaw(v: unknown): string {
}
if (needsScope) {
out.push(`function __wrnexusEncodeControl(value: unknown): string {
return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64");
}`);
out.push(`import { encodeControlTemplate as __wrnexusEncodeControl } from "@wrnexus/core";`);
out.push(`function __wrnexusSerializeScopeValue(value: unknown): string {
if (value === undefined) {
return "undefined";
+32
View File
@@ -1174,6 +1174,38 @@ test("nested component props retain parent-owned reactive bindings", () => {
expect(output).toContain("[&quot;value&quot;,&quot;{count}&quot;]");
expect(output).toContain('lowerName.startsWith("data-wrn-prop-bind-")');
});
test("a loop item's property is not mistaken for a prop of the same name", () => {
// `item.label` reads the loop item, not this component's `label` prop. A
// client binding here would be re-evaluated with the item out of scope, so
// the card would fall back to its default and lose the rendered value.
const output = generate(
parse(`component Grid {
props { items = [] label = "Features" }
view {
{#each items as item}
<Card title={item.title || item.label || "Feature"} />
{/each}
}
}`),
);
// The helper boilerplate mentions the prefix, so match an emitted binding.
expect(output).not.toContain("data-wrn-prop-bind-0");
});
test("a prop read directly still gets a parent-owned binding inside a loop", () => {
const output = generate(
parse(`component Grid {
props { items = [] label = "Features" }
view {
{#each items as item}
<Card title={label} />
{/each}
}
}`),
);
expect(output).toContain("data-wrn-prop-bind-0");
});
test("component functions are available during server rendering", () => {
const output = generate(
parse(`