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,6 +1,4 @@
1
1
  import { createContext } from "svelte";
2
- // A const and a type of the same name, as in Effect's modules.
3
- /* oxlint-disable eslint/no-redeclare */
4
2
  /**
5
3
  * Marks a value as a `ScopedAtom`: every scoped atom has this key, set to this same string, as
6
4
  * Effect's own types carry a type id.
@@ -13,10 +11,11 @@ export const TypeId = "~effect-atom-svelte/ScopedAtom";
13
11
  /**
14
12
  * Creates a scoped atom from a factory, which runs once per providing component.
15
13
  *
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.
14
+ * Each provider gets its own atom, so a factory that makes a serializable atom gives every copy of
15
+ * one input the same key, and two such providers on a server-rendered page make the render throw:
16
+ * two different atoms share the serialization key. For a value from the server, return a family's
17
+ * atom instead, `ScopedAtom.make((id: string) => userAtom(id))`, so every provider of one input
18
+ * holds the same atom.
20
19
  *
21
20
  * **Example** (A counter per subtree, started from an input)
22
21
  *
@@ -36,17 +35,27 @@ export const TypeId = "~effect-atom-svelte/ScopedAtom";
36
35
  */
37
36
  export const make = (f, options) => {
38
37
  const [get, set, has] = createContext();
39
- const provide = (...args) => set(args.length === 0
40
- ? f()
41
- : f(args[0]));
38
+ const provide = (...args) => {
39
+ if (args.length === 1) {
40
+ return set(f(args[0]));
41
+ }
42
+ // SAFETY: `provide`'s type allows no input only when `Input` is never or includes undefined, so
43
+ // `f` is the factory without input or one whose input is optional.
44
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the union of factories can't be narrowed by the argument count
45
+ return set(f());
46
+ };
42
47
  return {
43
48
  [TypeId]: TypeId,
49
+ // SAFETY: `provide` takes no input or one, which every branch of the conditional type accepts;
50
+ // TypeScript can't pick a branch while `Input` is generic.
51
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the conditional type can't be narrowed for a generic `Input`
44
52
  provide: provide,
45
53
  use: () => {
46
54
  if (!has()) {
47
55
  const name = options?.name;
48
- const which = name ? `ScopedAtom "${name}"` : "ScopedAtom";
49
- const call = name ? `${name}.provide()` : "its provide()";
56
+ const named = name !== undefined && name !== "";
57
+ const which = named ? `ScopedAtom "${name}"` : "ScopedAtom";
58
+ const call = named ? `${name}.provide()` : "its provide()";
50
59
  throw new Error(`${which} used outside of the component that provides it. Call ${call} in a parent component's script.`);
51
60
  }
52
61
  return get();
@@ -1,13 +1,3 @@
1
- /**
2
- * SvelteKit `handleError` hooks that keep an Effect error's `_tag`, so a `<svelte:boundary>`
3
- * `failed` snippet can tell typed errors apart. Import them from `effect-atom-svelte/sveltekit`.
4
- *
5
- * The module does not import SvelteKit: the hooks take the fields of SvelteKit's input they read
6
- * and return the fields they set, so they type-check against `HandleClientError` and
7
- * `HandleServerError` once `App.Error` declares `tag?: string`.
8
- *
9
- * @since 0.1.0
10
- */
11
1
  /**
12
2
  * The part of SvelteKit's `handleError` input that the hooks read. In SvelteKit 3, `kind` is
13
3
  * `"unknown"` for errors thrown by your code, `"app"` for `error(...)`, `"framework"` for
@@ -38,6 +28,27 @@ export interface EffectErrorBody {
38
28
  readonly message?: string;
39
29
  readonly tag?: string;
40
30
  }
31
+ /**
32
+ * SvelteKit 2's `handleError` input: no `kind`, and always a message, which the hooks fall back on.
33
+ *
34
+ * @stability unstable
35
+ * @since 0.2.1
36
+ * @category models
37
+ */
38
+ export interface CaughtErrorWithoutKind extends CaughtError {
39
+ readonly kind?: undefined;
40
+ readonly message: string;
41
+ }
42
+ /**
43
+ * What the hooks return for SvelteKit 2's input: always a message, as its `App.Error` requires one.
44
+ *
45
+ * @stability unstable
46
+ * @since 0.2.1
47
+ * @category models
48
+ */
49
+ export interface EffectErrorBodyWithMessage extends EffectErrorBody {
50
+ readonly message: string;
51
+ }
41
52
  /**
42
53
  * A client `handleError` hook (`src/hooks.client.ts`) that keeps the message and `_tag` of errors
43
54
  * thrown by your code, so a boundary's `failed` snippet shows them instead of
@@ -56,7 +67,8 @@ export interface EffectErrorBody {
56
67
  * @since 0.1.0
57
68
  * @category hooks
58
69
  */
59
- export declare const handleClientError: (input: CaughtError) => EffectErrorBody | undefined;
70
+ export declare function handleClientError(input: CaughtErrorWithoutKind): EffectErrorBodyWithMessage;
71
+ export declare function handleClientError(input: CaughtError): EffectErrorBody | undefined;
60
72
  /**
61
73
  * A server `handleError` hook (`src/hooks.server.ts`) that keeps the `_tag` of errors thrown by
62
74
  * your code but not their message, which could expose details of the server to users: the
@@ -75,4 +87,51 @@ export declare const handleClientError: (input: CaughtError) => EffectErrorBody
75
87
  * @since 0.1.0
76
88
  * @category hooks
77
89
  */
78
- export declare const handleServerError: (input: CaughtError) => EffectErrorBody | undefined;
90
+ export declare function handleServerError(input: CaughtErrorWithoutKind): EffectErrorBodyWithMessage;
91
+ export declare function handleServerError(input: CaughtError): EffectErrorBody | undefined;
92
+ /**
93
+ * Options for `useUnsavedChangesGuard`.
94
+ *
95
+ * @stability unstable
96
+ * @since 0.4.0
97
+ * @category models
98
+ */
99
+ export interface UnsavedChangesGuardOptions {
100
+ /**
101
+ * Asks whether to leave and lose the changes, resolving `true` to leave. Called for links,
102
+ * `goto`, form navigations and the back and forward buttons. Closing the tab, reloading and
103
+ * typing a URL show the browser's own dialog instead, as a page can't show its own then.
104
+ */
105
+ readonly confirm: () => boolean | Promise<boolean>;
106
+ }
107
+ /**
108
+ * Asks before the user navigates away from a page whose form is dirty, using SvelteKit's
109
+ * `beforeNavigate`. Call it while a component initializes, with any object that has `dirty`, such
110
+ * as a form instance from `useForm`.
111
+ *
112
+ * While the form is dirty, a navigation is cancelled and `confirm` asked; on yes it is retried, and
113
+ * the retry goes through. Closing the tab, reloading or typing a URL show the browser's own dialog
114
+ * instead. SvelteKit doesn't call `beforeNavigate` for back and forward over shallow history entries
115
+ * (from `pushState` or a hash change) or for links to a hash on the same page, so those aren't
116
+ * guarded.
117
+ *
118
+ * **Example** (Asking before leaving an edit page)
119
+ *
120
+ * ```ts
121
+ * import { useForm } from "effect-atom-svelte";
122
+ * import { useUnsavedChangesGuard } from "effect-atom-svelte/sveltekit";
123
+ *
124
+ * const form = useForm(EditProject, { id, initialValues, submit });
125
+ * useUnsavedChangesGuard(form, {
126
+ * confirm: () => window.confirm("Leave without saving your changes?"),
127
+ * });
128
+ * ```
129
+ *
130
+ * @see {@link https://atom.jarrednorris.dev/forms | Forms}
131
+ * @stability unstable
132
+ * @since 0.4.0
133
+ * @category forms
134
+ */
135
+ export declare const useUnsavedChangesGuard: (form: {
136
+ readonly dirty: boolean;
137
+ }, options: UnsavedChangesGuardOptions) => void;
package/dist/SvelteKit.js CHANGED
@@ -1,19 +1,26 @@
1
1
  /**
2
- * SvelteKit `handleError` hooks that keep an Effect error's `_tag`, so a `<svelte:boundary>`
3
- * `failed` snippet can tell typed errors apart. Import them from `effect-atom-svelte/sveltekit`.
2
+ * SvelteKit helpers. Import them from `effect-atom-svelte/sveltekit`.
4
3
  *
5
- * The module does not import SvelteKit: the hooks take the fields of SvelteKit's input they read
6
- * and return the fields they set, so they type-check against `HandleClientError` and
7
- * `HandleServerError` once `App.Error` declares `tag?: string`.
4
+ * `handleClientError` and `handleServerError` are `handleError` hooks that keep an Effect error's
5
+ * `_tag`, so a `<svelte:boundary>` `failed` snippet can tell typed errors apart. They take the
6
+ * fields of SvelteKit's input they read and return the fields they set, so they type-check against
7
+ * `HandleClientError` and `HandleServerError` once `App.Error` declares `tag?: string`. In
8
+ * SvelteKit 2, whose `App.Error` requires a message, they are typed to return one when there is no
9
+ * `kind`, as they then always set one.
10
+ *
11
+ * `useUnsavedChangesGuard` asks before leaving a page with a dirty form.
8
12
  *
9
13
  * @since 0.1.0
10
14
  */
11
- const tagOf = (error) => typeof error === "object" &&
12
- error !== null &&
13
- "_tag" in error &&
14
- typeof error._tag === "string"
15
- ? error._tag
16
- : undefined;
15
+ /// <reference types="@sveltejs/kit" />
16
+ import { beforeNavigate, goto } from "$app/navigation";
17
+ import { Effect } from "effect";
18
+ const tagOf = (error) => {
19
+ if (typeof error !== "object" || error === null || !("_tag" in error)) {
20
+ return undefined;
21
+ }
22
+ return typeof error._tag === "string" ? error._tag : undefined;
23
+ };
17
24
  const messageOf = (error) => {
18
25
  if (error instanceof Error) {
19
26
  return error.message;
@@ -26,57 +33,29 @@ const messageOf = (error) => {
26
33
  const fallbackMessage = (input) => input.kind === undefined ? input.message : undefined;
27
34
  // Leaves out empty fields, so SvelteKit's defaults apply, and returns nothing when both are empty.
28
35
  const body = (message, tag) => {
29
- if (!(message || tag)) {
30
- return undefined;
36
+ const fields = {};
37
+ if (message !== undefined && message !== "") {
38
+ fields.message = message;
31
39
  }
32
- return { ...(message ? { message } : {}), ...(tag ? { tag } : {}) };
40
+ if (tag !== undefined && tag !== "") {
41
+ fields.tag = tag;
42
+ }
43
+ return fields.message === undefined && fields.tag === undefined
44
+ ? undefined
45
+ : fields;
33
46
  };
34
- /**
35
- * A client `handleError` hook (`src/hooks.client.ts`) that keeps the message and `_tag` of errors
36
- * thrown by your code, so a boundary's `failed` snippet shows them instead of
37
- * `{ status: 500, message: "Internal Error" }`. It logs those errors like SvelteKit's default
38
- * hook, and leaves `error(...)` and SvelteKit's own errors as they are. Made for SvelteKit 3:
39
- * SvelteKit 2 doesn't say which errors are its own, so there every error is kept and logged.
40
- *
41
- * **Example** (Using it as the client hook)
42
- *
43
- * ```ts
44
- * // src/hooks.client.ts
45
- * export { handleClientError as handleError } from "effect-atom-svelte/sveltekit";
46
- * ```
47
- *
48
- * @stability unstable
49
- * @since 0.1.0
50
- * @category hooks
51
- */
52
- export const handleClientError = (input) => {
47
+ export function handleClientError(input) {
53
48
  // Destructured here, not in the parameter list, so the API reference shows a named parameter.
54
49
  const { error, kind = "unknown" } = input;
55
50
  if (kind !== "unknown") {
56
51
  return undefined;
57
52
  }
58
53
  console.error(error);
59
- return body(messageOf(error) ?? fallbackMessage(input), tagOf(error));
60
- };
61
- /**
62
- * A server `handleError` hook (`src/hooks.server.ts`) that keeps the `_tag` of errors thrown by
63
- * your code but not their message, which could expose details of the server to users: the
64
- * message stays SvelteKit's `"Internal Error"`. It logs errors with `console.error`.
65
- * Server errors reach a boundary's `failed` snippet when it renders on the server. Made for
66
- * SvelteKit 3: SvelteKit 2 doesn't say which errors are its own, so there every error is logged.
67
- *
68
- * **Example** (Using it as the server hook)
69
- *
70
- * ```ts
71
- * // src/hooks.server.ts
72
- * export { handleServerError as handleError } from "effect-atom-svelte/sveltekit";
73
- * ```
74
- *
75
- * @stability unstable
76
- * @since 0.1.0
77
- * @category hooks
78
- */
79
- export const handleServerError = (input) => {
54
+ // An error with an empty message gets SvelteKit 2's instead.
55
+ const message = messageOf(error);
56
+ return body(message === undefined || message === "" ? fallbackMessage(input) : message, tagOf(error));
57
+ }
58
+ export function handleServerError(input) {
80
59
  const { error, issues, kind = "unknown" } = input;
81
60
  if (kind === "validation") {
82
61
  console.error("Remote function schema validation failed:", issues);
@@ -87,4 +66,104 @@ export const handleServerError = (input) => {
87
66
  }
88
67
  console.error(error);
89
68
  return body(fallbackMessage(input), tagOf(error));
69
+ }
70
+ // Waits for the next popstate, or a second, should none come.
71
+ const nextPopState = () => Effect.runPromise(Effect.callback((resume) => {
72
+ const done = () => {
73
+ resume(Effect.undefined);
74
+ };
75
+ addEventListener("popstate", done, { once: true });
76
+ return Effect.sync(() => {
77
+ removeEventListener("popstate", done);
78
+ });
79
+ }).pipe(Effect.timeoutOption("1 second")));
80
+ // How long a "yes" lets the retried navigation through. The retry reaches beforeNavigate at once;
81
+ // a navigation that never does, such as a link to a download, doesn't leave a bypass behind.
82
+ const bypassFor = 2000;
83
+ /**
84
+ * Asks before the user navigates away from a page whose form is dirty, using SvelteKit's
85
+ * `beforeNavigate`. Call it while a component initializes, with any object that has `dirty`, such
86
+ * as a form instance from `useForm`.
87
+ *
88
+ * While the form is dirty, a navigation is cancelled and `confirm` asked; on yes it is retried, and
89
+ * the retry goes through. Closing the tab, reloading or typing a URL show the browser's own dialog
90
+ * instead. SvelteKit doesn't call `beforeNavigate` for back and forward over shallow history entries
91
+ * (from `pushState` or a hash change) or for links to a hash on the same page, so those aren't
92
+ * guarded.
93
+ *
94
+ * **Example** (Asking before leaving an edit page)
95
+ *
96
+ * ```ts
97
+ * import { useForm } from "effect-atom-svelte";
98
+ * import { useUnsavedChangesGuard } from "effect-atom-svelte/sveltekit";
99
+ *
100
+ * const form = useForm(EditProject, { id, initialValues, submit });
101
+ * useUnsavedChangesGuard(form, {
102
+ * confirm: () => window.confirm("Leave without saving your changes?"),
103
+ * });
104
+ * ```
105
+ *
106
+ * @see {@link https://atom.jarrednorris.dev/forms | Forms}
107
+ * @stability unstable
108
+ * @since 0.4.0
109
+ * @category forms
110
+ */
111
+ export const useUnsavedChangesGuard = (form, options) => {
112
+ // The retry of a navigation the user agreed to: where it goes, whether it unloads the page, and
113
+ // until when to expect it.
114
+ let retry;
115
+ beforeNavigate((navigation) => {
116
+ // Let the retry through, once. Any other navigation is asked about as usual.
117
+ if (retry !== undefined &&
118
+ // oxlint-disable-next-line effecttsgo/global-date -- SvelteKit calls beforeNavigate synchronously, outside any Effect program
119
+ retry.until > Date.now() &&
120
+ (navigation.type === "leave"
121
+ ? retry.unloads
122
+ : navigation.to?.url.href === retry.href)) {
123
+ retry = undefined;
124
+ return;
125
+ }
126
+ if (!form.dirty) {
127
+ return;
128
+ }
129
+ // Closing the tab, reloading or typing a URL: cancelling shows the browser's dialog, and no
130
+ // code of ours can run before the page goes.
131
+ navigation.cancel();
132
+ const { to } = navigation;
133
+ if (navigation.type === "leave" || to === null) {
134
+ return;
135
+ }
136
+ // Cancelling back or forward makes SvelteKit undo the move with history.go, which finishes with
137
+ // a popstate. A retry before then would be lost to it, so the retry waits for it.
138
+ const undone = navigation.type === "popstate" ? nextPopState() : undefined;
139
+ // oxlint-disable-next-line effecttsgo/async-function -- awaits the app's confirm, which may return a Promise, from SvelteKit's beforeNavigate, outside any Effect program
140
+ void (async () => {
141
+ if (!(await options.confirm())) {
142
+ return;
143
+ }
144
+ retry = {
145
+ href: to.url.href,
146
+ unloads: navigation.type !== "popstate" && navigation.willUnload,
147
+ // oxlint-disable-next-line effecttsgo/global-date -- compared with Date.now in beforeNavigate, outside any Effect program
148
+ until: Date.now() + bypassFor,
149
+ };
150
+ if (navigation.type === "popstate") {
151
+ await undone;
152
+ history.go(navigation.delta);
153
+ }
154
+ else if (navigation.willUnload) {
155
+ // An external link, or one SvelteKit hands to the browser: goto can't follow it. The page
156
+ // unloading fires a "leave", which the bypass lets through.
157
+ location.href = to.url.href;
158
+ }
159
+ else {
160
+ // SvelteKit 3 warns in dev whenever the `replaceState` key is there, even false, so it is
161
+ // passed only when true.
162
+ await goto(to.url, to.url.href === location.href
163
+ ? // oxlint-disable-next-line typescript/no-deprecated -- the peer range includes SvelteKit 2, whose goto has no `replace`, only `replaceState`
164
+ { replaceState: true }
165
+ : {});
166
+ }
167
+ })();
168
+ });
90
169
  };
package/dist/index.d.ts CHANGED
@@ -40,6 +40,15 @@ export { default as RegistryProvider } from "./RegistryProvider.svelte";
40
40
  * @category scoped atoms
41
41
  */
42
42
  export * as ScopedAtom from "./ScopedAtom.ts";
43
+ /**
44
+ * Forms driven by one Effect Schema: `Form.make` defines one, `useForm` creates an instance in a
45
+ * component.
46
+ *
47
+ * @stability unstable
48
+ * @since 0.4.0
49
+ * @category forms
50
+ */
51
+ export * as Form from "./Form.ts";
43
52
  /**
44
53
  * Effect Atom's modules, so an app can import everything from one place.
45
54
  *
@@ -47,4 +56,4 @@ export * as ScopedAtom from "./ScopedAtom.ts";
47
56
  * @since 0.1.0
48
57
  * @category re-exports
49
58
  */
50
- export { AsyncResult, Atom, AtomHttpApi, AtomRef, AtomRegistry, AtomRpc, Hydration, } from "effect/reactivity";
59
+ export { AsyncResult, Atom, AtomHttpApi, AtomRef, AtomRegistry, AtomRpc, Hydration, Reactivity, } from "effect/reactivity";
package/dist/index.js CHANGED
@@ -40,6 +40,15 @@ export { default as RegistryProvider } from "./RegistryProvider.svelte";
40
40
  * @category scoped atoms
41
41
  */
42
42
  export * as ScopedAtom from "./ScopedAtom.js";
43
+ /**
44
+ * Forms driven by one Effect Schema: `Form.make` defines one, `useForm` creates an instance in a
45
+ * component.
46
+ *
47
+ * @stability unstable
48
+ * @since 0.4.0
49
+ * @category forms
50
+ */
51
+ export * as Form from "./Form.js";
43
52
  /**
44
53
  * Effect Atom's modules, so an app can import everything from one place.
45
54
  *
@@ -47,4 +56,4 @@ export * as ScopedAtom from "./ScopedAtom.js";
47
56
  * @since 0.1.0
48
57
  * @category re-exports
49
58
  */
50
- export { AsyncResult, Atom, AtomHttpApi, AtomRef, AtomRegistry, AtomRpc, Hydration, } from "effect/reactivity";
59
+ export { AsyncResult, Atom, AtomHttpApi, AtomRef, AtomRegistry, AtomRpc, Hydration, Reactivity, } from "effect/reactivity";
@@ -0,0 +1,11 @@
1
+ import { Exit } from "effect";
2
+ import type { Cause } from "effect";
3
+ import { AsyncResult } from "effect/reactivity";
4
+ import type { Atom, AtomRegistry } from "effect/reactivity";
5
+ /**
6
+ * A promise-mode setter's wait: as `awaitResult` with `suspendOnWaiting`, but a change to a result
7
+ * from before the call, which is what `Atom.Reset` from another setter leaves (an idle `Initial`,
8
+ * or an `Atom.fn`'s `initialValue`), ends it as interrupted. The reset interrupts the call, so
9
+ * otherwise the wait would hang until the next call, or settle with the initial value.
10
+ */
11
+ export declare const awaitWrite: <A, E>(registry: AtomRegistry.AtomRegistry, atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>, start: number, signal: AbortSignal) => Promise<Exit.Exit<A, E | Cause.NoSuchElementError>>;
@@ -0,0 +1,27 @@
1
+ // Waiting for a write to an async atom to settle, for useAtomSet's promise modes and a form's
2
+ // submit. Internal: not exported from the package.
3
+ import { Effect, Exit } from "effect";
4
+ import { AsyncResult } from "effect/reactivity";
5
+ /**
6
+ * A promise-mode setter's wait: as `awaitResult` with `suspendOnWaiting`, but a change to a result
7
+ * from before the call, which is what `Atom.Reset` from another setter leaves (an idle `Initial`,
8
+ * or an `Atom.fn`'s `initialValue`), ends it as interrupted. The reset interrupts the call, so
9
+ * otherwise the wait would hang until the next call, or settle with the initial value.
10
+ */
11
+ export const awaitWrite = (registry, atom, start, signal) => Effect.runPromiseExit(Effect.callback((resume) => {
12
+ const first = registry.get(atom);
13
+ if (first._tag !== "Initial" && !first.waiting) {
14
+ resume(AsyncResult.toExit(first));
15
+ return;
16
+ }
17
+ const cancel = registry.subscribe(atom, (result) => {
18
+ if (result.waiting) {
19
+ return;
20
+ }
21
+ cancel();
22
+ const reset = result._tag === "Initial" ||
23
+ (result._tag === "Success" && result.timestamp < start);
24
+ resume(reset ? Exit.interrupt() : AsyncResult.toExit(result));
25
+ });
26
+ return Effect.sync(cancel);
27
+ }), { signal });
@@ -0,0 +1,9 @@
1
+ import type { AtomRegistry } from "effect/reactivity";
2
+ /**
3
+ * Per registry, key and queued value, how many live boundaries queued it. Boundaries given the
4
+ * same state, as a layout and its page might be, queue the same values, and one destroyed first
5
+ * must leave them for the other's children.
6
+ */
7
+ export declare const queuedBy: WeakMap<AtomRegistry.AtomRegistry, Map<string, Map<unknown, number>>>;
8
+ /** Whether a live boundary brought a value for the key: it sends that value to the browser itself. */
9
+ export declare const queuedByBoundary: (registry: AtomRegistry.AtomRegistry, key: string) => boolean;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Per registry, key and queued value, how many live boundaries queued it. Boundaries given the
3
+ * same state, as a layout and its page might be, queue the same values, and one destroyed first
4
+ * must leave them for the other's children.
5
+ */
6
+ export const queuedBy = new WeakMap();
7
+ /** Whether a live boundary brought a value for the key: it sends that value to the browser itself. */
8
+ export const queuedByBoundary = (registry, key) =>
9
+ // oxlint-disable-next-line sonarjs/no-empty-collection -- HydrationBoundary.svelte fills it; this module only declares it
10
+ queuedBy.get(registry)?.has(key) === true;
@@ -0,0 +1,13 @@
1
+ import type { AtomRegistry } from "effect/reactivity";
2
+ /** Calls `f` once if `registry` is disposed of through `disposeRegistry`; returns the canceler. */
3
+ export declare const onDispose: (registry: AtomRegistry.AtomRegistry, f: () => void) => (() => void);
4
+ /**
5
+ * Disposes of `registry`, then runs what waited on it, even when a finalizer the disposal runs
6
+ * throws.
7
+ *
8
+ * In the browser that error is rethrown on a microtask, so it is still reported as uncaught but
9
+ * stays out of Svelte's teardown: thrown from the `onDestroy` of a component being destroyed, it
10
+ * escapes Svelte's flush and leaves every `$derived` on the page running again on every read. On
11
+ * the server it is thrown here.
12
+ */
13
+ export declare const disposeRegistry: (registry: AtomRegistry.AtomRegistry) => void;
@@ -0,0 +1,43 @@
1
+ import { BROWSER } from "esm-env";
2
+ const callbacks = new WeakMap();
3
+ /** Calls `f` once if `registry` is disposed of through `disposeRegistry`; returns the canceler. */
4
+ export const onDispose = (registry, f) => {
5
+ let set = callbacks.get(registry);
6
+ if (set === undefined) {
7
+ set = new Set();
8
+ callbacks.set(registry, set);
9
+ }
10
+ set.add(f);
11
+ return () => {
12
+ set.delete(f);
13
+ };
14
+ };
15
+ /**
16
+ * Disposes of `registry`, then runs what waited on it, even when a finalizer the disposal runs
17
+ * throws.
18
+ *
19
+ * In the browser that error is rethrown on a microtask, so it is still reported as uncaught but
20
+ * stays out of Svelte's teardown: thrown from the `onDestroy` of a component being destroyed, it
21
+ * escapes Svelte's flush and leaves every `$derived` on the page running again on every read. On
22
+ * the server it is thrown here.
23
+ */
24
+ export const disposeRegistry = (registry) => {
25
+ try {
26
+ registry.dispose();
27
+ }
28
+ catch (error) {
29
+ if (!BROWSER) {
30
+ throw error;
31
+ }
32
+ queueMicrotask(() => {
33
+ throw error;
34
+ });
35
+ }
36
+ finally {
37
+ const set = callbacks.get(registry);
38
+ callbacks.delete(registry);
39
+ for (const f of set ?? []) {
40
+ f();
41
+ }
42
+ }
43
+ };
@@ -0,0 +1,21 @@
1
+ import type { Engine } from "./engine.ts";
2
+ import type { FieldImpl } from "./field.ts";
3
+ import type { GroupImpl } from "./group.ts";
4
+ import type { IndexPath, KeyPath } from "./paths.ts";
5
+ export declare class ArrayImpl {
6
+ #private;
7
+ readonly keyPath: KeyPath;
8
+ constructor(engine: Engine, keyPath: KeyPath);
9
+ get path(): IndexPath;
10
+ get items(): readonly {
11
+ readonly key: number;
12
+ readonly index: number;
13
+ readonly fields: GroupImpl;
14
+ readonly field: FieldImpl;
15
+ }[];
16
+ get error(): string | undefined;
17
+ append(item: unknown): void;
18
+ remove(index: number): void;
19
+ move(from: number, to: number): void;
20
+ replace(index: number, item: unknown): void;
21
+ }
@@ -0,0 +1,57 @@
1
+ import { pathId } from "./paths.js";
2
+ export class ArrayImpl {
3
+ #engine;
4
+ keyPath;
5
+ constructor(engine, keyPath) {
6
+ this.#engine = engine;
7
+ this.keyPath = keyPath;
8
+ }
9
+ get path() {
10
+ return this.#engine.indexPath(this.keyPath) ?? [];
11
+ }
12
+ get items() {
13
+ const keys = this.#engine.state.keys.get(pathId(this.keyPath)) ?? [];
14
+ return keys.map((key, index) => ({
15
+ field: this.#engine.field([...this.keyPath, key]),
16
+ fields: this.#engine.group([...this.keyPath, key]),
17
+ index,
18
+ key,
19
+ }));
20
+ }
21
+ get error() {
22
+ return this.#engine.errorAt(this.keyPath);
23
+ }
24
+ append(item) {
25
+ this.#engine.updateArray(this.keyPath, (items, keys) => {
26
+ items.push(item);
27
+ keys.push(this.#engine.newKey());
28
+ });
29
+ }
30
+ remove(index) {
31
+ this.#engine.updateArray(this.keyPath, (items, keys) => {
32
+ if (index >= 0 && index < items.length) {
33
+ items.splice(index, 1);
34
+ keys.splice(index, 1);
35
+ }
36
+ });
37
+ }
38
+ move(from, to) {
39
+ this.#engine.updateArray(this.keyPath, (items, keys) => {
40
+ const inRange = (index) => index >= 0 && index < items.length;
41
+ if (!(inRange(from) && inRange(to)) || from === to) {
42
+ return;
43
+ }
44
+ items.splice(to, 0, ...items.splice(from, 1));
45
+ keys.splice(to, 0, ...keys.splice(from, 1));
46
+ });
47
+ }
48
+ replace(index, item) {
49
+ this.#engine.updateArray(this.keyPath, (items, keys) => {
50
+ if (index >= 0 && index < items.length) {
51
+ items[index] = item;
52
+ // A new item: the old one's errors and touched state don't carry over.
53
+ keys[index] = this.#engine.newKey();
54
+ }
55
+ });
56
+ }
57
+ }
@@ -0,0 +1,13 @@
1
+ import { SchemaAST } from "effect";
2
+ import type { IndexPath } from "./paths.ts";
3
+ /** The attributes `field.constraints` can set. */
4
+ export interface Constraints {
5
+ readonly required?: boolean | undefined;
6
+ readonly minlength?: number | undefined;
7
+ readonly maxlength?: number | undefined;
8
+ readonly min?: number | undefined;
9
+ readonly max?: number | undefined;
10
+ readonly pattern?: string | undefined;
11
+ }
12
+ /** The HTML constraints for the field at `path` in `schema`'s AST. */
13
+ export declare const constraintsAt: (root: SchemaAST.AST, path: IndexPath) => Constraints;