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.
@@ -1,13 +1,22 @@
1
1
  <!--
2
2
  @component
3
3
  Hydrates dehydrated atom state, for example from `Hydration.dehydrate` returned by a remote
4
- function. Atoms new to the registry are hydrated before children render; in the browser, atoms
5
- that already exist are updated after render, so current UI does not jump to the incoming data
6
- mid-render. On the server, where nothing renders again, they are updated at once.
4
+ function. Atoms new to the registry, or holding only an initial value nobody has read yet, are
5
+ hydrated before children render; in the browser, atoms that already exist are updated after
6
+ render, so current UI does not jump to the incoming data mid-render. On the server, where nothing
7
+ renders again, and in the browser while it hydrates the server's markup, they are updated at once.
8
+ That needs Svelte's `experimental.async`: without it, the browser can't tell that it is hydrating,
9
+ and atoms that already exist, as one read above the boundary, are updated after the first render.
10
+
11
+ With `experimental.async` on, custom server rendering must `await render(...)`, as SvelteKit does,
12
+ so the boundary's `hydratable` entry is written into the page. Otherwise a production build of
13
+ Svelte logs `hydratable_missing_but_expected` once per boundary as the page hydrates.
7
14
 
8
15
  A value for an atom nobody reads waits in the registry until something does. When the boundary
9
16
  is destroyed, as at the end of a server render, the values it brought that are still unread are
10
- dropped, so a registry that outlives the boundary doesn't hand them to a later reader.
17
+ dropped, as is a promise-encoded value that lands afterwards, so a registry that outlives the
18
+ boundary doesn't hand them to a later reader. In the browser, a reader that already held the atom
19
+ when the boundary ended and still holds it, as a layout's reader above it, gets the late value.
11
20
 
12
21
  **Example** (Hydrating the state a load function dehydrated)
13
22
 
@@ -28,10 +37,15 @@
28
37
  -->
29
38
  <script lang="ts">
30
39
  import { Hydration } from "effect/reactivity";
40
+ import type { AtomRegistry } from "effect/reactivity";
31
41
  import { BROWSER } from "esm-env";
32
- import { onDestroy } from "svelte";
42
+ import { hydratable } from "svelte";
33
43
  import type { Snippet } from "svelte";
34
44
 
45
+ import { queuedBy } from "./internal/boundaries.js";
46
+ import { holdsInitialValue } from "./internal/nodeInternals.js";
47
+ import { onRenderEnd } from "./internal/renderEnd.js";
48
+ import { onTeardownAfterChildren } from "./internal/teardown.svelte.js";
35
49
  import { getRegistry } from "./RegistryContext.js";
36
50
 
37
51
  interface Props {
@@ -49,14 +63,93 @@
49
63
  };
50
64
  // The values this boundary queued, by key, so it can drop the ones nobody took.
51
65
  const queued = new Map<string, unknown>();
