Skip to content

Commit 91e300a

Browse files
fix: restore For hydration id parity at source position (#3161)
rc.4's patch-mode list seam made For's mapArray creation lazy (created on first read so an engaged driver never builds it), but hydration ids mint at CREATION time and the server spends the list's id scope at For's source position. Deferred creation ran at insert's hole evaluation — after later siblings had claimed their template keys — so every hydration key after a <For> shifted and the siblings hydrated detached (dead buttons; clean at rc.3). A hydrating For now creates the map eagerly at source position; outside hydration the laziness stands. Adds the issue's fragment shape to the parity harness. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8d17083 commit 91e300a

4 files changed

Lines changed: 53 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"solid-js": patch
3+
---
4+
5+
Fix `<For>` followed by siblings desyncing hydration (#3161, rc.4 regression). The patch-mode list seam made `For`'s `mapArray` creation lazy, but hydration ids mint at creation time — deferring to first read spent the list's id scope after later siblings had already claimed their template keys, shifting every hydration key after the list and leaving the siblings detached (dead buttons). A hydrating `For` now creates its map eagerly at source position, restoring rc.3 id parity; outside hydration the lazy creation stands.

packages/solid/src/client/flow.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
getOwner,
99
runWithOwner
1010
} from "@solidjs/signals";
11-
import { createErrorBoundary, createLoadingBoundary } from "./hydration.js";
11+
import { createErrorBoundary, createLoadingBoundary, sharedConfig } from "./hydration.js";
1212
import type { Accessor, RevealOrder } from "@solidjs/signals";
1313
export type { RevealOrder };
1414
import type { Element as SolidElement } from "../types.js";
@@ -98,13 +98,22 @@ export function For<T extends readonly any[], U extends SolidElement>(props: {
9898
// path, created lazily under the component's owner on first read.
9999
const owner = getOwner();
100100
let mapped: (() => any) | undefined;
101-
const list = () => {
102-
if (mapped === undefined)
103-
mapped = runWithOwner(owner, () =>
104-
mapArray(() => props.each, props.children as any, options as any)
105-
) as () => any;
106-
return mapped();
107-
};
101+
const create = () =>
102+
runWithOwner(owner, () =>
103+
mapArray(() => props.each, props.children as any, options as any)
104+
) as () => any;
105+
// Hydration id parity (#3161): hydration ids mint at CREATION time, and
106+
// the server spends the list's id slot at For's source position — so a
107+
// hydrating client must create the map HERE, not on first read. Deferred
108+
// creation ran at insert's hole evaluation, AFTER later siblings had
109+
// already claimed their template keys, shifting every hydration id after
110+
// the list (the siblings hydrated detached: dead buttons). Outside
111+
// hydration the laziness stands — it is the driver seam's point: an
112+
// engaged list never builds the mapArray at all. (A driver-ENGAGED
113+
// hydration does not read this accessor either; driveList claims rows
114+
// positionally through their own _hk keys.)
115+
if (sharedConfig.hydrating) mapped = create();
116+
const list = () => (mapped ?? (mapped = create()))();
108117
if (props.keyed !== false && !("fallback" in props) && props.children.length < 2)
109118
// `keyed` rides along so the driver implements the DECLARED identity
110119
// semantics (reference vs key fn) — see driveList's identity ruling.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "for-then-siblings",
3+
"shell": "<div _hk=000 class=\"row\">row <!--$-->1<!--/--></div><div _hk=010 class=\"row\">row <!--$-->2<!--/--></div><button _hk=2 id=\"bump\">bump</button><pre _hk=3 id=\"after\">count: <!--$-->0<!--/--></pre>",
4+
"rest": ""
5+
}

packages/web/test/harness/scenarios.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,24 @@ function ForList() {
198198
);
199199
}
200200

201+
// ---------------------------------------------------------------------------
202+
// 8b. For followed by static siblings in a top-level fragment (#3161): the
203+
// rc.4 regression — server spends top-level id slots on the rows and gives
204+
// the siblings the next ones; the rc.4 client asked for the siblings at 0/1,
205+
// so everything after the list hydrated detached (dead buttons).
206+
let bumpAfterFor!: () => void;
207+
function ForThenSiblings() {
208+
const [count, setCount] = createSignal(0);
209+
bumpAfterFor = () => setCount(c => c + 1);
210+
return (
211+
<>
212+
<For each={[{ id: 1 }, { id: 2 }]}>{row => <div class="row">row {row.id}</div>}</For>
213+
<button id="bump">bump</button>
214+
<pre id="after">count: {count()}</pre>
215+
</>
216+
);
217+
}
218+
201219
// ---------------------------------------------------------------------------
202220
// 9. Spread with children in the spread object
203221
function SpreadChildren() {
@@ -1558,6 +1576,14 @@ export const scenarios: Scenario[] = [
15581576
expectedTextAfterUpdate: "abcd",
15591577
stableSelector: "ul"
15601578
},
1579+
{
1580+
name: "for-then-siblings",
1581+
App: ForThenSiblings,
1582+
expectedText: "row 1row 2bumpcount: 0",
1583+
update: () => bumpAfterFor(),
1584+
expectedTextAfterUpdate: "row 1row 2bumpcount: 1",
1585+
stableSelector: "button, pre"
1586+
},
15611587
{
15621588
name: "spread-children",
15631589
App: SpreadChildren,

0 commit comments

Comments
 (0)