effect-atom-svelte 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,10 +6,15 @@
6
6
  import { Cause, Effect, Exit } from "effect";
7
7
  import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
8
8
  import { BROWSER, DEV } from "esm-env";
9
- import { getAbortSignal, hydratable, onDestroy, untrack } from "svelte";
9
+ import { getAbortSignal, hydratable, untrack } from "svelte";
10
10
  import { createSubscriber } from "svelte/reactivity";
11
- import { decodeSeed, encodeSeed, isWaiting, revalidatesOnHydrate, } from "./internal/hydration.js";
11
+ import { queuedByBoundary } from "./internal/boundaries.js";
12
+ import { onDispose } from "./internal/disposal.js";
13
+ import { decodeSeed, encodeSeed, isWaiting, noSeed, revalidatesOnHydrate, } from "./internal/hydration.js";
14
+ import { onRenderEnd } from "./internal/renderEnd.js";
12
15
  import { reportReads } from "./internal/scope.svelte.js";
16
+ import { onTeardown } from "./internal/teardown.svelte.js";
17
+ import { troubleshooting } from "./internal/troubleshooting.js";
13
18
  import { getRegistry } from "./RegistryContext.js";
14
19
  const toGetter = (input) => typeof input === "function" && !Atom.isAtom(input)
15
20
  ? input
@@ -38,20 +43,6 @@ const duringRead = (f) => {
38
43
  activeReads -= 1;
39
44
  }
40
45
  };
41
- /**
42
- * Runs `f` when the component is destroyed, even while its script is still awaiting. In the browser
43
- * `onDestroy` only takes effect once the component mounts, so a component removed while pending
44
- * would never call it. A pre effect runs during init and is torn down with the component (JND-16).
45
- * On the server, `onDestroy` runs when the render ends.
46
- */
47
- const onTeardown = (f) => {
48
- if (BROWSER) {
49
- $effect.pre(() => f);
50
- }
51
- else {
52
- onDestroy(f);
53
- }
54
- };
55
46
  /** Holds read and write closures; a class so `.current` is a real accessor `bind:` can use. */
56
47
  class AtomCell {
57
48
  #read;
@@ -68,7 +59,7 @@ class AtomCell {
68
59
  }
69
60
  }
70
61
  const readOnly = () => {
71
- throw new Error("This atom value is read-only");
62
+ throw new Error(`This atom value is read-only: useAtomValue and useAtomSuspense only read, as does useAtomRef for a ref without set, such as one from map. Write an atom with useAtom or useAtomSet, and a writable ref by assigning useAtomRef's current. ${troubleshooting("this-atom-value-is-read-only")}`);
72
63
  };
