effect-atom-svelte 0.2.0 → 0.4.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.
Files changed (56) hide show
  1. package/README.md +5 -2
  2. package/dist/Form.d.ts +605 -0
  3. package/dist/Form.js +67 -0
  4. package/dist/Hooks.svelte.d.ts +70 -10
  5. package/dist/Hooks.svelte.js +489 -163
  6. package/dist/HydrationBoundary.svelte +134 -17
  7. package/dist/HydrationBoundary.svelte.d.ts +13 -4
  8. package/dist/Inspector.d.ts +7 -3
  9. package/dist/Inspector.js +113 -29
  10. package/dist/RegistryContext.d.ts +2 -0
  11. package/dist/RegistryContext.js +13 -5
  12. package/dist/ScopedAtom.d.ts +5 -4
  13. package/dist/ScopedAtom.js +20 -11
  14. package/dist/SvelteKit.d.ts +71 -12
  15. package/dist/SvelteKit.js +133 -54
  16. package/dist/index.d.ts +10 -1
  17. package/dist/index.js +10 -1
  18. package/dist/internal/awaitWrite.d.ts +11 -0
  19. package/dist/internal/awaitWrite.js +27 -0
  20. package/dist/internal/boundaries.d.ts +9 -0
  21. package/dist/internal/boundaries.js +10 -0
  22. package/dist/internal/disposal.d.ts +13 -0
  23. package/dist/internal/disposal.js +43 -0
  24. package/dist/internal/form/array.d.ts +21 -0
  25. package/dist/internal/form/array.js +57 -0
  26. package/dist/internal/form/constraints.d.ts +13 -0
  27. package/dist/internal/form/constraints.js +166 -0
  28. package/dist/internal/form/engine.d.ts +56 -0
  29. package/dist/internal/form/engine.js +842 -0
  30. package/dist/internal/form/engineOf.d.ts +26 -0
  31. package/dist/internal/form/engineOf.js +43 -0
  32. package/dist/internal/form/field.d.ts +30 -0
  33. package/dist/internal/form/field.js +68 -0
  34. package/dist/internal/form/group.d.ts +13 -0
  35. package/dist/internal/form/group.js +20 -0
  36. package/dist/internal/form/instance.d.ts +41 -0
  37. package/dist/internal/form/instance.js +135 -0
  38. package/dist/internal/form/issues.d.ts +23 -0
  39. package/dist/internal/form/issues.js +144 -0
  40. package/dist/internal/form/paths.d.ts +47 -0
  41. package/dist/internal/form/paths.js +175 -0
  42. package/dist/internal/form/types.d.ts +80 -0
  43. package/dist/internal/form/types.js +1 -0
  44. package/dist/internal/form/validationError.d.ts +13 -0
  45. package/dist/internal/form/validationError.js +6 -0
  46. package/dist/internal/hydration.d.ts +11 -2
  47. package/dist/internal/hydration.js +26 -4
  48. package/dist/internal/nodeInternals.d.ts +6 -0
  49. package/dist/internal/nodeInternals.js +64 -7
  50. package/dist/internal/renderEnd.d.ts +13 -0
  51. package/dist/internal/renderEnd.js +34 -0
  52. package/dist/internal/teardown.svelte.d.ts +17 -0
  53. package/dist/internal/teardown.svelte.js +44 -0
  54. package/dist/internal/troubleshooting.d.ts +2 -0
  55. package/dist/internal/troubleshooting.js +3 -0
  56. package/package.json +12 -10
