From 377abf01df307a68864721e2cffca61660394e50 Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 02:07:27 +0100 Subject: [PATCH 01/11] fix(ssr): let a remounted serializable atom take its key in the browser 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 --- apps/demo/src/routes/scoped-atoms/+page.md | 4 +- apps/demo/src/routes/troubleshooting/+page.md | 4 +- .../effect-atom-svelte/src/Hooks.svelte.ts | 16 ++- .../test/async.browser.test.ts | 22 +++- .../test/fixtures/scoped-remount-fade.svelte | 25 +++++ .../test/fixtures/scoped-seed.ts | 25 +++++ .../test/fixtures/scoped-user-provider.svelte | 12 ++ .../test/fixtures/scoped-user-reader.svelte | 8 ++ .../test/fixtures/ssr-scoped-remount.svelte | 22 ++++ .../test/scoped.browser.test.ts | 103 ++++++++++++++++++ 10 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 packages/effect-atom-svelte/test/fixtures/scoped-remount-fade.svelte create mode 100644 packages/effect-atom-svelte/test/fixtures/scoped-seed.ts create mode 100644 packages/effect-atom-svelte/test/fixtures/scoped-user-provider.svelte create mode 100644 packages/effect-atom-svelte/test/fixtures/scoped-user-reader.svelte create mode 100644 packages/effect-atom-svelte/test/fixtures/ssr-scoped-remount.svelte create mode 100644 packages/effect-atom-svelte/test/scoped.browser.test.ts diff --git a/apps/demo/src/routes/scoped-atoms/+page.md b/apps/demo/src/routes/scoped-atoms/+page.md index 503b8769..ca35e33d 100644 --- a/apps/demo/src/routes/scoped-atoms/+page.md +++ b/apps/demo/src/routes/scoped-atoms/+page.md @@ -80,4 +80,6 @@ Both give you more than one atom from one definition. Choose by where the atom b Either way, the atom's value lives in the registry, and the usual [lifetimes](/lifetimes) apply. -A scoped atom can't take a fixed serialization key. Every provider makes its own atom, so two providers on one page would share the key, and the async hooks throw `Two different atoms share the serialization key`. If you need one, put the input in the key. Without a key, a scoped atom's value isn't sent from the server to the browser: see [Hydration](/hydration). +A scoped atom can't take a fixed serialization key. Every provider makes its own atom, so two providers on one page would share the key: the server render throws `Two different atoms share the serialization key`, and in the browser development builds warn. If you need one, put the input in the key. Without a key, a scoped atom's value isn't sent from the server to the browser: see [Hydration](/hydration). + +Remounting a provider with `{#key}` doesn't reset a serializable scoped atom. The registry keeps a serializable atom's value under its serialization key, not under the atom, so the new provider's atom reads the value the old one left. To start over, refresh the atom with `useAtomRefresh`, or set it. diff --git a/apps/demo/src/routes/troubleshooting/+page.md b/apps/demo/src/routes/troubleshooting/+page.md index b41d28dd..46ba1afb 100644 --- a/apps/demo/src/routes/troubleshooting/+page.md +++ b/apps/demo/src/routes/troubleshooting/+page.md @@ -48,7 +48,9 @@ It happens when a hook's getter picks a different serializable atom in the brows ### "Two different atoms share the serialization key" -Two atoms with the same serialization key were rendered at the same time. The server sends one result per key, so the browser couldn't tell which atom it belongs to. +Two atoms with the same serialization key were rendered at the same time. The server sends one result per key, so the browser couldn't tell which atom it belongs to. The server render throws this error. In the browser, development builds warn in the console instead, and the second atom starts without the server's result. + +The browser doesn't throw because a remount, as with `{#key}`, briefly has both the old and the new copy of an atom alive. It warns if another atom still holds the key once the update has finished, which can also happen while the old copy plays an outro transition. It usually means a fixed key on an atom that has more than one copy: `Atom.serializable({ key: "todo" })` inside a family, or on an atom created in a component. Put what tells the copies apart into the key, such as the todo's id, and define atoms at module level. For an `AtomRpc` or `AtomHttpApi` query, give each `serializationKey` to only one set of arguments. diff --git a/packages/effect-atom-svelte/src/Hooks.svelte.ts b/packages/effect-atom-svelte/src/Hooks.svelte.ts index 7182342d..4a68bb29 100644 --- a/packages/effect-atom-svelte/src/Hooks.svelte.ts +++ b/packages/effect-atom-svelte/src/Hooks.svelte.ts @@ -1154,7 +1154,21 @@ const seedFromServer = ( const { held, spent } = registrySeeds; let entry = held.get(key); if (entry && entry.atom !== atom) { - throw new Error(`Two different atoms share the serialization key "${key}"`); + // Svelte sets up a remounted branch, as under {#key}, before it destroys the old one, whose + // atom holds the key until then, or until its outro ends. Svelte gives no public signal that a + // branch is leaving, so the new atom takes no seed rather than throwing, as the server does. + // Development builds warn if another atom still holds the key once the update has committed. + if (DEV) { + $effect(() => { + const holder = held.get(key); + if (holder && holder.atom !== atom) { + console.warn( + `effect-atom-svelte: Two different atoms share the serialization key "${key}". The server sends one result per key, so the browser can't tell which atom it belongs to: put what tells the atoms apart into the key. See https://atom.jarrednorris.dev/troubleshooting#two-different-atoms-share-the-serialization-key` + ); + } + }); + } + return undefined; } if (!entry && spent.has(key)) { return undefined; diff --git a/packages/effect-atom-svelte/test/async.browser.test.ts b/packages/effect-atom-svelte/test/async.browser.test.ts index d49f32e6..5934c52e 100644 --- a/packages/effect-atom-svelte/test/async.browser.test.ts +++ b/packages/effect-atom-svelte/test/async.browser.test.ts @@ -1,6 +1,6 @@ import { Effect, Schema } from "effect"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; -import { describe, expect, test } from "vitest"; +import { describe, expect, onTestFinished, test, vi } from "vitest"; import { render } from "vitest-browser-svelte"; import { @@ -731,18 +731,28 @@ describe("serialization keys in the browser (JND-60)", () => { expect(mounts).toBe(3); }); - test("two different atoms in use at once with one key are rejected", async () => { + test("two different atoms in use at once with one key warn in development", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + onTestFinished(() => warn.mockRestore()); const screen = await render(Harness, { + async: true, registry: AtomRegistry.make(), setup: () => { const first = useAtomSuspense(keyed("dup", "first")); const second = useAtomSuspense(keyed("dup", "second")); - // The second hook throws during init, so this never renders. - return () => `${String(first)} ${String(second)}`; + // The registry keys a serializable atom's node by its key, so both read the first's node. + return async () => `${await first.current} ${await second.current}`; }, }); + await expect.poll(text(screen)).toBe("first first"); + // Not thrown in the browser, where a remount briefly has an old and a new atom holding the key: + // one still held by another atom once the update has committed is a second atom in use with it. await expect - .poll(text(screen)) - .toBe('failed: Two different atoms share the serialization key "dup"'); + .poll(() => warn.mock.calls.map(([message]) => String(message))) + .toEqual([ + expect.stringContaining( + 'Two different atoms share the serialization key "dup"' + ), + ]); }); }); diff --git a/packages/effect-atom-svelte/test/fixtures/scoped-remount-fade.svelte b/packages/effect-atom-svelte/test/fixtures/scoped-remount-fade.svelte new file mode 100644 index 00000000..47ec782f --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/scoped-remount-fade.svelte @@ -0,0 +1,25 @@ + + + + + + + {#key generation} +
+ +
+ {/key} + {#snippet failed(error)} + failed: {(error as Error).message} + {/snippet} +
+
diff --git a/packages/effect-atom-svelte/test/fixtures/scoped-seed.ts b/packages/effect-atom-svelte/test/fixtures/scoped-seed.ts new file mode 100644 index 00000000..327a5d0e --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/scoped-seed.ts @@ -0,0 +1,25 @@ +import { Effect, Schema } from "effect"; +import { AsyncResult, Atom } from "effect/reactivity"; + +import { ScopedAtom } from "../../src/index.ts"; + +/** Which scoped atoms were computed in this environment, so a test can tell a seed from a fetch. */ +export const scopedComputed: string[] = []; + +/** A serializable scoped atom with its input in the key, as the docs advise. */ +export const ScopedUser = ScopedAtom.make( + (id: string) => + Atom.make(() => { + scopedComputed.push(id); + const where = typeof window === "undefined" ? "server" : "browser"; + return Effect.succeed(`${id} from the ${where}`).pipe( + Effect.delay("20 millis") + ); + }).pipe( + Atom.serializable({ + key: `scoped-user-${id}`, + schema: AsyncResult.Schema({ success: Schema.String }), + }) + ), + { name: "ScopedUser" } +); diff --git a/packages/effect-atom-svelte/test/fixtures/scoped-user-provider.svelte b/packages/effect-atom-svelte/test/fixtures/scoped-user-provider.svelte new file mode 100644 index 00000000..267e127c --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/scoped-user-provider.svelte @@ -0,0 +1,12 @@ + + +{@render children()} diff --git a/packages/effect-atom-svelte/test/fixtures/scoped-user-reader.svelte b/packages/effect-atom-svelte/test/fixtures/scoped-user-reader.svelte new file mode 100644 index 00000000..df30d820 --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/scoped-user-reader.svelte @@ -0,0 +1,8 @@ + + +{await user.current} diff --git a/packages/effect-atom-svelte/test/fixtures/ssr-scoped-remount.svelte b/packages/effect-atom-svelte/test/fixtures/ssr-scoped-remount.svelte new file mode 100644 index 00000000..41d3f57e --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/ssr-scoped-remount.svelte @@ -0,0 +1,22 @@ + + + + + + + {#key generation} + + {/key} + + {#snippet failed(error)} + failed: {(error as Error).message} + {/snippet} + + diff --git a/packages/effect-atom-svelte/test/scoped.browser.test.ts b/packages/effect-atom-svelte/test/scoped.browser.test.ts new file mode 100644 index 00000000..aac934a8 --- /dev/null +++ b/packages/effect-atom-svelte/test/scoped.browser.test.ts @@ -0,0 +1,103 @@ +import { hydrate, unmount } from "svelte"; +import type { Component } from "svelte"; +import { beforeAll, describe, expect, onTestFinished, test, vi } from "vitest"; +import { render } from "vitest-browser-svelte"; +import { commands } from "vitest/browser"; + +import ScopedRemountFade from "./fixtures/scoped-remount-fade.svelte"; +import { scopedComputed } from "./fixtures/scoped-seed.ts"; +import SsrScopedRemount from "./fixtures/ssr-scoped-remount.svelte"; +import { sleep } from "./helpers.ts"; + +interface ServerOutput { + readonly body: string; + readonly head: string; +} + +declare module "vitest/browser" { + interface BrowserCommands { + renderOnServer: (path: string) => Promise; + } +} + +const outputs = (target: HTMLElement) => () => + [...target.querySelectorAll("output")].map((output) => output.textContent); + +const click = (target: HTMLElement, label: string) => + [...target.querySelectorAll("button")] + .find((button) => button.textContent === label) + ?.click(); + +/** Hydrates a fixture over its real server output, as hydration.browser.test.ts does. */ +const hydrateFromServer = async (path: string, component: Component) => { + const { body, head } = await commands.renderOnServer(path); + const script = document.createElement("script"); + script.textContent = + new DOMParser().parseFromString(head, "text/html").querySelector("script") + ?.textContent ?? ""; + document.head.append(script); + const target = document.createElement("div"); + target.innerHTML = body; + document.body.append(target); + const app = hydrate(component, { props: {}, target }); + onTestFinished(async () => { + await unmount(app); + target.remove(); + script.remove(); + Reflect.deleteProperty(window, "__svelte"); + }); + return target; +}; + +describe("ScopedAtom with server rendering", () => { + beforeAll(async () => { + await commands.renderOnServer("/test/fixtures/ssr-scoped-remount.svelte"); + }, 120_000); + + test("a serializable scoped atom keyed by its input hydrates, then survives a remount of its provider", async () => { + scopedComputed.length = 0; + const target = await hydrateFromServer( + "/test/fixtures/ssr-scoped-remount.svelte", + SsrScopedRemount + ); + await expect + .poll(outputs(target)) + .toEqual(["a from the server", "b from the server"]); + expect(scopedComputed).toEqual([]); + + click(target, "remount"); + await expect + .poll(outputs(target)) + .toEqual([expect.stringMatching(/^a from the /u), "b from the server"]); + }); + + test("without server rendering, remounting the provider of a serializable scoped atom neither throws nor warns", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + onTestFinished(() => warn.mockRestore()); + const screen = await render(SsrScopedRemount); + const target = screen.container as HTMLElement; + await expect + .poll(outputs(target)) + .toEqual(["a from the browser", "b from the browser"]); + click(target, "remount"); + await expect + .poll(outputs(target)) + .toEqual(["a from the browser", "b from the browser"]); + // The old provider is gone once the remount commits, so nothing holds the key twice. + await sleep("50 millis"); + expect(warn).not.toHaveBeenCalled(); + }); + + test("a remounted provider whose old branch is still fading out renders the new one", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + onTestFinished(() => warn.mockRestore()); + const screen = await render(ScopedRemountFade); + const target = screen.container as HTMLElement; + await expect.poll(outputs(target)).toEqual(["a from the browser"]); + click(target, "remount"); + // Both branches are alive while the old one fades out, then only the new one is left. + await expect.poll(outputs(target)).toEqual(["a from the browser"]); + await sleep("200 millis"); + expect(outputs(target)()).toEqual(["a from the browser"]); + }); +}); From 22a75514ffadae1604f9d30782c56a035d189e34 Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 02:07:27 +0100 Subject: [PATCH 02/11] fix(hydration): match the server's first render and drop late values 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 --- .changeset/hydration-seeds.md | 9 ++ .../src/HydrationBoundary.svelte | 73 ++++++++---- .../test/fixtures/hydrate-pending.svelte | 30 +++++ .../test/fixtures/provider-seed.ts | 21 ++++ .../fixtures/ssr-boundary-read-above.svelte | 34 ++++++ .../test/fixtures/ssr-provider-seed.svelte | 28 +++++ .../test/hydration.browser.test.ts | 104 ++++++++++++++++++ 7 files changed, 275 insertions(+), 24 deletions(-) create mode 100644 .changeset/hydration-seeds.md create mode 100644 packages/effect-atom-svelte/test/fixtures/hydrate-pending.svelte create mode 100644 packages/effect-atom-svelte/test/fixtures/provider-seed.ts create mode 100644 packages/effect-atom-svelte/test/fixtures/ssr-boundary-read-above.svelte create mode 100644 packages/effect-atom-svelte/test/fixtures/ssr-provider-seed.svelte diff --git a/.changeset/hydration-seeds.md b/.changeset/hydration-seeds.md new file mode 100644 index 00000000..736176dc --- /dev/null +++ b/.changeset/hydration-seeds.md @@ -0,0 +1,9 @@ +--- +"effect-atom-svelte": patch +--- + +Fix three cases where server-rendered state reached the browser wrongly: + +- Remounting a component that makes its own serializable atom, such as a `ScopedAtom` provider under `{#key}` with its input in the serialization key, no longer throws `Two different atoms share the serialization key`. Svelte sets up the new copy before it destroys the old one, so for a moment both hold the key. In the browser, two different atoms holding one key now warn in development instead of throwing; the server render still throws. +- The browser's first render inside a `HydrationBoundary` now matches the server's markup when the atom already exists, as with a value from `RegistryProvider`'s `initialValues` or an atom also read above the boundary. While the page hydrates, the boundary updates those atoms before its children render, as the server does. Each server-rendered page with a `HydrationBoundary` carries one more small `hydratable` entry for this. +- In the browser, a value dehydrated as a promise that lands after its `HydrationBoundary` is destroyed, including one destroyed while its children were still loading, is ignored, so it no longer reaches whoever reads the atom later. diff --git a/packages/effect-atom-svelte/src/HydrationBoundary.svelte b/packages/effect-atom-svelte/src/HydrationBoundary.svelte index 98465852..11a0317f 100644 --- a/packages/effect-atom-svelte/src/HydrationBoundary.svelte +++ b/packages/effect-atom-svelte/src/HydrationBoundary.svelte @@ -1,13 +1,15 @@ + + + + {#if show} + + + + {/if} + {#snippet pending()} + pending + {/snippet} + diff --git a/packages/effect-atom-svelte/test/fixtures/provider-seed.ts b/packages/effect-atom-svelte/test/fixtures/provider-seed.ts new file mode 100644 index 00000000..3b51f1cd --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/provider-seed.ts @@ -0,0 +1,21 @@ +import { Schema } from "effect"; +import { Atom } from "effect/reactivity"; +import type { Hydration } from "effect/reactivity"; + +/** An atom a RegistryProvider gives an initial value and a HydrationBoundary hydrates. */ +export const providerSeedAtom = Atom.make(0).pipe( + Atom.serializable({ key: "provider-seed", schema: Schema.Number }) +); + +/** What a load function would dehydrate: the atom's fresh value, 2. */ +export const providerSeedState = [ + { + dehydratedAt: 0, + key: "provider-seed", + value: 2, + "~effect/reactivity/Hydration/DehydratedAtom": true, + }, +] as unknown as readonly Hydration.DehydratedAtom[]; + +/** Every value the reader rendered in the browser, in order. */ +export const providerSeedSeen: number[] = []; diff --git a/packages/effect-atom-svelte/test/fixtures/ssr-boundary-read-above.svelte b/packages/effect-atom-svelte/test/fixtures/ssr-boundary-read-above.svelte new file mode 100644 index 00000000..293f8783 --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/ssr-boundary-read-above.svelte @@ -0,0 +1,34 @@ + + + + + { + const value = useAtomValue(providerSeedAtom); + return () => `above ${value.current}`; + }} + /> + + { + const value = useAtomValue(providerSeedAtom); + return () => { + if (BROWSER) { + providerSeedSeen.push(value.current); + } + return value.current; + }; + }} + /> + + diff --git a/packages/effect-atom-svelte/test/fixtures/ssr-provider-seed.svelte b/packages/effect-atom-svelte/test/fixtures/ssr-provider-seed.svelte new file mode 100644 index 00000000..29c0d222 --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/ssr-provider-seed.svelte @@ -0,0 +1,28 @@ + + + + + + { + const value = useAtomValue(providerSeedAtom); + return () => { + if (BROWSER) { + providerSeedSeen.push(value.current); + } + return value.current; + }; + }} + /> + + diff --git a/packages/effect-atom-svelte/test/hydration.browser.test.ts b/packages/effect-atom-svelte/test/hydration.browser.test.ts index ed837c7b..b515b114 100644 --- a/packages/effect-atom-svelte/test/hydration.browser.test.ts +++ b/packages/effect-atom-svelte/test/hydration.browser.test.ts @@ -7,8 +7,10 @@ import { render } from "vitest-browser-svelte"; import { commands } from "vitest/browser"; import { useAtomSuspense, useAtomValue } from "../src/index.ts"; +import HydratePending from "./fixtures/hydrate-pending.svelte"; import Hydrate from "./fixtures/hydrate.svelte"; import { pendingBoundaryComputed } from "./fixtures/pending-boundary.ts"; +import { providerSeedSeen } from "./fixtures/provider-seed.ts"; import { queryFetches } from "./fixtures/reactive-query.ts"; import { resetRevalidate, @@ -20,12 +22,14 @@ import { serverValueComputed } from "./fixtures/server-value.ts"; import { skewComputed } from "./fixtures/skew-seed.ts"; import SsrAfterAwait from "./fixtures/ssr-after-await.svelte"; import SsrAwaitedBoundary from "./fixtures/ssr-awaited-boundary.svelte"; +import SsrBoundaryReadAbove from "./fixtures/ssr-boundary-read-above.svelte"; import SsrBrowserChoice from "./fixtures/ssr-browser-choice.svelte"; import SsrHydrateRefresh from "./fixtures/ssr-hydrate-refresh.svelte"; import SsrHydrateResult from "./fixtures/ssr-hydrate-result.svelte"; import SsrHydrate from "./fixtures/ssr-hydrate.svelte"; import SsrPendingBoundaryChild from "./fixtures/ssr-pending-boundary-child.svelte"; import SsrPendingBoundary from "./fixtures/ssr-pending-boundary.svelte"; +import SsrProviderSeed from "./fixtures/ssr-provider-seed.svelte"; import SsrReactive from "./fixtures/ssr-reactive.svelte"; import SsrRevalidate from "./fixtures/ssr-revalidate.svelte"; import SsrScriptRead from "./fixtures/ssr-script-read.svelte"; @@ -222,6 +226,63 @@ describe("HydrationBoundary", () => { }); }); +describe("HydrationBoundary destroyed before a promise-encoded value lands", () => { + test("doesn't hand the late value to a later reader", async () => { + const registry = AtomRegistry.make(); + const late = Deferred.makeUnsafe(); + const state = [ + { + dehydratedAt: 0, + key: "count", + resultPromise: Effect.runPromise(Deferred.await(late)), + value: 0, + "~effect/reactivity/Hydration/DehydratedAtom": true, + }, + ] as unknown as Hydration.DehydratedAtom[]; + const screen = await render(Hydrate, { + registry, + setup: () => () => "not read", + state, + }); + // As a page navigated away from while its streamed state was still on its way. + await screen.unmount(); + Deferred.doneUnsafe(late, Effect.succeed(42)); + await sleep("20 millis"); + // Kept, the late value would wait in the registry for whoever reads the atom next, however late. + expect(registry.get(countAtom)).toBe(0); + registry.dispose(); + }); + + test("destroyed while its children are still pending, doesn't hand the late value to a later reader", async () => { + const registry = AtomRegistry.make(); + const late = Deferred.makeUnsafe(); + const state = [ + { + dehydratedAt: 0, + key: "count", + resultPromise: Effect.runPromise(Deferred.await(late)), + value: 0, + "~effect/reactivity/Hydration/DehydratedAtom": true, + }, + ] as unknown as Hydration.DehydratedAtom[]; + const screen = await render(HydratePending, { + registry, + // A child that never finishes loading, so the boundary never mounts. + setup: () => Effect.runPromise(Effect.never), + show: true, + state, + }); + await expect.poll(text(screen)).toBe("pending"); + // As a page navigated away from before it had finished loading. + await screen.rerender({ show: false }); + await sleep("20 millis"); + Deferred.doneUnsafe(late, Effect.succeed(42)); + await sleep("20 millis"); + expect(registry.get(countAtom)).toBe(0); + registry.dispose(); + }); +}); + /** The values the head's hydratable script left for hydration, by key. */ const hydratables = () => (window as unknown as { __svelte: { h: Map } }).__svelte.h; @@ -862,3 +923,46 @@ describe("seeding after client-side navigation", () => { expect(registry.getNodes().has("seeded-list-a")).toBe(false); }); }); + +describe("RegistryProvider initialValues with a HydrationBoundary", () => { + test("the browser's first render shows what the server rendered, the boundary's value", async () => { + providerSeedSeen.length = 0; + const { body } = await commands.renderOnServer( + "/test/fixtures/ssr-provider-seed.svelte" + ); + // The server renders the boundary's value over the provider's default. + expect( + new DOMParser() + .parseFromString(body, "text/html") + .body.textContent?.trim() + ).toBe("2"); + const target = await hydrateFromServer( + "/test/fixtures/ssr-provider-seed.svelte", + SsrProviderSeed + ); + await expect + .poll(() => target.querySelector("output")?.textContent) + .toBe("2"); + // Hydration must start from the server's markup, not flash the provider's default. + expect(providerSeedSeen).toEqual([2]); + }, 120_000); + + test("an atom read above the boundary: the boundary's children hydrate with what the server rendered", async () => { + providerSeedSeen.length = 0; + const { body } = await commands.renderOnServer( + "/test/fixtures/ssr-boundary-read-above.svelte" + ); + const server = [ + ...new DOMParser() + .parseFromString(body, "text/html") + .querySelectorAll("output"), + ].map((output) => output.textContent); + expect(server).toEqual(["above 0", "2"]); + const target = await hydrateFromServer( + "/test/fixtures/ssr-boundary-read-above.svelte", + SsrBoundaryReadAbove + ); + await expect.poll(outputs(target)).toEqual(["above 2", "2"]); + expect(providerSeedSeen).toEqual([2]); + }, 120_000); +}); From 572b9a011ad278f3fc99ee4dadba47add888ecf4 Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 02:11:50 +0100 Subject: [PATCH 03/11] fix(hydration): a seeded query also read with useAtomValue doesn't fetch again Co-Authored-By: Claude Opus 5.5 --- .changeset/hydration-seeds.md | 3 +- apps/demo/src/routes/hydration/+page.md | 2 +- .../effect-atom-svelte/src/Hooks.svelte.ts | 60 ++++++++++++++++++- .../fixtures/ssr-value-and-suspense.svelte | 10 ++++ .../fixtures/value-and-suspense-reader.svelte | 13 ++++ .../test/hydration.browser.test.ts | 15 +++++ 6 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 packages/effect-atom-svelte/test/fixtures/ssr-value-and-suspense.svelte create mode 100644 packages/effect-atom-svelte/test/fixtures/value-and-suspense-reader.svelte diff --git a/.changeset/hydration-seeds.md b/.changeset/hydration-seeds.md index 736176dc..605a9f42 100644 --- a/.changeset/hydration-seeds.md +++ b/.changeset/hydration-seeds.md @@ -2,8 +2,9 @@ "effect-atom-svelte": patch --- -Fix three cases where server-rendered state reached the browser wrongly: +Fix four cases where server-rendered state reached the browser wrongly: - Remounting a component that makes its own serializable atom, such as a `ScopedAtom` provider under `{#key}` with its input in the serialization key, no longer throws `Two different atoms share the serialization key`. Svelte sets up the new copy before it destroys the old one, so for a moment both hold the key. In the browser, two different atoms holding one key now warn in development instead of throwing; the server render still throws. - The browser's first render inside a `HydrationBoundary` now matches the server's markup when the atom already exists, as with a value from `RegistryProvider`'s `initialValues` or an atom also read above the boundary. While the page hydrates, the boundary updates those atoms before its children render, as the server does. Each server-rendered page with a `HydrationBoundary` carries one more small `hydratable` entry for this. - In the browser, a value dehydrated as a promise that lands after its `HydrationBoundary` is destroyed, including one destroyed while its children were still loading, is ignored, so it no longer reaches whoever reads the atom later. +- A serializable atom read with `useAtomResult` or `useAtomSuspense` and also with `useAtomValue`, as by a badge rendered earlier in the page, is no longer fetched again in the browser as the page hydrates. The `useAtomValue` reader now waits for the server's result, showing `Initial`, waiting, until it lands. diff --git a/apps/demo/src/routes/hydration/+page.md b/apps/demo/src/routes/hydration/+page.md index e2d95a1d..74bd0837 100644 --- a/apps/demo/src/routes/hydration/+page.md +++ b/apps/demo/src/routes/hydration/+page.md @@ -101,7 +101,7 @@ The encoded results are plain text in the page's HTML, where anyone who gets the What that means in practice: -- **During a render, only those two hooks carry results.** An atom read only with `useAtomValue` is computed again in the browser. +- **During a render, only those two hooks carry results.** An atom read only with `useAtomValue` is computed again in the browser. Once either hook sends an atom's result, every reader of that atom in the browser waits for it, `useAtomValue` included: until it lands, a `useAtomValue` reader shows `Initial`, waiting, as the server rendered it before the result was in. - **Only the first page load is hydrated.** After the browser navigates to another page, atoms run their effects as usual. - **Only hooks called before the script's first `await` get the server's result.** See [Call hooks before the first await](#call-hooks-before-the-first-await). - **A result arrives only if something still uses it.** If every component that reads the atom is gone before the result lands, it is dropped. diff --git a/packages/effect-atom-svelte/src/Hooks.svelte.ts b/packages/effect-atom-svelte/src/Hooks.svelte.ts index 4a68bb29..38b08850 100644 --- a/packages/effect-atom-svelte/src/Hooks.svelte.ts +++ b/packages/effect-atom-svelte/src/Hooks.svelte.ts @@ -256,6 +256,18 @@ const subscribedReader = ( }; } reportReads(registry, getAtom, "read"); + // While the server's value for the first atom is on its way, reading would compute what + // hydration is about to provide: the reader shows Initial, waiting, until the seed is in. + const seedAtom = untrack(getAtom); + // oxlint-disable-next-line eslint/no-use-before-define -- runs at component setup, once the module has loaded + const awaitingSeed = pendingSeed(registry, getAtom); + let seedLanded = $state(awaitingSeed === undefined); + if (awaitingSeed) { + void (async () => { + await awaitingSeed; + seedLanded = true; + })(); + } // The atom is picked on every read, not held in a $derived. In async mode Svelte renders a batch // with other pending batches' changes rolled back, deriveds included, but registry reads always // see the latest state; a derived atom could then pair an old atom with new state, and that @@ -340,6 +352,9 @@ const subscribedReader = ( return () => duringRead(() => { const current = getAtom(); + if (!seedLanded && current === seedAtom) { + return AsyncResult.initial(true) as A; + } if (current !== atom) { const previous = atom; const previousCancel = cancel; @@ -1068,7 +1083,7 @@ const noSeedMount = (): void => undefined; const claimedKeys = new WeakMap>(); // The store is not public API, so a Svelte that changes its shape or drops it turns the warning off -// rather than throwing. +// and makes a plain reader fetch a seeded atom again, as it did before, rather than throwing. const serverValues = (): ReadonlyMap | undefined => { const store = (globalThis as { __svelte?: { h?: unknown } }).__svelte?.h; return store instanceof Map ? store : undefined; @@ -1094,8 +1109,9 @@ const warnIfSent = async (key: string, sent: unknown): Promise => { * Records that a reader had its chance at the server's value for `key`. Development builds warn * when it missed one the server sent for it: Svelte reads them only while it is hydrating, which * stops at a component script's first top-level `await`, so a hook called after one gets nothing - * and its atom runs again in the browser (JND-96, fix proposed in sveltejs/svelte#18927). This reads - * Svelte's internal store only to warn. + * and its atom runs again in the browser (JND-96, fix proposed in sveltejs/svelte#18927). The store + * is read here to warn, and in `pendingSeed` to tell whether the server sent a key: no public API + * can tell without Svelte warning about a missing value. */ const claimServerValue = (key: string, missed: boolean): void => { const store = DEV ? serverValues() : undefined; @@ -1259,6 +1275,44 @@ const seedFromServer = ( }; }; +/** + * For a plain reader in the browser: resolves once the server's value for the getter's first atom + * is in the registry, when the server sent one that nothing has used yet, as an async hook on the + * server does. The reader claims and holds it as the async hooks do, so it is seeded whichever + * reader renders first. Undefined when there is nothing to wait for. + */ +const pendingSeed = ( + registry: AtomRegistry.AtomRegistry, + getAtom: () => Atom.Atom +): Promise | undefined => { + const atom = getAtom(); + if (!Atom.isSerializable(atom) || hasServerValue(atom)) { + return undefined; + } + const { key } = atom[Atom.SerializableTypeId]; + const registrySeeds = seeds.get(registry); + const entry = registrySeeds?.held.get(key); + if (entry) { + return entry.atom === atom ? entry.done : undefined; + } + if ( + registrySeeds?.spent.has(key) || + registry.getNodes().has(key) || + !serverValues()?.has(key) + ) { + return undefined; + } + const wait = seedFromServer( + registry, + getAtom as () => ResultAtom, + // Only the async hooks ask for a run after hydration. + false + ); + // The reader's own subscription holds the atom once it reads it. + wait?.letGo(); + return wait?.done; +}; + /** * Options for `useAtomResult`. * diff --git a/packages/effect-atom-svelte/test/fixtures/ssr-value-and-suspense.svelte b/packages/effect-atom-svelte/test/fixtures/ssr-value-and-suspense.svelte new file mode 100644 index 00000000..d8d5a8b1 --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/ssr-value-and-suspense.svelte @@ -0,0 +1,10 @@ + + + + + + diff --git a/packages/effect-atom-svelte/test/fixtures/value-and-suspense-reader.svelte b/packages/effect-atom-svelte/test/fixtures/value-and-suspense-reader.svelte new file mode 100644 index 00000000..399f1835 --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/value-and-suspense-reader.svelte @@ -0,0 +1,13 @@ + + +{badge.current._tag} + + {await list.current} + diff --git a/packages/effect-atom-svelte/test/hydration.browser.test.ts b/packages/effect-atom-svelte/test/hydration.browser.test.ts index b515b114..94d2e655 100644 --- a/packages/effect-atom-svelte/test/hydration.browser.test.ts +++ b/packages/effect-atom-svelte/test/hydration.browser.test.ts @@ -39,6 +39,7 @@ import SsrSkewSeed from "./fixtures/ssr-skew-seed.svelte"; import SsrStreamSeed from "./fixtures/ssr-stream-seed.svelte"; import SsrUnsentAfterAwait from "./fixtures/ssr-unsent-after-await.svelte"; import SsrUnsentSeed from "./fixtures/ssr-unsent-seed.svelte"; +import SsrValueAndSuspense from "./fixtures/ssr-value-and-suspense.svelte"; import ToggleScriptAwait from "./fixtures/toggle-script-await.svelte"; import TwoBoundaries from "./fixtures/two-boundaries.svelte"; import { @@ -462,6 +463,20 @@ describe("hydrating server output", () => { expect(computed).toEqual(["b"]); }); + test("an atom also read with useAtomValue takes the server's result without computing again", async () => { + computed.length = 0; + const target = await hydrateFromServer( + "/test/fixtures/ssr-value-and-suspense.svelte", + SsrValueAndSuspense + ); + + await expect + .poll(outputs(target)) + .toEqual(["Success", "a from the server"]); + await sleep(afterSweep); + expect(computed).toEqual([]); + }); + test("useAtomResult destroyed before its seed lands computes nothing", async () => { computed.length = 0; const seed = Deferred.makeUnsafe(); From 54752df13729d63d217716aa4519565e983428f1 Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 02:14:14 +0100 Subject: [PATCH 04/11] test(ssr): poll the fading remount until only the new branch is left Co-Authored-By: Claude Opus 5.5 --- packages/effect-atom-svelte/test/scoped.browser.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/effect-atom-svelte/test/scoped.browser.test.ts b/packages/effect-atom-svelte/test/scoped.browser.test.ts index aac934a8..650cfa08 100644 --- a/packages/effect-atom-svelte/test/scoped.browser.test.ts +++ b/packages/effect-atom-svelte/test/scoped.browser.test.ts @@ -96,8 +96,11 @@ describe("ScopedAtom with server rendering", () => { await expect.poll(outputs(target)).toEqual(["a from the browser"]); click(target, "remount"); // Both branches are alive while the old one fades out, then only the new one is left. - await expect.poll(outputs(target)).toEqual(["a from the browser"]); - await sleep("200 millis"); - expect(outputs(target)()).toEqual(["a from the browser"]); + await expect + .poll(outputs(target)) + .toEqual(["a from the browser", "a from the browser"]); + await expect + .poll(outputs(target), { timeout: 5000 }) + .toEqual(["a from the browser"]); }); }); From 1b5a63cd2c953070b4234a7aa79b575f1191aad8 Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 02:21:29 +0100 Subject: [PATCH 05/11] fix(hydration): keep the late value for a reader above the boundary and 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 --- .changeset/hydration-seeds.md | 2 +- .../effect-atom-svelte/src/Hooks.svelte.ts | 21 ++++++---- .../src/HydrationBoundary.svelte | 33 ++++++++++++--- .../test/async.browser.test.ts | 7 +++- .../hydrate-reader-above-toggle.svelte | 25 ++++++++++++ .../test/hydration.browser.test.ts | 40 +++++++++++++++++++ .../test/scoped.browser.test.ts | 11 +++++ 7 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 packages/effect-atom-svelte/test/fixtures/hydrate-reader-above-toggle.svelte diff --git a/.changeset/hydration-seeds.md b/.changeset/hydration-seeds.md index 605a9f42..5d9ba1ba 100644 --- a/.changeset/hydration-seeds.md +++ b/.changeset/hydration-seeds.md @@ -6,5 +6,5 @@ Fix four cases where server-rendered state reached the browser wrongly: - Remounting a component that makes its own serializable atom, such as a `ScopedAtom` provider under `{#key}` with its input in the serialization key, no longer throws `Two different atoms share the serialization key`. Svelte sets up the new copy before it destroys the old one, so for a moment both hold the key. In the browser, two different atoms holding one key now warn in development instead of throwing; the server render still throws. - The browser's first render inside a `HydrationBoundary` now matches the server's markup when the atom already exists, as with a value from `RegistryProvider`'s `initialValues` or an atom also read above the boundary. While the page hydrates, the boundary updates those atoms before its children render, as the server does. Each server-rendered page with a `HydrationBoundary` carries one more small `hydratable` entry for this. -- In the browser, a value dehydrated as a promise that lands after its `HydrationBoundary` is destroyed, including one destroyed while its children were still loading, is ignored, so it no longer reaches whoever reads the atom later. +- In the browser, a value dehydrated as a promise that lands after its `HydrationBoundary` is destroyed, including one destroyed while its children were still loading, is ignored, so it no longer reaches whoever reads the atom later. A reader that already held the atom when the boundary was destroyed, such as a layout's reader above it, still gets the value. - A serializable atom read with `useAtomResult` or `useAtomSuspense` and also with `useAtomValue`, as by a badge rendered earlier in the page, is no longer fetched again in the browser as the page hydrates. The `useAtomValue` reader now waits for the server's result, showing `Initial`, waiting, until it lands. diff --git a/packages/effect-atom-svelte/src/Hooks.svelte.ts b/packages/effect-atom-svelte/src/Hooks.svelte.ts index 38b08850..3ac5cc9f 100644 --- a/packages/effect-atom-svelte/src/Hooks.svelte.ts +++ b/packages/effect-atom-svelte/src/Hooks.svelte.ts @@ -1135,6 +1135,9 @@ const claimServerValue = (key: string, missed: boolean): void => { void warnIfSent(key, store.get(key)); }; +/** How long, in milliseconds, a branch leaving the page may hold a key before development builds warn that another atom shares it. */ +const duplicateKeyWarningDelay = 1000; + /** * For the getter's first atom, if serializable, resolves it and passes the encoded result from * server to client with `hydratable`, so hydration seeds the registry instead of fetching again. @@ -1173,15 +1176,19 @@ const seedFromServer = ( // Svelte sets up a remounted branch, as under {#key}, before it destroys the old one, whose // atom holds the key until then, or until its outro ends. Svelte gives no public signal that a // branch is leaving, so the new atom takes no seed rather than throwing, as the server does. - // Development builds warn if another atom still holds the key once the update has committed. + // Development builds warn if another atom still holds the key a second after the update has + // committed, by when an old branch's outro has usually ended; a longer one still warns. if (DEV) { $effect(() => { - const holder = held.get(key); - if (holder && holder.atom !== atom) { - console.warn( - `effect-atom-svelte: Two different atoms share the serialization key "${key}". The server sends one result per key, so the browser can't tell which atom it belongs to: put what tells the atoms apart into the key. See https://atom.jarrednorris.dev/troubleshooting#two-different-atoms-share-the-serialization-key` - ); - } + const timer = setTimeout(() => { + const holder = held.get(key); + if (holder && holder.atom !== atom) { + console.warn( + `effect-atom-svelte: Two different atoms share the serialization key "${key}". The server sends one result per key, so the browser can't tell which atom it belongs to: put what tells the atoms apart into the key. See https://atom.jarrednorris.dev/troubleshooting#two-different-atoms-share-the-serialization-key` + ); + } + }, duplicateKeyWarningDelay); + return () => clearTimeout(timer); }); } return undefined; diff --git a/packages/effect-atom-svelte/src/HydrationBoundary.svelte b/packages/effect-atom-svelte/src/HydrationBoundary.svelte index 11a0317f..cb127f97 100644 --- a/packages/effect-atom-svelte/src/HydrationBoundary.svelte +++ b/packages/effect-atom-svelte/src/HydrationBoundary.svelte @@ -9,7 +9,8 @@ A value for an atom nobody reads waits in the registry until something does. When the boundary is destroyed, as at the end of a server render, the values it brought that are still unread are dropped, as is a promise-encoded value that lands afterwards, so a registry that outlives the - boundary doesn't hand them to a later reader. + boundary doesn't hand them to a later reader. In the browser, a reader that already held the atom + when the boundary ended, as a layout's reader above it, still gets the late value. **Example** (Hydrating the state a load function dehydrated) @@ -88,22 +89,36 @@ // SAFETY: Hydration.hydrate ignores a promise-encoded value that resolves to this marker, which // Hydration.ts registers with Symbol.for (Effect 4.0.1) but doesn't export. const skipped = Symbol.for("effect/reactivity/Hydration/Skipped"); + // The keys of the promise-encoded values the boundary is waiting for. + const waiting = new Set(); + // In the browser, those whose key no node held when the boundary ended. A node that already held + // one, as a layout's reader above the boundary that took its waiting value, still needs the + // result, or it waits forever; one a later reader creates doesn't get it. + const unheldAtEnd = new Set(); /** A promise-encoded value as the boundary waits for it; one landing after the boundary ends is skipped. */ - const lateDropped = async (late: Promise): Promise => { + const lateDropped = async ( + key: string, + late: Promise + ): Promise => { + waiting.add(key); const value = await late; - return ended ? skipped : value; + return ended && (!BROWSER || unheldAtEnd.has(key)) ? skipped : value; }; const queue = (atoms: readonly Hydration.DehydratedAtomValue[]): void => { // A promise-encoded value landing after the boundary has ended, after the server render or once // the browser has navigated away, would stay queued in a registry that outlives it for a later - // reader, however late: it is ignored once the boundary ends. + // reader, however late: it is ignored once the boundary ends, unless, in the browser, a node + // already held its key then. Hydration.hydrate( registry, atoms.map((atom) => atom.resultPromise === undefined ? atom - : { ...atom, resultPromise: lateDropped(atom.resultPromise) } + : { + ...atom, + resultPromise: lateDropped(atom.key, atom.resultPromise), + } ) ); for (const { key, value } of atoms) { @@ -200,6 +215,14 @@ return; } ended = true; + if (BROWSER) { + const nodes = registry.getNodes(); + for (const key of waiting) { + if (!nodes.has(key)) { + unheldAtEnd.add(key); + } + } + } if (!preloaded) { return; } diff --git a/packages/effect-atom-svelte/test/async.browser.test.ts b/packages/effect-atom-svelte/test/async.browser.test.ts index 5934c52e..c8cd0cd3 100644 --- a/packages/effect-atom-svelte/test/async.browser.test.ts +++ b/packages/effect-atom-svelte/test/async.browser.test.ts @@ -746,9 +746,12 @@ describe("serialization keys in the browser (JND-60)", () => { }); await expect.poll(text(screen)).toBe("first first"); // Not thrown in the browser, where a remount briefly has an old and a new atom holding the key: - // one still held by another atom once the update has committed is a second atom in use with it. + // one still held by another atom a second after the update has committed, once an old + // branch's outro would have ended, is a second atom in use with it. await expect - .poll(() => warn.mock.calls.map(([message]) => String(message))) + .poll(() => warn.mock.calls.map(([message]) => String(message)), { + timeout: 10_000, + }) .toEqual([ expect.stringContaining( 'Two different atoms share the serialization key "dup"' diff --git a/packages/effect-atom-svelte/test/fixtures/hydrate-reader-above-toggle.svelte b/packages/effect-atom-svelte/test/fixtures/hydrate-reader-above-toggle.svelte new file mode 100644 index 00000000..6c801b0c --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/hydrate-reader-above-toggle.svelte @@ -0,0 +1,25 @@ + + + + +{#if show} + + page + +{/if} diff --git a/packages/effect-atom-svelte/test/hydration.browser.test.ts b/packages/effect-atom-svelte/test/hydration.browser.test.ts index 94d2e655..8574d9e3 100644 --- a/packages/effect-atom-svelte/test/hydration.browser.test.ts +++ b/packages/effect-atom-svelte/test/hydration.browser.test.ts @@ -8,6 +8,7 @@ import { commands } from "vitest/browser"; import { useAtomSuspense, useAtomValue } from "../src/index.ts"; import HydratePending from "./fixtures/hydrate-pending.svelte"; +import HydrateReaderAboveToggle from "./fixtures/hydrate-reader-above-toggle.svelte"; import Hydrate from "./fixtures/hydrate.svelte"; import { pendingBoundaryComputed } from "./fixtures/pending-boundary.ts"; import { providerSeedSeen } from "./fixtures/provider-seed.ts"; @@ -282,6 +283,45 @@ describe("HydrationBoundary destroyed before a promise-encoded value lands", () expect(registry.get(countAtom)).toBe(0); registry.dispose(); }); + + test("hands the late value to a reader above it that already held the atom", async () => { + const schema = AsyncResult.Schema({ success: Schema.String }); + const encode = Schema.encodeSync(schema); + const slowAtom = Atom.make(Effect.never as Effect.Effect).pipe( + Atom.serializable({ key: "slow-above", schema }) + ); + const registry = AtomRegistry.make(); + const late = Deferred.makeUnsafe(); + const state = [ + { + dehydratedAt: 0, + key: "slow-above", + resultPromise: Effect.runPromise(Deferred.await(late)), + value: encode(AsyncResult.initial(true)), + "~effect/reactivity/Hydration/DehydratedAtom": true, + }, + ] as unknown as Hydration.DehydratedAtom[]; + const screen = await render(HydrateReaderAboveToggle, { + // As a layout's reader above the page's boundary, which takes the boundary's waiting value. + above: () => { + const result = useAtomValue(slowAtom); + return () => result.current._tag; + }, + registry, + show: true, + state, + }); + await expect.poll(text(screen)).toContain("Initial"); + // As navigating to another page under the same layout while the streamed state is on its way. + await screen.rerender({ show: false }); + Deferred.doneUnsafe( + late, + Effect.succeed(encode(AsyncResult.success("from server"))) + ); + // Dropped, the late value would leave the layout's reader waiting forever. + await expect.poll(text(screen)).toContain("Success"); + registry.dispose(); + }); }); /** The values the head's hydratable script left for hydration, by key. */ diff --git a/packages/effect-atom-svelte/test/scoped.browser.test.ts b/packages/effect-atom-svelte/test/scoped.browser.test.ts index 650cfa08..e9db7cd9 100644 --- a/packages/effect-atom-svelte/test/scoped.browser.test.ts +++ b/packages/effect-atom-svelte/test/scoped.browser.test.ts @@ -99,8 +99,19 @@ describe("ScopedAtom with server rendering", () => { await expect .poll(outputs(target)) .toEqual(["a from the browser", "a from the browser"]); + // Set after the update committed, so this fires after the development build's delayed check + // for a duplicate key. + let checked = false; + setTimeout(() => (checked = true), 1100); await expect .poll(outputs(target), { timeout: 5000 }) .toEqual(["a from the browser"]); + await expect.poll(() => checked, { timeout: 10_000 }).toBe(true); + // Only the old branch, fading out, held the key: no two atoms share it. + expect( + warn.mock.calls.filter(([message]) => + String(message).includes("Two different atoms share") + ) + ).toEqual([]); }); }); From 326d285504f0d1485a8cfca29526b1970b1986bb Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 04:16:34 +0100 Subject: [PATCH 06/11] fix(hydration): give a late value only to the node that held its key, 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 --- .../effect-atom-svelte/src/Hooks.svelte.ts | 11 +++ .../src/HydrationBoundary.svelte | 28 +++--- .../test/hydration.browser.test.ts | 85 +++++++++++++++++++ 3 files changed, 114 insertions(+), 10 deletions(-) diff --git a/packages/effect-atom-svelte/src/Hooks.svelte.ts b/packages/effect-atom-svelte/src/Hooks.svelte.ts index 3ac5cc9f..b3187197 100644 --- a/packages/effect-atom-svelte/src/Hooks.svelte.ts +++ b/packages/effect-atom-svelte/src/Hooks.svelte.ts @@ -1309,6 +1309,17 @@ const pendingSeed = ( ) { return undefined; } + // Svelte keeps the server's values after hydration, so a key can still be there for a component + // rendered after client-side navigation: hydratable calls back only when Svelte isn't hydrating, + // and the reader then reads as it always has. + let hydrating = true; + hydratable(key, (): unknown => { + hydrating = false; + return undefined; + }); + if (!hydrating) { + return undefined; + } const wait = seedFromServer( registry, getAtom as () => ResultAtom, diff --git a/packages/effect-atom-svelte/src/HydrationBoundary.svelte b/packages/effect-atom-svelte/src/HydrationBoundary.svelte index cb127f97..9885b143 100644 --- a/packages/effect-atom-svelte/src/HydrationBoundary.svelte +++ b/packages/effect-atom-svelte/src/HydrationBoundary.svelte @@ -10,7 +10,7 @@ is destroyed, as at the end of a server render, the values it brought that are still unread are dropped, as is a promise-encoded value that lands afterwards, so a registry that outlives the boundary doesn't hand them to a later reader. In the browser, a reader that already held the atom - when the boundary ended, as a layout's reader above it, still gets the late value. + when the boundary ended and still holds it, as a layout's reader above it, gets the late value. **Example** (Hydrating the state a load function dehydrated) @@ -91,10 +91,11 @@ const skipped = Symbol.for("effect/reactivity/Hydration/Skipped"); // The keys of the promise-encoded values the boundary is waiting for. const waiting = new Set(); - // In the browser, those whose key no node held when the boundary ended. A node that already held - // one, as a layout's reader above the boundary that took its waiting value, still needs the - // result, or it waits forever; one a later reader creates doesn't get it. - const unheldAtEnd = new Set(); + // In the browser, the node that held each of those keys when the boundary ended. That node, as a + // layout's reader above the boundary that took its waiting value, still needs the result, or it + // waits forever. A node a later reader creates doesn't get it, nor does one that replaced a node + // swept since, as a reader inside the boundary's is just after it ends. + const nodesAtEnd = new Map>(); /** A promise-encoded value as the boundary waits for it; one landing after the boundary ends is skipped. */ const lateDropped = async ( key: string, @@ -102,14 +103,20 @@ ): Promise => { waiting.add(key); const value = await late; - return ended && (!BROWSER || unheldAtEnd.has(key)) ? skipped : value; + if (!ended) { + return value; + } + const held = BROWSER ? nodesAtEnd.get(key) : undefined; + return held !== undefined && registry.getNodes().get(key) === held + ? value + : skipped; }; const queue = (atoms: readonly Hydration.DehydratedAtomValue[]): void => { // A promise-encoded value landing after the boundary has ended, after the server render or once // the browser has navigated away, would stay queued in a registry that outlives it for a later - // reader, however late: it is ignored once the boundary ends, unless, in the browser, a node - // already held its key then. + // reader, however late: it is ignored once the boundary ends, unless, in the browser, the node + // that held its key then still does. Hydration.hydrate( registry, atoms.map((atom) => @@ -218,8 +225,9 @@ if (BROWSER) { const nodes = registry.getNodes(); for (const key of waiting) { - if (!nodes.has(key)) { - unheldAtEnd.add(key); + const node = nodes.get(key); + if (node) { + nodesAtEnd.set(key, node); } } } diff --git a/packages/effect-atom-svelte/test/hydration.browser.test.ts b/packages/effect-atom-svelte/test/hydration.browser.test.ts index 8574d9e3..2bdeb496 100644 --- a/packages/effect-atom-svelte/test/hydration.browser.test.ts +++ b/packages/effect-atom-svelte/test/hydration.browser.test.ts @@ -7,6 +7,7 @@ import { render } from "vitest-browser-svelte"; import { commands } from "vitest/browser"; import { useAtomSuspense, useAtomValue } from "../src/index.ts"; +import Harness from "./fixtures/harness.svelte"; import HydratePending from "./fixtures/hydrate-pending.svelte"; import HydrateReaderAboveToggle from "./fixtures/hydrate-reader-above-toggle.svelte"; import Hydrate from "./fixtures/hydrate.svelte"; @@ -284,6 +285,30 @@ describe("HydrationBoundary destroyed before a promise-encoded value lands", () registry.dispose(); }); + test("doesn't hand the late value to a later reader once the reader inside it is gone", async () => { + const registry = AtomRegistry.make(); + const late = Deferred.makeUnsafe(); + const state = [ + { + dehydratedAt: 0, + key: "count", + resultPromise: Effect.runPromise(Deferred.await(late)), + value: 0, + "~effect/reactivity/Hydration/DehydratedAtom": true, + }, + ] as unknown as Hydration.DehydratedAtom[]; + const screen = await render(Hydrate, { registry, setup: readCount, state }); + await expect.poll(text(screen)).toBe("0"); + // As a page that read its streamed atom, navigated away from while the value was on its way: + // its reader's node still exists when the boundary ends, and is swept just after. + await screen.unmount(); + await expect.poll(() => registry.getNodes().has("count")).toBe(false); + Deferred.doneUnsafe(late, Effect.succeed(42)); + await sleep("20 millis"); + expect(registry.get(countAtom)).toBe(0); + registry.dispose(); + }); + test("hands the late value to a reader above it that already held the atom", async () => { const schema = AsyncResult.Schema({ success: Schema.String }); const encode = Schema.encodeSync(schema); @@ -939,6 +964,22 @@ const renderCounted = (registry: AtomRegistry.AtomRegistry, read: boolean) => { return { options, screen }; }; +/** Leaves a value in the head script's store, as the first page load does, for this test only. */ +const leaveServerValue = (key: string, value: unknown) => { + const global = window as unknown as { + __svelte?: { h?: Map }; + }; + const before = global.__svelte; + global.__svelte = { h: new Map([[key, value]]) }; + onTestFinished(() => { + if (before === undefined) { + Reflect.deleteProperty(window, "__svelte"); + } else { + global.__svelte = before; + } + }); +}; + describe("seeding after client-side navigation", () => { // Nothing is hydrating, so hydratable fetches in the browser; that value is no seed (JND-37). test("coming back after the node is disposed fetches again", async () => { @@ -966,6 +1007,50 @@ describe("seeding after client-side navigation", () => { await expect.poll(text(screen)).toBe("1"); }); + test("a key the first page load left doesn't hold back a reader of a fresh registry", async () => { + const syncAtom = Atom.make(Effect.succeed("now")).pipe( + Atom.serializable({ + key: "leftover-sync", + schema: AsyncResult.Schema({ success: Schema.String }), + }) + ); + // As the head script of the first page load left it; Svelte never removes these. + leaveServerValue("leftover-sync", undefined); + const seen: string[] = []; + const screen = await render(Harness, { + registry: AtomRegistry.make(), + setup: () => { + const result = useAtomValue(syncAtom); + return () => { + seen.push(result.current._tag); + return result.current._tag; + }; + }, + }); + await expect.poll(text(screen)).toBe("Success"); + expect(seen).toEqual(["Success"]); + }); + + test("a key the first page load left doesn't reach a non-result atom's transform as a result", async () => { + const numberAtom = Atom.make(5).pipe( + Atom.serializable({ key: "leftover-number", schema: Schema.Number }) + ); + leaveServerValue("leftover-number", 5); + const seen: unknown[] = []; + const screen = await render(Harness, { + registry: AtomRegistry.make(), + setup: () => { + const plusOne = useAtomValue(numberAtom, (n) => n + 1); + return () => { + seen.push(plusOne.current); + return plusOne.current; + }; + }, + }); + await expect.poll(text(screen)).toBe("6"); + expect(seen.every((value) => value === 6)).toBe(true); + }); + test("a useAtomSuspense read in the script lets go of its atom once the getter moves on", async () => { computed.length = 0; const registry = AtomRegistry.make(); From b6155fa22fa009f35ab6e0bd273c613635c28ed2 Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 05:00:31 +0100 Subject: [PATCH 07/11] test(hydration): pin seeds and late values against mutations Co-Authored-By: Claude Opus 5.5 --- .../fixtures/provider-seed-read-above.svelte | 31 +++++++ .../fixtures/ssr-value-reader-switch.svelte | 9 ++ .../test/fixtures/value-reader-switch.svelte | 19 ++++ .../test/hydration.browser.test.ts | 89 +++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 packages/effect-atom-svelte/test/fixtures/provider-seed-read-above.svelte create mode 100644 packages/effect-atom-svelte/test/fixtures/ssr-value-reader-switch.svelte create mode 100644 packages/effect-atom-svelte/test/fixtures/value-reader-switch.svelte diff --git a/packages/effect-atom-svelte/test/fixtures/provider-seed-read-above.svelte b/packages/effect-atom-svelte/test/fixtures/provider-seed-read-above.svelte new file mode 100644 index 00000000..f9d32a16 --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/provider-seed-read-above.svelte @@ -0,0 +1,31 @@ + + + + + { + const value = useAtomValue(providerSeedAtom); + return () => `above ${value.current}`; + }} + /> + + { + const value = useAtomValue(providerSeedAtom); + return () => { + providerSeedSeen.push(value.current); + return value.current; + }; + }} + /> + + diff --git a/packages/effect-atom-svelte/test/fixtures/ssr-value-reader-switch.svelte b/packages/effect-atom-svelte/test/fixtures/ssr-value-reader-switch.svelte new file mode 100644 index 00000000..d9a0916f --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/ssr-value-reader-switch.svelte @@ -0,0 +1,9 @@ + + + + + + diff --git a/packages/effect-atom-svelte/test/fixtures/value-reader-switch.svelte b/packages/effect-atom-svelte/test/fixtures/value-reader-switch.svelte new file mode 100644 index 00000000..e44aef32 --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/value-reader-switch.svelte @@ -0,0 +1,19 @@ + + + +{badge.current._tag}{badge.current.waiting ? " waiting" : ""} + + + {await list.current} + diff --git a/packages/effect-atom-svelte/test/hydration.browser.test.ts b/packages/effect-atom-svelte/test/hydration.browser.test.ts index 2bdeb496..e7c7dcd0 100644 --- a/packages/effect-atom-svelte/test/hydration.browser.test.ts +++ b/packages/effect-atom-svelte/test/hydration.browser.test.ts @@ -12,6 +12,7 @@ import HydratePending from "./fixtures/hydrate-pending.svelte"; import HydrateReaderAboveToggle from "./fixtures/hydrate-reader-above-toggle.svelte"; import Hydrate from "./fixtures/hydrate.svelte"; import { pendingBoundaryComputed } from "./fixtures/pending-boundary.ts"; +import ProviderSeedReadAbove from "./fixtures/provider-seed-read-above.svelte"; import { providerSeedSeen } from "./fixtures/provider-seed.ts"; import { queryFetches } from "./fixtures/reactive-query.ts"; import { @@ -42,6 +43,7 @@ import SsrStreamSeed from "./fixtures/ssr-stream-seed.svelte"; import SsrUnsentAfterAwait from "./fixtures/ssr-unsent-after-await.svelte"; import SsrUnsentSeed from "./fixtures/ssr-unsent-seed.svelte"; import SsrValueAndSuspense from "./fixtures/ssr-value-and-suspense.svelte"; +import SsrValueReaderSwitch from "./fixtures/ssr-value-reader-switch.svelte"; import ToggleScriptAwait from "./fixtures/toggle-script-await.svelte"; import TwoBoundaries from "./fixtures/two-boundaries.svelte"; import { @@ -309,6 +311,34 @@ describe("HydrationBoundary destroyed before a promise-encoded value lands", () registry.dispose(); }); + test("doesn't hand the late value to a reader that came after the boundary's own reader was swept", async () => { + const registry = AtomRegistry.make(); + const late = Deferred.makeUnsafe(); + const resultPromise = Effect.runPromise(Deferred.await(late)); + const state = [ + { + dehydratedAt: 0, + key: "count", + resultPromise, + value: 0, + "~effect/reactivity/Hydration/DehydratedAtom": true, + }, + ] as unknown as Hydration.DehydratedAtom[]; + const screen = await render(Hydrate, { registry, setup: readCount, state }); + await expect.poll(text(screen)).toBe("0"); + await screen.unmount(); + await expect.poll(() => registry.getNodes().has("count")).toBe(false); + // A reader on the next page, whose node is a new one. + const release = registry.mount(countAtom); + Deferred.doneUnsafe(late, Effect.succeed(42)); + await resultPromise; + // Nothing to wait for when the value is rightly dropped, so give a wrong hand-off a moment. + await sleep("20 millis"); + expect(registry.get(countAtom)).toBe(0); + release(); + registry.dispose(); + }); + test("hands the late value to a reader above it that already held the atom", async () => { const schema = AsyncResult.Schema({ success: Schema.String }); const encode = Schema.encodeSync(schema); @@ -542,6 +572,44 @@ describe("hydrating server output", () => { expect(computed).toEqual([]); }); + test("a plain reader shows Initial, waiting, while the server's value for its atom is on its way", async () => { + computed.length = 0; + const seed = Deferred.makeUnsafe(); + let value: unknown; + const target = await hydrateFromServer( + "/test/fixtures/ssr-value-reader-switch.svelte", + SsrValueReaderSwitch, + () => { + const store = hydratables(); + value = store.get("seeded-list-a"); + store.set("seeded-list-a", Effect.runPromise(Deferred.await(seed))); + } + ); + await expect.poll(() => outputs(target)()[0]).toBe("Initial waiting"); + Deferred.doneUnsafe(seed, Effect.succeed(value)); + await expect.poll(() => outputs(target)()[0]).toBe("Success"); + expect(computed).toEqual([]); + }); + + test("a plain reader switched to another atom before its seed lands shows that atom at once", async () => { + computed.length = 0; + const seed = Deferred.makeUnsafe(); + let value: unknown; + const target = await hydrateFromServer( + "/test/fixtures/ssr-value-reader-switch.svelte", + SsrValueReaderSwitch, + () => { + const store = hydratables(); + value = store.get("seeded-list-a"); + store.set("seeded-list-a", Effect.runPromise(Deferred.await(seed))); + } + ); + await expect.poll(() => outputs(target)()[0]).toBe("Initial waiting"); + click(target, "other"); + await expect.poll(() => outputs(target)()[0]).toBe("Success"); + Deferred.doneUnsafe(seed, Effect.succeed(value)); + }); + test("useAtomResult destroyed before its seed lands computes nothing", async () => { computed.length = 0; const seed = Deferred.makeUnsafe(); @@ -1087,6 +1155,27 @@ describe("RegistryProvider initialValues with a HydrationBoundary", () => { expect(providerSeedSeen).toEqual([2]); }, 120_000); + test("rendered in the browser alone, the boundary's value replaces an unread initial value before the children render", async () => { + providerSeedSeen.length = 0; + const screen = await render(SsrProviderSeed); + await expect.poll(text(screen)).toBe("2"); + expect(providerSeedSeen).toEqual([2]); + }); + + test("rendered in the browser alone, an initial value already read above the boundary is updated after the children render", async () => { + providerSeedSeen.length = 0; + const screen = await render(ProviderSeedReadAbove); + await expect + .poll(() => + [...screen.container.querySelectorAll("output")].map( + (output) => output.textContent + ) + ) + .toEqual(["above 2", "2"]); + // What is on the page already doesn't jump to the incoming value mid-render. + expect(providerSeedSeen[0]).toBe(1); + }); + test("an atom read above the boundary: the boundary's children hydrate with what the server rendered", async () => { providerSeedSeen.length = 0; const { body } = await commands.renderOnServer( From 9a2401ea7c01abf1f207a541a865da8d8556fd5a Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 05:14:16 +0100 Subject: [PATCH 08/11] docs(hydration): note that seeding existing atoms needs experimental.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 --- .changeset/hydration-seeds.md | 2 +- apps/demo/src/routes/hydration/+page.md | 4 +++- packages/effect-atom-svelte/src/HydrationBoundary.svelte | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.changeset/hydration-seeds.md b/.changeset/hydration-seeds.md index 5d9ba1ba..7e161f60 100644 --- a/.changeset/hydration-seeds.md +++ b/.changeset/hydration-seeds.md @@ -5,6 +5,6 @@ Fix four cases where server-rendered state reached the browser wrongly: - Remounting a component that makes its own serializable atom, such as a `ScopedAtom` provider under `{#key}` with its input in the serialization key, no longer throws `Two different atoms share the serialization key`. Svelte sets up the new copy before it destroys the old one, so for a moment both hold the key. In the browser, two different atoms holding one key now warn in development instead of throwing; the server render still throws. -- The browser's first render inside a `HydrationBoundary` now matches the server's markup when the atom already exists, as with a value from `RegistryProvider`'s `initialValues` or an atom also read above the boundary. While the page hydrates, the boundary updates those atoms before its children render, as the server does. Each server-rendered page with a `HydrationBoundary` carries one more small `hydratable` entry for this. +- The browser's first render inside a `HydrationBoundary` now matches the server's markup when the atom already exists, as with a value from `RegistryProvider`'s `initialValues` or an atom also read above the boundary. While the page hydrates, the boundary updates those atoms before its children render, as the server does. This needs Svelte's `experimental.async`; without it they are updated after the first render, as before. Each server-rendered page with a `HydrationBoundary` carries one more small `hydratable` entry for this. - In the browser, a value dehydrated as a promise that lands after its `HydrationBoundary` is destroyed, including one destroyed while its children were still loading, is ignored, so it no longer reaches whoever reads the atom later. A reader that already held the atom when the boundary was destroyed, such as a layout's reader above it, still gets the value. - A serializable atom read with `useAtomResult` or `useAtomSuspense` and also with `useAtomValue`, as by a badge rendered earlier in the page, is no longer fetched again in the browser as the page hydrates. The `useAtomValue` reader now waits for the server's result, showing `Initial`, waiting, until it lands. diff --git a/apps/demo/src/routes/hydration/+page.md b/apps/demo/src/routes/hydration/+page.md index 74bd0837..6c5488b9 100644 --- a/apps/demo/src/routes/hydration/+page.md +++ b/apps/demo/src/routes/hydration/+page.md @@ -242,9 +242,11 @@ const state = Hydration.toValues(Hydration.dehydrate(registry)).filter( When the boundary puts its values into the registry: - **Atoms the registry doesn't have yet** get their values before the children render. -- **Atoms it already has** are updated after the render in the browser, so the page doesn't change halfway through one. On the server, they are updated before the children render. +- **Atoms it already has** are updated after the render in the browser, so the page doesn't change halfway through one. On the server, they are updated before the children render, and so are they in the browser while it hydrates the server's markup, so its first render matches the server's. That last part needs Svelte's `experimental.async`: without it, an atom that already exists, such as one read above the boundary, is updated after the first render. - **Atoms nothing reads** keep their value in the registry until something reads them. The value is dropped when the boundary goes away. +If you render on the server yourself instead of with SvelteKit, and `experimental.async` is on, `await render(...)` so the boundary's `hydratable` entry is written into the page. Without the `await`, a production build of Svelte logs `hydratable_missing_but_expected` once per boundary as the page hydrates. + `HydrationBoundary` uses Effect's `Hydration.hydrate`, so unlike the hooks, it runs atoms wrapped by `Atom.withReactivity` and similar again after hydrating: see [Running again after hydration](#running-again-after-hydration). Remote functions need `experimental: { remoteFunctions: true }` in SvelteKit's options. diff --git a/packages/effect-atom-svelte/src/HydrationBoundary.svelte b/packages/effect-atom-svelte/src/HydrationBoundary.svelte index 9885b143..6d5263fd 100644 --- a/packages/effect-atom-svelte/src/HydrationBoundary.svelte +++ b/packages/effect-atom-svelte/src/HydrationBoundary.svelte @@ -5,6 +5,12 @@ hydrated before children render; in the browser, atoms that already exist are updated after render, so current UI does not jump to the incoming data mid-render. On the server, where nothing renders again, and in the browser while it hydrates the server's markup, they are updated at once. + That needs Svelte's `experimental.async`: without it, the browser can't tell that it is hydrating, + and atoms that already exist, as one read above the boundary, are updated after the first render. + + With `experimental.async` on, custom server rendering must `await render(...)`, as SvelteKit does, + so the boundary's `hydratable` entry is written into the page. Otherwise a production build of + Svelte logs `hydratable_missing_but_expected` once per boundary as the page hydrates. A value for an atom nobody reads waits in the registry until something does. When the boundary is destroyed, as at the end of a server render, the values it brought that are still unread are From 1f5256531ca6bd8996f8e993bc1edc5517474f18 Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 05:30:32 +0100 Subject: [PATCH 09/11] fix(hydration): wait for a server seed only in plain value readers 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 --- .../effect-atom-svelte/src/Hooks.svelte.ts | 23 +++++++++--- .../fixtures/script-await-result-later.svelte | 12 +++++++ .../script-await-result-reader.svelte | 35 +++++++++++++++++++ .../fixtures/ssr-script-await-result.svelte | 9 +++++ .../test/hydration.browser.test.ts | 30 ++++++++++++++++ 5 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 packages/effect-atom-svelte/test/fixtures/script-await-result-later.svelte create mode 100644 packages/effect-atom-svelte/test/fixtures/script-await-result-reader.svelte create mode 100644 packages/effect-atom-svelte/test/fixtures/ssr-script-await-result.svelte diff --git a/packages/effect-atom-svelte/src/Hooks.svelte.ts b/packages/effect-atom-svelte/src/Hooks.svelte.ts index b3187197..34d1015b 100644 --- a/packages/effect-atom-svelte/src/Hooks.svelte.ts +++ b/packages/effect-atom-svelte/src/Hooks.svelte.ts @@ -210,7 +210,9 @@ interface KeptSubscription { const subscribedReader = ( registry: AtomRegistry.AtomRegistry, - getAtom: () => Atom.Atom + getAtom: () => Atom.Atom, + // The async hooks seed their atom themselves, with seedFromServer, and read it only once seeded. + seedsItself = false ): (() => A) => { if (!BROWSER) { // Nothing subscribes during SSR, so without a mount the registry would sweep the node while the @@ -258,9 +260,14 @@ const subscribedReader = ( reportReads(registry, getAtom, "read"); // While the server's value for the first atom is on its way, reading would compute what // hydration is about to provide: the reader shows Initial, waiting, until the seed is in. + // Not for an async hook's reader: waiting on the hook's own seed, it set seedLanded while Svelte + // was still committing hydration, after a top-level await in the script, and Svelte's dev build + // threw "Batch has scheduled effects". const seedAtom = untrack(getAtom); - // oxlint-disable-next-line eslint/no-use-before-define -- runs at component setup, once the module has loaded - const awaitingSeed = pendingSeed(registry, getAtom); + const awaitingSeed = seedsItself + ? undefined + : // oxlint-disable-next-line eslint/no-use-before-define -- runs at component setup, once the module has loaded + pendingSeed(registry, getAtom); let seedLanded = $state(awaitingSeed === undefined); if (awaitingSeed) { void (async () => { @@ -1381,7 +1388,10 @@ export const useAtomResult = async ( ): Promise>> => { const registry = getRegistry(); const getAtom = toGetter(input); - const value = useAtomValue(getAtom); + const value = new AtomCell, never>( + subscribedReader(registry, getAtom, true), + readOnly + ); const atom = getAtom(); let release: (() => void) | undefined; // Aborted when the component is destroyed, which interrupts the wait below so the atom is not held. @@ -1682,7 +1692,10 @@ export function useAtomSuspense( ): AtomValue> { const registry = getRegistry(); const getAtom = toGetter(input); - const result = useAtomValue(getAtom); + const result = new AtomCell, never>( + subscribedReader(registry, getAtom, true), + readOnly + ); const seed = seedFromServer( registry, getAtom, diff --git a/packages/effect-atom-svelte/test/fixtures/script-await-result-later.svelte b/packages/effect-atom-svelte/test/fixtures/script-await-result-later.svelte new file mode 100644 index 00000000..0ae549d2 --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/script-await-result-later.svelte @@ -0,0 +1,12 @@ + + + +{await later.current} diff --git a/packages/effect-atom-svelte/test/fixtures/script-await-result-reader.svelte b/packages/effect-atom-svelte/test/fixtures/script-await-result-reader.svelte new file mode 100644 index 00000000..4c33ecfb --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/script-await-result-reader.svelte @@ -0,0 +1,35 @@ + + + + + +{#if script.current._tag === "Success"} + {script.current.value} +{/if} + + + {#snippet pending()} + pending + {/snippet} + diff --git a/packages/effect-atom-svelte/test/fixtures/ssr-script-await-result.svelte b/packages/effect-atom-svelte/test/fixtures/ssr-script-await-result.svelte new file mode 100644 index 00000000..9318f561 --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/ssr-script-await-result.svelte @@ -0,0 +1,9 @@ + + + + + + diff --git a/packages/effect-atom-svelte/test/hydration.browser.test.ts b/packages/effect-atom-svelte/test/hydration.browser.test.ts index e7c7dcd0..361bddb1 100644 --- a/packages/effect-atom-svelte/test/hydration.browser.test.ts +++ b/packages/effect-atom-svelte/test/hydration.browser.test.ts @@ -35,6 +35,7 @@ import SsrPendingBoundary from "./fixtures/ssr-pending-boundary.svelte"; import SsrProviderSeed from "./fixtures/ssr-provider-seed.svelte"; import SsrReactive from "./fixtures/ssr-reactive.svelte"; import SsrRevalidate from "./fixtures/ssr-revalidate.svelte"; +import SsrScriptAwaitResult from "./fixtures/ssr-script-await-result.svelte"; import SsrScriptRead from "./fixtures/ssr-script-read.svelte"; import SsrServerValue from "./fixtures/ssr-server-value.svelte"; import SsrSharedSeed from "./fixtures/ssr-shared-seed.svelte"; @@ -972,6 +973,35 @@ describe("hydrating server output", () => { expect(errors).toEqual([]); }); + test("a useAtomResult awaited in the script hydrates beside a pending boundary", async () => { + const errors: unknown[] = []; + const onError = (event: ErrorEvent) => { + errors.push(event.error ?? event.message); + }; + const onRejection = (event: PromiseRejectionEvent) => { + errors.push(event.reason); + }; + window.addEventListener("error", onError); + window.addEventListener("unhandledrejection", onRejection); + onTestFinished(() => { + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onRejection); + }); + const target = await hydrateFromServer( + "/test/fixtures/ssr-script-await-result.svelte", + SsrScriptAwaitResult + ); + + // The hook seeds its atom itself. Its value reader also waited on that seed, as a plain + // useAtomValue does, and set state when it landed, while Svelte was still committing the + // hydration. Here Svelte dropped the server's result from the page; in the demo, under + // SvelteKit, its dev build threw "Batch has scheduled effects" instead. + await expect.poll(outputs(target)).toEqual(["server", "browser"]); + await sleep("100 millis"); + expect(outputs(target)()).toEqual(["server", "browser"]); + expect(errors).toEqual([]); + }); + describe("a getter that picks a different atom in the browser (JND-24)", () => { const path = "/test/fixtures/ssr-browser-choice.svelte"; From 5908ec845a25d92f90e504ce66d78eb33bf2e7dc Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 13:59:47 +0100 Subject: [PATCH 10/11] fix(hydration): let atoms with one serialization key share its server 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 --- .changeset/hydration-seeds.md | 2 +- apps/demo/src/routes/scoped-atoms/+page.md | 4 +-- apps/demo/src/routes/troubleshooting/+page.md | 4 +-- .../effect-atom-svelte/src/Hooks.svelte.ts | 32 +++---------------- .../test/async.browser.test.ts | 17 +++------- .../test/scoped.browser.test.ts | 21 ++++++------ 6 files changed, 21 insertions(+), 59 deletions(-) diff --git a/.changeset/hydration-seeds.md b/.changeset/hydration-seeds.md index 7e161f60..cf3ede57 100644 --- a/.changeset/hydration-seeds.md +++ b/.changeset/hydration-seeds.md @@ -4,7 +4,7 @@ Fix four cases where server-rendered state reached the browser wrongly: -- Remounting a component that makes its own serializable atom, such as a `ScopedAtom` provider under `{#key}` with its input in the serialization key, no longer throws `Two different atoms share the serialization key`. Svelte sets up the new copy before it destroys the old one, so for a moment both hold the key. In the browser, two different atoms holding one key now warn in development instead of throwing; the server render still throws. +- Remounting a component that makes its own serializable atom, such as a `ScopedAtom` provider under `{#key}` with its input in the serialization key, no longer throws `Two different atoms share the serialization key`. Svelte sets up the new copy before it destroys the old one, so for a moment both hold the key. In the browser, the server's value now belongs to the key, not to the atom that first claimed it, so the new copy shares it, as both already share the registry's value. The server render still throws. - The browser's first render inside a `HydrationBoundary` now matches the server's markup when the atom already exists, as with a value from `RegistryProvider`'s `initialValues` or an atom also read above the boundary. While the page hydrates, the boundary updates those atoms before its children render, as the server does. This needs Svelte's `experimental.async`; without it they are updated after the first render, as before. Each server-rendered page with a `HydrationBoundary` carries one more small `hydratable` entry for this. - In the browser, a value dehydrated as a promise that lands after its `HydrationBoundary` is destroyed, including one destroyed while its children were still loading, is ignored, so it no longer reaches whoever reads the atom later. A reader that already held the atom when the boundary was destroyed, such as a layout's reader above it, still gets the value. - A serializable atom read with `useAtomResult` or `useAtomSuspense` and also with `useAtomValue`, as by a badge rendered earlier in the page, is no longer fetched again in the browser as the page hydrates. The `useAtomValue` reader now waits for the server's result, showing `Initial`, waiting, until it lands. diff --git a/apps/demo/src/routes/scoped-atoms/+page.md b/apps/demo/src/routes/scoped-atoms/+page.md index ca35e33d..00b88e44 100644 --- a/apps/demo/src/routes/scoped-atoms/+page.md +++ b/apps/demo/src/routes/scoped-atoms/+page.md @@ -80,6 +80,4 @@ Both give you more than one atom from one definition. Choose by where the atom b Either way, the atom's value lives in the registry, and the usual [lifetimes](/lifetimes) apply. -A scoped atom can't take a fixed serialization key. Every provider makes its own atom, so two providers on one page would share the key: the server render throws `Two different atoms share the serialization key`, and in the browser development builds warn. If you need one, put the input in the key. Without a key, a scoped atom's value isn't sent from the server to the browser: see [Hydration](/hydration). - -Remounting a provider with `{#key}` doesn't reset a serializable scoped atom. The registry keeps a serializable atom's value under its serialization key, not under the atom, so the new provider's atom reads the value the old one left. To start over, refresh the atom with `useAtomRefresh`, or set it. +If the value comes from the server, use a family, not a scoped atom. The server sends one value per serialization key, and every atom with that key shares it, so a serializable scoped atom loses what makes it scoped: remounting its provider with `{#key}` doesn't reset it, and two of its providers on one page with the same key make the server render throw `Two different atoms share the serialization key`. Without a key, a scoped atom's value isn't sent from the server to the browser: see [Hydration](/hydration). diff --git a/apps/demo/src/routes/troubleshooting/+page.md b/apps/demo/src/routes/troubleshooting/+page.md index 46ba1afb..5bd46c66 100644 --- a/apps/demo/src/routes/troubleshooting/+page.md +++ b/apps/demo/src/routes/troubleshooting/+page.md @@ -48,9 +48,7 @@ It happens when a hook's getter picks a different serializable atom in the brows ### "Two different atoms share the serialization key" -Two atoms with the same serialization key were rendered at the same time. The server sends one result per key, so the browser couldn't tell which atom it belongs to. The server render throws this error. In the browser, development builds warn in the console instead, and the second atom starts without the server's result. - -The browser doesn't throw because a remount, as with `{#key}`, briefly has both the old and the new copy of an atom alive. It warns if another atom still holds the key once the update has finished, which can also happen while the old copy plays an outro transition. +The server rendered two atoms with the same serialization key. It sends one result per key, so it couldn't tell which atom the result belongs to. Only the server render throws this: in the browser, atoms with one key share its value, as they share it in the registry, because a remount, as with `{#key}`, briefly has both the old and the new copy of an atom alive. It usually means a fixed key on an atom that has more than one copy: `Atom.serializable({ key: "todo" })` inside a family, or on an atom created in a component. Put what tells the copies apart into the key, such as the todo's id, and define atoms at module level. For an `AtomRpc` or `AtomHttpApi` query, give each `serializationKey` to only one set of arguments. diff --git a/packages/effect-atom-svelte/src/Hooks.svelte.ts b/packages/effect-atom-svelte/src/Hooks.svelte.ts index 34d1015b..c6c7bef6 100644 --- a/packages/effect-atom-svelte/src/Hooks.svelte.ts +++ b/packages/effect-atom-svelte/src/Hooks.svelte.ts @@ -960,7 +960,6 @@ const serverGet = ( /** One serialization key's seed in a registry, shared by every component using that key. */ interface Seed { - readonly atom: Atom.Atom; readonly done: Promise; /** * Counts a component using the key until it calls the returned release, and whether it wants the @@ -1142,9 +1141,6 @@ const claimServerValue = (key: string, missed: boolean): void => { void warnIfSent(key, store.get(key)); }; -/** How long, in milliseconds, a branch leaving the page may hold a key before development builds warn that another atom shares it. */ -const duplicateKeyWarningDelay = 1000; - /** * For the getter's first atom, if serializable, resolves it and passes the encoded result from * server to client with `hydratable`, so hydration seeds the registry instead of fetching again. @@ -1178,28 +1174,11 @@ const seedFromServer = ( seeds.set(registry, registrySeeds); } const { held, spent } = registrySeeds; + // The server's value belongs to the key, not to an atom: the registry keeps a serializable atom's + // value under its key, so every atom with the key reads it, as HydrationBoundary assumes too. + // A remounted branch, as under {#key}, makes a new atom with the old one's key while the old one + // still holds it, until Svelte destroys the old branch or its outro ends: the new atom joins it. let entry = held.get(key); - if (entry && entry.atom !== atom) { - // Svelte sets up a remounted branch, as under {#key}, before it destroys the old one, whose - // atom holds the key until then, or until its outro ends. Svelte gives no public signal that a - // branch is leaving, so the new atom takes no seed rather than throwing, as the server does. - // Development builds warn if another atom still holds the key a second after the update has - // committed, by when an old branch's outro has usually ended; a longer one still warns. - if (DEV) { - $effect(() => { - const timer = setTimeout(() => { - const holder = held.get(key); - if (holder && holder.atom !== atom) { - console.warn( - `effect-atom-svelte: Two different atoms share the serialization key "${key}". The server sends one result per key, so the browser can't tell which atom it belongs to: put what tells the atoms apart into the key. See https://atom.jarrednorris.dev/troubleshooting#two-different-atoms-share-the-serialization-key` - ); - } - }, duplicateKeyWarningDelay); - return () => clearTimeout(timer); - }); - } - return undefined; - } if (!entry && spent.has(key)) { return undefined; } @@ -1217,7 +1196,6 @@ const seedFromServer = ( let holders = 0; let revalidating = 0; entry = { - atom, done: (async () => { const value = await encoded; // Only the server's value is a seed, and only while someone is there to read it now: a seed @@ -1307,7 +1285,7 @@ const pendingSeed = ( const registrySeeds = seeds.get(registry); const entry = registrySeeds?.held.get(key); if (entry) { - return entry.atom === atom ? entry.done : undefined; + return entry.done; } if ( registrySeeds?.spent.has(key) || diff --git a/packages/effect-atom-svelte/test/async.browser.test.ts b/packages/effect-atom-svelte/test/async.browser.test.ts index c8cd0cd3..ae54ef50 100644 --- a/packages/effect-atom-svelte/test/async.browser.test.ts +++ b/packages/effect-atom-svelte/test/async.browser.test.ts @@ -731,7 +731,7 @@ describe("serialization keys in the browser (JND-60)", () => { expect(mounts).toBe(3); }); - test("two different atoms in use at once with one key warn in development", async () => { + test("two different atoms in use at once with one key share its value, without an error or a warning", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); onTestFinished(() => warn.mockRestore()); const screen = await render(Harness, { @@ -745,17 +745,8 @@ describe("serialization keys in the browser (JND-60)", () => { }, }); await expect.poll(text(screen)).toBe("first first"); - // Not thrown in the browser, where a remount briefly has an old and a new atom holding the key: - // one still held by another atom a second after the update has committed, once an old - // branch's outro would have ended, is a second atom in use with it. - await expect - .poll(() => warn.mock.calls.map(([message]) => String(message)), { - timeout: 10_000, - }) - .toEqual([ - expect.stringContaining( - 'Two different atoms share the serialization key "dup"' - ), - ]); + // Only the server render throws for this: in the browser a remount, as under {#key}, briefly + // has an old and a new atom holding one key. + expect(warn).not.toHaveBeenCalled(); }); }); diff --git a/packages/effect-atom-svelte/test/scoped.browser.test.ts b/packages/effect-atom-svelte/test/scoped.browser.test.ts index e9db7cd9..173856e2 100644 --- a/packages/effect-atom-svelte/test/scoped.browser.test.ts +++ b/packages/effect-atom-svelte/test/scoped.browser.test.ts @@ -68,7 +68,14 @@ describe("ScopedAtom with server rendering", () => { click(target, "remount"); await expect .poll(outputs(target)) - .toEqual([expect.stringMatching(/^a from the /u), "b from the server"]); + .toEqual(["a from the server", "b from the server"]); + // The new provider's atom reads the value the old one left under the key: nothing ran again. + await sleep("50 millis"); + expect(outputs(target)()).toEqual([ + "a from the server", + "b from the server", + ]); + expect(scopedComputed).toEqual([]); }); test("without server rendering, remounting the provider of a serializable scoped atom neither throws nor warns", async () => { @@ -99,19 +106,9 @@ describe("ScopedAtom with server rendering", () => { await expect .poll(outputs(target)) .toEqual(["a from the browser", "a from the browser"]); - // Set after the update committed, so this fires after the development build's delayed check - // for a duplicate key. - let checked = false; - setTimeout(() => (checked = true), 1100); await expect .poll(outputs(target), { timeout: 5000 }) .toEqual(["a from the browser"]); - await expect.poll(() => checked, { timeout: 10_000 }).toBe(true); - // Only the old branch, fading out, held the key: no two atoms share it. - expect( - warn.mock.calls.filter(([message]) => - String(message).includes("Two different atoms share") - ) - ).toEqual([]); + expect(warn).not.toHaveBeenCalled(); }); }); From 8f229f56fb4ce9501c0ca40835fe44121de2be7c Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 15:01:38 +0100 Subject: [PATCH 11/11] revert(hydration): let useAtomValue read a server-sent atom as before 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 --- .changeset/hydration-seeds.md | 3 +- apps/demo/src/routes/async-atoms/+page.md | 2 +- apps/demo/src/routes/hydration/+page.md | 2 +- .../effect-atom-svelte/src/Hooks.svelte.ts | 90 +---------- .../fixtures/script-await-result-later.svelte | 12 -- .../script-await-result-reader.svelte | 35 ----- .../fixtures/ssr-script-await-result.svelte | 9 -- .../fixtures/ssr-value-and-suspense.svelte | 10 -- .../fixtures/ssr-value-reader-switch.svelte | 9 -- .../fixtures/value-and-suspense-reader.svelte | 13 -- .../test/fixtures/value-reader-switch.svelte | 19 --- .../test/hydration.browser.test.ts | 145 ------------------ 12 files changed, 9 insertions(+), 340 deletions(-) delete mode 100644 packages/effect-atom-svelte/test/fixtures/script-await-result-later.svelte delete mode 100644 packages/effect-atom-svelte/test/fixtures/script-await-result-reader.svelte delete mode 100644 packages/effect-atom-svelte/test/fixtures/ssr-script-await-result.svelte delete mode 100644 packages/effect-atom-svelte/test/fixtures/ssr-value-and-suspense.svelte delete mode 100644 packages/effect-atom-svelte/test/fixtures/ssr-value-reader-switch.svelte delete mode 100644 packages/effect-atom-svelte/test/fixtures/value-and-suspense-reader.svelte delete mode 100644 packages/effect-atom-svelte/test/fixtures/value-reader-switch.svelte diff --git a/.changeset/hydration-seeds.md b/.changeset/hydration-seeds.md index cf3ede57..300a410e 100644 --- a/.changeset/hydration-seeds.md +++ b/.changeset/hydration-seeds.md @@ -2,9 +2,8 @@ "effect-atom-svelte": patch --- -Fix four cases where server-rendered state reached the browser wrongly: +Fix three cases where server-rendered state reached the browser wrongly: - Remounting a component that makes its own serializable atom, such as a `ScopedAtom` provider under `{#key}` with its input in the serialization key, no longer throws `Two different atoms share the serialization key`. Svelte sets up the new copy before it destroys the old one, so for a moment both hold the key. In the browser, the server's value now belongs to the key, not to the atom that first claimed it, so the new copy shares it, as both already share the registry's value. The server render still throws. - The browser's first render inside a `HydrationBoundary` now matches the server's markup when the atom already exists, as with a value from `RegistryProvider`'s `initialValues` or an atom also read above the boundary. While the page hydrates, the boundary updates those atoms before its children render, as the server does. This needs Svelte's `experimental.async`; without it they are updated after the first render, as before. Each server-rendered page with a `HydrationBoundary` carries one more small `hydratable` entry for this. - In the browser, a value dehydrated as a promise that lands after its `HydrationBoundary` is destroyed, including one destroyed while its children were still loading, is ignored, so it no longer reaches whoever reads the atom later. A reader that already held the atom when the boundary was destroyed, such as a layout's reader above it, still gets the value. -- A serializable atom read with `useAtomResult` or `useAtomSuspense` and also with `useAtomValue`, as by a badge rendered earlier in the page, is no longer fetched again in the browser as the page hydrates. The `useAtomValue` reader now waits for the server's result, showing `Initial`, waiting, until it lands. diff --git a/apps/demo/src/routes/async-atoms/+page.md b/apps/demo/src/routes/async-atoms/+page.md index 556dc0ab..ab39ee0c 100644 --- a/apps/demo/src/routes/async-atoms/+page.md +++ b/apps/demo/src/routes/async-atoms/+page.md @@ -90,7 +90,7 @@ To say why it failed, find the typed error in the `cause` with `Cause.findErrorO diff --git a/apps/demo/src/routes/hydration/+page.md b/apps/demo/src/routes/hydration/+page.md index 6c5488b9..e3ca0a4e 100644 --- a/apps/demo/src/routes/hydration/+page.md +++ b/apps/demo/src/routes/hydration/+page.md @@ -101,7 +101,7 @@ The encoded results are plain text in the page's HTML, where anyone who gets the What that means in practice: -- **During a render, only those two hooks carry results.** An atom read only with `useAtomValue` is computed again in the browser. Once either hook sends an atom's result, every reader of that atom in the browser waits for it, `useAtomValue` included: until it lands, a `useAtomValue` reader shows `Initial`, waiting, as the server rendered it before the result was in. +- **During a render, only those two hooks carry results.** `useAtomValue` takes no part in hydration: an atom read only with it is computed again in the browser, and a `useAtomValue` read of an atom another component reads with one of the two hooks can start it in the browser before the server's result lands, so it is fetched twice. Read server-rendered data with `useAtomResult` or `useAtomSuspense` everywhere it appears, including small parts of the page such as a header's badge. - **Only the first page load is hydrated.** After the browser navigates to another page, atoms run their effects as usual. - **Only hooks called before the script's first `await` get the server's result.** See [Call hooks before the first await](#call-hooks-before-the-first-await). - **A result arrives only if something still uses it.** If every component that reads the atom is gone before the result lands, it is dropped. diff --git a/packages/effect-atom-svelte/src/Hooks.svelte.ts b/packages/effect-atom-svelte/src/Hooks.svelte.ts index c6c7bef6..e2785fcf 100644 --- a/packages/effect-atom-svelte/src/Hooks.svelte.ts +++ b/packages/effect-atom-svelte/src/Hooks.svelte.ts @@ -210,9 +210,7 @@ interface KeptSubscription { const subscribedReader = ( registry: AtomRegistry.AtomRegistry, - getAtom: () => Atom.Atom, - // The async hooks seed their atom themselves, with seedFromServer, and read it only once seeded. - seedsItself = false + getAtom: () => Atom.Atom ): (() => A) => { if (!BROWSER) { // Nothing subscribes during SSR, so without a mount the registry would sweep the node while the @@ -258,23 +256,6 @@ const subscribedReader = ( }; } reportReads(registry, getAtom, "read"); - // While the server's value for the first atom is on its way, reading would compute what - // hydration is about to provide: the reader shows Initial, waiting, until the seed is in. - // Not for an async hook's reader: waiting on the hook's own seed, it set seedLanded while Svelte - // was still committing hydration, after a top-level await in the script, and Svelte's dev build - // threw "Batch has scheduled effects". - const seedAtom = untrack(getAtom); - const awaitingSeed = seedsItself - ? undefined - : // oxlint-disable-next-line eslint/no-use-before-define -- runs at component setup, once the module has loaded - pendingSeed(registry, getAtom); - let seedLanded = $state(awaitingSeed === undefined); - if (awaitingSeed) { - void (async () => { - await awaitingSeed; - seedLanded = true; - })(); - } // The atom is picked on every read, not held in a $derived. In async mode Svelte renders a batch // with other pending batches' changes rolled back, deriveds included, but registry reads always // see the latest state; a derived atom could then pair an old atom with new state, and that @@ -359,9 +340,6 @@ const subscribedReader = ( return () => duringRead(() => { const current = getAtom(); - if (!seedLanded && current === seedAtom) { - return AsyncResult.initial(true) as A; - } if (current !== atom) { const previous = atom; const previousCancel = cancel; @@ -1089,7 +1067,7 @@ const noSeedMount = (): void => undefined; const claimedKeys = new WeakMap>(); // The store is not public API, so a Svelte that changes its shape or drops it turns the warning off -// and makes a plain reader fetch a seeded atom again, as it did before, rather than throwing. +// rather than throwing. const serverValues = (): ReadonlyMap | undefined => { const store = (globalThis as { __svelte?: { h?: unknown } }).__svelte?.h; return store instanceof Map ? store : undefined; @@ -1115,9 +1093,8 @@ const warnIfSent = async (key: string, sent: unknown): Promise => { * Records that a reader had its chance at the server's value for `key`. Development builds warn * when it missed one the server sent for it: Svelte reads them only while it is hydrating, which * stops at a component script's first top-level `await`, so a hook called after one gets nothing - * and its atom runs again in the browser (JND-96, fix proposed in sveltejs/svelte#18927). The store - * is read here to warn, and in `pendingSeed` to tell whether the server sent a key: no public API - * can tell without Svelte warning about a missing value. + * and its atom runs again in the browser (JND-96, fix proposed in sveltejs/svelte#18927). This reads + * Svelte's internal store only to warn. */ const claimServerValue = (key: string, missed: boolean): void => { const store = DEV ? serverValues() : undefined; @@ -1267,55 +1244,6 @@ const seedFromServer = ( }; }; -/** - * For a plain reader in the browser: resolves once the server's value for the getter's first atom - * is in the registry, when the server sent one that nothing has used yet, as an async hook on the - * server does. The reader claims and holds it as the async hooks do, so it is seeded whichever - * reader renders first. Undefined when there is nothing to wait for. - */ -const pendingSeed = ( - registry: AtomRegistry.AtomRegistry, - getAtom: () => Atom.Atom -): Promise | undefined => { - const atom = getAtom(); - if (!Atom.isSerializable(atom) || hasServerValue(atom)) { - return undefined; - } - const { key } = atom[Atom.SerializableTypeId]; - const registrySeeds = seeds.get(registry); - const entry = registrySeeds?.held.get(key); - if (entry) { - return entry.done; - } - if ( - registrySeeds?.spent.has(key) || - registry.getNodes().has(key) || - !serverValues()?.has(key) - ) { - return undefined; - } - // Svelte keeps the server's values after hydration, so a key can still be there for a component - // rendered after client-side navigation: hydratable calls back only when Svelte isn't hydrating, - // and the reader then reads as it always has. - let hydrating = true; - hydratable(key, (): unknown => { - hydrating = false; - return undefined; - }); - if (!hydrating) { - return undefined; - } - const wait = seedFromServer( - registry, - getAtom as () => ResultAtom, - // Only the async hooks ask for a run after hydration. - false - ); - // The reader's own subscription holds the atom once it reads it. - wait?.letGo(); - return wait?.done; -}; - /** * Options for `useAtomResult`. * @@ -1366,10 +1294,7 @@ export const useAtomResult = async ( ): Promise>> => { const registry = getRegistry(); const getAtom = toGetter(input); - const value = new AtomCell, never>( - subscribedReader(registry, getAtom, true), - readOnly - ); + const value = useAtomValue(getAtom); const atom = getAtom(); let release: (() => void) | undefined; // Aborted when the component is destroyed, which interrupts the wait below so the atom is not held. @@ -1670,10 +1595,7 @@ export function useAtomSuspense( ): AtomValue> { const registry = getRegistry(); const getAtom = toGetter(input); - const result = new AtomCell, never>( - subscribedReader(registry, getAtom, true), - readOnly - ); + const result = useAtomValue(getAtom); const seed = seedFromServer( registry, getAtom, diff --git a/packages/effect-atom-svelte/test/fixtures/script-await-result-later.svelte b/packages/effect-atom-svelte/test/fixtures/script-await-result-later.svelte deleted file mode 100644 index 0ae549d2..00000000 --- a/packages/effect-atom-svelte/test/fixtures/script-await-result-later.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - -{await later.current} diff --git a/packages/effect-atom-svelte/test/fixtures/script-await-result-reader.svelte b/packages/effect-atom-svelte/test/fixtures/script-await-result-reader.svelte deleted file mode 100644 index 4c33ecfb..00000000 --- a/packages/effect-atom-svelte/test/fixtures/script-await-result-reader.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - - - -{#if script.current._tag === "Success"} - {script.current.value} -{/if} - - - {#snippet pending()} - pending - {/snippet} - diff --git a/packages/effect-atom-svelte/test/fixtures/ssr-script-await-result.svelte b/packages/effect-atom-svelte/test/fixtures/ssr-script-await-result.svelte deleted file mode 100644 index 9318f561..00000000 --- a/packages/effect-atom-svelte/test/fixtures/ssr-script-await-result.svelte +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - diff --git a/packages/effect-atom-svelte/test/fixtures/ssr-value-and-suspense.svelte b/packages/effect-atom-svelte/test/fixtures/ssr-value-and-suspense.svelte deleted file mode 100644 index d8d5a8b1..00000000 --- a/packages/effect-atom-svelte/test/fixtures/ssr-value-and-suspense.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - diff --git a/packages/effect-atom-svelte/test/fixtures/ssr-value-reader-switch.svelte b/packages/effect-atom-svelte/test/fixtures/ssr-value-reader-switch.svelte deleted file mode 100644 index d9a0916f..00000000 --- a/packages/effect-atom-svelte/test/fixtures/ssr-value-reader-switch.svelte +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - diff --git a/packages/effect-atom-svelte/test/fixtures/value-and-suspense-reader.svelte b/packages/effect-atom-svelte/test/fixtures/value-and-suspense-reader.svelte deleted file mode 100644 index 399f1835..00000000 --- a/packages/effect-atom-svelte/test/fixtures/value-and-suspense-reader.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - -{badge.current._tag} - - {await list.current} - diff --git a/packages/effect-atom-svelte/test/fixtures/value-reader-switch.svelte b/packages/effect-atom-svelte/test/fixtures/value-reader-switch.svelte deleted file mode 100644 index e44aef32..00000000 --- a/packages/effect-atom-svelte/test/fixtures/value-reader-switch.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - -{badge.current._tag}{badge.current.waiting ? " waiting" : ""} - - - {await list.current} - diff --git a/packages/effect-atom-svelte/test/hydration.browser.test.ts b/packages/effect-atom-svelte/test/hydration.browser.test.ts index 361bddb1..5f18f203 100644 --- a/packages/effect-atom-svelte/test/hydration.browser.test.ts +++ b/packages/effect-atom-svelte/test/hydration.browser.test.ts @@ -7,7 +7,6 @@ import { render } from "vitest-browser-svelte"; import { commands } from "vitest/browser"; import { useAtomSuspense, useAtomValue } from "../src/index.ts"; -import Harness from "./fixtures/harness.svelte"; import HydratePending from "./fixtures/hydrate-pending.svelte"; import HydrateReaderAboveToggle from "./fixtures/hydrate-reader-above-toggle.svelte"; import Hydrate from "./fixtures/hydrate.svelte"; @@ -35,7 +34,6 @@ import SsrPendingBoundary from "./fixtures/ssr-pending-boundary.svelte"; import SsrProviderSeed from "./fixtures/ssr-provider-seed.svelte"; import SsrReactive from "./fixtures/ssr-reactive.svelte"; import SsrRevalidate from "./fixtures/ssr-revalidate.svelte"; -import SsrScriptAwaitResult from "./fixtures/ssr-script-await-result.svelte"; import SsrScriptRead from "./fixtures/ssr-script-read.svelte"; import SsrServerValue from "./fixtures/ssr-server-value.svelte"; import SsrSharedSeed from "./fixtures/ssr-shared-seed.svelte"; @@ -43,8 +41,6 @@ import SsrSkewSeed from "./fixtures/ssr-skew-seed.svelte"; import SsrStreamSeed from "./fixtures/ssr-stream-seed.svelte"; import SsrUnsentAfterAwait from "./fixtures/ssr-unsent-after-await.svelte"; import SsrUnsentSeed from "./fixtures/ssr-unsent-seed.svelte"; -import SsrValueAndSuspense from "./fixtures/ssr-value-and-suspense.svelte"; -import SsrValueReaderSwitch from "./fixtures/ssr-value-reader-switch.svelte"; import ToggleScriptAwait from "./fixtures/toggle-script-await.svelte"; import TwoBoundaries from "./fixtures/two-boundaries.svelte"; import { @@ -559,58 +555,6 @@ describe("hydrating server output", () => { expect(computed).toEqual(["b"]); }); - test("an atom also read with useAtomValue takes the server's result without computing again", async () => { - computed.length = 0; - const target = await hydrateFromServer( - "/test/fixtures/ssr-value-and-suspense.svelte", - SsrValueAndSuspense - ); - - await expect - .poll(outputs(target)) - .toEqual(["Success", "a from the server"]); - await sleep(afterSweep); - expect(computed).toEqual([]); - }); - - test("a plain reader shows Initial, waiting, while the server's value for its atom is on its way", async () => { - computed.length = 0; - const seed = Deferred.makeUnsafe(); - let value: unknown; - const target = await hydrateFromServer( - "/test/fixtures/ssr-value-reader-switch.svelte", - SsrValueReaderSwitch, - () => { - const store = hydratables(); - value = store.get("seeded-list-a"); - store.set("seeded-list-a", Effect.runPromise(Deferred.await(seed))); - } - ); - await expect.poll(() => outputs(target)()[0]).toBe("Initial waiting"); - Deferred.doneUnsafe(seed, Effect.succeed(value)); - await expect.poll(() => outputs(target)()[0]).toBe("Success"); - expect(computed).toEqual([]); - }); - - test("a plain reader switched to another atom before its seed lands shows that atom at once", async () => { - computed.length = 0; - const seed = Deferred.makeUnsafe(); - let value: unknown; - const target = await hydrateFromServer( - "/test/fixtures/ssr-value-reader-switch.svelte", - SsrValueReaderSwitch, - () => { - const store = hydratables(); - value = store.get("seeded-list-a"); - store.set("seeded-list-a", Effect.runPromise(Deferred.await(seed))); - } - ); - await expect.poll(() => outputs(target)()[0]).toBe("Initial waiting"); - click(target, "other"); - await expect.poll(() => outputs(target)()[0]).toBe("Success"); - Deferred.doneUnsafe(seed, Effect.succeed(value)); - }); - test("useAtomResult destroyed before its seed lands computes nothing", async () => { computed.length = 0; const seed = Deferred.makeUnsafe(); @@ -973,35 +917,6 @@ describe("hydrating server output", () => { expect(errors).toEqual([]); }); - test("a useAtomResult awaited in the script hydrates beside a pending boundary", async () => { - const errors: unknown[] = []; - const onError = (event: ErrorEvent) => { - errors.push(event.error ?? event.message); - }; - const onRejection = (event: PromiseRejectionEvent) => { - errors.push(event.reason); - }; - window.addEventListener("error", onError); - window.addEventListener("unhandledrejection", onRejection); - onTestFinished(() => { - window.removeEventListener("error", onError); - window.removeEventListener("unhandledrejection", onRejection); - }); - const target = await hydrateFromServer( - "/test/fixtures/ssr-script-await-result.svelte", - SsrScriptAwaitResult - ); - - // The hook seeds its atom itself. Its value reader also waited on that seed, as a plain - // useAtomValue does, and set state when it landed, while Svelte was still committing the - // hydration. Here Svelte dropped the server's result from the page; in the demo, under - // SvelteKit, its dev build threw "Batch has scheduled effects" instead. - await expect.poll(outputs(target)).toEqual(["server", "browser"]); - await sleep("100 millis"); - expect(outputs(target)()).toEqual(["server", "browser"]); - expect(errors).toEqual([]); - }); - describe("a getter that picks a different atom in the browser (JND-24)", () => { const path = "/test/fixtures/ssr-browser-choice.svelte"; @@ -1062,22 +977,6 @@ const renderCounted = (registry: AtomRegistry.AtomRegistry, read: boolean) => { return { options, screen }; }; -/** Leaves a value in the head script's store, as the first page load does, for this test only. */ -const leaveServerValue = (key: string, value: unknown) => { - const global = window as unknown as { - __svelte?: { h?: Map }; - }; - const before = global.__svelte; - global.__svelte = { h: new Map([[key, value]]) }; - onTestFinished(() => { - if (before === undefined) { - Reflect.deleteProperty(window, "__svelte"); - } else { - global.__svelte = before; - } - }); -}; - describe("seeding after client-side navigation", () => { // Nothing is hydrating, so hydratable fetches in the browser; that value is no seed (JND-37). test("coming back after the node is disposed fetches again", async () => { @@ -1105,50 +1004,6 @@ describe("seeding after client-side navigation", () => { await expect.poll(text(screen)).toBe("1"); }); - test("a key the first page load left doesn't hold back a reader of a fresh registry", async () => { - const syncAtom = Atom.make(Effect.succeed("now")).pipe( - Atom.serializable({ - key: "leftover-sync", - schema: AsyncResult.Schema({ success: Schema.String }), - }) - ); - // As the head script of the first page load left it; Svelte never removes these. - leaveServerValue("leftover-sync", undefined); - const seen: string[] = []; - const screen = await render(Harness, { - registry: AtomRegistry.make(), - setup: () => { - const result = useAtomValue(syncAtom); - return () => { - seen.push(result.current._tag); - return result.current._tag; - }; - }, - }); - await expect.poll(text(screen)).toBe("Success"); - expect(seen).toEqual(["Success"]); - }); - - test("a key the first page load left doesn't reach a non-result atom's transform as a result", async () => { - const numberAtom = Atom.make(5).pipe( - Atom.serializable({ key: "leftover-number", schema: Schema.Number }) - ); - leaveServerValue("leftover-number", 5); - const seen: unknown[] = []; - const screen = await render(Harness, { - registry: AtomRegistry.make(), - setup: () => { - const plusOne = useAtomValue(numberAtom, (n) => n + 1); - return () => { - seen.push(plusOne.current); - return plusOne.current; - }; - }, - }); - await expect.poll(text(screen)).toBe("6"); - expect(seen.every((value) => value === 6)).toBe(true); - }); - test("a useAtomSuspense read in the script lets go of its atom once the getter moves on", async () => { computed.length = 0; const registry = AtomRegistry.make();