effect-atom-svelte 0.3.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 (41) hide show
  1. package/README.md +4 -1
  2. package/dist/Form.d.ts +605 -0
  3. package/dist/Form.js +67 -0
  4. package/dist/Hooks.svelte.d.ts +54 -0
  5. package/dist/Hooks.svelte.js +98 -64
  6. package/dist/HydrationBoundary.svelte +4 -5
  7. package/dist/Inspector.js +49 -32
  8. package/dist/RegistryContext.js +3 -1
  9. package/dist/ScopedAtom.js +15 -7
  10. package/dist/SvelteKit.d.ts +46 -12
  11. package/dist/SvelteKit.js +130 -18
  12. package/dist/index.d.ts +9 -0
  13. package/dist/index.js +9 -0
  14. package/dist/internal/awaitWrite.d.ts +11 -0
  15. package/dist/internal/awaitWrite.js +27 -0
  16. package/dist/internal/boundaries.js +3 -1
  17. package/dist/internal/form/array.d.ts +21 -0
  18. package/dist/internal/form/array.js +57 -0
  19. package/dist/internal/form/constraints.d.ts +13 -0
  20. package/dist/internal/form/constraints.js +166 -0
  21. package/dist/internal/form/engine.d.ts +56 -0
  22. package/dist/internal/form/engine.js +842 -0
  23. package/dist/internal/form/engineOf.d.ts +26 -0
  24. package/dist/internal/form/engineOf.js +43 -0
  25. package/dist/internal/form/field.d.ts +30 -0
  26. package/dist/internal/form/field.js +68 -0
  27. package/dist/internal/form/group.d.ts +13 -0
  28. package/dist/internal/form/group.js +20 -0
  29. package/dist/internal/form/instance.d.ts +41 -0
  30. package/dist/internal/form/instance.js +135 -0
  31. package/dist/internal/form/issues.d.ts +23 -0
  32. package/dist/internal/form/issues.js +144 -0
  33. package/dist/internal/form/paths.d.ts +47 -0
  34. package/dist/internal/form/paths.js +175 -0
  35. package/dist/internal/form/types.d.ts +80 -0
  36. package/dist/internal/form/types.js +1 -0
  37. package/dist/internal/form/validationError.d.ts +13 -0
  38. package/dist/internal/form/validationError.js +6 -0
  39. package/dist/internal/hydration.js +3 -1
  40. package/dist/internal/nodeInternals.js +44 -11
  41. package/package.json +12 -10
@@ -4,8 +4,10 @@
4
4
  * @since 0.1.0
5
5
  */
6
6
  import { Exit } from "effect";
7
+ import type { Schema } from "effect";
7
8
  import { AsyncResult, Atom } from "effect/reactivity";
8
9
  import type { AtomRef } from "effect/reactivity";
