Repository navigation
fix: hydration seeds on remount, first render and late values - #38
Merged
Merged
Conversation
Svelte creates a remounted {#key} branch before destroying the old one, so a
per-instance serializable atom, such as a ScopedAtom with its input in the key,
briefly shares its key with the old copy and the hooks threw. In the browser the
new atom now takes no seed, and development builds warn if a different atom
still holds the key after the update commits. The server still throws.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HydrationBoundary now treats a node holding only an unread initial value as new, and while hydrating the server's markup applies existing atoms at once as the server does, read from a hydratable the server writes. A promise-encoded value landing after the boundary ends is dropped in the browser too, including for a boundary destroyed while still pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tch again Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd delay the duplicate-key warning
In the browser, a promise-encoded value landing after its HydrationBoundary
ended is now dropped only for a key no node held when the boundary ended, so
a layout's reader above the boundary that took its waiting value gets the
result instead of showing Initial forever. A node a later reader creates
still doesn't get it.
The development warning for two atoms sharing a serialization key now checks
again about a second after the update commits, so an old {#key} branch
playing an outro doesn't trigger it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and claim a seed only while hydrating A HydrationBoundary now records which node held each pending key when it ended, and keeps a late value only if that node is still the registry's node for the key, so a reader inside the boundary that is swept just after doesn't leave the value for a later reader. A plain reader claims a server seed only while Svelte is hydrating, so a key left from the first page load doesn't hold back a reader after client-side navigation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…async and an awaited render HydrationBoundary applies values to atoms that already exist before the first render only with Svelte's experimental.async; without it they are applied after the first render, as before. Custom server rendering with async on must await render(...) so the boundary's hydratable entry is written, or production Svelte logs hydratable_missing_but_expected once per boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useAtomResult and useAtomSuspense read through the same subscribed reader as useAtomValue, so their reader also waited on the hook's own seed and set state when it landed, while Svelte was still committing the hydration. Under SvelteKit's dev build that threw "Batch has scheduled effects" on /server-rendering; beside a pending boundary it dropped the server's result from the page. The async hooks seed themselves, so their reader no longer waits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… value in the browser
The browser tracked which atom first claimed a key and treated any other atom with the key as a
mistake, so a {#key} remount, which briefly has an old and a new atom alive, needed a delayed
development warning. The registry already keeps a serializable atom's value under its key, so the
new atom reads that value anyway: a second atom now joins the key's seed. Only the server render
still throws for two atoms with one key. The scoped-atoms docs now point to families for values
from the server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarrednorrisdev
marked this pull request as ready for review
October 9, 2026 13:01
useAtomValue waited for a server seed it found by reading Svelte's private hydration store, so a plain read of an atom an async hook also read wouldn't fetch it again. The docs already say to read server-rendered data with useAtomResult or useAtomSuspense, which also give a live result with `waiting` for loading states, so the private store and the extra rule weren't worth it. The hydration and async-atoms pages now say plainly that useAtomValue takes no part in hydration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarrednorrisdev
added a commit
that referenced
this pull request
Oct 9, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes three bugs where a server-rendered page and the browser disagree about an atom's value.
Background: when a page is server-rendered, the server sends each serializable atom's value with the HTML, named by the atom's serialization key. The browser picks the value up instead of computing it again. This is called hydration. The registry also stores a serializable atom's value under its key, so two atom objects with the same key share one value. The fixes below are simplified excerpts; the full code is in the diff.
1. Remounting a scoped atom crashes
A scoped atom is one atom per provider component:
User.provide(id)creates an atom for that component's subtree, andUser.use()below it reads it, like Svelte context.{#key reset}destroys and recreates its contents wheneverresetchanges, which is a common way to build a "reset" button.Bug: clicking Reset throws
Two different atoms share the serialization key "user-a". Svelte creates the newUserProviderbefore it destroys the old one, so for a moment two atoms claimuser-a.Fix: in the browser, a server value now belongs to its key, not to the first atom that claimed it, so the new atom simply shares it. That isn't new sharing: the registry already stores a serializable atom's value under its key, so the old and new atom were reading the same value anyway. No throw, no warning, and the value carries over without a refetch. The server still throws, because nothing remounts there, so two atoms with one key really is a mistake.
This example follows what the scoped-atoms docs used to advise, but a scoped atom with a serialization key is an odd fit: scoping promises a separate value per provider, while the key makes every copy share one. So the docs now say: if the value comes from the server, use a family (
Atom.family, one atom per id), not a scoped atom.2. The first render doesn't match the server's HTML
Bug: the server renders
2. The browser's first render shows1, then switches to2. The same happens if the layout itself readscountAtomabove the boundary.Fix: an atom holding only an unread initial value now counts as new, so it takes the server's value before the first render. While the browser is hydrating the server's HTML, it also updates atoms that already exist straight away, as the server does. That second part needs Svelte's
experimental.async; without it, an atom read above the boundary still updates after the first render, as before.Before the fix, step 10 of the page load: the browser renders 1, but the server's HTML said 2.
After the fix, the same step: the browser renders 2, matching the server.
Step through the whole page load, from
loadon the server to the browser's first render. Click either image to open it at that step.3. A late value turns up after its boundary is gone
Bug: navigate away before the todos arrive. When they do arrive, they are still written into the registry, and whichever page reads
todosAtomnext gets that stale value. The docs say it is dropped.Fix: the browser now drops late values once the boundary is gone, as the server already did. It also drops them when the boundary is torn down while it is still loading. The exception is a reader outside the boundary, such as one in the layout, that was already waiting for the value.
Hydration.hydrate( registry, - BROWSER ? atoms : atoms.map(withLateDropped) // only the server dropped late values + atoms.map(withLateDropped) // lateDropped(key, promise) skips a value landing after the boundary ends ); - (BROWSER ? onDestroy : onRenderEnd)(() => { ended = true; /* ... */ }); + (BROWSER ? onTeardownAfterChildren : onRenderEnd)(() => { ended = true; /* ... */ });Tests
Each fix has tests that fail on
main. Locally, the package suite passes in Chromium (266 tests). CI passed in Chromium, Firefox and WebKit.Decisions
All decided during review:
@effect/atom-react, which has no such check. Kept: the server render still throws for two atoms with one key, which catches two different atoms given the same name by mistake.Atom.familyfor values that come from the server, instead of a scoped atom with the id in its key.HydrationBoundarysends one more tinyhydratablevalue, the flag that tells the browser it's hydrating the server's HTML. Accepted.useAtomValuewhile another component loads it withuseAtomSuspenseoruseAtomResultcan fetch it twice in the browser. The fix madeuseAtomValuewait for the server's value, but it relied on Svelte's private hydration store and only covered direct reads. Instead, the hydration and async-atoms docs now state the rule:useAtomValuetakes no part in hydration, so read server-rendered data withuseAtomResultoruseAtomSuspense.Where to look in the diff
seedFromServerinsrc/Hooks.svelte.ts. Tests:test/scoped.browser.test.ts, and "two different atoms in use at once with one key share its value" intest/async.browser.test.ts.shownandhydratingServerMarkupinsrc/HydrationBoundary.svelte. Tests: "RegistryProvider initialValues with a HydrationBoundary" intest/hydration.browser.test.ts.lateDroppedand the teardown insrc/HydrationBoundary.svelte. Tests: "HydrationBoundary destroyed before a promise-encoded value lands".scoped-atoms,troubleshooting,hydrationandasync-atomspages inapps/demo/src/routes/.🤖 Generated with Claude Code