@@ -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 {
@@ -42,21 +56,99 @@
42
56
  const { children, state }: Props = $props();
43
57
  const registry = getRegistry();
44
58
 
45
- // SAFETY: preloadedSerializable is on the registry implementation, not the interface (Effect
46
- // 4.0.0). It holds the encoded values Hydration.hydrate queued, until a lookup of the key takes one.
47
- const { preloadedSerializable: preloaded } = registry as unknown as {
48
- readonly preloadedSerializable?: Map<string, unknown>;
49
- };
59
+ // The encoded values Hydration.hydrate queued, until a lookup of the key takes one. Public since
60
+ // Effect 4.0.2; typed as possibly missing for a registry that isn't Effect's own.
61
+ const preloaded: Map<string, unknown> | undefined =
62
+ registry.preloadedSerializable;
50
63
  // The values this boundary queued, by key, so it can drop the ones nobody took.
51
64
  const queued = new Map<string, unknown>();
65
+ // Whether the boundary has ended, as at the end of the server render or once the browser destroys
66
+ // it, after which a late promise-encoded value is dropped.
67
+ let ended = false;
68
+
69
+ const counts =
70
+ queuedBy.get(registry) ?? new Map<string, Map<unknown, number>>();
71
+ queuedBy.set(registry, counts);
72
+ /** Adds one to or takes one from the boundaries queueing a value, and returns the new count. */
73
+ const count = (key: string, value: unknown, delta: 1 | -1): number => {
74
+ const byValue = counts.get(key) ?? new Map<unknown, number>();
75
+ counts.set(key, byValue);
76
+ const next = (byValue.get(value) ?? 0) + delta;
77
+ if (next > 0) {
78
+ byValue.set(value, next);
79
+ } else {
80
+ byValue.delete(value);
81
+ if (byValue.size === 0) {
82
+ counts.delete(key);
83
+ }
84
+ }
85
+ return next;
86
+ };
87
+
88
+ // SAFETY: Hydration.hydrate ignores a promise-encoded value that resolves to this marker, which
89
+ // Hydration.ts registers with Symbol.for (Effect 4.0.1) but doesn't export.
90
+ const skipped = Symbol.for("effect/reactivity/Hydration/Skipped");
91
+ // The keys of the promise-encoded values the boundary is waiting for.
92
+ const waiting = new Set<string>();
93
+ // In the browser, the node that held each of those keys when the boundary ended. That node, as a
94
+ // layout's reader above the boundary that took its waiting value, still needs the result, or it
95
+ // waits forever. A node a later reader creates doesn't get it, nor does one that replaced a node
96
+ // swept since, as a reader inside the boundary's is just after it ends.
97
+ const nodesAtEnd = new Map<string, AtomRegistry.Node<unknown>>();
98
+ /** A promise-encoded value as the boundary waits for it; one landing after the boundary ends is skipped. */
99
+ const lateDropped = async (
100
+ key: string,
101
+ late: Promise<unknown>
102
+ ): Promise<unknown> => {
103
+ waiting.add(key);
104
+ const value = await late;
105
+ if (!ended) {
106
+ return value;
107
+ }
108
+ const held = BROWSER ? nodesAtEnd.get(key) : undefined;
109
+ return held !== undefined && registry.getNodes().get(key) === held
110
+ ? value
111
+ : skipped;
112
+ };
52
113
 
53
114
  const queue = (atoms: readonly Hydration.DehydratedAtomValue[]): void => {
54
- Hydration.hydrate(registry, atoms);
115
+ // A promise-encoded value landing after the boundary has ended, after the server render or once
116
+ // the browser has navigated away, would stay queued in a registry that outlives it for a later
117
+ // reader, however late: it is ignored once the boundary ends, unless, in the browser, the node
118
+ // that held its key then still does.
119
+ Hydration.hydrate(
120
+ registry,
121
+ atoms.map((atom) =>
122
+ atom.resultPromise === undefined
123
+ ? atom
124
+ : {
125
+ ...atom,
126
+ resultPromise: lateDropped(atom.key, atom.resultPromise),
127
+ }
128
+ )
129
+ );
55
130
  for (const { key, value } of atoms) {
131
+ if (queued.has(key)) {
132
+ if (queued.get(key) === value) {
133
+ continue;
134
+ }
135
+ count(key, queued.get(key), -1);
136
+ }
56
137
  queued.set(key, value);
138
+ count(key, value, 1);
57
139
  }
58
140
  };
59
141
 
142
+ /**
143
+ * Whether a node's value may be on the page already. A node holding only an initial value nobody
144
+ * has read, as RegistryProvider's initialValues leave it, shows nowhere yet: it takes the incoming
145
+ * value before children render, as on the server, so the browser's first render matches the
146
+ * server's markup.
147
+ */
148
+ const shown = (node: AtomRegistry.Node<unknown> | undefined): boolean =>
149
+ node !== undefined &&
150
+ !(holdsInitialValue(node) && node.currentState() === "stale");
151
+
60
152
  const hydrateNew = (
61
153
  incoming: Iterable<Hydration.DehydratedAtom> | undefined
62
154
  ): Hydration.DehydratedAtomValue[] => {
@@ -64,7 +156,7 @@
64
156
  const fresh: Hydration.DehydratedAtomValue[] = [];
65
157
  const existing: Hydration.DehydratedAtomValue[] = [];
66
158
  for (const atom of Hydration.toValues([...(incoming ?? [])])) {
67
- (nodes.has(atom.key) ? existing : fresh).push(atom);
159
+ (shown(nodes.get(atom.key)) ? existing : fresh).push(atom);
68
160
  }
69
161
  queue(fresh);
70
162
  return existing;
@@ -91,8 +183,18 @@
91
183
  // svelte-ignore state_referenced_locally
92
184
  let deferred = hydrateNew(state);
93
185
  // 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) {
186
+ // exist are updated now, before the children read them. The browser does the same while it
187
+ // hydrates the server's markup, so its first render matches the server's. The server writes true;
188
+ // the browser reads it only while hydrating, and false otherwise. hydratable throws in development
189
+ // for a key the server didn't write, as for a boundary the server didn't render: that is false too.
190
+ const hydratingServerMarkup = (() => {
191
+ try {
192
+ return hydratable("effect-atom-svelte/HydrationBoundary", () => !BROWSER);
193
+ } catch {
194
+ return false;
195
+ }
196
+ })();
197
+ if (!BROWSER || hydratingServerMarkup) {
96
198
  hydrateExisting(deferred);
97
199
  deferred = [];
98
200
  }
@@ -111,13 +213,28 @@
111
213
 
112
214
  // A queued value stays in the registry until its key is looked up. With a registry that outlives
113
215
  // 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(() => {
216
+ // request. Drop the ones still waiting, unless something queued another value since or another
217
+ // live boundary queued the same one. On the server they're dropped when the render ends, even if
218
+ // a failed boundary discarded this one; in the browser, also when it is destroyed while pending.
219
+ (BROWSER ? onTeardownAfterChildren : onRenderEnd)(() => {
220
+ if (ended) {
221
+ return;
222
+ }
223
+ ended = true;
224
+ if (BROWSER) {
225
+ const nodes = registry.getNodes();
226
+ for (const key of waiting) {
227
+ const node = nodes.get(key);
228
+ if (node) {
229
+ nodesAtEnd.set(key, node);
230
+ }
231
+ }
232
+ }
116
233
  if (!preloaded) {
117
234
  return;
118
235
  }
119
236
  for (const [key, value] of queued) {
120
- if (preloaded.get(key) === value) {
237
+ if (count(key, value, -1) <= 0 && preloaded.get(key) === value) {
121
238
  preloaded.delete(key);
122
239
  }
123
240
  }
@@ -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
+ const ownWait = AsyncResult.isAsyncResult(value) &&
90
+ value.waiting &&
91
+ (origins.get(value) ?? node) === node;
92
+ if (ownWait && !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";
@@ -115,8 +145,11 @@ const install = (registry) => {
115
145
  });
116
146
  complete &&= instrumented;
117
147
  };
118
- const patchable = registry;
119
- const { modify, onNodeAdded, onNodeRemoved, refresh, set, update } = patchable;
148
+ const { onNodeAdded, onNodeRemoved } = registry;
149
+ const modify = registry.modify.bind(registry);
150
+ const refresh = registry.refresh.bind(registry);
151
+ const set = registry.set.bind(registry);
152
+ const update = registry.update.bind(registry);
120
153
  const writes = (f) => {
121
154
  writing += 1;
122
155
  try {
@@ -126,25 +159,51 @@ const install = (registry) => {
126
159
  writing -= 1;
127
160
  }
128
161
  };
129
- patchable.set = (atom, value) => writes(() => set.call(registry, atom, value));
130
- patchable.update = (atom, f) => writes(() => update.call(registry, atom, f));
131
- patchable.modify = (atom, f) => writes(() => modify.call(registry, atom, f));
132
- // An atom with its own refresh (a wrapper such as withRefresh) calls the registry's refresh for
133
- // the atoms it wraps, through this replacement, so each of those is marked too.
134
- patchable.refresh = (atom) => {
135
- const node = registry.getNodes().get(nodeKey(atom));
136
- if (node !== undefined) {
137
- record(node).refreshed = true;
138
- }
139
- refresh.call(registry, atom);
162
+ const patched = {
163
+ modify: (atom, f) => writes(() => modify(atom, f)),
164
+ // An atom with its own refresh (a wrapper such as withRefresh) calls the registry's refresh for
165
+ // the atoms it wraps, through this replacement, so each of those is marked too.
166
+ refresh: (atom) => {
167
+ const node = registry.getNodes().get(nodeKey(atom));
168
+ if (node !== undefined) {
169
+ record(node).refreshed = true;
170
+ }
171
+ try {
172
+ refresh(atom);
173
+ }
174
+ finally {
175
+ // Still valid: the refresh didn't reach it (what it wraps computed an equal value), or it
176
+ // computed already. Either way the mark isn't the cause of a later computation.
177
+ if (node?.currentState() === "valid") {
178
+ record(node).refreshed = false;
179
+ }
180
+ }
181
+ },
182
+ set: (atom, value) => {
183
+ writes(() => {
184
+ set(atom, value);
185
+ });
186
+ },
187
+ update: (atom, f) => {
188
+ writes(() => {
189
+ update(atom, f);
190
+ });
191
+ },
140
192
  };
141
- patchable.onNodeAdded = (node) => {
193
+ // Readonly in the interface: replaced on this registry only, as its nodes are.
194
+ Object.assign(registry, patched);
195
+ registry.onNodeAdded = (node) => {
142
196
  onNodeAdded?.(node);
143
197
  instrument(node);
144
198
  emit({ _tag: "NodeAdded", node, time: performance.now() });
145
199
  };
146
- patchable.onNodeRemoved = (node) => {
200
+ registry.onNodeRemoved = (node) => {
147
201
  onNodeRemoved?.(node);
202
+ // An idle node is announced before it is removed; its teardown is reported first.
203
+ if (node.currentState() !== "removed" && records.has(node)) {
204
+ record(node).removing = true;
205
+ return;
206
+ }
148
207
  emit({ _tag: "NodeRemoved", node, time: performance.now() });
149
208
  };
150
209
  for (const node of registry.getNodes().values()) {
@@ -152,7 +211,7 @@ const install = (registry) => {
152
211
  // A node that has computed before isn't on its first read or value any more.
153
212
  if (node.currentState() !== "uninitialized") {
154
213
  const self = record(node);
155
- self.builds = 1;
214
+ self.builds = holdsInitialValue(node) ? 0 : 1;
156
215
  self.values = 1;
157
216
  }
158
217
  }
@@ -161,8 +220,9 @@ const install = (registry) => {
161
220
  return complete;
162
221
  },
163
222
  idleTTL: (atom) => {
164
- if (atom.keepAlive) {
165
- return undefined;
223
+ // An idle TTL of 0 is none: the registry removes the atom as soon as nothing reads it.
224
+ if (atom.keepAlive || atom.idleTTL === 0) {
225
+ return;
166
226
  }
167
227
  return atom.idleTTL ?? defaultIdleTTL(registry);
168
228
  },
@@ -239,7 +299,9 @@ const emptySnapshot = { edges: [], nodes: [], readers: [] };
239
299
  /** The scope on the server: always empty. */
240
300
  const inertScope = {
241
301
  snapshot: () => emptySnapshot,
242
- subscribe: () => () => undefined,
302
+ subscribe: () => () => {
303
+ // Nothing to unsubscribe from.
304
+ },
243
305
  };
244
306
  const stateOf = (node) => {
245
307
  // A valid node's value is read without computing anything.
@@ -251,6 +313,7 @@ const stateOf = (node) => {
251
313
  class Scope {
252
314
  #reads = new Map();
253
315
  #ids = new WeakMap();
316
+ #keyIds = new Map();
254
317
  #listeners = new Set();
255
318
  // The inspectors the scope follows while it has listeners, by registry.
256
319
  #following = new Map();
@@ -276,19 +339,31 @@ class Scope {
276
339
  },
277
340
  };
278
341
  #id(atom) {
279
- let id = this.#ids.get(atom);
342
+ const key = nodeKey(atom);
343
+ let id = typeof key === "string" ? this.#keyIds.get(key) : this.#ids.get(atom);
280
344
  if (id === undefined) {
281
345
  this.#nextId += 1;
282
346
  id = this.#nextId;
283
- this.#ids.set(atom, id);
347
+ if (typeof key === "string") {
348
+ this.#keyIds.set(key, id);
349
+ }
350
+ else {
351
+ this.#ids.set(atom, id);
352
+ }
284
353
  }
285
354
  return id;
286
355
  }
287
356
  /** Every node the scope's hooks use, and every node upstream of them. */
288
357
  #nodes() {
289
- if (this.#members !== undefined) {
290
- return this.#members;
358
+ // Without a listener the scope follows no inspector, so nothing would say the graph changed.
359
+ if (this.#listeners.size === 0) {
360
+ return this.#walk();
291
361
  }
362
+ this.#members ??= this.#walk();
363
+ return this.#members;
364
+ }
365
+ /** Works out the scope's nodes from the graph as it is now, without keeping them. */
366
+ #walk() {
292
367
  const members = new Set();
293
368
  const add = (node) => {
294
369
  if (!members.has(node)) {
@@ -304,7 +379,6 @@ class Scope {
304
379
  add(node);
305
380
  }
306
381
  }
307
- this.#members = members;
308
382
  return members;
309
383
  }
310
384
  /** The nearest shown atoms upstream of `node`, looking through those not shown. */
@@ -362,6 +436,11 @@ class Scope {
362
436
  return { edges, nodes: shown, readers };
363
437
  }
364
438
  subscribe(listener) {
439
+ if (this.#listeners.size === 0) {
440
+ // Kept from before the scope last stopped following, and the graph may have changed since.
441
+ this.#members = undefined;
442
+ this.#previous = new Set();
443
+ }
365
444
  this.#listeners.add(listener);
366
445
  for (const read of this.#reads.values()) {
367
446
  this.#follow(read.registry);
@@ -381,10 +460,15 @@ class Scope {
381
460
  if (this.#listeners.size === 0 || this.#following.has(registry)) {
382
461
  return;
383
462
  }
384
- this.#following.set(registry, inspect(registry).subscribe((event) => this.#receive(event)));
463
+ this.#following.set(registry, inspect(registry).subscribe((event) => {
464
+ this.#receive(event);
465
+ }));
385
466
  }
386
467
  #receive(event) {
387
- const inScope = this.#previous.has(event.node) || this.#nodes().has(event.node);
468
+ // Events arrive in the middle of the registry's work, while a computation is still changing
469
+ // the edges it reads through: what is worked out now isn't kept for the snapshot.
470
+ const inScope = this.#previous.has(event.node) ||
471
+ (this.#members ?? this.#walk()).has(event.node);
388
472
  if (event._tag === "NodeAdded" ||
389
473
  event._tag === "NodeRemoved" ||
390
474
  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,22 @@ 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(() => {
95
+ disposeRegistry(registry);
96
+ });
89
97
  }
90
98
  if (DEV && BROWSER) {
91
99
  // For developer tools to find (effect-atom-svelte/inspector).
92
- onDestroy(track(registry));
100
+ onTeardownAfterChildren(track(registry));
93
101
  }
94
102
  return registry;
95
103
  };
@@ -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
  *