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
5 changes: 5 additions & 0 deletions .changeset/reader-own-first-build.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 28 additions & 12 deletions packages/effect-atom-svelte/src/Hooks.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;

Expand Down Expand Up @@ -317,21 +321,24 @@ const subscribedReader = <A>(
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<A> | undefined;
const listen = (current: Atom.Atom<A>, update: () => void) =>
registry.subscribe(current, () => {
if (mounted || reading !== current) {
if (reading !== current) {
update();
}
});
Expand Down Expand Up @@ -404,8 +411,17 @@ const subscribedReader = <A>(
}
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;
Expand Down
14 changes: 14 additions & 0 deletions packages/effect-atom-svelte/test/async.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!-- Reads an atom through a transform in an $effect only, recording what each run sees. -->
<script lang="ts">
import type { Atom, AtomRegistry } from "effect/reactivity";

import { provideRegistry, useAtomValue } from "../../src/index.ts";

interface Props {
readonly atom: Atom.Atom<number>;
readonly registry: AtomRegistry.AtomRegistry;
readonly seen: unknown[];
readonly calls: number[];
}

const { atom, registry, seen, calls }: Props = $props();
// svelte-ignore state_referenced_locally
provideRegistry({ registry });
// svelte-ignore state_referenced_locally
const boxed = useAtomValue(atom, (n) => {
calls.push(n);
return { n };
});
$effect(() => {
seen.push(boxed.current);
});
</script>

<output>ok</output>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!-- Reads an atom with useAtomValue in an $effect only, counting the effect's runs. -->
<script lang="ts">
import type { Atom, AtomRegistry } from "effect/reactivity";

import { provideRegistry, useAtomValue } from "../../src/index.ts";

interface Props {
readonly atom: Atom.Atom<unknown>;
readonly registry: AtomRegistry.AtomRegistry;
readonly seen: unknown[];
}

const { atom, registry, seen }: Props = $props();
// svelte-ignore state_referenced_locally
provideRegistry({ registry });
// svelte-ignore state_referenced_locally
const value = useAtomValue(atom);
$effect(() => {
seen.push(value.current);
});
</script>

<output>ok</output>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!-- Awaits useAtomSuspense's promise in an $effect only, recording each value the await yields. -->
<script lang="ts">
import type { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";

import { provideRegistry, useAtomSuspense } from "../../src/index.ts";

interface Props {
readonly atom: Atom.Atom<AsyncResult.AsyncResult<number, never>>;
readonly registry: AtomRegistry.AtomRegistry;
readonly seen: unknown[];
}

const { atom, registry, seen }: Props = $props();
// svelte-ignore state_referenced_locally
provideRegistry({ registry });
// svelte-ignore state_referenced_locally
const value = useAtomSuspense(atom);
$effect(() => {
const promise = value.current;
void (async () => {
seen.push(await promise);
})();
});
</script>

<output>ok</output>
Loading
Loading