From 9e454fbb649a2ed2cc4f9b116aa54b2d59f34a03 Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 04:47:53 +0100 Subject: [PATCH 1/4] fix(hooks): a reader's own first build after mount doesn't run it again Co-Authored-By: Claude Opus 5.5 --- .changeset/reader-own-first-build.md | 5 + .../effect-atom-svelte/src/Hooks.svelte.ts | 21 +-- .../test/async.browser.test.ts | 14 ++ .../fixtures/effect-transform-reader.svelte | 27 +++ .../test/fixtures/effect-value-reader.svelte | 23 +++ .../test/fixtures/suspense-in-effect.svelte | 26 +++ .../test/hooks.browser.test.ts | 175 +++++++++++++++++- 7 files changed, 277 insertions(+), 14 deletions(-) create mode 100644 .changeset/reader-own-first-build.md create mode 100644 packages/effect-atom-svelte/test/fixtures/effect-transform-reader.svelte create mode 100644 packages/effect-atom-svelte/test/fixtures/effect-value-reader.svelte create mode 100644 packages/effect-atom-svelte/test/fixtures/suspense-in-effect.svelte diff --git a/.changeset/reader-own-first-build.md b/.changeset/reader-own-first-build.md new file mode 100644 index 00000000..728f1627 --- /dev/null +++ b/.changeset/reader-own-first-build.md @@ -0,0 +1,5 @@ +--- +"effect-atom-svelte": patch +--- + +A reader whose atom is built for the first time by its own read, after the component has mounted, no longer updates a second time with the value it just read. `useAtomValue(() => family(id), transform)` ran the transform twice when `id` switched to an atom not built yet, returning a new object for an unchanged value; an `$effect` that first read an atom ran twice; and code after awaiting `useAtomSuspense` of an atom that resolves at once, in an `$effect`, ran twice. diff --git a/packages/effect-atom-svelte/src/Hooks.svelte.ts b/packages/effect-atom-svelte/src/Hooks.svelte.ts index 7182342d..4b6b25e8 100644 --- a/packages/effect-atom-svelte/src/Hooks.svelte.ts +++ b/packages/effect-atom-svelte/src/Hooks.svelte.ts @@ -273,21 +273,21 @@ const subscribedReader = ( kept?.cancel(); kept = undefined; }; - // Whether the component has mounted: its effects have run once. - let mounted = false; // The atom this reader's own registry.get is reading. A subscribed node is rebuilt as soon as it // goes stale, so the read builds it only the first time, and that build announces the very value - // the read returns. Before the component mounts, that announcement can only re-render its first - // render, which already has the value, so it is dropped. Delivered on a microtask it started a - // Svelte batch that, when the first render ran after an await in markup (a HydrationBoundary - // awaiting its state), committed before hydration's own and made Svelte's dev build throw "Batch - // has scheduled effects" (JND-95). After mount it is still delivered, as before. Nothing depends - // on the update it starts: a getter switched in onMount once did, by accident, as that update - // rendered before the registry swept the old atom (JND-98, fixed in the effect below). + // the read returns, so it is dropped: it could only re-render a read that already has the value. + // Before mount, delivered on a microtask it started a Svelte batch that, when the first render ran + // after an await in markup (a HydrationBoundary awaiting its state), committed before hydration's + // own and made Svelte's dev build throw "Batch has scheduled effects" (JND-95). After mount, a + // getter switched to an atom not built yet ran a transform twice, made a new object for an + // unchanged value, and re-ran an $effect that first read the atom. Only the build's own + // announcement is dropped: a value set during a registry batch is announced at commit, after + // `reading` is reset, and later changes arrive as usual. A getter switched in onMount once + // depended on that update, by accident (JND-98, fixed in the effect below). let reading: Atom.Atom | undefined; const listen = (current: Atom.Atom, update: () => void) => registry.subscribe(current, () => { - if (mounted || reading !== current) { + if (reading !== current) { update(); } }); @@ -300,7 +300,6 @@ const subscribedReader = ( return notify ? listen(current, notify) : undefined; }; $effect(() => { - mounted = true; committed = getAtom(); if (kept && kept.atom !== committed) { releaseKept(); diff --git a/packages/effect-atom-svelte/test/async.browser.test.ts b/packages/effect-atom-svelte/test/async.browser.test.ts index d49f32e6..9273076f 100644 --- a/packages/effect-atom-svelte/test/async.browser.test.ts +++ b/packages/effect-atom-svelte/test/async.browser.test.ts @@ -14,6 +14,7 @@ import CityWeather from "./fixtures/city-weather.svelte"; import Harness from "./fixtures/harness.svelte"; import SequentialAwaits from "./fixtures/sequential-awaits.svelte"; import StateGetter from "./fixtures/state-getter.svelte"; +import SuspenseInEffect from "./fixtures/suspense-in-effect.svelte"; import SuspenseToggle from "./fixtures/suspense-toggle.svelte"; import ToggleScriptAwait from "./fixtures/toggle-script-await.svelte"; import Toggle from "./fixtures/toggle.svelte"; @@ -104,6 +105,19 @@ describe("useAtomSuspense", () => { await expect.poll(text(screen)).toBe("failed: boom"); }); + test("a synchronously resolving atom awaited in an $effect runs the code after the await once", async () => { + const registry = AtomRegistry.make(); + const seen: unknown[] = []; + await render(SuspenseInEffect, { + atom: Atom.make(Effect.succeed(7)), + registry, + seen, + }); + await expect.poll(() => seen.length).toBeGreaterThan(0); + await sleep("50 millis"); + expect(seen).toEqual([7]); + }); + test("includeFailure resolves with the Failure instead of rejecting", async () => { const atom = Atom.make(Effect.fail("nope" as const)); const screen = await render(Harness, { diff --git a/packages/effect-atom-svelte/test/fixtures/effect-transform-reader.svelte b/packages/effect-atom-svelte/test/fixtures/effect-transform-reader.svelte new file mode 100644 index 00000000..24a2905e --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/effect-transform-reader.svelte @@ -0,0 +1,27 @@ + + + +ok diff --git a/packages/effect-atom-svelte/test/fixtures/effect-value-reader.svelte b/packages/effect-atom-svelte/test/fixtures/effect-value-reader.svelte new file mode 100644 index 00000000..a8669f8f --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/effect-value-reader.svelte @@ -0,0 +1,23 @@ + + + +ok diff --git a/packages/effect-atom-svelte/test/fixtures/suspense-in-effect.svelte b/packages/effect-atom-svelte/test/fixtures/suspense-in-effect.svelte new file mode 100644 index 00000000..57f63885 --- /dev/null +++ b/packages/effect-atom-svelte/test/fixtures/suspense-in-effect.svelte @@ -0,0 +1,26 @@ + + + +ok diff --git a/packages/effect-atom-svelte/test/hooks.browser.test.ts b/packages/effect-atom-svelte/test/hooks.browser.test.ts index 7ac49d00..9e987819 100644 --- a/packages/effect-atom-svelte/test/hooks.browser.test.ts +++ b/packages/effect-atom-svelte/test/hooks.browser.test.ts @@ -1,5 +1,5 @@ import { Cause, Effect, Exit, Stream } from "effect"; -import { Atom, AtomRef, AtomRegistry } from "effect/reactivity"; +import { AsyncResult, Atom, AtomRef, AtomRegistry } from "effect/reactivity"; import { onDestroy, onMount } from "svelte"; import { SvelteMap } from "svelte/reactivity"; import { describe, expect, onTestFinished, test, vi } from "vitest"; @@ -21,17 +21,23 @@ import { } from "../src/index.ts"; import type { AtomState, ProvideRegistryOptions } from "../src/index.ts"; import DerivedInHandler from "./fixtures/derived-in-handler.svelte"; +import EffectTransformReader from "./fixtures/effect-transform-reader.svelte"; +import EffectValueReader from "./fixtures/effect-value-reader.svelte"; import Harness from "./fixtures/harness.svelte"; import Provider from "./fixtures/provider.svelte"; import Run from "./fixtures/run.svelte"; import SubscribeIntoState from "./fixtures/subscribe-into-state.svelte"; import ToggleScriptAwait from "./fixtures/toggle-script-await.svelte"; import Toggle from "./fixtures/toggle.svelte"; -import { sleep } from "./helpers.ts"; +import { sleep, text } from "./helpers.ts"; const output = (screen: Awaited>) => screen.locator.getByRole("status"); +/** A result's value, or its tag while it has none. */ +const show = (result: AsyncResult.AsyncResult) => + AsyncResult.isSuccess(result) ? result.value : result._tag; + describe("useAtomValue", () => { test("reads a simple atom", async () => { const atom = Atom.make(1); @@ -882,7 +888,7 @@ describe("AtomRef", () => { registry, setup: () => { const value = useAtomRef(ref); - const shown = useAtomValue(read, (text) => text); + const shown = useAtomValue(read, (name) => name); return () => `${value.current} ${shown.current}`; }, }); @@ -992,6 +998,169 @@ describe("getter switches (JND-60)", () => { }); }); +describe("a reader's own first build after mount", () => { + test("a getter switch to an atom not built yet runs the transform once for it", async () => { + const registry = AtomRegistry.make(); + const calls: string[] = []; + const pick = Atom.make("a"); + const named = Atom.family((name: string) => Atom.make(name)); + const boxes: { readonly name: string }[] = []; + const screen = await render(Harness, { + registry, + setup: () => { + const picked = useAtomValue(pick); + const boxed = useAtomValue( + () => named(picked.current), + (name) => { + calls.push(name); + return { name }; + } + ); + return () => { + boxes.push(boxed.current); + return boxed.current.name; + }; + }, + }); + await expect.element(output(screen)).toHaveTextContent("a"); + registry.set(pick, "b"); + await expect.element(output(screen)).toHaveTextContent("b"); + await sleep("50 millis"); + expect(calls).toEqual(["a", "b"]); + expect(new Set(boxes).size).toBe(2); + }); + + test("a transform read only in an $effect runs once while the atom is unchanged", async () => { + const registry = AtomRegistry.make(); + const seen: unknown[] = []; + const calls: number[] = []; + await render(EffectTransformReader, { + atom: Atom.make(1), + calls, + registry, + seen, + }); + await expect.poll(() => seen.length).toBeGreaterThan(0); + await sleep("50 millis"); + expect(calls).toEqual([1]); + expect(seen).toHaveLength(1); + }); + + test("an $effect that first builds the atom it reads runs once", async () => { + const registry = AtomRegistry.make(); + const seen: unknown[] = []; + await render(EffectValueReader, { atom: Atom.make(1), registry, seen }); + await expect.poll(() => seen.length).toBeGreaterThan(0); + await sleep("50 millis"); + expect(seen).toEqual([1]); + }); + + describe.each([false, true])("a getter switch, async: %s", (async) => { + /** Renders a reader of `family(name)` whose name a test switches from "a" to "b". */ + const switching = async ( + family: (name: string) => Atom.Atom, + view: (value: A) => string, + extra?: () => () => string + ) => { + const registry = AtomRegistry.make(); + const pick = Atom.make("a"); + const screen = await render(Harness, { + async, + registry, + setup: () => { + const picked = useAtomValue(pick); + const value = useAtomValue(() => family(picked.current)); + const more = extra?.(); + return () => view(value.current) + (more ? `|${more()}` : ""); + }, + }); + return { pick, registry, screen }; + }; + test("a stream's sync emissions in the first build, then an async one, all arrive", async () => { + const family = Atom.family((name: string) => + Atom.make( + Stream.concat( + Stream.make(`${name}1`, `${name}2`), + Stream.fromEffect( + Effect.sleep("30 millis").pipe(Effect.as(`${name}3`)) + ) + ) + ) + ); + const { pick, registry, screen } = await switching(family, show); + await expect.poll(text(screen)).toBe("a3"); + registry.set(pick, "b"); + await expect.poll(text(screen)).toBe("b3"); + }); + + test("a first build that sets another atom the component reads shows the set value", async () => { + const other = Atom.make("none"); + const family = Atom.family((name: string) => + Atom.make((get) => { + get.set(other, `set-${name}`); + return name; + }) + ); + const { pick, registry, screen } = await switching( + family, + (name) => name, + () => { + const value = useAtomValue(other); + return () => value.current; + } + ); + await expect.poll(text(screen)).toBe("a|set-a"); + registry.set(pick, "b"); + await expect.poll(text(screen)).toBe("b|set-b"); + }); + + test("a first build that sets an atom it read, and so rebuilds, shows the rebuilt value", async () => { + const family = Atom.family((name: string) => { + const ticks = Atom.make(0); + return Atom.make((get) => { + const tick = get(ticks); + if (tick === 0) { + get.set(ticks, 1); + } + return `${name}${tick}`; + }); + }); + const { pick, registry, screen } = await switching(family, (s) => s); + await expect.poll(text(screen)).toBe("a1"); + registry.set(pick, "b"); + await expect.poll(text(screen)).toBe("b1"); + }); + + test("a first build that sets itself at once and later shows the later value", async () => { + const family = Atom.family((name: string) => + Atom.make((get) => { + get.setSelf(`${name}-sync`); + const timer = setTimeout(() => get.setSelf(`${name}-late`), 20); + get.addFinalizer(() => clearTimeout(timer)); + return `${name}-returned`; + }) + ); + const { pick, registry, screen } = await switching(family, (s) => s); + await expect.poll(text(screen)).toBe("a-late"); + registry.set(pick, "b"); + await expect.poll(text(screen)).toBe("b-late"); + }); + + test("later changes of the switched-to atom still arrive", async () => { + const family = Atom.family((name: string) => Atom.make(name)); + const { pick, registry, screen } = await switching(family, (s) => s); + await expect.poll(text(screen)).toBe("a"); + registry.set(pick, "b"); + await expect.poll(text(screen)).toBe("b"); + registry.set(family("b"), "b2"); + await expect.poll(text(screen)).toBe("b2"); + registry.refresh(family("b")); + registry.set(pick, "a"); + await expect.poll(text(screen)).toBe("a"); + }); + }); +}); + describe("RegistryProvider", () => { test("provides the registry it is given", async () => { const registry = AtomRegistry.make(); From cf06b31de7cfbceacbfe02485d1168054db6d6cc Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 05:05:43 +0100 Subject: [PATCH 2/4] fix(hooks): a stale node a later read rebuilds still reaches the page After mount the reader now drops only the announcement of a first build. A stale node its read rebuilds, as after a failed rebuild, carries a value its other reads haven't seen, so that announcement is delivered. Co-Authored-By: Claude Opus 5.5 --- .../effect-atom-svelte/src/Hooks.svelte.ts | 19 +++++++++++-- .../test/hooks.browser.test.ts | 28 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/effect-atom-svelte/src/Hooks.svelte.ts b/packages/effect-atom-svelte/src/Hooks.svelte.ts index 4b6b25e8..b246d087 100644 --- a/packages/effect-atom-svelte/src/Hooks.svelte.ts +++ b/packages/effect-atom-svelte/src/Hooks.svelte.ts @@ -150,6 +150,7 @@ interface RegistryNode { readonly _value: unknown; readonly setValue: (value: unknown) => void; readonly setInitialValue: (value: unknown) => void; + readonly currentState: () => string; readonly subscribe: (listener: () => void) => () => void; } @@ -283,7 +284,10 @@ const subscribedReader = ( // unchanged value, and re-ran an $effect that first read the atom. Only the build's own // announcement is dropped: a value set during a registry batch is announced at commit, after // `reading` is reset, and later changes arrive as usual. A getter switched in onMount once - // depended on that update, by accident (JND-98, fixed in the effect below). + // depended on that update, by accident (JND-98, fixed in the effect below). After mount, only a + // first build is dropped: a stale node the read rebuilds, as after a failed rebuild, announces a + // value the reader's other reads haven't seen. + let mounted = false; let reading: Atom.Atom | undefined; const listen = (current: Atom.Atom, update: () => void) => registry.subscribe(current, () => { @@ -300,6 +304,7 @@ const subscribedReader = ( return notify ? listen(current, notify) : undefined; }; $effect(() => { + mounted = true; committed = getAtom(); if (kept && kept.atom !== committed) { releaseKept(); @@ -359,8 +364,18 @@ const subscribedReader = ( } subscribe(); const outer = reading; - reading = current; + if (!mounted) { + reading = current; + } try { + // After mount, only a first build is dropped: a stale node rebuilt here, as after a failed + // rebuild, announces a change other reads of this reader have not seen. + if ( + internals(registry).ensureNode(current).currentState() === + "uninitialized" + ) { + reading = current; + } return registry.get(current); } finally { reading = outer; diff --git a/packages/effect-atom-svelte/test/hooks.browser.test.ts b/packages/effect-atom-svelte/test/hooks.browser.test.ts index 9e987819..fe1a0c61 100644 --- a/packages/effect-atom-svelte/test/hooks.browser.test.ts +++ b/packages/effect-atom-svelte/test/hooks.browser.test.ts @@ -999,6 +999,34 @@ describe("getter switches (JND-60)", () => { }); describe("a reader's own first build after mount", () => { + test("a failed rebuild that a later read recovers reaches the page", async () => { + const registry = AtomRegistry.make(); + const flag = { n: 1, ok: true }; + const atom = Atom.make(() => { + if (!flag.ok) { + throw new Error("broken"); + } + return flag.n; + }); + let cell: { readonly current: number } | undefined; + const screen = await render(Harness, { + registry, + setup: () => { + const value = useAtomValue(atom); + cell = value; + return () => value.current; + }, + }); + await expect.element(output(screen)).toHaveTextContent("1"); + flag.ok = false; + flag.n = 2; + expect(() => registry.refresh(atom)).toThrow("broken"); + flag.ok = true; + // An imperative read, as from an event handler, rebuilds the stale node and announces it. + expect(cell?.current).toBe(2); + await expect.element(output(screen)).toHaveTextContent("2"); + }); + test("a getter switch to an atom not built yet runs the transform once for it", async () => { const registry = AtomRegistry.make(); const calls: string[] = []; From 33d4eac87dfeeef7e5d6a4151e5442a0587223ed Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Fri, 9 Oct 2026 05:39:45 +0100 Subject: [PATCH 3/4] fix(hooks): a failed first build that a later read recovers reaches the page A node whose first build throws stays uninitialized, so the reader took its recovery for a first build and dropped it, though the page showed the failure. Only a node that hasn't failed counts as a first build now. Co-Authored-By: Claude Opus 5.5 --- .../effect-atom-svelte/src/Hooks.svelte.ts | 10 ++--- .../test/hooks.browser.test.ts | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/effect-atom-svelte/src/Hooks.svelte.ts b/packages/effect-atom-svelte/src/Hooks.svelte.ts index b246d087..34985e64 100644 --- a/packages/effect-atom-svelte/src/Hooks.svelte.ts +++ b/packages/effect-atom-svelte/src/Hooks.svelte.ts @@ -151,6 +151,7 @@ interface RegistryNode { readonly setValue: (value: unknown) => void; readonly setInitialValue: (value: unknown) => void; readonly currentState: () => string; + readonly failed: boolean; readonly subscribe: (listener: () => void) => () => void; } @@ -369,11 +370,10 @@ const subscribedReader = ( } try { // After mount, only a first build is dropped: a stale node rebuilt here, as after a failed - // rebuild, announces a change other reads of this reader have not seen. - if ( - internals(registry).ensureNode(current).currentState() === - "uninitialized" - ) { + // rebuild, announces a change other reads of this reader have not seen. A node whose first + // build failed is still uninitialized, but the page has shown that failure, so it counts too. + const node = internals(registry).ensureNode(current); + if (node.currentState() === "uninitialized" && !node.failed) { reading = current; } return registry.get(current); diff --git a/packages/effect-atom-svelte/test/hooks.browser.test.ts b/packages/effect-atom-svelte/test/hooks.browser.test.ts index fe1a0c61..b20ff0e8 100644 --- a/packages/effect-atom-svelte/test/hooks.browser.test.ts +++ b/packages/effect-atom-svelte/test/hooks.browser.test.ts @@ -999,6 +999,46 @@ describe("getter switches (JND-60)", () => { }); describe("a reader's own first build after mount", () => { + test("a failed first build that a later read recovers reaches the page", async () => { + const registry = AtomRegistry.make(); + const flag = { ok: false }; + const pick = Atom.make("a"); + const broken = Atom.make(() => { + if (!flag.ok) { + throw new Error("broken"); + } + return "b"; + }); + const atoms = new Map>([ + ["a", Atom.make("a")], + ["b", broken], + ]); + let cell: { readonly current: string } | undefined; + const screen = await render(Harness, { + registry, + setup: () => { + const picked = useAtomValue(pick); + const value = useAtomValue(() => atoms.get(picked.current) ?? broken); + cell = value; + return () => { + try { + return value.current; + } catch { + return "error"; + } + }; + }, + }); + await expect.element(output(screen)).toHaveTextContent("a"); + // After mount, the getter switches to an atom whose first build throws. + registry.set(pick, "b"); + await expect.element(output(screen)).toHaveTextContent("error"); + flag.ok = true; + // An imperative read, as from an event handler, builds the node its failure left uninitialized. + expect(cell?.current).toBe("b"); + await expect.element(output(screen)).toHaveTextContent("b"); + }); + test("a failed rebuild that a later read recovers reaches the page", async () => { const registry = AtomRegistry.make(); const flag = { n: 1, ok: true }; From 125a1aaf9c1200e7de0e41c840e1cf5eeafeb455 Mon Sep 17 00:00:00 2001 From: Jarred Norris Date: Sat, 10 Oct 2026 18:21:17 +0100 Subject: [PATCH 4/4] docs(hooks): name the node internals the reader relies on Co-Authored-By: Claude Opus 5.5 --- packages/effect-atom-svelte/src/Hooks.svelte.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/effect-atom-svelte/src/Hooks.svelte.ts b/packages/effect-atom-svelte/src/Hooks.svelte.ts index 47da92a6..aa9b1194 100644 --- a/packages/effect-atom-svelte/src/Hooks.svelte.ts +++ b/packages/effect-atom-svelte/src/Hooks.svelte.ts @@ -164,7 +164,9 @@ interface RegistryInternals { } // SAFETY: ensureNode and scheduleNodeRemoval are on the registry implementation, not the interface -// (Effect 4.0.0); @effect/atom-react uses ensureNode the same way. +// (Effect 4.0.0); @effect/atom-react uses ensureNode the same way. Of the node's parts, +// preserveInitialValueOnBuild, _value, setValue and failed (set while a first build has thrown) +// are on its implementation too; the rest, currentState included, are on Effect's Node interface. const internals = (registry: AtomRegistry.AtomRegistry): RegistryInternals => registry as unknown as RegistryInternals;