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
+56
-9
No files matched your search
@@ -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";
|
||||||
|
|||||||
@@ -1174,6 +1174,38 @@ test("nested component props retain parent-owned reactive bindings", () => {
|
|||||||
expect(output).toContain("["value","{count}"]");
|
expect(output).toContain("["value","{count}"]");
|
||||||
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(`
|
||||||
|
|||||||
@@ -58,7 +58,17 @@ component Timeline {
|
|||||||
>
|
>
|
||||||
<div class="wrn-timeline__card-header">
|
<div class="wrn-timeline__card-header">
|
||||||
<div>
|
<div>
|
||||||
|
<!--
|
||||||
|
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>
|
<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}
|
||||||
|
|||||||
Reference in new issue
Block a user