10
+ import type * as Form from "./Form.ts";
9
11
  /**
10
12
  * An atom, or a getter so the hook follows the atom when reactive state picks a different one.
11
13
  *
@@ -400,3 +402,55 @@ export declare function useAtomSuspense<A, E>(input: AtomInput<Atom.Atom<AsyncRe
400
402
  export declare function useAtomSuspense<A, E>(input: AtomInput<Atom.Atom<AsyncResult.AsyncResult<A, E>>>, options?: SuspenseOptions & {
401
403
  readonly includeFailure?: boolean | undefined;
402
404
  }): AtomValue<Promise<A | AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>;
405
+ /**
406
+ * Creates an instance of a form from `Form.make` in a component. Its state lives in the
407
+ * component's registry and is released when the component is destroyed; a submit in flight keeps
408
+ * it, and keeps its atom running, until the submit settles, so a save finishes after a dialog
409
+ * closes. Share an instance by passing it down, or create it in the parent whose lifetime it
410
+ * should have, such as a wizard around its steps.
411
+ *
412
+ * Pass `id` from `$props.id()`: field ids are built from it, and must match between the server and
413
+ * the browser. On the server the form renders from `initialValues` and doesn't validate, so there
414
+ * is nothing to hydrate. A getter for `initialValues` is followed: when it changes, a clean form
415
+ * takes the new values and a dirty one keeps its edits.
416
+ *
417
+ * **Example** (A login form)
418
+ *
419
+ * ```svelte
420
+ * <script lang="ts">
421
+ * import { useForm } from "effect-atom-svelte";
422
+ * import { Login, loginAtom } from "./forms.ts";
423
+ *
424
+ * const id = $props.id();
425
+ * const form = useForm(Login, {
426
+ * id,
427
+ * initialValues: { email: "", password: "" },
428
+ * submit: { atom: loginAtom },
429
+ * mapError: (error) =>
430
+ * error._tag === "WrongPassword"
431
+ * ? { form: "Wrong email or password" }
432
+ * : undefined,
433
+ * });
434
+ * const email = form.field("email");
435
+ * </script>
436
+ *
437
+ * <form {...form.attrs}>
438
+ * <label for={email.id}>Email</label>
439
+ * <input {...email.attrs} bind:value={email.value} />
440
+ * {#if email.error}
441
+ * <p id={email.errorId}>{email.error}</p>
442
+ * {/if}
443
+ * {#if form.rootError}
444
+ * <p role="alert">{form.rootError}</p>
445
+ * {/if}
446
+ * <button disabled={form.submitting}>Log in</button>
447
+ * </form>
448
+ * ```
449
+ *
450
+ * @see {@link https://atom.jarrednorris.dev/forms | Forms}
451
+ * @stability unstable
452
+ * @since 0.4.0
453
+ * @category hooks
454
+ */
455
+ export declare function useForm<F extends Form.Form<Schema.Top>, A, E, W, Args = undefined>(form: F, options: Form.InstanceOptions<Form.Encoded<F>, Form.Decoded<F>, A, E, W, Args>): Form.Instance<Form.Encoded<F>, Form.Decoded<F>, A, E, Args>;
456
+ export declare function useForm<F extends Form.Form<Schema.Top>>(form: F, options: Form.ValidateOnlyOptions<Form.Encoded<F>>): Form.Instance<Form.Encoded<F>, Form.Decoded<F>, Form.Decoded<F>, never, void>;
@@ -8,17 +8,18 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
8
8
  import { BROWSER, DEV } from "esm-env";
9
9
  import { getAbortSignal, hydratable, untrack } from "svelte";
10
10
  import { createSubscriber } from "svelte/reactivity";
11
+ import { awaitWrite } from "./internal/awaitWrite.js";
11
12
  import { queuedByBoundary } from "./internal/boundaries.js";
12
13
  import { onDispose } from "./internal/disposal.js";
14
+ import { engineOf, initialGetter } from "./internal/form/engineOf.js";
13
15
  import { decodeSeed, encodeSeed, isWaiting, noSeed, revalidatesOnHydrate, } from "./internal/hydration.js";
14
16
  import { onRenderEnd } from "./internal/renderEnd.js";
15
17
  import { reportReads } from "./internal/scope.svelte.js";
16
18
  import { onTeardown } from "./internal/teardown.svelte.js";
17
19
  import { troubleshooting } from "./internal/troubleshooting.js";
18
20
  import { getRegistry } from "./RegistryContext.js";
19
- const toGetter = (input) => typeof input === "function" && !Atom.isAtom(input)
20
- ? input
21
- : () => input;
21
+ const isGetter = (input) => typeof input === "function" && !Atom.isAtom(input);
22
+ const toGetter = (input) => isGetter(input) ? input : () => input;
22
23
  // The registry notifies subscribers synchronously while it computes an atom, and Svelte throws
23
24
  // state_unsafe_mutation if state changes while a template or $derived is evaluating. Reads are
24
25
  // counted, and a notification raised during one is delivered on a microtask instead. Outside a
@@ -33,7 +34,9 @@ const afterReads = (update) => {
33
34
  update();
34
35
  }
35
36
  };