73
64
  const valueOrThrow = (exit) => {
74
65
  if (Exit.isSuccess(exit)) {
@@ -76,13 +67,20 @@ const valueOrThrow = (exit) => {
76
67
  }
77
68
  throw Cause.squash(exit.cause);
78
69
  };
70
+ /**
71
+ * Whether a result is `Initial` with nothing running to settle it, as an `Atom.fn` nothing has
72
+ * called. On the server nothing will start it during the render, so waiting would hang it.
73
+ */
74
+ const notStarted = (result) => result._tag === "Initial" && !result.waiting;
79
75
  /**
80
76
  * Whether the atom has a `withServerValue` override. The server reads that instead and never
81
77
  * computes the atom, as its real read is often browser-only.
82
78
  */
83
79
  const hasServerValue = (atom) => Atom.ServerValueTypeId in atom;
84
80
  // SAFETY: ensureNode and scheduleNodeRemoval are on the registry implementation, not the interface
85
- // (Effect 4.0.0); @effect/atom-react uses ensureNode the same way.
81
+ // (Effect 4.0.0); @effect/atom-react uses ensureNode the same way. Of the node's parts,
82
+ // preserveInitialValueOnBuild, _value, setValue and failed (set while a first build has thrown)
83
+ // are on its implementation too; the rest, currentState included, are on Effect's Node interface.
86
84
  const internals = (registry) => registry;
87
85
  /** The atom that holds a value given to `atom`: a wrapper such as `withRefresh` passes it on. */
88
86
  const initialValueTarget = (atom) => {
@@ -92,14 +90,14 @@ const initialValueTarget = (atom) => {
92
90
  }
93
91
  return target;
94
92
  };
95
- const noop = () => undefined;
96
93
  /**
97
94
  * Keeps an atom's node in the registry without computing it, as a mount would but without the read.
98
95
  * The registry sweeps a node without listeners, so a listener holds it; letting go schedules the
99
- * sweep, as an unsubscribe does.
96
+ * sweep, as an unsubscribe does. Each hold has its own listener: the node keeps its listeners in a
97
+ * set, so holds sharing one would all end with the first to let go.
100
98
  */
101
99
  const holdNode = (registry, node) => {
102
- const unsubscribe = node.subscribe(noop);
100
+ const unsubscribe = node.subscribe(() => undefined);
103
101
  return () => {
104
102
  unsubscribe();
105
103
  if (node.canBeRemoved) {
@@ -107,34 +105,182 @@ const holdNode = (registry, node) => {
107
105
  }
108
106
  };
109
107
  };
108
+ /**
109
+ * Lets a node that has taken a value since it was given an initial one compute as usual again.
110
+ * Effect's `setValue` (4.0.1) leaves the mark that tells a node's next build to keep its initial
111
+ * value, so a node given a seed or a `HydrationBoundary` value over one would keep that through its
112
+ * next build, a refresh's, and drop the build's result. Call it once the node holds a value of its
113
+ * own: after `setValue`, or after subscribing, which builds a node still waiting for its first build
114
+ * (using the mark up) and, as a lookup, applies a value a `HydrationBoundary` queued. Only a valid
115
+ * node loses the mark: a stale one still keeps its initial value through its first build. The atoms
116
+ * a wrapper such as `withRefresh` passes an initial value to are cleared too, as subscribing to the
117
+ * wrapper builds them.
118
+ */
119
+ const dropInitialValueMark = (registry, atom) => {
120
+ let current = atom;
121
+ while (current) {
122
+ const node = internals(registry).ensureNode(current);
123
+ if (node.currentState() === "valid") {
124
+ node.preserveInitialValueOnBuild = false;
125
+ }
126
+ current = current.initialValueTarget;
127
+ }
128
+ };
129
+ /**
130
+ * On the server, the node holding a value from `useAtomInitialValues` for an atom: its own, or its
131
+ * source's for a wrapper such as `withRefresh`, which passes the value on and reads as its source.
132
+ * The server renders that value: mounting, reading or waiting for the atom would build it, and a
133
+ * build runs the atom's read, the source's included, even when it keeps the value. That read may be
134
+ * browser-only or start a request the value was there to save.
135
+ *
136
+ * For a wrapper, this assumes it reads as its source, as every wrapper Effect makes with
137
+ * `initialValueTarget` does. A user's `Atom.transform` given `initialValueTarget` that changes the
138
+ * value would render its source's value on the server, where Effect computes the transform.
139
+ */
140
+ const initialNodeOnServer = (registry, atom) => {
141
+ if (BROWSER) {
142
+ return undefined;
143
+ }
144
+ const node = internals(registry).ensureNode(initialValueTarget(atom));
145
+ return node.preserveInitialValueOnBuild ? node : undefined;
146
+ };
147
+ /** Whether, on the server, an atom renders a value from `useAtomInitialValues`. */
148
+ const initialOnServer = (registry, atom) => initialNodeOnServer(registry, atom) !== undefined;
149
+ /** Keeps an atom for the server render: a node with an initial value is held, not mounted. */
150
+ const serverMount = (registry, atom) => {
151
+ const node = initialNodeOnServer(registry, atom);
152
+ return node ? holdNode(internals(registry), node) : registry.mount(atom);
153
+ };
154
+ /** Reads an atom without building it on the server while it renders an initial value. */
155
+ const serverGet = (registry, atom) => {
156
+ const node = initialNodeOnServer(registry, atom);
157
+ return node ? node._value : registry.get(atom);
158
+ };
110
159
  const awaitResult = (registry, atom, options, signal) => Effect.runPromiseExit(AtomRegistry.getResult(registry, atom, options), {
111
160
  signal,
112
161
  });
113
- const subscribedReader = (registry, getAtom) => {
162
+ /** Whether a hook stops waiting at a result: it has left `Initial`, and isn't refreshing if asked. */
163
+ const settles = (result, suspendOnWaiting) => result._tag !== "Initial" && !(suspendOnWaiting && result.waiting);
164
+ /**
165
+ * Waits for an async atom to settle, as `awaitResult` does, but resolves with the atom's own result
166
+ * rather than an `Exit` rebuilt from it, which would drop a `Failure`'s `previousSuccess`.
167
+ * Interrupted, with a failed `Exit`, once `signal` aborts.
168
+ */
169
+ const awaitSettled = (registry, atom, options, signal) => {
170
+ const suspendOnWaiting = options.suspendOnWaiting ?? false;
171
+ return Effect.runPromiseExit(Effect.callback((resume) => {
172
+ const current = registry.get(atom);
173
+ if (settles(current, suspendOnWaiting)) {
174
+ resume(Effect.succeed(current));
175
+ return;
176
+ }
177
+ const cancel = registry.subscribe(atom, (value) => {
178
+ if (settles(value, suspendOnWaiting)) {
179
+ resume(Effect.succeed(value));
180
+ cancel();
181
+ }
182
+ });
183
+ return Effect.sync(cancel);
184
+ }), { signal });
185
+ };
186
+ /**
187
+ * A promise-mode setter's wait: as `awaitResult` with `suspendOnWaiting`, but a change to a result
188
+ * from before the call, which is what `Atom.Reset` from another setter leaves (an idle `Initial`,
189
+ * or an `Atom.fn`'s `initialValue`), ends it as interrupted. The reset interrupts the call, so
190
+ * otherwise the wait would hang until the next call, or settle with the initial value.
191
+ */
192
+ const awaitWrite = (registry, atom, start, signal) => Effect.runPromiseExit(Effect.callback((resume) => {
193
+ const first = registry.get(atom);
194
+ if (first._tag !== "Initial" && !first.waiting) {
195
+ resume(AsyncResult.toExit(first));
196
+ return;
197
+ }
198
+ const cancel = registry.subscribe(atom, (result) => {
199
+ if (result.waiting) {
200
+ return;
201
+ }
202
+ cancel();
203
+ const reset = result._tag === "Initial" ||
204
+ (result._tag === "Success" && result.timestamp < start);
205
+ resume(reset
206
+ ? Exit.interrupt()
207
+ : AsyncResult.toExit(result));
208
+ });
209
+ return Effect.sync(cancel);
210
+ }), { signal });
211
+ // On the server, per registry, how many renders' seeds carry each key, until those renders end.
212
+ const seededKeys = new WeakMap();
213
+ /** Whether a render using the registry sends the key's result to the browser with a seed. */
214
+ const seededOnServer = (registry, key) => (seededKeys.get(registry)?.get(key) ?? 0) > 0;
215
+ /** Counts the key as seeded until the render ends. */
216
+ const countSeeded = (registry, key) => {
217
+ let keys = seededKeys.get(registry);
218
+ if (!keys) {
219
+ keys = new Map();
220
+ seededKeys.set(registry, keys);
221
+ }
222
+ keys.set(key, (keys.get(key) ?? 0) + 1);
223
+ onRenderEnd(() => {
224
+ const count = (keys.get(key) ?? 1) - 1;
225
+ if (count > 0) {
226
+ keys.set(key, count);
227
+ }
228
+ else {
229
+ keys.delete(key);
230
+ }
231
+ });
232
+ };
233
+ // The keys of the atoms useAtomValue has warned about, so the server warns once per key, not per
234
+ // request.
235
+ const warnedUnsent = new Set();
236
+ /**
237
+ * In development, warns once per key that a plain read on the server met a serializable async atom
238
+ * still running: only useAtomResult and useAtomSuspense send an atom's result to the browser, so
239
+ * the browser runs it again, and the page can show a loading state over what the server rendered.
240
+ * Not for a key the page sends anyway, with a seed or a HydrationBoundary's value, nor for a result
241
+ * nothing is computing, such as an Atom.fn nobody called or a value written to the atom.
242
+ */
243
+ const warnUnsent = (registry, atom, value, hook) => {
244
+ if (!(DEV && Atom.isSerializable(atom) && AsyncResult.isAsyncResult(value)) ||
245
+ !value.waiting) {
246
+ return;
247
+ }
248
+ const { key } = atom[Atom.SerializableTypeId];
249
+ if (warnedUnsent.has(key) ||
250
+ queuedByBoundary(registry, key) ||
251
+ seededOnServer(registry, key)) {
252
+ return;
253
+ }
254
+ warnedUnsent.add(key);
255
+ console.warn(`effect-atom-svelte: ${hook} read the serializable atom "${key}" on the server while it was still running, but only useAtomResult and useAtomSuspense send an atom's result to the browser, so it runs again there. Read it with one of them. See https://atom.jarrednorris.dev/hydration#how-the-result-travels`);
256
+ };
257
+ /**
258
+ * Reads the getter's atom and follows it. `plainRead` names the plain read, `useAtomValue` or
259
+ * `useAtom`, for `warnUnsent`; the async hooks send their atom's result themselves.
260
+ */
261
+ const subscribedReader = (registry, getAtom, plainRead) => {
114
262
  if (!BROWSER) {
115
263
  // Nothing subscribes during SSR, so without a mount the registry would sweep the node while the
116
264
  // render awaits, losing initial values and refetching async atoms. Mounting at setup, before any
117
- // await, keeps it for the request. The mounts are released in onDestroy, which runs when the
118
- // server render ends, because a registry passed in by the caller outlives the request (JND-17).
265
+ // await, keeps it for the request. The mounts are released when the server render ends,
266
+ // because a registry passed in by the caller outlives the request (JND-17).
119
267
  // An atom with a withServerValue override is never computed on the server, so it is not mounted:
120
- // mounting would run its real read, which is often browser-only. Nor is one that holds a value
121
- // from useAtomInitialValues: the server renders that value, which is what the atom's first build
122
- // would keep, without running a read that may be browser-only or start a request.
268
+ // mounting would run its real read, which is often browser-only. One with a value from
269
+ // useAtomInitialValues is held and read without building it (see initialNodeOnServer).
270
+ // Once the render has ended, a read that switches atoms, as from a reader kept past the render,
271
+ // takes no mount: nothing would release it.
272
+ let ended = false;
123
273
  const releases = [];
124
- onDestroy(() => {
274
+ onRenderEnd(() => {
275
+ ended = true;
125
276
  for (const release of releases) {
126
277
  release();
127
278
  }
128
279
  });
129
- const nodes = internals(registry);
130
280
  const mount = (atom) => {
131
- if (hasServerValue(atom)) {
132
- return atom;
281
+ if (!ended && !hasServerValue(atom)) {
282
+ releases.push(serverMount(registry, atom));
133
283
  }
134
- const node = nodes.ensureNode(atom);
135
- releases.push(node.preserveInitialValueOnBuild
136
- ? holdNode(nodes, node)
137
- : registry.mount(atom));
138
284
  return atom;
139
285
  };
140
286
  let mounted = mount(getAtom());
@@ -143,13 +289,18 @@ const subscribedReader = (registry, getAtom) => {
143
289
  if (atom !== mounted) {
144
290
  mounted = mount(atom);
145
291
  }
146
- if (!hasServerValue(atom)) {
147
- const node = nodes.ensureNode(atom);
148
- if (node.preserveInitialValueOnBuild) {
149
- return node._value;
150
- }
292
+ if (hasServerValue(atom)) {
293
+ return Atom.getServerValue(atom, registry);
294
+ }
295
+ // An initial value is what the browser starts from too.
296
+ if (initialOnServer(registry, atom)) {
297
+ return serverGet(registry, atom);
151
298
  }
152
- return Atom.getServerValue(atom, registry);
299
+ const value = registry.get(atom);
300
+ if (plainRead) {
301
+ warnUnsent(registry, atom, value, plainRead);
302
+ }
303
+ return value;
153
304
  };
154
305
  }
155
306
  reportReads(registry, getAtom, "read");
@@ -170,23 +321,30 @@ const subscribedReader = (registry, getAtom) => {
170
321
  kept?.cancel();
171
322
  kept = undefined;
172
323
  };
173
- // Whether the component has mounted: its effects have run once.
174
- let mounted = false;
175
324
  // The atom this reader's own registry.get is reading. A subscribed node is rebuilt as soon as it
176
325
  // goes stale, so the read builds it only the first time, and that build announces the very value
177
- // the read returns. Before the component mounts, that announcement can only re-render its first
178
- // render, which already has the value, so it is dropped. Delivered on a microtask it started a
179
- // Svelte batch that, when the first render ran after an await in markup (a HydrationBoundary
180
- // awaiting its state), committed before hydration's own and made Svelte's dev build throw "Batch
181
- // has scheduled effects" (JND-95). After mount it is still delivered, as before. Nothing depends
182
- // on the update it starts: a getter switched in onMount once did, by accident, as that update
183
- // rendered before the registry swept the old atom (JND-98, fixed in the effect below).
326
+ // the read returns, so it is dropped: it could only re-render a read that already has the value.
327
+ // Before mount, delivered on a microtask it started a Svelte batch that, when the first render ran
328
+ // after an await in markup (a HydrationBoundary awaiting its state), committed before hydration's
329
+ // own and made Svelte's dev build throw "Batch has scheduled effects" (JND-95). After mount, a
330
+ // getter switched to an atom not built yet ran a transform twice, made a new object for an
331
+ // unchanged value, and re-ran an $effect that first read the atom. Only the build's own
332
+ // announcement is dropped: a value set during a registry batch is announced at commit, after
333
+ // `reading` is reset, and later changes arrive as usual. A getter switched in onMount once
334
+ // depended on that update, by accident (JND-98, fixed in the effect below). After mount, only a
335
+ // first build is dropped: a stale node the read rebuilds, as after a failed rebuild, announces a
336
+ // value the reader's other reads haven't seen.
337
+ let mounted = false;
184
338
  let reading;
185
- const listen = (current, update) => registry.subscribe(current, () => {
186
- if (mounted || reading !== current) {
187
- update();
188
- }
189
- });
339
+ const listen = (current, update) => {
340
+ const unsubscribe = registry.subscribe(current, () => {
341
+ if (reading !== current) {
342
+ update();
343
+ }
344
+ });
345
+ dropInitialValueMark(registry, current);
346
+ return unsubscribe;
347
+ };
190
348
  const follow = (current) => {
191
349
  if (kept?.atom === current) {
192
350
  const { cancel: keptCancel } = kept;
@@ -213,8 +371,10 @@ const subscribedReader = (registry, getAtom) => {
213
371
  if (atom !== undefined && atom !== committed) {
214
372
  const previous = atom;
215
373
  const previousCancel = cancel;
374
+ // Subscribing can throw, as it rebuilds a stale atom; nothing moves until it has succeeded.
375
+ const next = follow(committed);
216
376
  atom = committed;
217
- cancel = follow(committed);
377
+ cancel = next;
218
378
  if (previousCancel) {
219
379
  releaseKept();
220
380
  kept = { atom: previous, cancel: previousCancel };
@@ -236,8 +396,10 @@ const subscribedReader = (registry, getAtom) => {
236
396
  if (current !== atom) {
237
397
  const previous = atom;
238
398
  const previousCancel = cancel;
399
+ // As in the effect: a throwing subscribe leaves the reader on its old atom, to try again.
400
+ const next = follow(current);
239
401
  atom = current;
240
- cancel = follow(current);
402
+ cancel = next;
241
403
  if (previous === committed &&
242
404
  previous !== undefined &&
243
405
  previousCancel) {
@@ -250,8 +412,17 @@ const subscribedReader = (registry, getAtom) => {
250
412
  }
251
413
  subscribe();
252
414
  const outer = reading;
253
- reading = current;
415
+ if (!mounted) {
416
+ reading = current;
417
+ }
254
418
  try {
419
+ // After mount, only a first build is dropped: a stale node rebuilt here, as after a failed
420
+ // rebuild, announces a change other reads of this reader have not seen. A node whose first
421
+ // build failed is still uninitialized, but the page has shown that failure, so it counts too.
422
+ const node = internals(registry).ensureNode(current);
423
+ if (node.currentState() === "uninitialized" && !node.failed) {
424
+ reading = current;
425
+ }
255
426
  return registry.get(current);
256
427
  }
257
428
  finally {
@@ -260,7 +431,7 @@ const subscribedReader = (registry, getAtom) => {
260
431
  });
261
432
  };
262
433
  export function useAtomValue(input, f) {
263
- const read = subscribedReader(getRegistry(), toGetter(input));
434
+ const read = subscribedReader(getRegistry(), toGetter(input), "useAtomValue");
264
435
  if (!f) {
265
436
  return new AtomCell(read, readOnly);
266
437
  }
@@ -290,14 +461,19 @@ export function useAtomValue(input, f) {
290
461
  export const useAtom = (input) => {
291
462
  const registry = getRegistry();
292
463
  const getAtom = toGetter(input);
293
- return new AtomCell(subscribedReader(registry, getAtom), (value) => registry.set(getAtom(), value));
464
+ return new AtomCell(subscribedReader(registry, getAtom, "useAtom"), (value) => registry.set(getAtom(), value));
294
465
  };
295
466
  /** useAtomMount, telling an inspector scope why the atom is held: `useAtomSet` holds it to write. */
296
467
  const mountWhileAlive = (input, kind) => {
297
468
  const registry = getRegistry();
298
469
  const getAtom = toGetter(input);
299
470
  reportReads(registry, getAtom, kind);
300
- $effect(() => registry.mount(getAtom()));
471
+ $effect(() => {
472
+ const atom = getAtom();
473
+ const release = registry.mount(atom);
474
+ dropInitialValueMark(registry, atom);
475
+ return release;
476
+ });
301
477
  };
302
478
  /**
303
479
  * Keeps an atom alive for as long as this component is mounted, even when no component reads
@@ -349,8 +525,21 @@ export function useAtomSet(input, options) {
349
525
  exit = Exit.interrupt();
350
526
  }
351
527
  else {
528
+ const start = Date.now();
352
529
  registry.set(atom, value);
353
- exit = await awaitResult(registry, atom, { suspendOnWaiting: true }, writeOptions?.signal);
530
+ // A provider disposing of its registry ends the call without a last result, so the wait ends
531
+ // there too, as interrupted.
532
+ const controller = new AbortController();
533
+ const abort = () => controller.abort();
534
+ writeOptions?.signal?.addEventListener("abort", abort, { once: true });
535
+ const cancel = onDispose(registry, abort);
536
+ try {
537
+ exit = await awaitWrite(registry, atom, start, controller.signal);
538
+ }
539
+ finally {
540
+ cancel();
541
+ writeOptions?.signal?.removeEventListener("abort", abort);
542
+ }
354
543
  }
355
544
  return mode === "promiseExit" ? exit : valueOrThrow(exit);
356
545
  };
@@ -450,8 +639,11 @@ export const useAtomSubscribe = (input, f, options) => {
450
639
  const registry = getRegistry();
451
640
  const getAtom = toGetter(input);
452
641
  reportReads(registry, getAtom, "subscribe");
642
+ // The effect follows the atom, not what the getter reads: a getter that runs again and returns
643
+ // the same atom must not call `immediate` again, nor drop a change still waiting for delivery.
644
+ const current = $derived(getAtom());
453
645
  $effect(() => {
454
- const atom = getAtom();
646
+ const atom = current;
455
647
  // `immediate` calls `f` now, inside this effect; what `f` reads must not re-run it.
456
648
  return untrack(() => {
457
649
  // registry.subscribe does not compute a node that has never been read, so an atom nothing
@@ -463,6 +655,7 @@ export const useAtomSubscribe = (input, f, options) => {
463
655
  }
464
656
  const deliver = orderedDelivery(f);
465
657
  const cancel = registry.subscribe(atom, deliver);
658
+ dropInitialValueMark(registry, atom);
466
659
  return () => {
467
660
  deliver.stop();
468
661
  cancel();
@@ -476,10 +669,14 @@ export const useAtomSubscribe = (input, f, options) => {
476
669
  */
477
670
  const setNodeValue = (registry, atom, value) => {
478
671
  internals(registry).ensureNode(atom).setValue(value);
672
+ dropInitialValueMark(registry, atom);
479
673
  };
480
674
  // The nodes given a value by useAtomInitialValues. A node belongs to one registry, and a node the
481
675
  // registry sweeps is gone from here too, so the next component to start it gives it the value again.
482
676
  const initialValuesApplied = new WeakSet();
677
+ // On the server, how many renders hold each applied node. The value applies again only once none
678
+ // does, so a request ending doesn't let a request starting overwrite the value another still renders.
679
+ const serverHolders = new WeakMap();
483
680
  /**
484
681
  * Sets starting values as `AtomRegistry.make({ initialValues })` would: on the atom that receives it
485
682
  * (a wrapper such as `withRefresh` passes it to its source), kept as the value of the atom's first
@@ -489,9 +686,10 @@ const initialValuesApplied = new WeakSet();
489
686
  * an atom that can only compute in the browser can start from a value on the server.
490
687
  *
491
688
  * A value applies once while the atom is held, so a component mounted later, or a second
492
- * component with its own value, does not overwrite a value the atom has moved on from. Once nothing
493
- * holds the atom and the registry disposes it, the next component to start it applies its value
494
- * again. On the server a value lasts one render, so each request against a shared registry applies
689
+ * component with its own value, does not overwrite a value the atom has moved on from since the
690
+ * first applied one. An atom that is already in use without one, read by another component, is set
691
+ * to the value, as `@effect/atom-react` does. Once nothing holds the atom and the registry disposes
692
+ * it, the next component to start it applies its value again. On the server a value lasts one render, so each request against a shared registry applies
495
693
  * its own; requests rendering at the same time share the registry's atoms, and with them the value
496
694
  * the first one applied.
497
695
  *
@@ -515,51 +713,46 @@ const initialValuesApplied = new WeakSet();
515
713
  export const useAtomInitialValues = (initialValues) => {
516
714
  const registry = internals(getRegistry());
517
715
  const releases = [];
716
+ // Registered before the loop: an entry that throws midway leaves the earlier ones held otherwise.
717
+ onTeardown(() => {
718
+ for (const release of releases) {
719
+ release();
720
+ }
721
+ });
518
722
  for (const [atom, value] of initialValues) {
519
723
  const node = registry.ensureNode(initialValueTarget(atom));
520
724
  if (!initialValuesApplied.has(node)) {
521
725
  initialValuesApplied.add(node);
522
726
  node.setInitialValue(value);
523
- if (!BROWSER) {
524
- // A registry passed in by the caller outlives the request, and its node may not be swept
525
- // before the next request starts, whose value must apply too (JND-17).
526
- releases.push(() => initialValuesApplied.delete(node));
527
- }
727
+ }
728
+ if (!BROWSER) {
729
+ // A registry passed in by the caller outlives the request, and its node may not be swept
730
+ // before the next request starts, whose value must apply too (JND-17). Requests rendering at
731
+ // the same time share the value the first applied, until the last of them ends.
732
+ serverHolders.set(node, (serverHolders.get(node) ?? 0) + 1);
733
+ releases.push(() => {
734
+ const holders = (serverHolders.get(node) ?? 1) - 1;
735
+ serverHolders.set(node, holders);
736
+ if (holders === 0) {
737
+ initialValuesApplied.delete(node);
738
+ }
739
+ });
528
740
  }
529
741
  releases.push(holdNode(registry, node));
530
742
  }
531
- onTeardown(() => {
532
- for (const release of releases) {
533
- release();
534
- }
535
- });
536
743
  };
537
- /**
538
- * Reads an `AtomRef`, following it when the getter returns a different ref. For one property of a
539
- * ref, use `useAtomRefPropValue`.
540
- *
541
- * A ref has no registry. On the server, one created at module level is shared by every request,
542
- * so don't write a visitor's data to it there: see
543
- * https://atom.jarrednorris.dev/server-rendering#module-state-is-shared-between-visitors
544
- *
545
- * **Example** (Reading a ref)
546
- *
547
- * ```ts
548
- * import { useAtomRef } from "effect-atom-svelte";
549
- * import { profile } from "./refs.ts";
550
- *
551
- * const current = useAtomRef(profile);
552
- * // In the markup: {current.current.name}
553
- * ```
554
- *
555
- * @stability unstable
556
- * @since 0.1.0
557
- * @category hooks
558
- */
559
- export const useAtomRef = (input) => {
744
+ /** Writes a ref that has `set`; a read-only ref, as from `map`, throws as `useAtomValue` would. */
745
+ const writeRef = (getRef) => (value) => {
746
+ const ref = getRef();
747
+ if (!("set" in ref)) {
748
+ readOnly();
749
+ }
750
+ ref.set(value);
751
+ };
752
+ export function useAtomRef(input) {
560
753
  const getRef = toGetter(input);
561
754
  if (!BROWSER) {
562
- return new AtomCell(() => getRef().value, readOnly);
755
+ return new AtomCell(() => getRef().value, writeRef(getRef));
563
756
  }
564
757
  const ref = $derived(getRef());
565
758
  const subscribe = $derived.by(() => {
@@ -569,8 +762,8 @@ export const useAtomRef = (input) => {
569
762
  return new AtomCell(() => duringRead(() => {
570
763
  subscribe();
571
764
  return ref.value;
572
- }), readOnly);
573
- };
765
+ }), writeRef(() => ref));
766
+ }
574
767
  /**
575
768
  * Returns `ref.prop(prop)`, the `AtomRef` for one property of an `AtomRef`. It takes the ref
576
769
  * itself, not a getter, and does not follow later changes to `ref` or `prop`; to read a property
@@ -593,7 +786,8 @@ export const useAtomRef = (input) => {
593
786
  export const useAtomRefProp = (ref, prop) => ref.prop(prop);
594
787
  /**
595
788
  * Reads one property of an `AtomRef`, following the getter to a different ref. `prop` is taken
596
- * once, when the component starts, and is not followed.
789
+ * once, when the component starts, and is not followed. Assigning `current` sets the property, so
790
+ * `bind:checked={done.current}` works.
597
791
  *
598
792
  * **Example** (Reading one property of a ref)
599
793
  *
@@ -614,11 +808,6 @@ export const useAtomRefPropValue = (input, prop) => {
614
808
  const propRef = $derived(getRef().prop(prop));
615
809
  return useAtomRef(() => propRef);
616
810
  };
617
- /**
618
- * Whether a result is `Initial` with nothing running to settle it, as an `Atom.fn` nothing has
619
- * called. On the server nothing will start it during the render, so waiting would hang it.
620
- */
621
- const notStarted = (result) => result._tag === "Initial" && !result.waiting;
622
811
  const notStartedError = (hook) => new Error(`${hook} read an atom that has not started on the server: its result is Initial and nothing is running it, as for an Atom.fn that has not been called, so the render would wait for it forever. Read it with useAtomValue, which renders Initial, or inside a <svelte:boundary> with a pending snippet, which the server renders instead.`);
623
812
  /**
624
813
  * Puts the server's value in the registry as current, so nothing fetches it again (JND-19). Seeding
@@ -639,15 +828,25 @@ const applySeed = (registry, atom, value, revalidate) => {
639
828
  const seeds = new WeakMap();
640
829
  // hydratable returns one promise per key per render, so it identifies the atom that claimed a key.
641
830
  const serverSeeds = new WeakMap();
831
+ // The keys, by their render's promise, that some reader reads with suspendOnWaiting: the seed is
832
+ // then the settled result that reader renders, whichever reader claimed the key first.
833
+ const settledSeeds = new WeakSet();
642
834
  /**
643
835
  * On the server each request seeds for itself: `hydratable` already shares one result per key
644
836
  * within a render, and a per-registry record would outlive the request when the caller owns the
645
837
  * registry, so later requests would skip embedding their seed (JND-17).
646
838
  */
647
- const seedOnServer = (registry, atom, key, encode) => {
839
+ const seedOnServer = (registry, atom, key, encode, suspendOnWaiting) => {
648
840
  // Mounted until the render ends, so the settled node is what the render reads.
649
- const release = registry.mount(atom);
650
- onDestroy(release);
841
+ const release = serverMount(registry, atom);
842
+ // A render ends before its seeds settle only when it fails, as when a later reader throws during
843
+ // setup. The provider has disposed of the registry by then, so the seed reads nothing from it.
844
+ let ended = false;
845
+ onRenderEnd(() => {
846
+ ended = true;
847
+ release();
848
+ });
849
+ countSeeded(registry, key);
651
850
  // hydratable hands every later reader of a key the first reader's value, but Svelte's dev build
652
851
  // also runs each later reader's callback and throws hydratable_clobbering if what it encodes
653
852
  // differs. Reading the atom again there would encode whatever it holds by then, so a later
@@ -657,21 +856,38 @@ const seedOnServer = (registry, atom, key, encode) => {
657
856
  const encoded = hydratable(key, () => {
658
857
  claim.own = (async () => {
659
858
  await undefined;
859
+ if (ended) {
860
+ return noSeed;
861
+ }
660
862
  if (claim.first !== claim.own) {
661
863
  return await claim.first;
662
864
  }
663
- // Not waited for if nothing has started it: the hook rejects once seeded.
664
- if (!notStarted(registry.get(atom))) {
665
- await awaitResult(registry, atom);
865
+ // Not waited for if nothing has started it: the hook rejects once seeded. Waited for as the hook
866
+ // waits, so with suspendOnWaiting the seed is the settled result the render shows, not one the
867
+ // browser would run again because it was still waiting.
868
+ if (!initialOnServer(registry, atom) && !notStarted(registry.get(atom))) {
869
+ let settled = suspendOnWaiting === true;
870
+ await awaitResult(registry, atom, { suspendOnWaiting: settled });
871
+ // A later reader of the key may want the settled result once this one's wait has begun.
872
+ if (!settled && claim.first && settledSeeds.has(claim.first)) {
873
+ settled = true;
874
+ await awaitResult(registry, atom, { suspendOnWaiting: settled });
875
+ }
876
+ }
877
+ if (ended) {
878
+ return noSeed;
666
879
  }
667
- return encodeSeed(key, encode, registry.get(atom));
880
+ return encodeSeed(key, encode, serverGet(registry, atom));
668
881
  })();
669
882
  return claim.own;
670
883
  });
671
884
  claim.first = encoded;
885
+ if (suspendOnWaiting === true) {
886
+ settledSeeds.add(encoded);
887
+ }
672
888
  const claimed = serverSeeds.get(encoded);
673
889
  if (claimed && claimed !== atom) {
674
- throw new Error(`Two different atoms share the serialization key "${key}"`);
890
+ throw new Error(`Two different atoms share the serialization key "${key}", so the server can't tell whose result to send. Put what tells the copies apart into the key, such as an id, and define atoms at module level. ${troubleshooting("two-different-atoms-share-the-serialization-key")}`);
675
891
  }
676
892
  serverSeeds.set(encoded, atom);
677
893
  return (async () => {
@@ -694,7 +910,10 @@ const serverValues = () => {
694
910
  };
695
911
  const warnIfSent = async (key, sent) => {
696
912
  try {
697
- if ((await sent) === undefined) {
913
+ // Undefined when the key wasn't sent, null when the server sent that it had no seed for it, as
914
+ // for a defect: either way the browser was always going to compute the atom.
915
+ const value = await sent;
916
+ if (value === undefined || value === null) {
698
917
  return;
699
918
  }
700
919
  }
@@ -738,7 +957,7 @@ const claimServerValue = (key, missed) => {
738
957
  * serialization key. The atom is fetched again once seeded if any component using the key then
739
958
  * asked to revalidate.
740
959
  */
741
- const seedFromServer = (registry, getAtom, revalidateOption) => {
960
+ const seedFromServer = (registry, getAtom, revalidateOption, suspendOnWaiting) => {
742
961
  const atom = getAtom();
743
962
  // The server never computes an atom with a server value, so it has no value to pass on, and the
744
963
  // browser must not ask for one: hydratable throws for a key the server didn't write (JND-58).
@@ -748,7 +967,7 @@ const seedFromServer = (registry, getAtom, revalidateOption) => {
748
967
  const { decode, encode, key } = atom[Atom.SerializableTypeId];
749
968
  if (!BROWSER) {
750
969
  return {
751
- done: seedOnServer(registry, atom, key, encode),
970
+ done: seedOnServer(registry, atom, key, encode, suspendOnWaiting),
752
971
  letGo: noSeedMount,
753
972
  };
754
973
  }
@@ -759,10 +978,11 @@ const seedFromServer = (registry, getAtom, revalidateOption) => {
759
978
  seeds.set(registry, registrySeeds);
760
979
  }
761
980
  const { held, spent } = registrySeeds;
981
+ // The server's value belongs to the key, not to an atom: the registry keeps a serializable atom's
982
+ // value under its key, so every atom with the key reads it, as HydrationBoundary assumes too.
983
+ // A remounted branch, as under {#key}, makes a new atom with the old one's key while the old one
984
+ // still holds it, until Svelte destroys the old branch or its outro ends: the new atom joins it.
762
985
  let entry = held.get(key);
763
- if (entry && entry.atom !== atom) {
764
- throw new Error(`Two different atoms share the serialization key "${key}"`);
765
- }
766
986
  if (!entry && spent.has(key)) {
767
987
  return undefined;
768
988
  }
@@ -780,7 +1000,6 @@ const seedFromServer = (registry, getAtom, revalidateOption) => {
780
1000
  let holders = 0;
781
1001
  let revalidating = 0;
782
1002
  entry = {
783
- atom,
784
1003
  done: (async () => {
785
1004
  const value = await encoded;
786
1005
  // Only the server's value is a seed, and only while someone is there to read it now: a seed
@@ -788,7 +1007,9 @@ const seedFromServer = (registry, getAtom, revalidateOption) => {
788
1007
  // The server sends no seed for a result it can't or mustn't pass on, such as a defect: the
789
1008
  // atom computes here instead. A seed still waiting, as a stream's between its values, runs
790
1009
  // again too: the server's run ended with the render, so it would wait forever.
791
- const seed = !computedHere && holders > 0 ? decodeSeed(value, decode) : undefined;
1010
+ const seed = !computedHere && holders > 0
1011
+ ? decodeSeed(key, value, decode)
1012
+ : undefined;
792
1013
  if (seed !== undefined) {
793
1014
  applySeed(registry, atom, seed, revalidating > 0 || isWaiting(seed));
794
1015
  }
@@ -878,7 +1099,7 @@ const seedFromServer = (registry, getAtom, revalidateOption) => {
878
1099
  export const useAtomResult = async (input, options) => {
879
1100
  const registry = getRegistry();
880
1101
  const getAtom = toGetter(input);
881
- const value = useAtomValue(getAtom);
1102
+ const value = new AtomCell(subscribedReader(registry, getAtom), readOnly);
882
1103
  const atom = getAtom();
883
1104
  let release;
884
1105
  // Aborted when the component is destroyed, which interrupts the wait below so the atom is not held.
@@ -897,7 +1118,7 @@ export const useAtomResult = async (input, options) => {
897
1118
  release = undefined;
898
1119
  return unmount;
899
1120
  });
900
- const seed = seedFromServer(registry, getAtom, options?.revalidateOnHydrate);
1121
+ const seed = seedFromServer(registry, getAtom, options?.revalidateOnHydrate, options?.suspendOnWaiting);
901
1122
  if (seed) {
902
1123
  await seed.done;
903
1124
  }
@@ -906,6 +1127,11 @@ export const useAtomResult = async (input, options) => {
906
1127
  if (lifetime.signal.aborted || (!BROWSER && hasServerValue(atom))) {
907
1128
  return value;
908
1129
  }
1130
+ // On the server, an initial value is what the render shows, with nothing to wait for.
1131
+ if (initialOnServer(registry, atom)) {
1132
+ release = serverMount(registry, atom);
1133
+ return value;
1134
+ }
909
1135
  if (effectHolds) {
910
1136
  // Mounted while seeding, as when the promise is not awaited straight away: the effect holds the
911
1137
  // getter's atom, and a mount here would never be released. Once the getter has switched away,
@@ -946,7 +1172,7 @@ const fromSettled = (current, options) => {
946
1172
  */
947
1173
  const fromServerValue = (current, options) => {
948
1174
  if (isPending(current, options)) {
949
- return Promise.reject(new Error("useAtomSuspense read an atom whose server value is pending, so the server has nothing to render. Read it inside a <svelte:boundary> with a pending snippet, which the server renders instead, or use useAtomResult."));
1175
+ return Promise.reject(new Error(`useAtomSuspense read an atom whose server value is pending, so the server has nothing to render. Read it inside a <svelte:boundary> with a pending snippet, which the server renders instead, or use useAtomResult. ${troubleshooting("useatomsuspense-read-an-atom-whose-server-value-is-pending")}`));
950
1176
  }
951
1177
  try {
952
1178
  return Promise.resolve(fromSettled(current, options));
@@ -960,17 +1186,16 @@ const suspend = async (registry, atom, current, options, signal) => {
960
1186
  if (!BROWSER && notStarted(current)) {
961
1187
  throw notStartedError("useAtomSuspense");
962
1188
  }
963
- const exit = await awaitResult(registry, atom, { suspendOnWaiting: options.suspendOnWaiting }, signal);
1189
+ const exit = await awaitSettled(registry, atom, { suspendOnWaiting: options.suspendOnWaiting }, signal);
964
1190
  // Abandoned: rejects with the reason its last reader's signal was aborted with. For a re-run
965
1191
  // that is Svelte's STALE_REACTION, which it ignores, so a pending render that still awaits this
966
1192
  // wait keeps waiting for its next run instead of showing the interruption (JND-22).
967
1193
  signal.throwIfAborted();
968
- if (options.includeFailure) {
969
- return Exit.isSuccess(exit)
970
- ? AsyncResult.success(exit.value)
971
- : AsyncResult.failure(exit.cause);
1194
+ // Only an abort, handled above, ends the wait without a result.
1195
+ if (Exit.isFailure(exit)) {
1196
+ throw Cause.squash(exit.cause);
972
1197
  }
973
- return valueOrThrow(exit);
1198
+ return fromSettled(exit.value, options);
974
1199
  }
975
1200
  return fromSettled(current, options);
976
1201
  };
@@ -1056,13 +1281,14 @@ const sharedWait = (start, onAbandoned) => {
1056
1281
  listeners.push([signal, listener]);
1057
1282
  },
1058
1283
  promise,
1284
+ settled: () => settled,
1059
1285
  };
1060
1286
  };
1061
1287
  export function useAtomSuspense(input, options = {}) {
1062
1288
  const registry = getRegistry();
1063
1289
  const getAtom = toGetter(input);
1064
- const result = useAtomValue(getAtom);
1065
- const seed = seedFromServer(registry, getAtom, options.revalidateOnHydrate);
1290
+ const result = new AtomCell(subscribedReader(registry, getAtom), readOnly);
1291
+ const seed = seedFromServer(registry, getAtom, options.revalidateOnHydrate, options.suspendOnWaiting);
1066
1292
  // A plain variable, not $state: a render in a batch with this write rolled back would take the
1067
1293
  // pre-seed path and track nothing but the flag. Reading `trackSeed` asks for a re-read instead.
1068
1294
  let seeded = seed === undefined;
@@ -1091,10 +1317,48 @@ export function useAtomSuspense(input, options = {}) {
1091
1317
  // Holds waits read outside any derived or effect (a top-level await in the script, an event
1092
1318
  // handler), which cannot say when they are done with them, until the component is destroyed.
1093
1319
  const lifetime = new AbortController();
1094
- onTeardown(() => lifetime.abort());
1320
+ // Each reaction's signal, also aborted when the component is destroyed. Svelte aborts a derived's
1321
+ // signal when it runs again or loses its last reader, and doesn't destroy deriveds with their
1322
+ // component, so a derived only the script or an event handler reads would never abort it, and
1323
+ // the wait it holds would keep the atom after the component is gone. Kept per signal, so a signal
1324
+ // still holds a wait once (see sharedWait); those still live are aborted at teardown.
1325
+ const bounded = new WeakMap();
1326
+ const liveAborts = new Set();
1327
+ onTeardown(() => {
1328
+ lifetime.abort();
1329
+ // Each abort deletes itself, which a Set's iteration allows.
1330
+ for (const abort of liveAborts) {
1331
+ abort();
1332
+ }
1333
+ });
1334
+ const untilDestroyed = (signal) => {
1335
+ let combined = bounded.get(signal);
1336
+ if (!combined) {
1337
+ const controller = new AbortController();
1338
+ combined = controller.signal;
1339
+ bounded.set(signal, combined);
1340
+ // The reaction's reason, such as Svelte's STALE_REACTION, which it ignores (JND-22).
1341
+ const reason = () => signal.aborted ? signal.reason : lifetime.signal.reason;
1342
+ if (signal.aborted || lifetime.signal.aborted) {
1343
+ controller.abort(reason());
1344
+ }
1345
+ else {
1346
+ const abort = () => {
1347
+ liveAborts.delete(abort);
1348
+ signal.removeEventListener("abort", abort);
1349
+ controller.abort(reason());
1350
+ };
1351
+ liveAborts.add(abort);
1352
+ signal.addEventListener("abort", abort, { once: true });
1353
+ }
1354
+ }
1355
+ return combined;
1356
+ };
1095
1357
  // Keyed by atom, then result: atoms can share a result object, as a derived atom returning its
1096
1358
  // source's result does, and a wait holds and waits for one atom.
1097
1359
  const waits = new WeakMap();
1360
+ // Per atom, the wait the last read got, for a read after the component is destroyed.
1361
+ const lastWaits = new WeakMap();
1098
1362
  const settle = (atom, current, signal) => {
1099
1363
  let atomWaits = waits.get(atom);
1100
1364
  if (!atomWaits) {
@@ -1108,6 +1372,7 @@ export function useAtomSuspense(input, options = {}) {
1108
1372
  atomWaits.set(current, wait);
1109
1373
  }
1110
1374
  wait.hold(signal);
1375
+ lastWaits.set(atom, wait);
1111
1376
  return wait.promise;
1112
1377
  };
1113
1378
  const fromServer = new WeakMap();
@@ -1122,6 +1387,27 @@ export function useAtomSuspense(input, options = {}) {
1122
1387
  return promise;
1123
1388
  }
1124
1389
  const signal = readerSignal();
1390
+ const holder = signal ? untilDestroyed(signal) : lifetime.signal;
1391
+ if (holder.aborted) {
1392
+ // Read once the reader is gone, as in onDestroy or a handler that resumes after the component
1393
+ // is destroyed. A result it had settled is still there; a new wait would hold the atom until it
1394
+ // settles, as nothing could let go of it. Handled here, so a read nobody awaits isn't reported
1395
+ // as unhandled.
1396
+ const last = lastWaits.get(getAtom());
1397
+ if (last?.settled()) {
1398
+ return last.promise;
1399
+ }
1400
+ const gone = Promise.reject(holder.reason);
1401
+ void (async () => {
1402
+ try {
1403
+ await gone;
1404
+ }
1405
+ catch {
1406
+ // Belongs to whoever awaits it.
1407
+ }
1408
+ })();
1409
+ return gone;
1410
+ }
1125
1411
  if (!seeded && seed) {
1126
1412
  // Reading the atom before the seed lands would fetch what hydration is about to provide.
1127
1413
  const atom = getAtom();
@@ -1138,9 +1424,15 @@ export function useAtomSuspense(input, options = {}) {
1138
1424
  if (live.length === 0) {
1139
1425
  return undefined;
1140
1426
  }
1141
- holdWhileRead(registry, atom, live);
1427
+ // A read outside any reaction can't say when it is done, so it doesn't hold the atom: held
1428
+ // by the component's lifetime, the atom would stay mounted after the getter moves on.
1429
+ const reactions = live.filter((reader) => reader !== lifetime.signal);
1430
+ if (reactions.length > 0) {
1431
+ holdWhileRead(registry, atom, reactions);
1432
+ }
1142
1433
  seed.letGo();
1143
- const current = registry.get(atom);
1434
+ // On the server, an initial value is read without building the atom.
1435
+ const current = serverGet(registry, atom);
1144
1436
  let waited;
1145
1437
  for (const reader of live) {
1146
1438
  waited = settle(atom, current, reader);
@@ -1150,10 +1442,10 @@ export function useAtomSuspense(input, options = {}) {
1150
1442
  pending = { promise, readers };
1151
1443
  afterSeed.set(atom, pending);
1152
1444
  }
1153
- pending.readers.add(signal ?? lifetime.signal);
1445
+ pending.readers.add(holder);
1154
1446
  return pending.promise;
1155
1447
  }
1156
- const promise = settle(getAtom(), result.current, signal ?? lifetime.signal);
1448
+ const promise = settle(getAtom(), result.current, holder);
1157
1449
  if (signal) {
1158
1450
  // A reaction read it, so the reader's subscription holds the atom from here.
1159
1451
  seed?.letGo();