66
+ // Whether the boundary has ended, as at the end of the server render or once the browser destroys
67
+ // it, after which a late promise-encoded value is dropped.
68
+ let ended = false;
69
+
70
+ const counts =
71
+ queuedBy.get(registry) ?? new Map<string, Map<unknown, number>>();
72
+ queuedBy.set(registry, counts);
73
+ /** Adds one to or takes one from the boundaries queueing a value, and returns the new count. */
74
+ const count = (key: string, value: unknown, delta: 1 | -1): number => {
75
+ const byValue = counts.get(key) ?? new Map<unknown, number>();
76
+ counts.set(key, byValue);
77
+ const next = (byValue.get(value) ?? 0) + delta;
78
+ if (next > 0) {
79
+ byValue.set(value, next);
80
+ } else {
81
+ byValue.delete(value);
82
+ if (byValue.size === 0) {
83
+ counts.delete(key);
84
+ }
85
+ }
86
+ return next;
87
+ };
88
+
89
+ // SAFETY: Hydration.hydrate ignores a promise-encoded value that resolves to this marker, which
90
+ // Hydration.ts registers with Symbol.for (Effect 4.0.1) but doesn't export.
91
+ const skipped = Symbol.for("effect/reactivity/Hydration/Skipped");
92
+ // The keys of the promise-encoded values the boundary is waiting for.
93
+ const waiting = new Set<string>();
94
+ // In the browser, the node that held each of those keys when the boundary ended. That node, as a
95
+ // layout's reader above the boundary that took its waiting value, still needs the result, or it
96
+ // waits forever. A node a later reader creates doesn't get it, nor does one that replaced a node
97
+ // swept since, as a reader inside the boundary's is just after it ends.
98
+ const nodesAtEnd = new Map<string, AtomRegistry.Node<unknown>>();
99
+ /** A promise-encoded value as the boundary waits for it; one landing after the boundary ends is skipped. */
100
+ const lateDropped = async (
101
+ key: string,
102
+ late: Promise<unknown>
103
+ ): Promise<unknown> => {
104
+ waiting.add(key);
105
+ const value = await late;
106
+ if (!ended) {
107
+ return value;
108
+ }
109
+ const held = BROWSER ? nodesAtEnd.get(key) : undefined;
110
+ return held !== undefined && registry.getNodes().get(key) === held
111
+ ? value
112
+ : skipped;
113
+ };
52
114
 
53
115
  const queue = (atoms: readonly Hydration.DehydratedAtomValue[]): void => {
54
- Hydration.hydrate(registry, atoms);
116
+ // A promise-encoded value landing after the boundary has ended, after the server render or once
117
+ // the browser has navigated away, would stay queued in a registry that outlives it for a later
118
+ // reader, however late: it is ignored once the boundary ends, unless, in the browser, the node
119
+ // that held its key then still does.
120
+ Hydration.hydrate(
121
+ registry,
122
+ atoms.map((atom) =>
123
+ atom.resultPromise === undefined
124
+ ? atom
125
+ : {
126
+ ...atom,
127
+ resultPromise: lateDropped(atom.key, atom.resultPromise),
128
+ }
129
+ )
130
+ );
55
131
  for (const { key, value } of atoms) {
132
+ if (queued.has(key)) {
133
+ if (queued.get(key) === value) {
134
+ continue;
135
+ }
136
+ count(key, queued.get(key), -1);
137
+ }
56
138
  queued.set(key, value);
139
+ count(key, value, 1);
57
140
  }
58
141
  };
59
142
 
