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; 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 { function exprRefsState(expr: string, stateNames: Set<string>): boolean {
for (const name of stateNames) { 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; return false;
} }
@@ -2371,16 +2380,14 @@ function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
expression = expression =
branch.cond === null branch.cond === null
? bodyExpression ? `__branches[${index}].body`
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`; : `(${ctx.resolveExpr(branch.cond)}) ? __branches[${index}].body : ${expression}`;
} }
// Render all branches on the server into an inert, encoded definition. // Render all branches on the server into an inert, encoded definition.
// A branch first selected in the browser therefore contains the real nested // A branch first selected in the browser therefore contains the real nested
// component HTML and scope metadata, not a data-component placeholder. // component HTML and scope metadata, not a data-component placeholder.
const definition = `\${__wrnexusEncodeControl([${clientBranches.join(",")}])}`; return '${(() => { const __branches = [' + clientBranches.join(",") + ']; return `<template data-wrn-if="${__wrnexusEncodeControl(__branches)}"></template>${' + expression + '}<template data-wrn-control-end></template>`; })()}';
return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
} }
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string { function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
@@ -3104,9 +3111,7 @@ function __wrnRaw(v: unknown): string {
} }
if (needsScope) { if (needsScope) {
out.push(`function __wrnexusEncodeControl(value: unknown): string { out.push(`import { encodeControlTemplate as __wrnexusEncodeControl } from "@wrnexus/core";`);
return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64");
}`);
out.push(`function __wrnexusSerializeScopeValue(value: unknown): string { out.push(`function __wrnexusSerializeScopeValue(value: unknown): string {
if (value === undefined) { if (value === undefined) {
return "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("[&quot;value&quot;,&quot;{count}&quot;]");
expect(output).toContain('lowerName.startsWith("data-wrn-prop-bind-")'); 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", () => { test("component functions are available during server rendering", () => {
const output = generate( const output = generate(
parse(` parse(`
+11 -1
View File
@@ -58,7 +58,17 @@ component Timeline {
> >
<div class="wrn-timeline__card-header"> <div class="wrn-timeline__card-header">
<div> <div>
<h4 class="wrn-timeline__item-title">{item.title || item.label}</h4> <!--
Items sit under this component's own title when it has one.
Without that title they are the first heading here, so they
take its level instead of skipping one and leaving a gap in
the page's heading order.
-->
{#if title}
<h4 class="wrn-timeline__item-title">{item.title || item.label}</h4>
{:else}
<h3 class="wrn-timeline__item-title">{item.title || item.label}</h3>
{/if}
{#if item.subtitle} {#if item.subtitle}
<p class="wrn-timeline__subtitle">{item.subtitle}</p> <p class="wrn-timeline__subtitle">{item.subtitle}</p>
{/if} {/if}