Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/hydration-seeds.md
Original file line number Diff line number Diff line change
@@ -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, 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.
2 changes: 1 addition & 1 deletion apps/demo/src/routes/async-atoms/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ To say why it failed, find the typed error in the `cause` with `Cause.findErrorO

<Aside type="tip" title="Await instead of checking tags">

With Svelte's experimental async turned on, you can `await` an async atom in markup and let `<svelte:boundary>` handle loading and failure. See [Suspense](/suspense).
With Svelte's experimental async turned on, you can `await` an async atom in markup and let `<svelte:boundary>` handle loading and failure. See [Suspense](/suspense). If the page is rendered on the server, read async atoms that way, with `useAtomSuspense` or `useAtomResult`: only those two send the server's result to the browser, so an atom read with `useAtomValue` is fetched again there. See [Hydration](/hydration).

</Aside>

Expand Down
6 changes: 4 additions & 2 deletions apps/demo/src/routes/hydration/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.** `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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/routes/scoped-atoms/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +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, 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).
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).
2 changes: 1 addition & 1 deletion apps/demo/src/routes/troubleshooting/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +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 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.

Expand Down
9 changes: 4 additions & 5 deletions packages/effect-atom-svelte/src/Hooks.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -938,7 +938,6 @@ const serverGet = <A>(

/** One serialization key's seed in a registry, shared by every component using that key. */
interface Seed {
readonly atom: Atom.Atom<unknown>;
readonly done: Promise<void>;
/**
* Counts a component using the key until it calls the returned release, and whether it wants the
Expand Down Expand Up @@ -1152,10 +1151,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) {
throw new Error(`Two different atoms share the serialization key "${key}"`);
}
if (!entry && spent.has(key)) {
return undefined;
}
Expand All @@ -1173,7 +1173,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
Expand Down
114 changes: 88 additions & 26 deletions packages/effect-atom-svelte/src/HydrationBoundary.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
<!--
@component
Hydrates dehydrated atom state, for example from `Hydration.dehydrate` returned by a remote
function. Atoms new to the registry are 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, they are updated at once.
function. Atoms new to the registry, or holding only an initial value nobody has read yet, are
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
dropped, so a registry that outlives the boundary doesn't hand them to a later reader.
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 and still holds it, as a layout's reader above it, gets the late value.

**Example** (Hydrating the state a load function dehydrated)

Expand Down Expand Up @@ -35,11 +44,14 @@

<script lang="ts">
import { Hydration } from "effect/reactivity";
import type { AtomRegistry } from "effect/reactivity";
import { BROWSER } from "esm-env";
import { onDestroy } from "svelte";
import { hydratable } from "svelte";
import type { Snippet } from "svelte";

import { holdsInitialValue } from "./internal/nodeInternals.ts";
import { onRenderEnd } from "./internal/renderEnd.ts";
import { onTeardownAfterChildren } from "./internal/teardown.svelte.ts";
import { getRegistry } from "./RegistryContext.ts";

interface Props {
Expand All @@ -57,7 +69,8 @@
};
// The values this boundary queued, by key, so it can drop the ones nobody took.
const queued = new Map<string, unknown>();
// On the server, whether the render has ended, after which a late promise-encoded value is dropped.
// Whether the boundary has ended, as at the end of the server render or once the browser destroys
// it, after which a late promise-encoded value is dropped.
let ended = false;

const counts =
Expand All @@ -82,27 +95,44 @@
// 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");
/** A promise-encoded value as the server render waits for it; one landing after the render is skipped. */
const lateDropped = async (late: Promise<unknown>): Promise<unknown> => {
// The keys of the promise-encoded values the boundary is waiting for.
const waiting = new Set<string>();
// 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<string, AtomRegistry.Node<unknown>>();
/** A promise-encoded value as the boundary waits for it; one landing after the boundary ends is skipped. */
const lateDropped = async (
key: string,
late: Promise<unknown>
): Promise<unknown> => {
waiting.add(key);
const value = await late;
return ended ? 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 => {
// On the server a promise-encoded value is waited for by the render, but one landing after the
// render would stay queued in a registry that outlives it: it is ignored once the boundary ends.
// 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, the node
// that held its key then still does.
Hydration.hydrate(
registry,
BROWSER
? atoms
: atoms.map((atom) =>
atom.resultPromise === undefined
? atom
: {
...atom,
resultPromise: lateDropped(atom.resultPromise),
}
)
atoms.map((atom) =>
atom.resultPromise === undefined
? atom
: {
...atom,
resultPromise: lateDropped(atom.key, atom.resultPromise),
}
)
);
for (const { key, value } of atoms) {
if (queued.has(key)) {
Expand All @@ -116,14 +146,24 @@
}
};

/**
* Whether a node's value may be on the page already. A node holding only an initial value nobody
* has read, as RegistryProvider's initialValues leave it, shows nowhere yet: it takes the incoming
* value before children render, as on the server, so the browser's first render matches the
* server's markup.
*/
const shown = (node: AtomRegistry.Node<unknown> | undefined): boolean =>
node !== undefined &&
!(holdsInitialValue(node) && node.currentState() === "stale");

const hydrateNew = (
incoming: Iterable<Hydration.DehydratedAtom> | undefined
): Hydration.DehydratedAtomValue[] => {
const nodes = registry.getNodes();
const fresh: Hydration.DehydratedAtomValue[] = [];
const existing: Hydration.DehydratedAtomValue[] = [];
for (const atom of Hydration.toValues([...(incoming ?? [])])) {
(nodes.has(atom.key) ? existing : fresh).push(atom);
(shown(nodes.get(atom.key)) ? existing : fresh).push(atom);
}
queue(fresh);
return existing;
Expand All @@ -150,8 +190,18 @@
// svelte-ignore state_referenced_locally
let deferred = hydrateNew(state);
// Effects don't run on the server, and nothing there renders again, so the atoms that already
// exist are updated now, before the children read them.
if (!BROWSER) {
// exist are updated now, before the children read them. The browser does the same while it
// hydrates the server's markup, so its first render matches the server's. The server writes true;
// the browser reads it only while hydrating, and false otherwise. hydratable throws in development
// for a key the server didn't write, as for a boundary the server didn't render: that is false too.
const hydratingServerMarkup = (() => {
try {
return hydratable("effect-atom-svelte/HydrationBoundary", () => !BROWSER);
} catch {
return false;
}
})();
if (!BROWSER || hydratingServerMarkup) {
hydrateExisting(deferred);
deferred = [];
}
Expand All @@ -172,9 +222,21 @@
// the boundary, such as one the caller passes to the server's provider, it would reach a later
// request. Drop the ones still waiting, unless something queued another value since or another
// live boundary queued the same one. On the server they're dropped when the render ends, even if
// a failed boundary discarded this one.
(BROWSER ? onDestroy : onRenderEnd)(() => {
// a failed boundary discarded this one; in the browser, also when it is destroyed while pending.
(BROWSER ? onTeardownAfterChildren : onRenderEnd)(() => {
if (ended) {
return;
}
ended = true;
if (BROWSER) {
const nodes = registry.getNodes();
for (const key of waiting) {
const node = nodes.get(key);
if (node) {
nodesAtEnd.set(key, node);
}
}
}
if (!preloaded) {
return;
}
Expand Down
18 changes: 11 additions & 7 deletions packages/effect-atom-svelte/test/async.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -731,18 +731,22 @@ 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 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, {
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('failed: Two different atoms share the serialization key "dup"');
await expect.poll(text(screen)).toBe("first first");
// 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();
});
});
30 changes: 30 additions & 0 deletions packages/effect-atom-svelte/test/fixtures/hydrate-pending.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!-- Renders HydrationBoundary around an AsyncRun while `show` is true, inside a <svelte:boundary>
that shows its pending snippet until the run has rendered. -->
<script lang="ts">
import type { AtomRegistry, Hydration } from "effect/reactivity";

import { HydrationBoundary, provideRegistry } from "../../src/index.ts";
import AsyncRun from "./async-run.svelte";

interface Props {
readonly setup: () => unknown;
readonly registry: AtomRegistry.AtomRegistry;
readonly show: boolean;
readonly state: Iterable<Hydration.DehydratedAtom> | undefined;
}

const { registry, setup, show, state }: Props = $props();
// svelte-ignore state_referenced_locally
provideRegistry({ registry });
</script>

<svelte:boundary>
{#if show}
<HydrationBoundary {state}>
<AsyncRun {setup} />
</HydrationBoundary>
{/if}
{#snippet pending()}
<output>pending</output>
{/snippet}
</svelte:boundary>
Loading
Loading