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 c50b9e43..aa9b1194 100644
--- a/packages/effect-atom-svelte/src/Hooks.svelte.ts
+++ b/packages/effect-atom-svelte/src/Hooks.svelte.ts
@@ -152,6 +152,8 @@ interface RegistryNode {
readonly _value: unknown;
readonly setValue: (value: unknown) => void;
readonly setInitialValue: (value: unknown) => void;
+ readonly currentState: () => string;
+ readonly failed: boolean;
readonly subscribe: (listener: () => void) => () => void;
}
@@ -162,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;
@@ -317,21 +321,24 @@ 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). 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, () => {
- if (mounted || reading !== current) {
+ if (reading !== current) {
update();
}
});
@@ -404,8 +411,17 @@ 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. 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);
} finally {
reading = outer;
diff --git a/packages/effect-atom-svelte/test/async.browser.test.ts b/packages/effect-atom-svelte/test/async.browser.test.ts
index ae54ef50..732ee7f3 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 @@
+
+
+
+
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 @@
+
+
+
+
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 @@
+
+
+
+
diff --git a/packages/effect-atom-svelte/test/hooks.browser.test.ts b/packages/effect-atom-svelte/test/hooks.browser.test.ts
index bb1d4ad4..4380f4e0 100644
--- a/packages/effect-atom-svelte/test/hooks.browser.test.ts
+++ b/packages/effect-atom-svelte/test/hooks.browser.test.ts
@@ -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);
@@ -1111,7 +1117,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}`;
},
});
@@ -1252,6 +1258,237 @@ 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 };
+ 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[] = [];
+ 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();