143
+ /**
144
+ * Whether a node's value may be on the page already. A node holding only an initial value nobody
145
+ * has read, as RegistryProvider's initialValues leave it, shows nowhere yet: it takes the incoming
146
+ * value before children render, as on the server, so the browser's first render matches the
147
+ * server's markup.
148
+ */
149
+ const shown = (node: AtomRegistry.Node<unknown> | undefined): boolean =>
150
+ node !== undefined &&
151
+ !(holdsInitialValue(node) && node.currentState() === "stale");
152
+
60
153
  const hydrateNew = (
61
154
  incoming: Iterable<Hydration.DehydratedAtom> | undefined
62
155
  ): Hydration.DehydratedAtomValue[] => {
@@ -64,7 +157,7 @@
64
157
  const fresh: Hydration.DehydratedAtomValue[] = [];
65
158
  const existing: Hydration.DehydratedAtomValue[] = [];
66
159
  for (const atom of Hydration.toValues([...(incoming ?? [])])) {
67
- (nodes.has(atom.key) ? existing : fresh).push(atom);
160
+ (shown(nodes.get(atom.key)) ? existing : fresh).push(atom);
68
161
  }
69
162
  queue(fresh);
70
163
  return existing;
@@ -91,8 +184,18 @@
91
184
  // svelte-ignore state_referenced_locally
92
185
  let deferred = hydrateNew(state);
93
186
  // Effects don't run on the server, and nothing there renders again, so the atoms that already
94
- // exist are updated now, before the children read them.
95
- if (!BROWSER) {
187
+ // exist are updated now, before the children read them. The browser does the same while it
188
+ // hydrates the server's markup, so its first render matches the server's. The server writes true;
189
+ // the browser reads it only while hydrating, and false otherwise. hydratable throws in development
190
+ // for a key the server didn't write, as for a boundary the server didn't render: that is false too.
191
+ const hydratingServerMarkup = (() => {
192
+ try {
193
+ return hydratable("effect-atom-svelte/HydrationBoundary", () => !BROWSER);
194
+ } catch {
195
+ return false;
196
+ }
197
+ })();
198
+ if (!BROWSER || hydratingServerMarkup) {
96
199
  hydrateExisting(deferred);
97
200
  deferred = [];
98
201
  }
@@ -111,13 +214,28 @@
111
214
 
112
215
  // A queued value stays in the registry until its key is looked up. With a registry that outlives
113
216
  // the boundary, such as one the caller passes to the server's provider, it would reach a later
114
- // request. Drop the ones still waiting, unless something queued another value since.
115
- onDestroy(() => {
217
+ // request. Drop the ones still waiting, unless something queued another value since or another
218
+ // live boundary queued the same one. On the server they're dropped when the render ends, even if
219
+ // a failed boundary discarded this one; in the browser, also when it is destroyed while pending.
220
+ (BROWSER ? onTeardownAfterChildren : onRenderEnd)(() => {
221
+ if (ended) {
222
+ return;
223
+ }
224
+ ended = true;
225
+ if (BROWSER) {
226
+ const nodes = registry.getNodes();
227
+ for (const key of waiting) {
228
+ const node = nodes.get(key);
229
+ if (node) {
230
+ nodesAtEnd.set(key, node);
231
+ }
232
+ }
233
+ }
116
234
  if (!preloaded) {
117
235
  return;
118
236
  }
119
237
  for (const [key, value] of queued) {
120
- if (preloaded.get(key) === value) {
238
+ if (count(key, value, -1) <= 0 && preloaded.get(key) === value) {
121
239
  preloaded.delete(key);
122
240
  }
123
241
  }
@@ -6,13 +6,22 @@ interface Props {
6
6
  }
7
7
  /**
8
8
  * Hydrates dehydrated atom state, for example from `Hydration.dehydrate` returned by a remote
9
- * function. Atoms new to the registry are hydrated before children render; in the browser, atoms
10
- * that already exist are updated after render, so current UI does not jump to the incoming data
11
- * mid-render. On the server, where nothing renders again, they are updated at once.
9
+ * function. Atoms new to the registry, or holding only an initial value nobody has read yet, are
10
+ * hydrated before children render; in the browser, atoms that already exist are updated after
11
+ * render, so current UI does not jump to the incoming data mid-render. On the server, where nothing
12
+ * renders again, and in the browser while it hydrates the server's markup, they are updated at once.
13
+ * That needs Svelte's `experimental.async`: without it, the browser can't tell that it is hydrating,
14
+ * and atoms that already exist, as one read above the boundary, are updated after the first render.
15
+ *
16
+ * With `experimental.async` on, custom server rendering must `await render(...)`, as SvelteKit does,
17
+ * so the boundary's `hydratable` entry is written into the page. Otherwise a production build of
18
+ * Svelte logs `hydratable_missing_but_expected` once per boundary as the page hydrates.
12
19
  *
13
20
  * A value for an atom nobody reads waits in the registry until something does. When the boundary
14
21
  * is destroyed, as at the end of a server render, the values it brought that are still unread are
15
- * dropped, so a registry that outlives the boundary doesn't hand them to a later reader.
22
+ * dropped, as is a promise-encoded value that lands afterwards, so a registry that outlives the
23
+ * boundary doesn't hand them to a later reader. In the browser, a reader that already held the atom
24
+ * when the boundary ended and still holds it, as a layout's reader above it, gets the late value.
16
25
  *
17
26
  * **Example** (Hydrating the state a load function dehydrated)
18
27
  *
@@ -17,8 +17,10 @@ import type { ReadKind } from "./internal/scope.svelte.ts";
17
17
  * - `FirstRead`: its first computation.
18
18
  * - `ParentChanged`: atoms it read changed since its last computation; `parents` are those nodes.
19
19
  * - `Refreshed`: `registry.refresh` (or `useAtomRefresh`) asked for it, or for an atom it wraps.
20
- * - `Invalidated`: something else made it stale, such as the atom refreshing itself (`refreshSelf`,
21
- * a reactivity key, a window-focus signal).
20
+ * A signal that refreshes the atom (`refreshOnWindowFocus`, `makeRefreshOnSignal`, `swr`) calls
21
+ * `registry.refresh` too, so it is reported the same way.
22
+ * - `Invalidated`: something else made it stale, such as the atom refreshing itself (`refreshSelf`)
23
+ * or a reactivity key.
22
24
  *
23
25
  * @stability unstable
24
26
  * @since 0.2.0
@@ -58,7 +60,9 @@ export type UpdateSource = "build" | "write" | "async";
58
60
  * - `ReadersChanged`: a listener was added or removed; `readers` is how many it has now. Hooks,
59
61
  * `registry.subscribe` and `mount`, and atoms that subscribe to others all count.
60
62
  * - `Interrupted`: its effect hadn't finished when what its computation started was torn down, so
61
- * the fiber was interrupted. Followed by `Finalized`.
63
+ * the fiber was interrupted. Followed by `Finalized`. Not reported for an `Atom.runtime` atom,
64
+ * whose layer build isn't interrupted. An effect atom made with `{ uninterruptible: true }` is
65
+ * reported all the same, though its fiber keeps running: the inspector can't tell it apart.
62
66
  * - `Finalized`: what its last computation started was torn down, before it computes again or as
63
67
  * it is removed; `finalizers` ran.
64
68
  * - `NodeRemoved`: the registry disposed of the node, as nothing read it any more.
package/dist/Inspector.js CHANGED
@@ -10,13 +10,23 @@
10
10
  */
11
11
  import { AsyncResult, Atom } from "effect/reactivity";
12
12
  import { BROWSER } from "esm-env";
13
- import { defaultIdleTTL, instrumentNode } from "./internal/nodeInternals.js";
13
+ import { buildWillRead, defaultIdleTTL, holdsInitialValue, instrumentNode, } from "./internal/nodeInternals.js";
14
14
  import { list, watch } from "./internal/registries.js";
15
15
  import { nameComponent as setComponentName, setReporter, } from "./internal/scope.svelte.js";
16
16
  const nodeKey = (atom) => Atom.isSerializable(atom) ? atom[Atom.SerializableTypeId].key : atom;
17
+ /**
18
+ * An `Atom.runtime` atom, by the `layer` and `factory` it carries. It builds its layer with
19
+ * `uninterruptible: true`, so a build torn down midway keeps running.
20
+ */
21
+ const isRuntime = (atom) => "layer" in atom &&
22
+ Atom.isAtom(atom.layer) &&
23
+ "factory" in atom &&
24
+ typeof atom.factory === "function";
17
25
  const install = (registry) => {
18
26
  const listeners = new Set();
19
27
  const records = new WeakMap();
28
+ // The node that held each result first: the one whose computation or effect made it.
29
+ const origins = new WeakMap();
20
30
  let writing = 0;
21
31
  let complete = true;
22
32
  const emit = (event) => {
@@ -38,6 +48,7 @@ const install = (registry) => {
38
48
  builds: 0,
39
49
  changed: new Set(),
40
50
  refreshed: false,
51
+ removing: false,
41
52
  values: 0,
42
53
  };
43
54
  records.set(node, found);
@@ -73,7 +84,12 @@ const install = (registry) => {
73
84
  }
74
85
  // An effect atom's computation leaves a waiting result until its fiber is done, and a
75
86
  // finalizer that interrupts the fiber.
76
- if (AsyncResult.isAsyncResult(value) && value.waiting) {
87
+ // A wrapper that passes on another atom's result (debounce, makeRefreshOnSignal) holds a
88
+ // value that atom made, and has no fiber of its own. A runtime's fiber isn't interrupted.
89
+ if (AsyncResult.isAsyncResult(value) &&
90
+ value.waiting &&
91
+ (origins.get(value) ?? node) === node &&
92
+ !isRuntime(node.atom)) {
77
93
  emit({ _tag: "Interrupted", node, time: performance.now() });
78
94
  }
79
95
  emit({ _tag: "Finalized", finalizers, node, time: performance.now() });
@@ -86,13 +102,27 @@ const install = (registry) => {
86
102
  time: performance.now(),
87
103
  });
88
104
  },
105
+ removed: () => {
106
+ const self = record(node);
107
+ if (self.removing) {
108
+ self.removing = false;
109
+ emit({ _tag: "NodeRemoved", node, time: performance.now() });
110
+ }
111
+ },
89
112
  value: (previous, value) => {
90
113
  const self = record(node);
114
+ if (AsyncResult.isAsyncResult(value) && !origins.has(value)) {
115
+ origins.set(value, node);
116
+ }
91
117
  const first = self.values === 0;
92
118
  self.values += 1;
93
119
  if (!first) {
94
120
  for (const child of node.children) {
95
- record(child).changed.add(node);
121
+ // A running build that hasn't read this node yet reads the new value: not a cause of
122
+ // its next computation.
123
+ if (!buildWillRead(child, node)) {
124
+ record(child).changed.add(node);
125
+ }
96
126
  }
97
127
  }
98
128
  let source = "async";
@@ -136,7 +166,16 @@ const install = (registry) => {
136
166
  if (node !== undefined) {
137
167
  record(node).refreshed = true;
138
168
  }
139
- refresh.call(registry, atom);
169
+ try {
170
+ refresh.call(registry, atom);
171
+ }
172
+ finally {
173
+ // Still valid: the refresh didn't reach it (what it wraps computed an equal value), or it
174
+ // computed already. Either way the mark isn't the cause of a later computation.
175
+ if (node?.currentState() === "valid") {
176
+ record(node).refreshed = false;
177
+ }
178
+ }
140
179
  };
141
180
  patchable.onNodeAdded = (node) => {
142
181
  onNodeAdded?.(node);
@@ -145,6 +184,11 @@ const install = (registry) => {
145
184
  };
146
185
  patchable.onNodeRemoved = (node) => {
147
186
  onNodeRemoved?.(node);
187
+ // An idle node is announced before it is removed; its teardown is reported first.
188
+ if (node.currentState() !== "removed" && records.has(node)) {
189
+ record(node).removing = true;
190
+ return;
191
+ }
148
192
  emit({ _tag: "NodeRemoved", node, time: performance.now() });
149
193
  };
150
194
  for (const node of registry.getNodes().values()) {
@@ -152,7 +196,7 @@ const install = (registry) => {
152
196
  // A node that has computed before isn't on its first read or value any more.
153
197
  if (node.currentState() !== "uninitialized") {
154
198
  const self = record(node);
155
- self.builds = 1;
199
+ self.builds = holdsInitialValue(node) ? 0 : 1;
156
200
  self.values = 1;
157
201
  }
158
202
  }
@@ -161,7 +205,8 @@ const install = (registry) => {
161
205
  return complete;
162
206
  },
163
207
  idleTTL: (atom) => {
164
- if (atom.keepAlive) {
208
+ // An idle TTL of 0 is none: the registry removes the atom as soon as nothing reads it.
209
+ if (atom.keepAlive || atom.idleTTL === 0) {
165
210
  return undefined;
166
211
  }
167
212
  return atom.idleTTL ?? defaultIdleTTL(registry);
@@ -251,6 +296,7 @@ const stateOf = (node) => {
251
296
  class Scope {
252
297
  #reads = new Map();
253
298
  #ids = new WeakMap();
299
+ #keyIds = new Map();
254
300
  #listeners = new Set();
255
301
  // The inspectors the scope follows while it has listeners, by registry.
256
302
  #following = new Map();
@@ -276,19 +322,33 @@ class Scope {
276
322
  },
277
323
  };
278
324
  #id(atom) {
279
- let id = this.#ids.get(atom);
325
+ const key = nodeKey(atom);
326
+ let id = typeof key === "string" ? this.#keyIds.get(key) : this.#ids.get(atom);
280
327
  if (id === undefined) {
281
328
  this.#nextId += 1;
282
329
  id = this.#nextId;
283
- this.#ids.set(atom, id);
330
+ if (typeof key === "string") {
331
+ this.#keyIds.set(key, id);
332
+ }
333
+ else {
334
+ this.#ids.set(atom, id);
335
+ }
284
336
  }
285
337
  return id;
286
338
  }
287
339
  /** Every node the scope's hooks use, and every node upstream of them. */
288
340
  #nodes() {
289
- if (this.#members !== undefined) {
290
- return this.#members;
341
+ // Without a listener the scope follows no inspector, so nothing would say the graph changed.
342
+ if (this.#listeners.size === 0) {
343
+ return this.#walk();
344
+ }
345
+ if (this.#members === undefined) {
346
+ this.#members = this.#walk();
291
347
  }
348
+ return this.#members;
349
+ }
350
+ /** Works out the scope's nodes from the graph as it is now, without keeping them. */
351
+ #walk() {
292
352
  const members = new Set();
293
353
  const add = (node) => {
294
354
  if (!members.has(node)) {
@@ -304,7 +364,6 @@ class Scope {
304
364
  add(node);
305
365
  }
306
366
  }
307
- this.#members = members;
308
367
  return members;
309
368
  }
310
369
  /** The nearest shown atoms upstream of `node`, looking through those not shown. */
@@ -362,6 +421,11 @@ class Scope {
362
421
  return { edges, nodes: shown, readers };
363
422
  }
364
423
  subscribe(listener) {
424
+ if (this.#listeners.size === 0) {
425
+ // Kept from before the scope last stopped following, and the graph may have changed since.
426
+ this.#members = undefined;
427
+ this.#previous = new Set();
428
+ }
365
429
  this.#listeners.add(listener);
366
430
  for (const read of this.#reads.values()) {
367
431
  this.#follow(read.registry);
@@ -384,7 +448,10 @@ class Scope {
384
448
  this.#following.set(registry, inspect(registry).subscribe((event) => this.#receive(event)));
385
449
  }
386
450
  #receive(event) {
387
- const inScope = this.#previous.has(event.node) || this.#nodes().has(event.node);
451
+ // Events arrive in the middle of the registry's work, while a computation is still changing
452
+ // the edges it reads through: what is worked out now isn't kept for the snapshot.
453
+ const inScope = this.#previous.has(event.node) ||
454
+ (this.#members ?? this.#walk()).has(event.node);
388
455
  if (event._tag === "NodeAdded" ||
389
456
  event._tag === "NodeRemoved" ||
390
457
  event._tag === "Built") {
@@ -99,6 +99,8 @@ export type ProvideRegistryOptions = ProvideExistingRegistry | ProvideNewRegistr
99
99
  *
100
100
  * A registry created here is owned by the component and disposed of with it, which on the server
101
101
  * means at the end of the request. A registry passed in is left for the caller to dispose of.
102
+ * In the browser, a finalizer that throws while the provider disposes of its registry is reported
103
+ * as an uncaught error after teardown, rather than thrown into Svelte's teardown.
102
104
  *
103
105
  * **Example** (Giving the app a registry from the root layout)
104
106
  *
@@ -5,9 +5,12 @@
5
5
  */
6
6
  import { AtomRegistry } from "effect/reactivity";
7
7
  import { BROWSER, DEV } from "esm-env";
8
- import { createContext, onDestroy } from "svelte";
8
+ import { createContext } from "svelte";
9
+ import { disposeRegistry } from "./internal/disposal.js";
9
10
  import { setRevalidateOnHydrate } from "./internal/hydration.js";
10
11
  import { track } from "./internal/registries.js";
12
+ import { onTeardownAfterChildren } from "./internal/teardown.svelte.js";
13
+ import { troubleshooting } from "./internal/troubleshooting.js";
11
14
  const [getContextRegistry, setContextRegistry, hasContextRegistry] = createContext();
12
15
  let browserRegistry;
13
16
  /**
@@ -35,7 +38,7 @@ export const getRegistry = () => {
35
38
  return getContextRegistry();
36
39
  }
37
40
  if (!BROWSER) {
38
- throw new Error("No AtomRegistry in context. Wrap the app in <RegistryProvider> so each server request gets its own registry.");
41
+ throw new Error(`No AtomRegistry in context. Wrap the app in <RegistryProvider> so each server request gets its own registry. ${troubleshooting("no-atomregistry-in-context")}`);
39
42
  }
40
43
  if (browserRegistry === undefined) {
41
44
  browserRegistry = AtomRegistry.make();
@@ -50,6 +53,8 @@ export const getRegistry = () => {
50
53
  *
51
54
  * A registry created here is owned by the component and disposed of with it, which on the server
52
55
  * means at the end of the request. A registry passed in is left for the caller to dispose of.
56
+ * In the browser, a finalizer that throws while the provider disposes of its registry is reported
57
+ * as an uncaught error after teardown, rather than thrown into Svelte's teardown.
53
58
  *
54
59
  * **Example** (Giving the app a registry from the root layout)
55
60
  *
@@ -77,19 +82,20 @@ export const provideRegistry = (options = {}) => {
77
82
  if (provided &&
78
83
  Object.values(registryOptions).some((value) => value !== undefined)) {
79
84
  // The types rule this out; a caller without them would otherwise lose the options silently.
80
- throw new Error("provideRegistry takes an existing registry or options for a new one, not both. Apply initialValues to the existing registry yourself.");
85
+ throw new Error(`provideRegistry takes an existing registry or options for a new one, not both. Apply initialValues to the existing registry yourself. ${troubleshooting("provideregistry-takes-an-existing-registry-or-options-for-a-new-one-not-both")}`);
81
86
  }
82
87
  const registry = provided ?? AtomRegistry.make(registryOptions);
83
88
  setContextRegistry(registry);
84
89
  if (revalidateOnHydrate !== undefined) {
85
90
  setRevalidateOnHydrate(revalidateOnHydrate);
86
91
  }
92
+ // Not onDestroy: in the browser it never runs for a component destroyed while its script awaits.
87
93
  if (!provided) {
88
- onDestroy(() => registry.dispose());
94
+ onTeardownAfterChildren(() => disposeRegistry(registry));
89
95
  }
90
96
  if (DEV && BROWSER) {
91
97
  // For developer tools to find (effect-atom-svelte/inspector).
92
- onDestroy(track(registry));
98
+ onTeardownAfterChildren(track(registry));
93
99
  }
94
100
  return registry;
95
101
  };
@@ -49,10 +49,11 @@ export interface MakeOptions {
49
49
  /**
50
50
  * Creates a scoped atom from a factory, which runs once per providing component.
51
51
  *
52
- * Each provider gets its own atom, so a factory that adds `Atom.serializable` with a fixed key
53
- * gives every copy the same key: once two providers are on a page, reading them with
54
- * `useAtomResult` or `useAtomSuspense` throws, as two different atoms share the serialization
55
- * key. Put the input in the key, or leave scoped atoms unserialized.
52
+ * Each provider gets its own atom, so a factory that makes a serializable atom gives every copy of
53
+ * one input the same key, and two such providers on a server-rendered page make the render throw:
54
+ * two different atoms share the serialization key. For a value from the server, return a family's
55
+ * atom instead, `ScopedAtom.make((id: string) => userAtom(id))`, so every provider of one input
56
+ * holds the same atom.
56
57
  *
57
58
  * **Example** (A counter per subtree, started from an input)
58
59
  *
@@ -13,10 +13,11 @@ export const TypeId = "~effect-atom-svelte/ScopedAtom";
13
13
  /**
14
14
  * Creates a scoped atom from a factory, which runs once per providing component.
15
15
  *
16
- * Each provider gets its own atom, so a factory that adds `Atom.serializable` with a fixed key
17
- * gives every copy the same key: once two providers are on a page, reading them with
18
- * `useAtomResult` or `useAtomSuspense` throws, as two different atoms share the serialization
19
- * key. Put the input in the key, or leave scoped atoms unserialized.
16
+ * Each provider gets its own atom, so a factory that makes a serializable atom gives every copy of
17
+ * one input the same key, and two such providers on a server-rendered page make the render throw:
18
+ * two different atoms share the serialization key. For a value from the server, return a family's
19
+ * atom instead, `ScopedAtom.make((id: string) => userAtom(id))`, so every provider of one input
20
+ * holds the same atom.
20
21
  *
21
22
  * **Example** (A counter per subtree, started from an input)
22
23
  *
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * The module does not import SvelteKit: the hooks take the fields of SvelteKit's input they read
6
6
  * and return the fields they set, so they type-check against `HandleClientError` and
7
- * `HandleServerError` once `App.Error` declares `tag?: string`.
7
+ * `HandleServerError` once `App.Error` declares `tag?: string`. In SvelteKit 2, whose `App.Error`
8
+ * requires a message, they are typed to return one when there is no `kind`, as they then always
9
+ * set one.
8
10
  *
9
11
  * @since 0.1.0
10
12
  */
@@ -38,6 +40,27 @@ export interface EffectErrorBody {
38
40
  readonly message?: string;
39
41
  readonly tag?: string;
40
42
  }
43
+ /**
44
+ * SvelteKit 2's `handleError` input: no `kind`, and always a message, which the hooks fall back on.
45
+ *
46
+ * @stability unstable
47
+ * @since 0.2.1
48
+ * @category models
49
+ */
50
+ export interface CaughtErrorWithoutKind extends CaughtError {
51
+ readonly kind?: undefined;
52
+ readonly message: string;
53
+ }
54
+ /**
55
+ * What the hooks return for SvelteKit 2's input: always a message, as its `App.Error` requires one.
56
+ *
57
+ * @stability unstable
58
+ * @since 0.2.1
59
+ * @category models
60
+ */
61
+ export interface EffectErrorBodyWithMessage extends EffectErrorBody {
62
+ readonly message: string;
63
+ }
41
64
  /**
42
65
  * A client `handleError` hook (`src/hooks.client.ts`) that keeps the message and `_tag` of errors
43
66
  * thrown by your code, so a boundary's `failed` snippet shows them instead of
@@ -56,7 +79,8 @@ export interface EffectErrorBody {
56
79
  * @since 0.1.0
57
80
  * @category hooks
58
81
  */
59
- export declare const handleClientError: (input: CaughtError) => EffectErrorBody | undefined;
82
+ export declare function handleClientError(input: CaughtErrorWithoutKind): EffectErrorBodyWithMessage;
83
+ export declare function handleClientError(input: CaughtError): EffectErrorBody | undefined;
60
84
  /**
61
85
  * A server `handleError` hook (`src/hooks.server.ts`) that keeps the `_tag` of errors thrown by
62
86
  * your code but not their message, which could expose details of the server to users: the
@@ -75,4 +99,5 @@ export declare const handleClientError: (input: CaughtError) => EffectErrorBody
75
99
  * @since 0.1.0
76
100
  * @category hooks
77
101
  */
78
- export declare const handleServerError: (input: CaughtError) => EffectErrorBody | undefined;
102
+ export declare function handleServerError(input: CaughtErrorWithoutKind): EffectErrorBodyWithMessage;
103
+ export declare function handleServerError(input: CaughtError): EffectErrorBody | undefined;