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:
1 parent
a4a31032b9
commit
a154e6c26e
3 files changed
+57
-10
No files matched your search
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
<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(`
|
||||
|
||||
Reference in new issue
Block a user