Skip to content

Commit edb3e36

Browse files
Arm clientOnly's swap without an owner so sibling hydration ids stay aligned
A hydrated clientOnly desynced DOM bookkeeping for every FOLLOWING sibling: the client half armed its post-settle swap with onSettled, which registers a tracked effect — a non-transparent owner, and every such owner consumes one of the component's child ids at creation. The server half's only owner is the fallback mirror memo, so the client ran one id ahead: a sibling created after the clientOnly derived its hydration id one slot past the server's (_hk=30 server, key 40 asked), its template claim missed the registry, and insert tracked a freshly-cloned phantom node that was never inserted (insertExpression skips while hydrating). The DOM looked correct — the unclaimed server node just stayed put — until the sibling's first post-hydration re-render reconciled against the detached phantom and inserted the new content beside the orphaned server node instead of replacing it. First surfaced as duplicated nodes after an HMR hot-swap of a component following a clientOnly; any signal-driven element swap in a following sibling hits it. The swap is now armed through sharedConfig.onHydrationEnd — the ownerless "all hydration complete" channel (waits for pending streamed boundaries, microtask-fires when already done) — so clientOnly consumes exactly one child id on both sides. New parity-harness scenario client-only-sibling-update pins the contract: a resolving clientOnly followed by an element-swapping hole, asserting the post-settle update REPLACES (both loaded and streamed modes). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2bb02e0 commit edb3e36

4 files changed

Lines changed: 59 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+
"@solidjs/web": patch
3+
---
4+
5+
Fix hydrated `clientOnly` desyncing DOM bookkeeping for following siblings. The client half's post-settle swap was armed with `onSettled`, which registers a tracked effect — an id-consuming owner the server half (whose only owner is the fallback mirror memo) never mints. Every sibling created after a hydrated `clientOnly` therefore derived its hydration id one slot past the server's, its template claim missed the registry, and `insert` tracked a never-inserted phantom node: the sibling's first post-hydration re-render reconciled against the phantom and inserted the new content beside the orphaned server node instead of replacing it (first surfaced as duplicated nodes after an HMR hot-swap of a component following a `clientOnly`). The swap is now armed through `sharedConfig.onHydrationEnd` — the ownerless "all hydration complete" channel — so `clientOnly` consumes exactly one child id on both sides.

packages/solid-web/src/index.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
createRoot,
1919
createSignal,
2020
getOwner,
21-
onSettled,
2221
runWithOwner,
2322
untrack,
2423
omit,
@@ -495,21 +494,28 @@ export function clientOnly<T extends Component<any>>(
495494
// fallback must render DURING the walk (mounted=false branch) to adopt
496495
// that DOM, and only the post-settle swap is deferred.
497496
const [mounted, setMounted] = createSignal(!sharedConfig.hydrating);
498-
// The gate memo must be the component's FIRST id-consuming child so the
497+
// The gate memo must be the component's ONLY id-consuming child so the
499498
// fallback's elements derive the same hydration ids as under the server
500-
// half's mirror memo and get claimed instead of duplicated. onSettled
501-
// creates a tracked effect (an id-consuming owner), so it registers
502-
// AFTER the memo.
499+
// half's mirror memo and get claimed instead of duplicated.
503500
const gate = createMemo(
504501
() => (
505502
(Comp = comp()),
506503
(m = mounted()),
507504
untrack(() => (Comp && m ? Comp(rest) : props.fallback))
508505
)
509506
) as unknown as JSX.Element;
510-
onSettled(() => {
511-
setMounted(true);
512-
});
507+
// The swap trigger must NOT hold an owner: onSettled registers a tracked
508+
// effect, and every non-transparent owner consumes one of the component's
509+
// child ids — an id the server half (whose only owner is the mirror memo)
510+
// never mints. That shifted the hydration ids of every sibling AFTER a
511+
// hydrated clientOnly by one slot, so their template claims missed the
512+
// registry and insert tracked never-inserted phantom nodes: the sibling's
513+
// first post-hydration re-render then reconciled against the phantom and
514+
// inserted beside the orphaned server node instead of replacing it.
515+
// onHydrationEnd is the ownerless "all hydration complete" channel
516+
// (waits for pending streamed boundaries; microtask-fires if already
517+
// done) — exactly the gate's semantics, and it consumes nothing.
518+
sharedConfig.onHydrationEnd!(() => setMounted(true));
513519
return gate;
514520
};
515521
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "client-only-sibling-update",
3+
"shell": "<div _hk=0><span>lead </span><!--$--><button _hk=10>fb</button><!--/--><!--$--><em _hk=30>one</em><!--/--><span> tail</span></div>",
4+
"rest": ""
5+
}

packages/solid-web/test/harness/scenarios.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,31 @@ function ClientOnlyElementFallback() {
649649
);
650650
}
651651

652+
// ---------------------------------------------------------------------------
653+
// clientOnly whose module RESOLVES, followed by a reactive element hole. The
654+
// post-settle swap (fallback → widget) must not desync insert bookkeeping for
655+
// siblings AFTER the clientOnly: when the sibling's hole later flips its
656+
// element, insert must REPLACE the old element (single node), not leave it
657+
// orphaned in place and append a fresh copy beside it. Found via a refresh
658+
// hot-swap of a component following a hydrated clientOnly (the duplicated
659+
// node surfaced there first), but any signal-driven element swap hits it.
660+
const ResolvingWidget = clientOnly(() =>
661+
Promise.resolve({ default: (_props: {}) => <b>widget</b> })
662+
);
663+
let setSiblingSwap: (v: boolean) => void;
664+
function ClientOnlySiblingUpdate() {
665+
const [sw, setSw] = createSignal(true);
666+
setSiblingSwap = setSw;
667+
return (
668+
<div>
669+
<span>lead </span>
670+
<ResolvingWidget fallback={<button>fb</button>} />
671+
{sw() ? <em>one</em> : <q>two</q>}
672+
<span> tail</span>
673+
</div>
674+
);
675+
}
676+
652677
// ---------------------------------------------------------------------------
653678
// A <Loading> whose fragment settles AFTER hydration completes (#2964). The
654679
// boundary renders behind an async gate with NO boundary above it — the
@@ -924,6 +949,16 @@ export const scenarios: Scenario[] = [
924949
expectedText: "lead fb tail",
925950
stableSelector: "div, span, button"
926951
},
952+
{
953+
name: "client-only-sibling-update",
954+
App: ClientOnlySiblingUpdate,
955+
async: true,
956+
expectedText: "lead widgetone tail",
957+
serverText: "lead fbone tail",
958+
update: () => setSiblingSwap(false),
959+
expectedTextAfterUpdate: "lead widgettwo tail",
960+
stableSelector: "div, span"
961+
},
927962
{
928963
name: "late-boundary-after-done",
929964
App: LateBoundaryAfterDone,

0 commit comments

Comments
 (0)