From a154e6c26e5aa230e8446a7bf1d02fda75c0f698 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Fri, 18 Sep 2026 12:09:28 +0530 Subject: [PATCH] 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 --- packages/compiler/src/codegen.ts | 23 +++++++++++------- packages/compiler/test/compiler.test.ts | 32 +++++++++++++++++++++++++ packages/ui/components/Timeline.wrn | 12 +++++++++- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 41fce70e..c3070ff5 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -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): boolean { for (const name of stateNames) { - if (new RegExp(`\\b${name}\\b`).test(expr)) return true; + if (new RegExp(`(?${"${" + expression + "}"}`; + return '${(() => { const __branches = [' + clientBranches.join(",") + ']; return `${' + expression + '}`; })()}'; } 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"; diff --git a/packages/compiler/test/compiler.test.ts b/packages/compiler/test/compiler.test.ts index 0ffbea5c..7d2b2c84 100644 --- a/packages/compiler/test/compiler.test.ts +++ b/packages/compiler/test/compiler.test.ts @@ -1174,6 +1174,38 @@ test("nested component props retain parent-owned reactive bindings", () => { expect(output).toContain("["value","{count}"]"); 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} + + {/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} + + {/each} + } + }`), + ); + + expect(output).toContain("data-wrn-prop-bind-0"); +}); test("component functions are available during server rendering", () => { const output = generate( parse(` diff --git a/packages/ui/components/Timeline.wrn b/packages/ui/components/Timeline.wrn index 18020ce7..6c4dab2e 100644 --- a/packages/ui/components/Timeline.wrn +++ b/packages/ui/components/Timeline.wrn @@ -58,7 +58,17 @@ component Timeline { >
-

{item.title || item.label}

+ + {#if title} +

{item.title || item.label}

+ {:else} +

{item.title || item.label}

+ {/if} {#if item.subtitle}

{item.subtitle}

{/if}