Skip to content

Commit be467d3

Browse files
bartlomiejuclaude
andauthored
fix: warn when Partial with append/prepend mode is missing key prop (#3738)
## Summary - Adds a `console.warn` during SSR when a `<Partial>` uses `mode="append"` or `mode="prepend"` without a `key` prop - Without a key, Preact cannot correctly reconcile prepended/appended children, leading to subtle rendering bugs - No warning for `mode="replace"` (default) since keys aren't required there --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 103427f commit be467d3

3 files changed

Lines changed: 108 additions & 4 deletions

File tree

docs/latest/advanced/partials.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ shops for example.
156156
export default function AddToCartPartial() {
157157
return (
158158
<>
159-
<Partial name="cart-items" mode="append">
159+
<Partial name="cart-items" mode="append" key={newItem.id}>
160160
{/* Render the new cart item here */}
161161
</Partial>
162162
<Partial name="total-price">
@@ -187,7 +187,7 @@ export default function LogView() {
187187
const lines = getNewLogLines();
188188

189189
return (
190-
<Partial name="logs-list" mode="append">
190+
<Partial name="logs-list" mode="append" key={lines[0]}>
191191
{lines.map((line) => {
192192
return <li key={line}>{line}</li>;
193193
})}
@@ -196,8 +196,10 @@ export default function LogView() {
196196
}
197197
```
198198

199-
> [info]: When picking the `prepend` or `append` mode, make sure to add keys to
200-
> the elements.
199+
> [warn]: When using `prepend` or `append` mode, you **must** add a `key` prop
200+
> to the `<Partial>` component. Without it, Preact cannot distinguish new
201+
> children from existing ones, leading to subtle rendering bugs. Fresh will log
202+
> a warning if it detects a missing key on an append/prepend partial.
201203
202204
## Bypassing or disabling Partials
203205

packages/fresh/src/runtime/server/preact_hooks.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,19 @@ options[OptionsType.DIFF] = (vnode) => {
194194
`<Partial> components cannot be used inside islands.`,
195195
);
196196
}
197+
198+
const mode = (vnode.props as PartialProps).mode;
199+
if (
200+
(mode === "append" || mode === "prepend") &&
201+
vnode.key == null
202+
) {
203+
// deno-lint-ignore no-console
204+
console.warn(
205+
`<Partial name="${name}" mode="${mode}"> is missing a "key" prop. ` +
206+
`Without a key, Preact cannot correctly reconcile ${mode}ed children. ` +
207+
`Add a unique key to fix this.`,
208+
);
209+
}
197210
} else if (
198211
!PATCHED.has(vnode)
199212
) {

packages/fresh/tests/partials_test.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "./test_utils.tsx";
1616
import { SelfCounter } from "./fixtures_islands/SelfCounter.tsx";
1717
import { expect } from "@std/expect";
18+
import { assertSpyCalls, spy } from "@std/testing/mock";
1819
import { PartialInIsland } from "./fixtures_islands/PartialInIsland.tsx";
1920
import { FakeServer } from "../src/test_utils.ts";
2021
import { JsonIsland } from "./fixtures_islands/JsonIsland.tsx";
@@ -2775,3 +2776,91 @@ Deno.test({
27752776
});
27762777
},
27772778
});
2779+
2780+
Deno.test({
2781+
name: "partials - warns when append/prepend missing key",
2782+
fn: async () => {
2783+
const app = testApp()
2784+
.get("/", (ctx) => {
2785+
return ctx.render(
2786+
<Doc>
2787+
<Partial name="no-key-append" mode="append">
2788+
<p>content</p>
2789+
</Partial>
2790+
<Partial name="no-key-prepend" mode="prepend">
2791+
<p>content</p>
2792+
</Partial>
2793+
</Doc>,
2794+
);
2795+
});
2796+
2797+
const warnSpy = spy(console, "warn");
2798+
try {
2799+
const server = new FakeServer(app.handler());
2800+
await server.get("/");
2801+
assertSpyCalls(warnSpy, 2);
2802+
expect(String(warnSpy.calls[0].args[0])).toContain("no-key-append");
2803+
expect(String(warnSpy.calls[0].args[0])).toContain("append");
2804+
expect(String(warnSpy.calls[1].args[0])).toContain("no-key-prepend");
2805+
expect(String(warnSpy.calls[1].args[0])).toContain("prepend");
2806+
} finally {
2807+
warnSpy.restore();
2808+
}
2809+
},
2810+
});
2811+
2812+
Deno.test({
2813+
name: "partials - no warning when append/prepend has key",
2814+
fn: async () => {
2815+
const app = testApp()
2816+
.get("/", (ctx) => {
2817+
return ctx.render(
2818+
<Doc>
2819+
<Partial name="keyed-append" mode="append" key="a">
2820+
<p>content</p>
2821+
</Partial>
2822+
<Partial name="keyed-prepend" mode="prepend" key="b">
2823+
<p>content</p>
2824+
</Partial>
2825+
</Doc>,
2826+
);
2827+
});
2828+
2829+
const warnSpy = spy(console, "warn");
2830+
try {
2831+
const server = new FakeServer(app.handler());
2832+
await server.get("/");
2833+
assertSpyCalls(warnSpy, 0);
2834+
} finally {
2835+
warnSpy.restore();
2836+
}
2837+
},
2838+
});
2839+
2840+
Deno.test({
2841+
name: "partials - no warning for replace mode without key",
2842+
fn: async () => {
2843+
const app = testApp()
2844+
.get("/", (ctx) => {
2845+
return ctx.render(
2846+
<Doc>
2847+
<Partial name="replace-no-key" mode="replace">
2848+
<p>content</p>
2849+
</Partial>
2850+
<Partial name="default-no-key">
2851+
<p>content</p>
2852+
</Partial>
2853+
</Doc>,
2854+
);
2855+
});
2856+
2857+
const warnSpy = spy(console, "warn");
2858+
try {
2859+
const server = new FakeServer(app.handler());
2860+
await server.get("/");
2861+
assertSpyCalls(warnSpy, 0);
2862+
} finally {
2863+
warnSpy.restore();
2864+
}
2865+
},
2866+
});

0 commit comments

Comments
 (0)