36
- const notifyAfterReads = (update) => () => afterReads(update);
37
+ const notifyAfterReads = (update) => () => {
38
+ afterReads(update);
39
+ };
37
40
  const duringRead = (f) => {
38
41
  activeReads += 1;
39
42
  try {
@@ -51,6 +54,7 @@ class AtomCell {
51
54
  this.#read = read;
52
55
  this.#write = write;
53
56
  }
57
+ // oxlint-disable-next-line typescript/related-getter-setter-pairs -- implements AtomState, whose read and write types differ
54
58
  get current() {
55
59
  return this.#read();
56
60
  }
@@ -77,11 +81,13 @@ const notStarted = (result) => result._tag === "Initial" && !result.waiting;
77
81
  * computes the atom, as its real read is often browser-only.
78
82
  */
79
83
  const hasServerValue = (atom) => Atom.ServerValueTypeId in atom;
80
- // SAFETY: ensureNode and scheduleNodeRemoval are on the registry implementation, not the interface
81
- // (Effect 4.0.0); @effect/atom-react uses ensureNode the same way. Of the node's parts,
84
+ /** The atom's node, created if the registry has none yet. */
85
+ const nodeOf = (registry, atom) =>
86
+ // SAFETY: ensureNode returns the implementation's node (Effect 4.0.5), which has
82
87
  // 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.
84
- const internals = (registry) => registry;
88
+ // beside the Node interface; @effect/atom-react calls its setValue the same way.
89
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- those members are on the implementation only, which Effect doesn't export
90
+ registry.ensureNode(atom);
85
91
  /** The atom that holds a value given to `atom`: a wrapper such as `withRefresh` passes it on. */
86
92
  const initialValueTarget = (atom) => {
87
93
  let target = atom;
@@ -97,7 +103,9 @@ const initialValueTarget = (atom) => {
97
103
  * set, so holds sharing one would all end with the first to let go.
98
104
  */
99
105
  const holdNode = (registry, node) => {
100
- const unsubscribe = node.subscribe(() => undefined);
106
+ const unsubscribe = node.subscribe(() => {
107
+ // Only there to hold the node.
108
+ });
101
109
  return () => {
102
110
  unsubscribe();
103
111
  if (node.canBeRemoved) {
@@ -119,7 +127,7 @@ const holdNode = (registry, node) => {
119
127
  const dropInitialValueMark = (registry, atom) => {
120
128
  let current = atom;
121
129
  while (current) {
122
- const node = internals(registry).ensureNode(current);
130
+ const node = nodeOf(registry, current);
123
131
  if (node.currentState() === "valid") {
124
132
  node.preserveInitialValueOnBuild = false;
125
133
  }
@@ -141,7 +149,7 @@ const initialNodeOnServer = (registry, atom) => {
141
149
  if (BROWSER) {
142
150
  return undefined;
143
151
  }
144
- const node = internals(registry).ensureNode(initialValueTarget(atom));
152
+ const node = nodeOf(registry, initialValueTarget(atom));
145
153
  return node.preserveInitialValueOnBuild ? node : undefined;
146
154
  };
147
155
  /** Whether, on the server, an atom renders a value from `useAtomInitialValues`. */
@@ -149,7 +157,7 @@ const initialOnServer = (registry, atom) => initialNodeOnServer(registry, atom)
149
157
  /** Keeps an atom for the server render: a node with an initial value is held, not mounted. */
150
158
  const serverMount = (registry, atom) => {
151
159
  const node = initialNodeOnServer(registry, atom);
152
- return node ? holdNode(internals(registry), node) : registry.mount(atom);
160
+ return node ? holdNode(registry, node) : registry.mount(atom);
153
161
  };
154
162
  /** Reads an atom without building it on the server while it renders an initial value. */
155
163
  const serverGet = (registry, atom) => {
@@ -183,31 +191,6 @@ const awaitSettled = (registry, atom, options, signal) => {
183
191
  return Effect.sync(cancel);
184
192
  }), { signal });
185
193
  };
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
194
  // On the server, per registry, how many renders' seeds carry each key, until those renders end.
212
195
  const seededKeys = new WeakMap();
213
196
  /** Whether a render using the registry sends the key's result to the browser with a seed. */
@@ -419,7 +402,7 @@ const subscribedReader = (registry, getAtom, plainRead) => {
419
402
  // After mount, only a first build is dropped: a stale node rebuilt here, as after a failed
420
403
  // rebuild, announces a change other reads of this reader have not seen. A node whose first
421
404
  // build failed is still uninitialized, but the page has shown that failure, so it counts too.
422
- const node = internals(registry).ensureNode(current);
405
+ const node = nodeOf(registry, current);
423
406
  if (node.currentState() === "uninitialized" && !node.failed) {
424
407
  reading = current;
425
408
  }
@@ -461,7 +444,9 @@ export function useAtomValue(input, f) {
461
444
  export const useAtom = (input) => {
462
445
  const registry = getRegistry();
463
446
  const getAtom = toGetter(input);
464
- return new AtomCell(subscribedReader(registry, getAtom, "useAtom"), (value) => registry.set(getAtom(), value));
447
+ return new AtomCell(subscribedReader(registry, getAtom, "useAtom"), (value) => {
448
+ registry.set(getAtom(), value);
449
+ });
465
450
  };
466
451
  /** useAtomMount, telling an inspector scope why the atom is held: `useAtomSet` holds it to write. */
467
452
  const mountWhileAlive = (input, kind) => {
@@ -505,9 +490,14 @@ export function useAtomSet(input, options) {
505
490
  if (mode === "value") {
506
491
  return (value) => {
507
492
  const atom = getAtom();
508
- registry.set(atom, typeof value === "function"
509
- ? value(registry.get(atom))
510
- : value);
493
+ if (typeof value !== "function") {
494
+ registry.set(atom, value);
495
+ return;
496
+ }
497
+ // SAFETY: the overloads type a function value as an update from the current value.
498
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- a typeof check can't recover the function's signature
499
+ const updateFrom = value;
500
+ registry.set(atom, updateFrom(registry.get(atom)));
511
501
  };
512
502
  }
513
503
  return async (value, writeOptions) => {
@@ -516,21 +506,26 @@ export function useAtomSet(input, options) {
516
506
  // never end.
517
507
  throw new TypeError(`useAtomSet's ${mode} mode cannot wait for Atom.Reset; reset with a value mode setter`);
518
508
  }
509
+ // SAFETY: the overloads offer the promise modes only for an atom that holds an AsyncResult.
510
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the implementation signature can't carry the overloads' atom type
519
511
  const atom = getAtom();
520
512
  // An already aborted signal settles as interrupted without writing, as fetch does (JND-25). It
521
513
  // skips the wait too: Effect checks the signal only after a first run, which would return a
522
514
  // result the atom already holds from an earlier call.
523
515
  let exit;
524
- if (writeOptions?.signal?.aborted) {
516
+ if (writeOptions?.signal?.aborted === true) {
525
517
  exit = Exit.interrupt();
526
518
  }
527
519
  else {
520
+ // oxlint-disable-next-line effecttsgo/global-date -- compared with an AsyncResult's timestamp, which Effect takes from Date.now
528
521
  const start = Date.now();
529
522
  registry.set(atom, value);
530
523
  // A provider disposing of its registry ends the call without a last result, so the wait ends
531
524
  // there too, as interrupted.
532
525
  const controller = new AbortController();
533
- const abort = () => controller.abort();
526
+ const abort = () => {
527
+ controller.abort();
528
+ };
534
529
  writeOptions?.signal?.addEventListener("abort", abort, { once: true });
535
530
  const cancel = onDispose(registry, abort);
536
531
  try {
@@ -565,7 +560,9 @@ export const useAtomRefresh = (input) => {
565
560
  const registry = getRegistry();
566
561
  const getAtom = toGetter(input);
567
562
  useAtomMount(getAtom);
568
- return () => registry.refresh(getAtom());
563
+ return () => {
564
+ registry.refresh(getAtom());
565
+ };
569
566
  };
570
567
  /**
571
568
  * Delivers each value to `f` as afterReads would, but in order and only while subscribed. Once a
@@ -575,12 +572,13 @@ export const useAtomRefresh = (input) => {
575
572
  const orderedDelivery = (f) => {
576
573
  let live = true;
577
574
  let flushing = false;
575
+ // Boxed, so an empty queue is told apart from a queued undefined.
578
576
  const queue = [];
579
577
  // Stopping empties the queue, which ends a flush in progress.
580
578
  const flush = () => {
581
579
  try {
582
- while (queue.length > 0) {
583
- f(queue.shift());
580
+ for (let next = queue.shift(); next; next = queue.shift()) {
581
+ f(next.value);
584
582
  }
585
583
  }
586
584
  finally {
@@ -596,7 +594,7 @@ const orderedDelivery = (f) => {
596
594
  return;
597
595
  }
598
596
  if (flushing || activeReads > 0) {
599
- queue.push(value);
597
+ queue.push({ value });
600
598
  if (!flushing) {
601
599
  flushing = true;
602
600
  queueMicrotask(flush);
@@ -668,7 +666,7 @@ export const useAtomSubscribe = (input, f, options) => {
668
666
  * atom's next read; this sets it on the node now, which is valid, so nothing reads it again.
669
667
  */
670
668
  const setNodeValue = (registry, atom, value) => {
671
- internals(registry).ensureNode(atom).setValue(value);
669
+ nodeOf(registry, atom).setValue(value);
672
670
  dropInitialValueMark(registry, atom);
673
671
  };
674
672
  // The nodes given a value by useAtomInitialValues. A node belongs to one registry, and a node the
@@ -711,7 +709,7 @@ const serverHolders = new WeakMap();
711
709
  * @category hooks
712
710
  */
713
711
  export const useAtomInitialValues = (initialValues) => {
714
- const registry = internals(getRegistry());
712
+ const registry = getRegistry();
715
713
  const releases = [];
716
714
  // Registered before the loop: an entry that throws midway leaves the earlier ones held otherwise.
717
715
  onTeardown(() => {
@@ -741,11 +739,12 @@ export const useAtomInitialValues = (initialValues) => {
741
739
  releases.push(holdNode(registry, node));
742
740
  }
743
741
  };
742
+ const isWritableRef = (ref) => "set" in ref;
744
743
  /** Writes a ref that has `set`; a read-only ref, as from `map`, throws as `useAtomValue` would. */
745
744
  const writeRef = (getRef) => (value) => {
746
745
  const ref = getRef();
747
- if (!("set" in ref)) {
748
- readOnly();
746
+ if (!isWritableRef(ref)) {
747
+ return readOnly();
749
748
  }
750
749
  ref.set(value);
751
750
  };
@@ -836,7 +835,7 @@ const settledSeeds = new WeakSet();
836
835
  * within a render, and a per-registry record would outlive the request when the caller owns the
837
836
  * registry, so later requests would skip embedding their seed (JND-17).
838
837
  */
839
- const seedOnServer = (registry, atom, key, encode, suspendOnWaiting) => {
838
+ const seedOnServer = (registry, atom, key, encode, suspendOnWaiting = false) => {
840
839
  // Mounted until the render ends, so the settled node is what the render reads.
841
840
  const release = serverMount(registry, atom);
842
841
  // A render ends before its seeds settle only when it fails, as when a later reader throws during
@@ -855,7 +854,7 @@ const seedOnServer = (registry, atom, key, encode, suspendOnWaiting) => {
855
854
  const claim = {};
856
855
  const encoded = hydratable(key, () => {
857
856
  claim.own = (async () => {
858
- await undefined;
857
+ await Promise.resolve();
859
858
  if (ended) {
860
859
  return noSeed;
861
860
  }
@@ -866,7 +865,7 @@ const seedOnServer = (registry, atom, key, encode, suspendOnWaiting) => {
866
865
  // waits, so with suspendOnWaiting the seed is the settled result the render shows, not one the
867
866
  // browser would run again because it was still waiting.
868
867
  if (!initialOnServer(registry, atom) && !notStarted(registry.get(atom))) {
869
- let settled = suspendOnWaiting === true;
868
+ let settled = suspendOnWaiting;
870
869
  await awaitResult(registry, atom, { suspendOnWaiting: settled });
871
870
  // A later reader of the key may want the settled result once this one's wait has begun.
872
871
  if (!settled && claim.first && settledSeeds.has(claim.first)) {
@@ -882,7 +881,7 @@ const seedOnServer = (registry, atom, key, encode, suspendOnWaiting) => {
882
881
  return claim.own;
883
882
  });
884
883
  claim.first = encoded;
885
- if (suspendOnWaiting === true) {
884
+ if (suspendOnWaiting) {
886
885
  settledSeeds.add(encoded);
887
886
  }
888
887
  const claimed = serverSeeds.get(encoded);
@@ -905,7 +904,10 @@ const claimedKeys = new WeakMap();
905
904
  // The store is not public API, so a Svelte that changes its shape or drops it turns the warning off
906
905
  // rather than throwing.
907
906
  const serverValues = () => {
908
- const store = globalThis.__svelte?.h;
907
+ const svelte = Object.getOwnPropertyDescriptor(globalThis, "__svelte")?.value;
908
+ const store = typeof svelte === "object" && svelte !== null && "h" in svelte
909
+ ? svelte.h
910
+ : undefined;
909
911
  return store instanceof Map ? store : undefined;
910
912
  };
911
913
  const warnIfSent = async (key, sent) => {
@@ -957,7 +959,9 @@ const claimServerValue = (key, missed) => {
957
959
  * serialization key. The atom is fetched again once seeded if any component using the key then
958
960
  * asked to revalidate.
959
961
  */
960
- const seedFromServer = (registry, getAtom, revalidateOption, suspendOnWaiting) => {
962
+ const seedFromServer = (registry, getAtom,
963
+ // oxlint-disable-next-line sonarjs/bool-param-default -- undefined is "the hook gave no option", so the provider's default applies
964
+ revalidateOption, suspendOnWaiting = false) => {
961
965
  const atom = getAtom();
962
966
  // The server never computes an atom with a server value, so it has no value to pass on, and the
963
967
  // browser must not ask for one: hydratable throws for a key the server didn't write (JND-58).
@@ -1158,9 +1162,9 @@ const isPending = (current, options) => current._tag === "Initial" ||
1158
1162
  /** What the promise resolves with, or the error it rejects with, for a result that is not pending. */
1159
1163
  const fromSettled = (current, options) => {
1160
1164
  if (current._tag === "Success") {
1161
- return options.includeFailure ? current : current.value;
1165
+ return options.includeFailure === true ? current : current.value;
1162
1166
  }
1163
- if (current._tag === "Failure" && !options.includeFailure) {
1167
+ if (current._tag === "Failure" && options.includeFailure !== true) {
1164
1168
  throw Cause.squash(current.cause);
1165
1169
  }
1166
1170
  return current;
@@ -1178,6 +1182,7 @@ const fromServerValue = (current, options) => {
1178
1182
  return Promise.resolve(fromSettled(current, options));
1179
1183
  }
1180
1184
  catch (error) {
1185
+ // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- rejects with the atom's squashed error, as awaiting the atom would, whatever its type
1181
1186
  return Promise.reject(error);
1182
1187
  }
1183
1188
  };
@@ -1276,7 +1281,9 @@ const sharedWait = (start, onAbandoned) => {
1276
1281
  }
1277
1282
  held.add(signal);
1278
1283
  holders += 1;
1279
- const listener = () => release(signal.reason);
1284
+ const listener = () => {
1285
+ release(signal.reason);
1286
+ };
1280
1287
  signal.addEventListener("abort", listener, { once: true });
1281
1288
  listeners.push([signal, listener]);
1282
1289
  },
@@ -1394,9 +1401,10 @@ export function useAtomSuspense(input, options = {}) {
1394
1401
  // settles, as nothing could let go of it. Handled here, so a read nobody awaits isn't reported
1395
1402
  // as unhandled.
1396
1403
  const last = lastWaits.get(getAtom());
1397
- if (last?.settled()) {
1404
+ if (last?.settled() === true) {
1398
1405
  return last.promise;
1399
1406
  }
1407
+ // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- rejects with the reader's abort reason, such as Svelte's STALE_REACTION, which Svelte recognises
1400
1408
  const gone = Promise.reject(holder.reason);
1401
1409
  void (async () => {
1402
1410
  try {
@@ -1422,7 +1430,7 @@ export function useAtomSuspense(input, options = {}) {
1422
1430
  // reading would compute the atom for nobody.
1423
1431
  const live = [...readers].filter((reader) => !reader.aborted);
1424
1432
  if (live.length === 0) {
1425
- return undefined;
1433
+ return;
1426
1434
  }
1427
1435
  // A read outside any reaction can't say when it is done, so it doesn't hold the atom: held
1428
1436
  // by the component's lifetime, the atom would stay mounted after the getter moves on.
@@ -1437,7 +1445,7 @@ export function useAtomSuspense(input, options = {}) {
1437
1445
  for (const reader of live) {
1438
1446
  waited = settle(atom, current, reader);
1439
1447
  }
1440
- return waited;
1448
+ return await waited;
1441
1449
  })();
1442
1450
  pending = { promise, readers };
1443
1451
  afterSeed.set(atom, pending);
@@ -1453,3 +1461,29 @@ export function useAtomSuspense(input, options = {}) {
1453
1461
  return promise;
1454
1462
  }, readOnly);
1455
1463
  }
1464
+ export function useForm(form, options) {
1465
+ const registry = getRegistry();
1466
+ const initial = options.initialValues;
1467
+ const getInitial = initialGetter(initial);
1468
+ const engine = engineOf(form, {
1469
+ ...options,
1470
+ initialValues: getInitial ? getInitial() : initial,
1471
+ registry,
1472
+ validates: BROWSER,
1473
+ });
1474
+ const state = useAtomValue(engine.stateAtom);
1475
+ const values = useAtomValue(engine.valuesAtom);
1476
+ engine.setReaders(() => state.current, () => values.current);
1477
+ if (getInitial && BROWSER) {
1478
+ $effect.pre(() => {
1479
+ const next = getInitial();
1480
+ untrack(() => {
1481
+ engine.setInitial(next);
1482
+ });
1483
+ });
1484
+ }
1485
+ onTeardown(() => {
1486
+ engine.dispose();
1487
+ });
1488
+ return engine.root;
1489
+ }
@@ -56,11 +56,10 @@
56
56
  const { children, state }: Props = $props();
57
57
  const registry = getRegistry();
58
58
 
59
- // SAFETY: preloadedSerializable is on the registry implementation, not the interface (Effect
60
- // 4.0.0). It holds the encoded values Hydration.hydrate queued, until a lookup of the key takes one.
61
- const { preloadedSerializable: preloaded } = registry as unknown as {
62
- readonly preloadedSerializable?: Map<string, unknown>;
63
- };
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;
64
63
  // The values this boundary queued, by key, so it can drop the ones nobody took.
65
64
  const queued = new Map<string, unknown>();
66
65
  // Whether the boundary has ended, as at the end of the server render or once the browser destroys
package/dist/Inspector.js CHANGED
@@ -86,10 +86,10 @@ const install = (registry) => {
86
86
  // finalizer that interrupts the fiber.
87
87
  // A wrapper that passes on another atom's result (debounce, makeRefreshOnSignal) holds a
88
88
  // value that atom made, and has no fiber of its own. A runtime's fiber isn't interrupted.
89
- if (AsyncResult.isAsyncResult(value) &&
89
+ const ownWait = AsyncResult.isAsyncResult(value) &&
90
90
  value.waiting &&
91
- (origins.get(value) ?? node) === node &&
92
- !isRuntime(node.atom)) {
91
+ (origins.get(value) ?? node) === node;
92
+ if (ownWait && !isRuntime(node.atom)) {
93
93
  emit({ _tag: "Interrupted", node, time: performance.now() });
94
94
  }
95
95
  emit({ _tag: "Finalized", finalizers, node, time: performance.now() });
@@ -145,8 +145,11 @@ const install = (registry) => {
145
145
  });
146
146
  complete &&= instrumented;
147
147
  };
148
- const patchable = registry;
149
- 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);
150
153
  const writes = (f) => {
151
154
  writing += 1;
152
155
  try {
@@ -156,33 +159,45 @@ const install = (registry) => {
156
159
  writing -= 1;
157
160
  }
158
161
  };
159
- patchable.set = (atom, value) => writes(() => set.call(registry, atom, value));
160
- patchable.update = (atom, f) => writes(() => update.call(registry, atom, f));
161
- patchable.modify = (atom, f) => writes(() => modify.call(registry, atom, f));
162
- // An atom with its own refresh (a wrapper such as withRefresh) calls the registry's refresh for
163
- // the atoms it wraps, through this replacement, so each of those is marked too.
164
- patchable.refresh = (atom) => {
165
- const node = registry.getNodes().get(nodeKey(atom));
166
- if (node !== undefined) {
167
- record(node).refreshed = true;
168
- }
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;
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;
177
170
  }
178
- }
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
+ },
179
192
  };
180
- 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) => {
181
196
  onNodeAdded?.(node);
182
197
  instrument(node);
183
198
  emit({ _tag: "NodeAdded", node, time: performance.now() });
184
199
  };
185
- patchable.onNodeRemoved = (node) => {
200
+ registry.onNodeRemoved = (node) => {
186
201
  onNodeRemoved?.(node);
187
202
  // An idle node is announced before it is removed; its teardown is reported first.
188
203
  if (node.currentState() !== "removed" && records.has(node)) {
@@ -207,7 +222,7 @@ const install = (registry) => {
207
222
  idleTTL: (atom) => {
208
223
  // An idle TTL of 0 is none: the registry removes the atom as soon as nothing reads it.
209
224
  if (atom.keepAlive || atom.idleTTL === 0) {
210
- return undefined;
225
+ return;
211
226
  }
212
227
  return atom.idleTTL ?? defaultIdleTTL(registry);
213
228
  },
@@ -284,7 +299,9 @@ const emptySnapshot = { edges: [], nodes: [], readers: [] };
284
299
  /** The scope on the server: always empty. */
285
300
  const inertScope = {
286
301
  snapshot: () => emptySnapshot,
287
- subscribe: () => () => undefined,
302
+ subscribe: () => () => {
303
+ // Nothing to unsubscribe from.
304
+ },
288
305
  };
289
306
  const stateOf = (node) => {
290
307
  // A valid node's value is read without computing anything.
@@ -342,9 +359,7 @@ class Scope {
342
359
  if (this.#listeners.size === 0) {
343
360
  return this.#walk();
344
361
  }
345
- if (this.#members === undefined) {
346
- this.#members = this.#walk();
347
- }
362
+ this.#members ??= this.#walk();
348
363
  return this.#members;
349
364
  }
350
365
  /** Works out the scope's nodes from the graph as it is now, without keeping them. */
@@ -445,7 +460,9 @@ class Scope {
445
460
  if (this.#listeners.size === 0 || this.#following.has(registry)) {
446
461
  return;
447
462
  }
448
- 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
+ }));
449
466
  }
450
467
  #receive(event) {
451
468
  // Events arrive in the middle of the registry's work, while a computation is still changing
@@ -91,7 +91,9 @@ export const provideRegistry = (options = {}) => {
91
91
  }
92
92
  // Not onDestroy: in the browser it never runs for a component destroyed while its script awaits.
93
93
  if (!provided) {
94
- onTeardownAfterChildren(() => disposeRegistry(registry));
94
+ onTeardownAfterChildren(() => {
95
+ disposeRegistry(registry);
96
+ });
95
97
  }
96
98
  if (DEV && BROWSER) {
97
99
  // For developer tools to find (effect-atom-svelte/inspector).