[2.0 rc.10–rc.13] A live source created outside a streamed <Loading> takes over when the shell finishes hydrating, before the boundary's fragment hydrates: unclaimed server DOM + duplicate render, or undefined rows in a keyed <For>
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 35/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- javascript, typescript
- Domain
- frontend
Research direction
Reproduce with the described @solidjs/vite-plugin SSR setup and inspect hydratedCreateRoot, markTopLevelSnapshotScope, openLiveScope, releaseLiveScope, and armLiveTakeover. Trace why root-scoped takeover gates release before streamed boundaries hydrate, including the keyed store case. Done means the serialized snapshot is adopted when the boundary hydrates, takeover occurs once, and A and B produce no duplicate DOM or errors.
Written by the indexing model from the issue text.
Description
Setup: @solidjs/vite-plugin start mode, ssr: true, production build + vite preview (Node). Reproduces on 2.0.0-rc.10 and 2.0.0-rc.13 (solid-js, @solidjs/web, @solidjs/signals, compiler all at rc.13, vite-plugin next.47), 5/5 runs each. No router, no Cloudflare/workerd needed.
The server answer resolves after 300 ms, so the boundary streams. The browser value is an async iterable branded Symbol.for('solid.LiveSource'); it subscribes inside the first next()'s executor.
function source(liveMs) {
if (isServer) return (getRequestEvent().locals.answer ??= new Promise(r => setTimeout(() => r(ROWS), 300)))
let sent = false
return { [Symbol.for('solid.LiveSource')]: true, [Symbol.asyncIterator]: () => ({
next: () => sent ? new Promise(() => {}) : new Promise(res => setTimeout(() => (sent = true, res({ done: false, value: ROWS })), liveMs)),
return: () => Promise.resolve({ done: true }) }) }
}
// A: duplicate render
function A() {
const rows = createMemo(() => source(600))
return <Loading fallback={null}><Show when={rows().length > 0}><ul><li>rows: {rows().length}</li></ul></Show></Loading>
}
// B: undefined rows in a keyed <For>
function B() {
const [rows] = createOptimisticStore(() => source(0), [], { key: 'id' })
createMemo(() => rows.map(r => r.id).join(','))
return <Errored fallback={e => <p>{String(e())}</p>}><Loading fallback={null}>
<ul><For each={[...rows]} keyed={r => r.id}>{r => <li>{r().id}</li>}</For></ul></Loading></Errored>
}
Expected: the source is adopted from the serialized snapshot when its boundary hydrates, then taken over once. One copy of the DOM, no errors.
Actual: the live source subscribes at ~30 ms (when the shell finishes hydrating); the boundary's fragment arrives at ~300 ms.
- A: the server
<ul _hk=…>stays and a second<ul>is client-rendered. In dev:Hydration completed with 1 unclaimed server-rendered node(s). - B:
TypeError: Cannot read properties of undefined (reading 'id')from thekeyedfunction; the server list stays beside the error fallback. - With
ssrSource: 'hybrid', A is fixed (takeover waits for the boundary), but B still throws, at takeover time. - If the live answer arrives before the fragment and differs from the snapshot, the late snapshot overwrites the newer live value.
- Dev reproduces too once the server delay exceeds the dev entry's load time (e.g. 4000 ms) — production builds hit it with ordinary delays because the entry loads fast.
- A source created inside the boundary (its owner is the boundary) behaves correctly: it waits for the boundary to hydrate before subscribing.
Cause (as far as I can read): hydratedCreateRoot → markTopLevelSnapshotScope → openLiveScope(root). Setting sharedConfig.hydrating = false calls releaseLiveScope(root), which releases every root-scoped takeover gate armed by armLiveTakeover while boundaries still waiting on streamed fragments (_pendingBoundaries > 0) read those sources.
Workarounds we use: ssrSource: 'client' for such sources, or create the source inside the streamed boundary; ssrSource: 'hybrid' works for memos but not for stores read by a derived memo.
Happy to share the full minimal Vite app if useful.
- Dominant language
- TypeScript
- Stars
- 36.1k
- Forks
- 1.1k
- Avg merge
- 10h 7m
- Merged PRs (30d)
- 289
Getting set up
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from solidjs/solid
-
Difficulty 4/5 3-5 days Newbie friendliness 48/100
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 55/100
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 48/100
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 58/100
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 48/100
Maintainers usually reply within 1 day
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
Comfy-Org/ComfyUI_frontend#20346 ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 90/100
decentralized-identity/didwebvh-ts#203 ·
Maintainers usually reply within 1 day
-
community first-timers-only good first issue hacktoberfest help wanted low hanging fruit up-for-grabs
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
lingdojo/kana-dojo#31791 · 1 comment · 5 reactions ·
Maintainers usually reply within 1 day
-
Telegram webhook: line breaks lost since switch to rich messagesPossibly taken @Kshot3000 claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
-
github_actions security
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
Maintainers usually reply within 1 day