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.
- package/README.md +5 -2
- package/dist/Form.d.ts +605 -0
- package/dist/Form.js +67 -0
- package/dist/Hooks.svelte.d.ts +70 -10
- package/dist/Hooks.svelte.js +489 -163
- package/dist/HydrationBoundary.svelte +134 -17
- package/dist/HydrationBoundary.svelte.d.ts +13 -4
- package/dist/Inspector.d.ts +7 -3
- package/dist/Inspector.js +113 -29
- package/dist/RegistryContext.d.ts +2 -0
- package/dist/RegistryContext.js +13 -5
- package/dist/ScopedAtom.d.ts +5 -4
- package/dist/ScopedAtom.js +20 -11
- package/dist/SvelteKit.d.ts +71 -12
- package/dist/SvelteKit.js +133 -54
- package/dist/index.d.ts +10 -1
- package/dist/index.js +10 -1
- package/dist/internal/awaitWrite.d.ts +11 -0
- package/dist/internal/awaitWrite.js +27 -0
- package/dist/internal/boundaries.d.ts +9 -0
- package/dist/internal/boundaries.js +10 -0
- package/dist/internal/disposal.d.ts +13 -0
- package/dist/internal/disposal.js +43 -0
- package/dist/internal/form/array.d.ts +21 -0
- package/dist/internal/form/array.js +57 -0
- package/dist/internal/form/constraints.d.ts +13 -0
- package/dist/internal/form/constraints.js +166 -0
- package/dist/internal/form/engine.d.ts +56 -0
- package/dist/internal/form/engine.js +842 -0
- package/dist/internal/form/engineOf.d.ts +26 -0
- package/dist/internal/form/engineOf.js +43 -0
- package/dist/internal/form/field.d.ts +30 -0
- package/dist/internal/form/field.js +68 -0
- package/dist/internal/form/group.d.ts +13 -0
- package/dist/internal/form/group.js +20 -0
- package/dist/internal/form/instance.d.ts +41 -0
- package/dist/internal/form/instance.js +135 -0
- package/dist/internal/form/issues.d.ts +23 -0
- package/dist/internal/form/issues.js +144 -0
- package/dist/internal/form/paths.d.ts +47 -0
- package/dist/internal/form/paths.js +175 -0
- package/dist/internal/form/types.d.ts +80 -0
- package/dist/internal/form/types.js +1 -0
- package/dist/internal/form/validationError.d.ts +13 -0
- package/dist/internal/form/validationError.js +6 -0
- package/dist/internal/hydration.d.ts +11 -2
- package/dist/internal/hydration.js +26 -4
- package/dist/internal/nodeInternals.d.ts +6 -0
- package/dist/internal/nodeInternals.js +64 -7
- package/dist/internal/renderEnd.d.ts +13 -0
- package/dist/internal/renderEnd.js +34 -0
- package/dist/internal/teardown.svelte.d.ts +17 -0
- package/dist/internal/teardown.svelte.js +44 -0
- package/dist/internal/troubleshooting.d.ts +2 -0
- package/dist/internal/troubleshooting.js +3 -0
- package/package.json +12 -10
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Paths into a form's values, and the item keys that let state follow an array item when it moves.
|
|
2
|
+
// Internal: not exported from the package.
|
|
3
|
+
//
|
|
4
|
+
// Two kinds of path: an index path (["items", 0, "qty"]) addresses the values as they are now, and
|
|
5
|
+
// a key path (["items", 7, "qty"]) names array items by their key instead of their index. Touched
|
|
6
|
+
// state and errors are stored by key path, so they move with their item. Both are arrays of
|
|
7
|
+
// strings (object keys) and numbers (an index, or an item key).
|
|
8
|
+
import { Array as Arr, Equal, Predicate } from "effect";
|
|
9
|
+
/** A string for a key path, to use as a map key. */
|
|
10
|
+
export const pathId = (path) => JSON.stringify(path);
|
|
11
|
+
/** Parses a `pathId` back into its path. */
|
|
12
|
+
export const fromPathId = (id) => {
|
|
13
|
+
const path = JSON.parse(id);
|
|
14
|
+
// SAFETY: every id comes from `pathId`, which stringifies a KeyPath.
|
|
15
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- JSON.parse can't say what it parsed, and the id is only ever pathId's
|
|
16
|
+
return path;
|
|
17
|
+
};
|
|
18
|
+
const isRecord = Predicate.isObject;
|
|
19
|
+
/** The value at `path`, or `undefined` when a step is missing. */
|
|
20
|
+
export const getIn = (root, path) => {
|
|
21
|
+
let current = root;
|
|
22
|
+
for (const segment of path) {
|
|
23
|
+
if (Array.isArray(current) && typeof segment === "number") {
|
|
24
|
+
current = current[segment];
|
|
25
|
+
}
|
|
26
|
+
else if (isRecord(current) && typeof segment === "string") {
|
|
27
|
+
current = current[segment];
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return current;
|
|
34
|
+
};
|
|
35
|
+
/** A copy of `root` with `value` at `path`, sharing everything off the path. */
|
|
36
|
+
export const setIn = (root, path, value) => {
|
|
37
|
+
if (path.length === 0) {
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
const [head, ...rest] = path;
|
|
41
|
+
if (typeof head === "number") {
|
|
42
|
+
const array = Arr.isArray(root) ? [...root] : [];
|
|
43
|
+
array[head] = setIn(array[head], rest, value);
|
|
44
|
+
return array;
|
|
45
|
+
}
|
|
46
|
+
if (head === undefined) {
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
const record = isRecord(root) ? root : {};
|
|
50
|
+
return {
|
|
51
|
+
...record,
|
|
52
|
+
[head]: setIn(record[head], rest, value),
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
/** Whether `path` starts with `prefix`. */
|
|
56
|
+
export const startsWith = (path, prefix) => prefix.length <= path.length &&
|
|
57
|
+
prefix.every((segment, index) => path[index] === segment);
|
|
58
|
+
/**
|
|
59
|
+
* The key path for an index path, or `undefined` when an index has no item. A number segment is an
|
|
60
|
+
* index only where the value so far is an array; a record's numeric-looking keys are strings.
|
|
61
|
+
*/
|
|
62
|
+
export const toKeyPath = (keys, path) => {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const segment of path) {
|
|
65
|
+
if (typeof segment === "number") {
|
|
66
|
+
const key = keys.get(pathId(out))?.[segment];
|
|
67
|
+
if (key === undefined) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
out.push(key);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
out.push(segment);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
/** The index path for a key path, or `undefined` when one of its items is gone. */
|
|
79
|
+
export const toIndexPath = (keys, path) => {
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const [position, segment] of path.entries()) {
|
|
82
|
+
if (typeof segment === "number") {
|
|
83
|
+
const arrayKeys = keys.get(pathId(path.slice(0, position)));
|
|
84
|
+
const index = arrayKeys?.indexOf(segment) ?? -1;
|
|
85
|
+
if (index < 0) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
out.push(index);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
out.push(segment);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Item keys for every array in `values`. Arrays that already have keys keep them, by position, and
|
|
98
|
+
* new positions take fresh keys from `next`; keys for arrays that are gone are dropped.
|
|
99
|
+
*/
|
|
100
|
+
export const reconcileKeys = (values, previous, next) => {
|
|
101
|
+
const out = new Map();
|
|
102
|
+
const walk = (value, path) => {
|
|
103
|
+
if (Array.isArray(value)) {
|
|
104
|
+
const id = pathId(path);
|
|
105
|
+
const old = previous.get(id) ?? [];
|
|
106
|
+
const keys = old.length === value.length
|
|
107
|
+
? old
|
|
108
|
+
: Array.from({ length: value.length }, (_, index) => old[index] ?? next());
|
|
109
|
+
out.set(id, keys);
|
|
110
|
+
for (const [index, item] of value.entries()) {
|
|
111
|
+
const key = keys[index];
|
|
112
|
+
if (key !== undefined) {
|
|
113
|
+
walk(item, [...path, key]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else if (isRecord(value)) {
|
|
118
|
+
for (const [key, item] of Object.entries(value)) {
|
|
119
|
+
walk(item, [...path, key]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
walk(values, []);
|
|
124
|
+
return out;
|
|
125
|
+
};
|
|
126
|
+
/** Whether every item key in `path` still belongs to its array. */
|
|
127
|
+
export const isLive = (keys, path) => toIndexPath(keys, path) !== undefined;
|
|
128
|
+
// An object literal, or one made with `Object.create(null)`: not a class instance.
|
|
129
|
+
const isPlain = (value) => {
|
|
130
|
+
const prototype = Object.getPrototypeOf(value);
|
|
131
|
+
return prototype === Object.prototype || prototype === null;
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Equality for form values. Arrays and plain objects compare by content, with a key set to
|
|
135
|
+
* `undefined` the same as a missing one; dates by time; values that implement Effect's `Equal`
|
|
136
|
+
* (`Option`, `HashMap`) by `Equal.equals`. Any other class instance compares by identity: a
|
|
137
|
+
* `File`, a `Map`, and also a `Data.Class` or `Schema.Class` value, which in Effect 4 doesn't
|
|
138
|
+
* implement `Equal`. So a fresh instance with the same content counts as a change.
|
|
139
|
+
*/
|
|
140
|
+
export const equals = (a, b) => {
|
|
141
|
+
if (Object.is(a, b)) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (a instanceof Date && b instanceof Date) {
|
|
145
|
+
return a.getTime() === b.getTime();
|
|
146
|
+
}
|
|
147
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
148
|
+
return (a.length === b.length && a.every((item, index) => equals(item, b[index])));
|
|
149
|
+
}
|
|
150
|
+
if (Equal.isEqual(a) && Equal.isEqual(b)) {
|
|
151
|
+
return Equal.equals(a, b);
|
|
152
|
+
}
|
|
153
|
+
if (isRecord(a) && isRecord(b) && isPlain(a) && isPlain(b)) {
|
|
154
|
+
const keysA = Object.keys(a).filter((key) => a[key] !== undefined);
|
|
155
|
+
const keysB = Object.keys(b).filter((key) => b[key] !== undefined);
|
|
156
|
+
return (keysA.length === keysB.length &&
|
|
157
|
+
keysA.every((key) => equals(a[key], b[key])));
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
};
|
|
161
|
+
// One character of a key that isn't a letter or a digit, as `_` and its code in hex: `-` is `_2d`.
|
|
162
|
+
const escapeChar = (char) => `_${(char.codePointAt(0) ?? 0).toString(16)}_`;
|
|
163
|
+
/**
|
|
164
|
+
* A string for a key path that is safe in an element id and different for every path:
|
|
165
|
+
* `items-_7-qty`. Segments are joined with `-`, an item key is `_` and the number, and any other
|
|
166
|
+
* character of a key than a letter or a digit is escaped (`a-b` is `a_2d_b`), so a key can't
|
|
167
|
+
* produce a `-` or an item key's form.
|
|
168
|
+
*/
|
|
169
|
+
export const idSuffix = (path) => path
|
|
170
|
+
.map((segment) => typeof segment === "number"
|
|
171
|
+
? `_${segment}`
|
|
172
|
+
: segment.replaceAll(/[^A-Za-z0-9]/gu, escapeChar))
|
|
173
|
+
.join("-");
|
|
174
|
+
/** The `name` attribute for an index path: `items.0.qty`. */
|
|
175
|
+
export const nameOf = (path) => path.join(".");
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Cause, Option, Schema } from "effect";
|
|
2
|
+
import type { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
|
|
3
|
+
import type { CheckHook, LeafHook } from "./issues.ts";
|
|
4
|
+
import type { IndexPath, Keys } from "./paths.ts";
|
|
5
|
+
/**
|
|
6
|
+
* When a field's error shows: as the user types, when they leave it, or only on submit. The Schema
|
|
7
|
+
* checks every change whatever the timing.
|
|
8
|
+
*/
|
|
9
|
+
export type Timing = "submit" | "blur" | "input";
|
|
10
|
+
/** Where an error came from, which decides when it clears. */
|
|
11
|
+
export type ErrorSource = "schema" | "submit" | "manual";
|
|
12
|
+
/** Errors to put on a form: per top-level field, per path, and form-level. */
|
|
13
|
+
export interface ErrorsInput {
|
|
14
|
+
readonly fields?: Readonly<Record<string, string | undefined>> | undefined;
|
|
15
|
+
readonly paths?: readonly (readonly [IndexPath | string, string])[] | undefined;
|
|
16
|
+
readonly form?: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
/** A definition's settings, shared by every instance. */
|
|
19
|
+
export interface Definition {
|
|
20
|
+
readonly schema: Schema.Top;
|
|
21
|
+
/**
|
|
22
|
+
* The form's name, for its atoms' labels (`SignUp.values`). Writable: the devtools' Vite plugin
|
|
23
|
+
* fills it in from the variable a definition is declared as, when `Form.make` was given none.
|
|
24
|
+
*/
|
|
25
|
+
name: string | undefined;
|
|
26
|
+
/** An `Atom.AtomRuntime`, kept untyped: only its `fn` is used. */
|
|
27
|
+
readonly runtime?: unknown;
|
|
28
|
+
readonly validateOn: Timing;
|
|
29
|
+
readonly revalidateOn: Timing;
|
|
30
|
+
readonly debounce: number;
|
|
31
|
+
readonly leafHook?: LeafHook | undefined;
|
|
32
|
+
readonly checkHook?: CheckHook | undefined;
|
|
33
|
+
}
|
|
34
|
+
/** One instance's settings. */
|
|
35
|
+
export interface EngineOptions {
|
|
36
|
+
readonly registry: AtomRegistry.AtomRegistry;
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly initialValues: unknown;
|
|
39
|
+
readonly values?: Atom.Writable<unknown> | undefined;
|
|
40
|
+
readonly submit?: {
|
|
41
|
+
readonly atom: () => Atom.Writable<AsyncResult.AsyncResult<unknown, unknown>, unknown>;
|
|
42
|
+
readonly payload?: ((decoded: unknown, args: unknown) => unknown) | undefined;
|
|
43
|
+
} | undefined;
|
|
44
|
+
readonly mapError?: ((error: unknown) => ErrorsInput | undefined) | undefined;
|
|
45
|
+
readonly autoSubmit?: boolean | undefined;
|
|
46
|
+
readonly resetOnSuccess?: boolean | undefined;
|
|
47
|
+
/** Whether validation runs at all: not while rendering on the server. */
|
|
48
|
+
readonly validates: boolean;
|
|
49
|
+
}
|
|
50
|
+
export interface ExternalError {
|
|
51
|
+
readonly message: string;
|
|
52
|
+
readonly source: "submit" | "manual";
|
|
53
|
+
}
|
|
54
|
+
/** An instance's state, in one atom. Error maps are keyed by `pathId` of a key path. */
|
|
55
|
+
export interface State {
|
|
56
|
+
readonly initial: unknown;
|
|
57
|
+
readonly keys: Keys;
|
|
58
|
+
readonly touched: ReadonlySet<string>;
|
|
59
|
+
/** Schema errors on show, by path. A path drops out once its check passes. */
|
|
60
|
+
readonly revealed: ReadonlySet<string>;
|
|
61
|
+
/** Paths that have shown an error since the last reset: they follow `revalidateOn`. */
|
|
62
|
+
readonly shown: ReadonlySet<string>;
|
|
63
|
+
/** The latest validation run's messages, in the Schema's order. */
|
|
64
|
+
readonly issues: ReadonlyMap<string, string>;
|
|
65
|
+
/** The values that run decoded, to tell whether its result is current. */
|
|
66
|
+
readonly checked: unknown;
|
|
67
|
+
readonly decoded: Option.Option<unknown>;
|
|
68
|
+
/** Why the latest run couldn't decode at all (a defect, a runtime that failed), if it couldn't. */
|
|
69
|
+
readonly failure: Cause.Cause<unknown> | undefined;
|
|
70
|
+
readonly validating: boolean;
|
|
71
|
+
readonly external: ReadonlyMap<string, ExternalError>;
|
|
72
|
+
readonly submitCount: number;
|
|
73
|
+
readonly validationCount: number;
|
|
74
|
+
readonly submitting: boolean;
|
|
75
|
+
readonly submitResult: AsyncResult.AsyncResult<unknown, unknown>;
|
|
76
|
+
readonly lastSubmitted: Option.Option<{
|
|
77
|
+
readonly encoded: unknown;
|
|
78
|
+
readonly decoded: unknown;
|
|
79
|
+
}>;
|
|
80
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { IndexPath } from "./paths.ts";
|
|
2
|
+
declare const FormValidationError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
3
|
+
readonly _tag: "FormValidationError";
|
|
4
|
+
} & Readonly<A>;
|
|
5
|
+
/** A form's validation failure, as `submit` reports it. */
|
|
6
|
+
export declare class FormValidationError extends FormValidationError_base<{
|
|
7
|
+
readonly errors: readonly {
|
|
8
|
+
readonly path: IndexPath;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
}[];
|
|
11
|
+
}> {
|
|
12
|
+
}
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The failure a form's submit reports when its values don't decode. Internal: `Form.ts` exports it.
|
|
2
|
+
import { Data } from "effect";
|
|
3
|
+
/** A form's validation failure, as `submit` reports it. */
|
|
4
|
+
// oxlint-disable-next-line effecttsgo/prefer-schema-tagged-error -- a local error that never crosses the network; a Schema class would change Form.ValidationError's public type and check its fields on every construction
|
|
5
|
+
export class FormValidationError extends Data.TaggedError("FormValidationError") {
|
|
6
|
+
}
|
|
@@ -6,6 +6,11 @@ export declare const setRevalidateOnHydrate: (value: boolean) => void;
|
|
|
6
6
|
* provider's, else no (JND-19). Call during component init.
|
|
7
7
|
*/
|
|
8
8
|
export declare const revalidatesOnHydrate: (option: boolean | undefined) => boolean;
|
|
9
|
+
/**
|
|
10
|
+
* What the server sends for a result it doesn't pass on, so the browser computes the atom itself.
|
|
11
|
+
* An encoded `AsyncResult` is an object, never `null`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const noSeed: null;
|
|
9
14
|
/**
|
|
10
15
|
* Encodes the server's result for the page, or returns the no-seed marker for a result that must
|
|
11
16
|
* not or cannot travel:
|
|
@@ -17,8 +22,12 @@ export declare const revalidatesOnHydrate: (option: boolean | undefined) => bool
|
|
|
17
22
|
* failing the whole render. Development builds warn.
|
|
18
23
|
*/
|
|
19
24
|
export declare const encodeSeed: (key: string, encode: (value: AsyncResult.AsyncResult<unknown, unknown>) => unknown, result: AsyncResult.AsyncResult<unknown, unknown>) => unknown;
|
|
20
|
-
/**
|
|
21
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The server's result, decoded, or undefined when the server sent none or it doesn't decode with
|
|
27
|
+
* the browser's schema: the browser then computes the atom itself, as for a result the server
|
|
28
|
+
* couldn't encode. Development builds warn.
|
|
29
|
+
*/
|
|
30
|
+
export declare const decodeSeed: (key: string, value: unknown, decode: (value: unknown) => unknown) => unknown;
|
|
22
31
|
/**
|
|
23
32
|
* Whether a seed is still waiting, as a stream's result is between its values. The browser runs
|
|
24
33
|
* the atom again from it, as the server's run ended with the render.
|
|
@@ -14,12 +14,14 @@ export const setRevalidateOnHydrate = (value) => {
|
|
|
14
14
|
* Whether a hook runs its atom again once the page has hydrated: its own option, else the nearest
|
|
15
15
|
* provider's, else no (JND-19). Call during component init.
|
|
16
16
|
*/
|
|
17
|
-
export const revalidatesOnHydrate = (
|
|
17
|
+
export const revalidatesOnHydrate = (
|
|
18
|
+
// oxlint-disable-next-line sonarjs/bool-param-default -- undefined is "the hook gave no option", which a default would erase
|
|
19
|
+
option) => option ?? (hasDefault() ? getDefault() : false);
|
|
18
20
|
/**
|
|
19
21
|
* What the server sends for a result it doesn't pass on, so the browser computes the atom itself.
|
|
20
22
|
* An encoded `AsyncResult` is an object, never `null`.
|
|
21
23
|
*/
|
|
22
|
-
const noSeed = null;
|
|
24
|
+
export const noSeed = null;
|
|
23
25
|
/**
|
|
24
26
|
* Encodes the server's result for the page, or returns the no-seed marker for a result that must
|
|
25
27
|
* not or cannot travel:
|
|
@@ -48,8 +50,28 @@ export const encodeSeed = (key, encode, result) => {
|
|
|
48
50
|
return noSeed;
|
|
49
51
|
}
|
|
50
52
|
};
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The server's result, decoded, or undefined when the server sent none or it doesn't decode with
|
|
55
|
+
* the browser's schema: the browser then computes the atom itself, as for a result the server
|
|
56
|
+
* couldn't encode. Development builds warn.
|
|
57
|
+
*/
|
|
58
|
+
export const decodeSeed = (key, value, decode) => {
|
|
59
|
+
if (value === noSeed) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return decode(value);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (!Schema.isSchemaError(error)) {
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
if (DEV) {
|
|
70
|
+
console.warn(`effect-atom-svelte: the server's result for the serializable atom "${key}" doesn't decode with its schema, so the browser computes the atom again.`, error);
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
53
75
|
/**
|
|
54
76
|
* Whether a seed is still waiting, as a stream's result is between its values. The browser runs
|
|
55
77
|
* the atom again from it, as the server's run ended with the render.
|
|
@@ -14,6 +14,8 @@ export interface NodeHooks {
|
|
|
14
14
|
readonly dispose: (finalizers: number, value: unknown) => void;
|
|
15
15
|
/** A listener was added or removed; the node's `listeners` hold the new set. */
|
|
16
16
|
readonly readers: () => void;
|
|
17
|
+
/** The node's remove() has returned. */
|
|
18
|
+
readonly removed: () => void;
|
|
17
19
|
}
|
|
18
20
|
/** Reports what happens to a node, or returns false if it isn't the implementation this expects. */
|
|
19
21
|
export declare const instrumentNode: (node: AtomRegistry.Node<unknown>, hooks: NodeHooks) => boolean;
|
|
@@ -22,3 +24,7 @@ export declare const instrumentNode: (node: AtomRegistry.Node<unknown>, hooks: N
|
|
|
22
24
|
* which the implementation keeps but the interface doesn't show.
|
|
23
25
|
*/
|
|
24
26
|
export declare const defaultIdleTTL: (registry: AtomRegistry.AtomRegistry) => number | undefined;
|
|
27
|
+
/** Whether the node holds an initial value it hasn't computed (setInitialValue on a new node). */
|
|
28
|
+
export declare const holdsInitialValue: (node: AtomRegistry.Node<unknown>) => boolean;
|
|
29
|
+
/** Whether the node is building and its running build has not read `parent` yet. */
|
|
30
|
+
export declare const buildWillRead: (node: AtomRegistry.Node<unknown>, parent: AtomRegistry.Node<unknown>) => boolean;
|
|
@@ -1,7 +1,38 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// The one place the inspector (../Inspector.ts) relies on how Effect implements AtomRegistry rather
|
|
2
|
+
// than on its public API. Written against effect 4.0.1 (effect/src/reactivity/AtomRegistry.ts); the
|
|
3
|
+
// peer range is ~4.0.0 because of code like this.
|
|
4
|
+
//
|
|
5
|
+
// The public `AtomRegistry` announces nodes added and removed (`onNodeAdded`, `onNodeRemoved`), and
|
|
6
|
+
// a `Node` shows its parents, children and listeners. It says nothing when a node's value changes,
|
|
7
|
+
// when it computes, when what a computation started is torn down or when its listeners change.
|
|
8
|
+
// Each of those goes through a member of the implementation's node (`NodeImpl`), which is replaced
|
|
9
|
+
// on that one node:
|
|
10
|
+
//
|
|
11
|
+
// - `build()`: computes the node's value, running the atom's `read`. Wrapped, for when a
|
|
12
|
+
// computation starts and ends.
|
|
13
|
+
// - `_value`: the node's value. Written by `setValue` (a computation's result, `setSelf`, a write)
|
|
14
|
+
// and `setInitialValue`, and only when the value is not equal to the last. Made an accessor.
|
|
15
|
+
// - `lifetime`: what the current computation holds. `disposeLifetime` clears it, then runs its
|
|
16
|
+
// `finalizers`, which interrupt the fiber of an effect atom that hasn't finished. That happens
|
|
17
|
+
// before every recomputation and when the node is removed. Made an accessor.
|
|
18
|
+
// - `listeners`: public, but only as a set to read. Everything that reads an atom reactively
|
|
19
|
+
// (`AtomRegistry.subscribe` and `mount`, an atom's `get.subscribe`, the hooks' `holdNode`) adds
|
|
20
|
+
// to it and deletes from it through the node's field, so a set put in its place hears each one,
|
|
21
|
+
// including the deletes of subscriptions made before.
|
|
22
|
+
//
|
|
23
|
+
// A node that lacks any of them is left alone (`instrumentNode` returns false), so a change in
|
|
24
|
+
// Effect stops the inspector from reporting rather than breaking the registry.
|
|
25
|
+
//
|
|
26
|
+
// docs/proposals/atom-registry-inspector.md proposes an API in Effect that would replace this file.
|
|
27
|
+
import { Predicate } from "effect";
|
|
28
|
+
const isNodeImpl = (node) => {
|
|
29
|
+
if (!(Object.hasOwn(node, "_value") && Object.hasOwn(node, "lifetime"))) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return ("build" in node &&
|
|
33
|
+
typeof node.build === "function" &&
|
|
34
|
+
node.listeners instanceof Set);
|
|
35
|
+
};
|
|
5
36
|
/** A node's listeners that report each one added or removed. */
|
|
6
37
|
class Listeners extends Set {
|
|
7
38
|
#changed;
|
|
@@ -20,6 +51,13 @@ class Listeners extends Set {
|
|
|
20
51
|
}
|
|
21
52
|
return this;
|
|
22
53
|
}
|
|
54
|
+
clear() {
|
|
55
|
+
const had = this.size > 0;
|
|
56
|
+
super.clear();
|
|
57
|
+
if (had) {
|
|
58
|
+
this.#changed();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
23
61
|
delete(listener) {
|
|
24
62
|
const deleted = super.delete(listener);
|
|
25
63
|
if (deleted) {
|
|
@@ -33,7 +71,15 @@ export const instrumentNode = (node, hooks) => {
|
|
|
33
71
|
if (!isNodeImpl(node)) {
|
|
34
72
|
return false;
|
|
35
73
|
}
|
|
36
|
-
const { build: compute } = node;
|
|
74
|
+
const { build: compute, remove: removeNode } = node;
|
|
75
|
+
node.remove = function remove() {
|
|
76
|
+
try {
|
|
77
|
+
removeNode.call(this);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
hooks.removed();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
37
83
|
let value = node._value;
|
|
38
84
|
let { lifetime } = node;
|
|
39
85
|
node.build = function build() {
|
|
@@ -77,7 +123,18 @@ export const instrumentNode = (node, hooks) => {
|
|
|
77
123
|
* which the implementation keeps but the interface doesn't show.
|
|
78
124
|
*/
|
|
79
125
|
export const defaultIdleTTL = (registry) => {
|
|
80
|
-
const ttl = registry
|
|
81
|
-
.defaultIdleTTL;
|
|
126
|
+
const ttl = "defaultIdleTTL" in registry ? registry.defaultIdleTTL : undefined;
|
|
82
127
|
return typeof ttl === "number" ? ttl : undefined;
|
|
83
128
|
};
|
|
129
|
+
/** Whether the node holds an initial value it hasn't computed (setInitialValue on a new node). */
|
|
130
|
+
export const holdsInitialValue = (node) => "preserveInitialValueOnBuild" in node &&
|
|
131
|
+
node.preserveInitialValueOnBuild === true;
|
|
132
|
+
/** Whether the node is building and its running build has not read `parent` yet. */
|
|
133
|
+
export const buildWillRead = (node, parent) => {
|
|
134
|
+
// The lifetime is the build's `get`, a function.
|
|
135
|
+
const lifetime = "lifetime" in node ? node.lifetime : undefined;
|
|
136
|
+
const reads = Predicate.hasProperty(lifetime, "reads")
|
|
137
|
+
? lifetime.reads
|
|
138
|
+
: undefined;
|
|
139
|
+
return reads instanceof Set && !reads.has(parent);
|
|
140
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs `f` once when the server render ends: in `onDestroy`, and failing that when the render's
|
|
3
|
+
* abort signal fires. A `<svelte:boundary>` with a `failed` snippet discards the content of children
|
|
4
|
+
* that throw while setting up, and their `onDestroy` callbacks with it, so a component set up before
|
|
5
|
+
* the throw would otherwise keep its mounts and holds on a caller-owned registry, and the next
|
|
6
|
+
* request would render this one's initial values (JND-17). On the server `getAbortSignal` returns the
|
|
7
|
+
* render's signal, which Svelte aborts once every `onDestroy` has run. A dropped component whose
|
|
8
|
+
* script awaits before calling this resumes after the render has ended and the signal has aborted,
|
|
9
|
+
* where a listener would never fire, so `f` then runs in a microtask: callers take what `f` releases
|
|
10
|
+
* synchronously after calling this. Call it during component init. Svelte bug workaround, to be
|
|
11
|
+
* removed with the fix (https://github.com/jarrednorrisdev/effect-atom-svelte/issues/35).
|
|
12
|
+
*/
|
|
13
|
+
export declare const onRenderEnd: (f: () => void) => void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// When a server render ends, for the hooks that release what they hold in a registry the caller may
|
|
2
|
+
// keep across requests. Internal: not exported from the package.
|
|
3
|
+
import { getAbortSignal, onDestroy } from "svelte";
|
|
4
|
+
/**
|
|
5
|
+
* Runs `f` once when the server render ends: in `onDestroy`, and failing that when the render's
|
|
6
|
+
* abort signal fires. A `<svelte:boundary>` with a `failed` snippet discards the content of children
|
|
7
|
+
* that throw while setting up, and their `onDestroy` callbacks with it, so a component set up before
|
|
8
|
+
* the throw would otherwise keep its mounts and holds on a caller-owned registry, and the next
|
|
9
|
+
* request would render this one's initial values (JND-17). On the server `getAbortSignal` returns the
|
|
10
|
+
* render's signal, which Svelte aborts once every `onDestroy` has run. A dropped component whose
|
|
11
|
+
* script awaits before calling this resumes after the render has ended and the signal has aborted,
|
|
12
|
+
* where a listener would never fire, so `f` then runs in a microtask: callers take what `f` releases
|
|
13
|
+
* synchronously after calling this. Call it during component init. Svelte bug workaround, to be
|
|
14
|
+
* removed with the fix (https://github.com/jarrednorrisdev/effect-atom-svelte/issues/35).
|
|
15
|
+
*/
|
|
16
|
+
export const onRenderEnd = (f) => {
|
|
17
|
+
let done = false;
|
|
18
|
+
const once = () => {
|
|
19
|
+
if (!done) {
|
|
20
|
+
done = true;
|
|
21
|
+
f();
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
onDestroy(once);
|
|
25
|
+
const signal = getAbortSignal();
|
|
26
|
+
if (signal.aborted) {
|
|
27
|
+
// The render already ended, as for a component a failed boundary dropped whose script resumed
|
|
28
|
+
// after an await. Deferred, as callers register before taking what `f` releases.
|
|
29
|
+
queueMicrotask(once);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
signal.addEventListener("abort", once, { once: true });
|
|
33
|
+
}
|
|
34
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs `f` when the component is destroyed, even while its script is still awaiting. In the browser
|
|
3
|
+
* `onDestroy` only takes effect once the component mounts, so a component removed while pending
|
|
4
|
+
* would never call it. A pre effect runs during init and is torn down with the component (JND-16).
|
|
5
|
+
* On the server, `f` runs when the render ends, even for a component a failed boundary dropped.
|
|
6
|
+
*/
|
|
7
|
+
export declare const onTeardown: (f: () => void) => void;
|
|
8
|
+
/**
|
|
9
|
+
* As `onTeardown`, but for a mounted component `f` runs after its children are destroyed, as with
|
|
10
|
+
* `onDestroy`: a pre effect is torn down before the children, whose own teardown may still use what
|
|
11
|
+
* `f` ends, such as a provider's registry. The pre effect only covers a component destroyed before
|
|
12
|
+
* it mounted. On the server `f` runs through `onRenderEnd`, so `provideRegistry` disposes its
|
|
13
|
+
* registry even when a failed boundary dropped the provider. A successful render keeps the
|
|
14
|
+
* children-first order through `onDestroy`; when the render ends without it, `f` may run before
|
|
15
|
+
* the children's releases, which are then no-ops on the disposed registry.
|
|
16
|
+
*/
|
|
17
|
+
export declare const onTeardownAfterChildren: (f: () => void) => void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// When a component is destroyed, for the hooks and providers. Internal: not exported from the
|
|
2
|
+
// package.
|
|
3
|
+
import { BROWSER } from "esm-env";
|
|
4
|
+
import { onDestroy, onMount } from "svelte";
|
|
5
|
+
import { onRenderEnd } from "./renderEnd.js";
|
|
6
|
+
/**
|
|
7
|
+
* Runs `f` when the component is destroyed, even while its script is still awaiting. In the browser
|
|
8
|
+
* `onDestroy` only takes effect once the component mounts, so a component removed while pending
|
|
9
|
+
* would never call it. A pre effect runs during init and is torn down with the component (JND-16).
|
|
10
|
+
* On the server, `f` runs when the render ends, even for a component a failed boundary dropped.
|
|
11
|
+
*/
|
|
12
|
+
export const onTeardown = (f) => {
|
|
13
|
+
if (BROWSER) {
|
|
14
|
+
$effect.pre(() => f);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
onRenderEnd(f);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* As `onTeardown`, but for a mounted component `f` runs after its children are destroyed, as with
|
|
22
|
+
* `onDestroy`: a pre effect is torn down before the children, whose own teardown may still use what
|
|
23
|
+
* `f` ends, such as a provider's registry. The pre effect only covers a component destroyed before
|
|
24
|
+
* it mounted. On the server `f` runs through `onRenderEnd`, so `provideRegistry` disposes its
|
|
25
|
+
* registry even when a failed boundary dropped the provider. A successful render keeps the
|
|
26
|
+
* children-first order through `onDestroy`; when the render ends without it, `f` may run before
|
|
27
|
+
* the children's releases, which are then no-ops on the disposed registry.
|
|
28
|
+
*/
|
|
29
|
+
export const onTeardownAfterChildren = (f) => {
|
|
30
|
+
if (!BROWSER) {
|
|
31
|
+
onRenderEnd(f);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
let mounted = false;
|
|
35
|
+
$effect.pre(() => () => {
|
|
36
|
+
if (!mounted) {
|
|
37
|
+
f();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
onMount(() => {
|
|
41
|
+
mounted = true;
|
|
42
|
+
});
|
|
43
|
+
onDestroy(f);
|
|
44
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Where error messages point for more. Internal: not exported from the package.
|
|
2
|
+
/** The sentence that ends an error message: a link to its entry on the troubleshooting page. */
|
|
3
|
+
export const troubleshooting = (entry) => `See https://atom.jarrednorris.dev/troubleshooting#${entry}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "effect-atom-svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Community-built Svelte 5 bindings for Effect Atom (not affiliated with the Effect team)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"build": "svelte-package -i src -o dist",
|
|
48
|
-
"check": "svelte-check --tsconfig ./tsconfig.json && bun scripts/check-docs.ts",
|
|
48
|
+
"check": "svelte-check --tsconfig ./tsconfig.json && tsc -p scripts && bun scripts/check-docs.ts",
|
|
49
49
|
"test": "vitest run"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
@@ -53,16 +53,18 @@
|
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@demo/domain": "workspace:*",
|
|
56
|
-
"@sveltejs/package": "^3.0.
|
|
57
|
-
"@sveltejs/vite-plugin-svelte": "^7.
|
|
58
|
-
"@
|
|
59
|
-
"@vitest/browser
|
|
60
|
-
"
|
|
56
|
+
"@sveltejs/package": "^3.0.1",
|
|
57
|
+
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
|
58
|
+
"@types/bun": "^1.4.2",
|
|
59
|
+
"@vitest/browser": "^5.0.5",
|
|
60
|
+
"@vitest/browser-playwright": "^5.0.5",
|
|
61
|
+
"effect": "4.0.5",
|
|
61
62
|
"playwright": "^1.57.0",
|
|
62
63
|
"svelte": "^5.57.2",
|
|
63
|
-
"svelte-check": "^4.6
|
|
64
|
-
"
|
|
65
|
-
"
|
|
64
|
+
"svelte-check": "^4.7.6",
|
|
65
|
+
"typescript": "^6.0.3",
|
|
66
|
+
"vite": "^8.3.4",
|
|
67
|
+
"vitest": "^5.0.5",
|
|
66
68
|
"vitest-browser-svelte": "^3.1.0"
|
|
67
69
|
},
|
|
68
70
|
"peerDependencies": {
|