Skip to content

fix: hydration seeds on remount, first render and late values - #38

Merged
jarrednorrisdev merged 11 commits into
mainfrom
fix/hydration-seeds
Oct 9, 2026
Merged

jarrednorrisdev merged 11 commits into
mainfrom
fix/hydration-seeds

Conversation

@jarrednorrisdev

@jarrednorrisdev jarrednorrisdev commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

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, and User.use() below it reads it, like Svelte context. {#key reset} destroys and recreates its contents whenever reset changes, which is a common way to build a "reset" button.

// user.ts: a scoped atom per user, with the id in the key, as the scoped-atoms docs advise
export const User = ScopedAtom.make((id: string) =>
  Atom.make(fetchUser(id)).pipe(
    Atom.serializable({ key: `user-${id}`, schema: UserResult })
  )
);
<!-- UserProvider.svelte -->
<script lang="ts">
  const { id, children } = $props();
  User.provide(id);
</script>
{@render children()}

<!-- +page.svelte (server-rendered) -->
<button onclick={() => (reset += 1)}>Reset</button>
{#key reset}
  <UserProvider id="a"><UserName /></UserProvider>
{/key}

Bug: clicking Reset throws Two different atoms share the serialization key "user-a". Svelte creates the new UserProvider before it destroys the old one, so for a moment two atoms claim user-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.

  // seedFromServer (browser only)
  let entry = held.get(key);
- if (entry && entry.atom !== atom) {
-   throw new Error(`Two different atoms share the serialization key "${key}"`);
- }
  // ...the new atom joins the existing entry

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

<!-- +layout.svelte: a default of 1 -->
<RegistryProvider initialValues={[[countAtom, 1]]}>
  {@render children()}
</RegistryProvider>

<!-- +page.svelte: the load function dehydrated countAtom as 2 -->
<HydrationBoundary state={data.state}>
  <Count />  <!-- useAtomValue(countAtom) -->
</HydrationBoundary>

Bug: the server renders 2. The browser's first render shows 1, then switches to 2. The same happens if the layout itself reads countAtom above 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.

- (nodes.has(atom.key) ? existing : fresh).push(atom);
+ (shown(nodes.get(atom.key)) ? existing : fresh).push(atom); // an unread initial value isn't "shown"

- if (!BROWSER) {
+ const hydratingServerMarkup = hydratable("effect-atom-svelte/HydrationBoundary", () => !BROWSER);
+ if (!BROWSER || hydratingServerMarkup) {
    hydrateExisting(deferred);
  }

Before the fix, step 10 of the page load: the browser renders 1, but the server's HTML said 2.

Step 10 before the fix: Count renders 1, but the server's HTML said 2

After the fix, the same step: the browser renders 2, matching the server.

Step 10 after the fix: Count renders 2, matching the server

Step through the whole page load, from load on 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

// +page.server.ts: atoms still loading are sent as promises that resolve later
return { state: Hydration.dehydrate(registry, { encodeInitialAs: "promise" }) };
<!-- +page.svelte -->
<HydrationBoundary state={data.state}>
  <Todos />
</HydrationBoundary>

Bug: navigate away before the todos arrive. When they do arrive, they are still written into the registry, and whichever page reads todosAtom next 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:

  1. Duplicate keys in the browser share the value (fix 1). The browser no longer checks which atom owns a server value, so it never throws or warns about a duplicate key. This matches @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.
  2. Scoped atoms and server values (fix 1). The scoped-atoms docs now recommend Atom.family for values that come from the server, instead of a scoped atom with the id in its key.
  3. One extra entry per page (fix 2). Every server-rendered page with a HydrationBoundary sends one more tiny hydratable value, the flag that tells the browser it's hydrating the server's HTML. Accepted.
  4. A fourth fix was dropped. Reading an atom with useAtomValue while another component loads it with useAtomSuspense or useAtomResult can fetch it twice in the browser. The fix made useAtomValue wait 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: useAtomValue takes no part in hydration, so read server-rendered data with useAtomResult or useAtomSuspense.
Where to look in the diff
  • Fix 1: seedFromServer in src/Hooks.svelte.ts. Tests: test/scoped.browser.test.ts, and "two different atoms in use at once with one key share its value" in test/async.browser.test.ts.
  • Fix 2: shown and hydratingServerMarkup in src/HydrationBoundary.svelte. Tests: "RegistryProvider initialValues with a HydrationBoundary" in test/hydration.browser.test.ts.
  • Fix 3: lateDropped and the teardown in src/HydrationBoundary.svelte. Tests: "HydrationBoundary destroyed before a promise-encoded value lands".
  • Docs: scoped-atoms, troubleshooting, hydration and async-atoms pages in apps/demo/src/routes/.

🤖 Generated with Claude Code

jarrednorrisdev and others added 6 commits October 9, 2026 02:07
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>
jarrednorrisdev and others added 4 commits October 9, 2026 05:00
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
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
jarrednorrisdev merged commit 0555dd0 into main Oct 9, 2026
16 checks passed
@jarrednorrisdev
jarrednorrisdev deleted the fix/hydration-seeds branch October 9, 2026 15:38
jarrednorrisdev added a commit that referenced this pull request Oct 9, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant