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
@@ -0,0 +1,842 @@
1
+ // The form engine behind `Form.instance` and `useForm`: one instance's state, its validation runs
2
+ // and its submit. Internal: not exported from the package. It is untyped (values are `unknown`);
3
+ // `Form.ts` puts the Schema's types on it.
4
+ import { Array as Arr, Cause, Deferred, Effect, Exit, Option, Schema, } from "effect";
5
+ import { AsyncResult, Atom } from "effect/reactivity";
6
+ import { awaitWrite } from "../awaitWrite.js";
7
+ import { onDispose } from "../disposal.js";
8
+ import { ArrayImpl } from "./array.js";
9
+ import { constraintsAt } from "./constraints.js";
10
+ import { FieldImpl } from "./field.js";
11
+ import { GroupImpl } from "./group.js";
12
+ import { InstanceImpl } from "./instance.js";
13
+ import { toPathIssues } from "./issues.js";
14
+ import { equals, fromPathId, getIn, idSuffix, isLive, pathId, reconcileKeys, setIn, startsWith, toIndexPath, toKeyPath, } from "./paths.js";
15
+ import { FormValidationError } from "./validationError.js";
16
+ const ROOT = pathId([]);
17
+ const notChecked = Symbol("notChecked");
18
+ const freshState = (initial, keys) => ({
19
+ checked: notChecked,
20
+ decoded: Option.none(),
21
+ external: new Map(),
22
+ failure: undefined,
23
+ initial,
24
+ issues: new Map(),
25
+ keys,
26
+ lastSubmitted: Option.none(),
27
+ revealed: new Set(),
28
+ shown: new Set(),
29
+ submitCount: 0,
30
+ submitResult: AsyncResult.initial(),
31
+ submitting: false,
32
+ touched: new Set(),
33
+ validating: false,
34
+ validationCount: 0,
35
+ });
36
+ /**
37
+ * The definition's Schema as a codec that needs no services. A runtime, when the definition has
38
+ * one, provides those its checks need.
39
+ */
40
+ const codecOf = (definition) =>
41
+ // SAFETY: make checked that the Schema's decoding services are ones the definition's runtime
42
+ // provides, and validation decodes through that runtime. Without a runtime, make allowed none.
43
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- a definition keeps its Schema as Schema.Top, without its types
44
+ definition.schema;
45
+ /** The values `setValues` writes: `values`, or what an update function makes of the current ones. */
46
+ const updatedValues = (values, current) => {
47
+ if (typeof values !== "function") {
48
+ return values;
49
+ }
50
+ // SAFETY: setValues' type takes the values, or a function from the current values to new ones.
51
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- a typeof check can't recover the function's signature
52
+ const update = values;
53
+ return update(current);
54
+ };
55
+ /** Turns a path given as `"email"` or `["items", 0]` into an index path. */
56
+ const toPath = (path) => typeof path === "string" ? [path] : path;
57
+ export class Engine {
58
+ definition;
59
+ options;
60
+ registry;
61
+ stateAtom;
62
+ valuesAtom;
63
+ root;
64
+ #validateAtom;
65
+ #original;
66
+ // The values the latest validation run decodes.
67
+ #runValues;
68
+ #readState;
69
+ #readValues;
70
+ #nextKey = 0;
71
+ #token = 0;
72
+ #session = 0;
73
+ #timer;
74
+ #scheduled = false;
75
+ #pending = [];
76
+ #pendingAll = false;
77
+ // The field the latest change came from, when the user typed into it; undefined for a change
78
+ // made in code. Its errors follow its own timing; touched fields it changes the checks of don't.
79
+ #editing;
80
+ // The values `setField` last wrote. Values arriving that aren't these were written by code,
81
+ // through the instance or straight to an app's own `values` atom.
82
+ #fieldWrite;
83
+ #waiters = [];
84
+ #seen;
85
+ #inflight;
86
+ #queued = false;
87
+ #releases = [];
88
+ #disposed = false;
89
+ #fields = new Map();
90
+ #groups = new Map();
91
+ #arrays = new Map();
92
+ #constraints = new Map();
93
+ constructor(definition, options) {
94
+ this.definition = definition;
95
+ this.options = options;
96
+ this.registry = options.registry;
97
+ const { registry } = options;
98
+ this.#original = options.initialValues;
99
+ // Labelled for the devtools and inspector: an instance's atoms are made here, not by the app.
100
+ const name = definition.name ?? "form";
101
+ this.valuesAtom =
102
+ options.values ??
103
+ Atom.make(options.initialValues).pipe(Atom.withLabel(`${name}.values`));
104
+ const values = registry.get(this.valuesAtom);
105
+ const keys = reconcileKeys(values, new Map(), () => this.#key());
106
+ this.stateAtom = Atom.make(freshState(options.initialValues, keys)).pipe(Atom.withLabel(`${name}.state`));
107
+ this.#seen = { keys, values };
108
+ const decode = Schema.decodeUnknownEffect(codecOf(definition));
109
+ const run = (input) => decode(input.values, { errors: "all" }).pipe(Effect.map((decoded) => ({ _tag: "Valid", decoded, input })), Effect.catchTag("SchemaError", (failure) => Effect.succeed({
110
+ _tag: "Invalid",
111
+ input,
112
+ issues: toPathIssues(failure.issue, definition),
113
+ })));
114
+ // SAFETY: the runtime provides the services the Schema's checks need; the types were checked in
115
+ // make, which is the only place a definition gets its runtime.
116
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- a definition keeps its runtime untyped, as it keeps its Schema's services
117
+ const runtime = definition.runtime;
118
+ this.#validateAtom = (runtime ? runtime.fn(run) : Atom.fn(run)).pipe(Atom.withLabel(`${name}.check`));
119
+ this.#readState = () => registry.get(this.stateAtom);
120
+ this.#readValues = () => registry.get(this.valuesAtom);
121
+ this.#releases.push(registry.mount(this.stateAtom), registry.subscribe(this.valuesAtom, (next) => {
122
+ this.#onValues(next);
123
+ }));
124
+ if (options.validates) {
125
+ this.#releases.push(registry.subscribe(this.#validateAtom, (result) => {
126
+ this.#onRun(result);
127
+ }));
128
+ this.#schedule();
129
+ }
130
+ this.root = new InstanceImpl(this);
131
+ }
132
+ /** Reads state and values through these instead, as `useForm` does to make them reactive. */
133
+ setReaders(state, values) {
134
+ this.#readState = state;
135
+ this.#readValues = values;
136
+ }
137
+ get state() {
138
+ return this.#readState();
139
+ }
140
+ get values() {
141
+ return this.#readValues();
142
+ }
143
+ #key() {
144
+ this.#nextKey += 1;
145
+ return this.#nextKey;
146
+ }
147
+ // Reads the latest state from the registry: writes build on it, not on a reactive read that may
148
+ // lag behind.
149
+ #current() {
150
+ return this.registry.get(this.stateAtom);
151
+ }
152
+ #update(f) {
153
+ if (!this.#disposed) {
154
+ this.registry.set(this.stateAtom, f(this.#current()));
155
+ }
156
+ }
157
+ #currentValues() {
158
+ return this.registry.get(this.valuesAtom);
159
+ }
160
+ // ── values ──────────────────────────────────────────────────────────────────────────────────
161
+ // Every change to the values passes here, whoever made it: item keys follow the arrays, errors
162
+ // from outside the Schema clear where their field's value changed, a form-level one on any
163
+ // change, and validation runs again.
164
+ #onValues(next) {
165
+ // Compared by content: a derived `values` atom hands back a copy of what was written.
166
+ if (!equals(next, this.#fieldWrite)) {
167
+ this.#editing = undefined;
168
+ }
169
+ const previous = this.#seen;
170
+ const state = this.#current();
171
+ const keys = reconcileKeys(next, state.keys, () => this.#key());
172
+ const external = new Map();
173
+ for (const [id, error] of state.external) {
174
+ if (id === ROOT) {
175
+ // A form-level error isn't tied to a field, so any edit may have answered it. Kept, it
176
+ // would hold `valid` false until the next submit.
177
+ if (equals(previous.values, next)) {
178
+ external.set(id, error);
179
+ }
180
+ continue;
181
+ }
182
+ const path = fromPathId(id);
183
+ const before = toIndexPath(previous.keys, path);
184
+ const after = toIndexPath(keys, path);
185
+ if (after !== undefined &&
186
+ before !== undefined &&
187
+ equals(getIn(previous.values, before), getIn(next, after))) {
188
+ external.set(id, error);
189
+ }
190
+ }
191
+ const live = (ids) => new Set([...ids].filter((id) => id === ROOT || isLive(keys, fromPathId(id))));
192
+ this.#seen = { keys, values: next };
193
+ // Fields of items that are gone can't come back: their keys are never reused.
194
+ for (const cache of [this.#fields, this.#groups, this.#arrays]) {
195
+ for (const id of cache.keys()) {
196
+ if (!isLive(keys, fromPathId(id))) {
197
+ cache.delete(id);
198
+ }
199
+ }
200
+ }
201
+ this.#update((current) => ({
202
+ ...current,
203
+ external,
204
+ keys,
205
+ revealed: live(current.revealed),
206
+ shown: live(current.shown),
207
+ touched: live(current.touched),
208
+ }));
209
+ this.#schedule();
210
+ }
211
+ /** Writes a field's value, as typing into it does. */
212
+ setField(path, value) {
213
+ const index = toIndexPath(this.#current().keys, path);
214
+ if (index === undefined) {
215
+ return;
216
+ }
217
+ this.#editing = path;
218
+ this.#fieldWrite = setIn(this.#currentValues(), index, value);
219
+ this.registry.set(this.valuesAtom, this.#fieldWrite);
220
+ this.#trigger(path, "input");
221
+ }
222
+ /** Marks a field touched, as leaving it does, and checks it if `validateOn` says so. */
223
+ leave(path) {
224
+ this.setTouched(path, true);
225
+ this.#trigger(path, "blur");
226
+ }
227
+ setTouched(path, touched) {
228
+ const id = pathId(path);
229
+ this.#update((state) => {
230
+ if (state.touched.has(id) === touched) {
231
+ return state;
232
+ }
233
+ const next = new Set(state.touched);
234
+ if (touched) {
235
+ next.add(id);
236
+ }
237
+ else {
238
+ next.delete(id);
239
+ }
240
+ return { ...state, touched: next };
241
+ });
242
+ }
243
+ setValues(values) {
244
+ this.#editing = undefined;
245
+ this.registry.set(this.valuesAtom, updatedValues(values, this.#currentValues()));
246
+ }
247
+ /** Changes an array and its item keys together, so state stays with each item. */
248
+ updateArray(path, f) {
249
+ this.#editing = undefined;
250
+ const state = this.#current();
251
+ const index = toIndexPath(state.keys, path);
252
+ if (index === undefined) {
253
+ return;
254
+ }
255
+ const current = getIn(this.#currentValues(), index);
256
+ const items = Arr.isArray(current) ? [...current] : [];
257
+ const keys = [...(state.keys.get(pathId(path)) ?? [])];
258
+ f(items, keys);
259
+ const nextKeys = new Map([...state.keys, [pathId(path), keys]]);
260
+ this.#update((latest) => ({ ...latest, keys: nextKeys }));
261
+ this.registry.set(this.valuesAtom, setIn(this.#currentValues(), index, items));
262
+ }
263
+ newKey() {
264
+ return this.#key();
265
+ }
266
+ /** The current initial values change: a clean form takes them, a dirty one keeps its edits. */
267
+ setInitial(initial) {
268
+ this.#editing = undefined;
269
+ const state = this.#current();
270
+ if (equals(initial, state.initial)) {
271
+ return;
272
+ }
273
+ const clean = equals(this.#currentValues(), state.initial);
274
+ this.#original = initial;
275
+ this.#update((latest) => ({ ...latest, initial }));
276
+ if (clean) {
277
+ this.registry.set(this.valuesAtom, initial);
278
+ }
279
+ }
280
+ reset(values) {
281
+ if (values !== undefined) {
282
+ this.#original = values;
283
+ }
284
+ this.#restart(values === undefined ? this.#current().initial : values, {
285
+ keepSubmit: false,
286
+ });
287
+ }
288
+ #restart(initial, options) {
289
+ this.#session += 1;
290
+ this.#editing = undefined;
291
+ this.#pending = [];
292
+ this.#pendingAll = false;
293
+ this.#inflight = undefined;
294
+ this.#queued = false;
295
+ const state = this.#current();
296
+ const keys = reconcileKeys(initial, new Map(), () => this.#key());
297
+ const fresh = freshState(initial, keys);
298
+ this.#update(() => options.keepSubmit
299
+ ? {
300
+ ...fresh,
301
+ lastSubmitted: state.lastSubmitted,
302
+ submitResult: state.submitResult,
303
+ }
304
+ : fresh);
305
+ this.#seen = { keys, values: this.#currentValues() };
306
+ this.registry.set(this.valuesAtom, initial);
307
+ this.#schedule();
308
+ }
309
+ revertToLastSubmit() {
310
+ this.#editing = undefined;
311
+ const state = this.#current();
312
+ this.registry.set(this.valuesAtom, Option.match(state.lastSubmitted, {
313
+ onNone: () => state.initial,
314
+ onSome: ({ encoded }) => encoded,
315
+ }));
316
+ }
317
+ setErrors(errors, source) {
318
+ const state = this.#current();
319
+ const external = new Map(state.external);
320
+ const put = (path, message) => {
321
+ const keyPath = toKeyPath(state.keys, path);
322
+ if (keyPath !== undefined && message !== undefined) {
323
+ external.set(pathId(keyPath), { message, source });
324
+ }
325
+ };
326
+ for (const [name, message] of Object.entries(errors.fields ?? {})) {
327
+ put([name], message);
328
+ }
329
+ for (const [path, message] of errors.paths ?? []) {
330
+ put(toPath(path), message);
331
+ }
332
+ if (errors.form !== undefined) {
333
+ external.set(ROOT, { message: errors.form, source });
334
+ }
335
+ this.#update((latest) => ({ ...latest, external }));
336
+ }
337
+ // ── validation ──────────────────────────────────────────────────────────────────────────────
338
+ #schedule() {
339
+ if (!this.options.validates || this.#disposed) {
340
+ return;
341
+ }
342
+ if (!this.#current().validating) {
343
+ this.#update((state) => ({ ...state, validating: true }));
344
+ }
345
+ clearTimeout(this.#timer);
346
+ if (this.definition.debounce > 0) {
347
+ // oxlint-disable-next-line effecttsgo/global-timers -- debounces validation between the user's edits, outside any Effect program
348
+ this.#timer = setTimeout(() => {
349
+ this.#run();
350
+ }, this.definition.debounce);
351
+ return;
352
+ }
353
+ // Several changes in one tick, such as a reset, run once.
354
+ if (!this.#scheduled) {
355
+ this.#scheduled = true;
356
+ queueMicrotask(() => {
357
+ this.#scheduled = false;
358
+ this.#run();
359
+ });
360
+ }
361
+ }
362
+ // Runs now rather than after the debounce, as submit and validate need the result.
363
+ #flush() {
364
+ if (this.#timer !== undefined) {
365
+ clearTimeout(this.#timer);
366
+ this.#timer = undefined;
367
+ this.#run();
368
+ }
369
+ }
370
+ #run() {
371
+ this.#timer = undefined;
372
+ if (this.#disposed) {
373
+ return;
374
+ }
375
+ this.#token += 1;
376
+ this.#runValues = this.#currentValues();
377
+ this.registry.set(this.#validateAtom, {
378
+ keys: this.#current().keys,
379
+ token: this.#token,
380
+ values: this.#runValues,
381
+ });
382
+ }
383
+ #onRun(result) {
384
+ if (result.waiting || result._tag === "Initial") {
385
+ return;
386
+ }
387
+ if (result._tag === "Failure") {
388
+ // The decode itself failed, not the values: a runtime whose layer failed, or a defect. Every
389
+ // field is unchecked, so the form isn't valid.
390
+ console.error("effect-atom-svelte: a form's validation failed", Cause.pretty(result.cause));
391
+ const values = this.#runValues;
392
+ this.#update((state) => ({
393
+ ...state,
394
+ checked: values,
395
+ decoded: Option.none(),
396
+ failure: result.cause,
397
+ validating: false,
398
+ }));
399
+ this.#release();
400
+ return;
401
+ }
402
+ const run = result.value;
403
+ if (run.input.token !== this.#token) {
404
+ return;
405
+ }
406
+ const issues = new Map();
407
+ const list = run._tag === "Invalid" ? run.issues : [];
408
+ for (const issue of list) {
409
+ // The run's own keys: items may have moved since it started.
410
+ let keyPath = toKeyPath(run.input.keys, issue.path);
411
+ // An index with no item, as from a tuple, lands on the nearest path that has one.
412
+ for (let length = issue.path.length - 1; keyPath === undefined && length >= 0; length -= 1) {
413
+ keyPath = toKeyPath(run.input.keys, issue.path.slice(0, length));
414
+ }
415
+ const id = pathId(keyPath ?? []);
416
+ if (!issues.has(id)) {
417
+ issues.set(id, issue.message);
418
+ }
419
+ }
420
+ const fresh = run.input.values === this.#currentValues() && this.#timer === undefined;
421
+ this.#update((state) => ({
422
+ ...state,
423
+ checked: run.input.values,
424
+ decoded: run._tag === "Valid" ? Option.some(run.decoded) : Option.none(),
425
+ failure: undefined,
426
+ issues,
427
+ validating: !fresh,
428
+ }));
429
+ // Once the form has been submitted, every error shows: with input timing, as soon as it appears,
430
+ // such as on a field a union's tag just brought in.
431
+ if (fresh &&
432
+ this.#current().submitCount > 0 &&
433
+ this.definition.revalidateOn === "input") {
434
+ this.#pendingAll = true;
435
+ }
436
+ this.#reveal(fresh);
437
+ if (fresh) {
438
+ this.#release();
439
+ this.#autoSubmit();
440
+ }
441
+ }
442
+ // Shows the current run's errors on the paths asked for, and on paths already showing one. A
443
+ // path whose check now passes stops showing, whatever the timing ("reward early"). The paths
444
+ // asked for are used up only by a run of the current values.
445
+ #reveal(consume) {
446
+ const pending = this.#pending;
447
+ const all = this.#pendingAll;
448
+ const editing = this.#editing;
449
+ if (consume) {
450
+ this.#pending = [];
451
+ this.#pendingAll = false;
452
+ }
453
+ this.#update((state) => {
454
+ const revealed = new Set();
455
+ for (const id of state.issues.keys()) {
456
+ const path = fromPathId(id);
457
+ // An error on a field the user has left, which a change elsewhere brought on (a
458
+ // "passwords do not match" after editing the password), shows at once: the field is
459
+ // touched. The field being edited keeps to its own timing, and so does a field whose
460
+ // timing is "submit": its errors wait for a submit or validate().
461
+ const fromElsewhere = state.touched.has(id) &&
462
+ this.#phase(state, id) !== "submit" &&
463
+ (editing === undefined || !startsWith(path, editing));
464
+ const asked = all ||
465
+ fromElsewhere ||
466
+ pending.some((prefix) => startsWith(path, prefix));
467
+ if (state.revealed.has(id) || (consume && asked)) {
468
+ revealed.add(id);
469
+ }
470
+ }
471
+ return {
472
+ ...state,
473
+ revealed,
474
+ shown: new Set([...state.shown, ...revealed]),
475
+ };
476
+ });
477
+ }
478
+ // A field's timing: `validateOn` until the field has shown an error or the form was submitted,
479
+ // then `revalidateOn`.
480
+ #phase(state, id) {
481
+ return state.shown.has(id) || state.submitCount > 0
482
+ ? this.definition.revalidateOn
483
+ : this.definition.validateOn;
484
+ }
485
+ // A field event asks for its errors to show when the field's timing matches it.
486
+ #trigger(path, event) {
487
+ if (this.#phase(this.#current(), pathId(path)) !== event) {
488
+ return;
489
+ }
490
+ this.#pending.push(path);
491
+ if (this.#isCurrent()) {
492
+ this.#reveal(true);
493
+ }
494
+ }
495
+ #isCurrent() {
496
+ const state = this.#current();
497
+ return state.checked === this.#currentValues() && !state.validating;
498
+ }
499
+ // A run that couldn't decode at all (a check that threw, a runtime that failed) may have been a
500
+ // blip, so validate and submit run it again rather than reuse its failure.
501
+ #retryFailed() {
502
+ if (this.options.validates &&
503
+ !this.#disposed &&
504
+ this.#current().failure !== undefined) {
505
+ this.#update((state) => ({ ...state, validating: true }));
506
+ clearTimeout(this.#timer);
507
+ this.#run();
508
+ }
509
+ }
510
+ // Resolves once a run of the current values has settled.
511
+ #settled() {
512
+ if (!this.options.validates || this.#disposed || this.#isCurrent()) {
513
+ return Promise.resolve(undefined);
514
+ }
515
+ // Waiting first: a run with nothing async settles inside #flush, and releases its waiters.
516
+ const waiter = Deferred.makeUnsafe();
517
+ this.#waiters.push(waiter);
518
+ this.#flush();
519
+ return Effect.runPromise(Deferred.await(waiter));
520
+ }
521
+ #release() {
522
+ const waiters = this.#waiters;
523
+ this.#waiters = [];
524
+ for (const waiter of waiters) {
525
+ Deferred.doneUnsafe(waiter, Effect.undefined);
526
+ }
527
+ }
528
+ /**
529
+ * Checks the given paths (all by default), shows their errors, and says whether they passed: no
530
+ * Schema error and no external error at or under any of them.
531
+ */
532
+ // oxlint-disable-next-line effecttsgo/async-function -- the form's validate, part of the library's Promise-facing API for Svelte handlers
533
+ async validate(paths) {
534
+ const state = this.#current();
535
+ const keyPaths = paths === undefined
536
+ ? undefined
537
+ : paths.flatMap((path) => {
538
+ const keyPath = toKeyPath(state.keys, toPath(path));
539
+ return keyPath === undefined ? [] : [keyPath];
540
+ });
541
+ if (keyPaths === undefined) {
542
+ this.#pendingAll = true;
543
+ }
544
+ else {
545
+ this.#pending.push(...keyPaths);
546
+ }
547
+ this.#update((latest) => ({
548
+ ...latest,
549
+ validationCount: latest.validationCount + 1,
550
+ }));
551
+ this.#retryFailed();
552
+ await this.#settled();
553
+ if (this.#isCurrent()) {
554
+ this.#reveal(true);
555
+ }
556
+ const latest = this.#current();
557
+ if (latest.failure !== undefined) {
558
+ return false;
559
+ }
560
+ // A server or setErrors error counts as a Schema one does; it already shows, as it always does.
561
+ return [...latest.issues.keys(), ...latest.external.keys()].every((id) => {
562
+ if (keyPaths === undefined) {
563
+ return false;
564
+ }
565
+ const path = fromPathId(id);
566
+ return !keyPaths.some((prefix) => startsWith(path, prefix));
567
+ });
568
+ }
569
+ // ── submit ──────────────────────────────────────────────────────────────────────────────────
570
+ submit(args) {
571
+ // One submit at a time per instance: a second call while one runs gets the same result.
572
+ if (this.#inflight !== undefined) {
573
+ return this.#inflight;
574
+ }
575
+ const inflight = this.#submitOnce(args);
576
+ this.#inflight = inflight;
577
+ return inflight;
578
+ }
579
+ // oxlint-disable-next-line effecttsgo/async-function -- behind the form's submit, part of the library's Promise-facing API for Svelte handlers
580
+ async #submitOnce(args) {
581
+ const session = this.#session;
582
+ try {
583
+ return await this.#submit(args);
584
+ }
585
+ finally {
586
+ // A reset has already let go of this submit.
587
+ if (session === this.#session) {
588
+ this.#inflight = undefined;
589
+ if (this.#queued) {
590
+ this.#queued = false;
591
+ if (!this.#disposeAfterSubmit) {
592
+ this.#autoSubmit();
593
+ }
594
+ }
595
+ }
596
+ if (this.#disposeAfterSubmit) {
597
+ this.#disposeAfterSubmit = false;
598
+ this.dispose();
599
+ }
600
+ }
601
+ }
602
+ // oxlint-disable-next-line effecttsgo/async-function -- behind the form's submit, part of the library's Promise-facing API for Svelte handlers
603
+ async #submit(args) {
604
+ const session = this.#session;
605
+ this.#pendingAll = true;
606
+ this.#update((state) => ({
607
+ ...state,
608
+ // Errors from the last submit, and a form-level error from setErrors, give way to this one.
609
+ external: new Map([...state.external].filter(([id, error]) => error.source !== "submit" && id !== ROOT)),
610
+ submitCount: state.submitCount + 1,
611
+ }));
612
+ this.#retryFailed();
613
+ await this.#settled();
614
+ if (session !== this.#session) {
615
+ return Exit.interrupt();
616
+ }
617
+ if (this.options.validates) {
618
+ this.#reveal(true);
619
+ }
620
+ const state = this.#current();
621
+ // The values the run decoded: a write since the submit started isn't part of it.
622
+ const encoded = this.options.validates
623
+ ? state.checked
624
+ : this.#currentValues();
625
+ let decoded;
626
+ if (this.options.validates) {
627
+ // The decode couldn't run at all: there is nothing to send.
628
+ if (state.failure !== undefined) {
629
+ // Not the user's input, and nothing claims it: a defect, as the error a runtime failed with
630
+ // isn't in the submit's error type. The app shows it, as for any defect.
631
+ const cause = Cause.die(Cause.squash(state.failure));
632
+ this.#update((latest) => ({
633
+ ...latest,
634
+ submitResult: AsyncResult.failure(cause),
635
+ }));
636
+ return Exit.failCause(cause);
637
+ }
638
+ if (state.issues.size > 0 || Option.isNone(state.decoded)) {
639
+ return Exit.fail(new FormValidationError({ errors: this.root.errors }));
640
+ }
641
+ decoded = state.decoded.value;
642
+ }
643
+ else {
644
+ // Not validating (on the server): decode now.
645
+ const exit = await Effect.runPromiseExit(Schema.decodeUnknownEffect(codecOf(this.definition))(encoded, {
646
+ errors: "all",
647
+ }));
648
+ if (Exit.isFailure(exit)) {
649
+ return Exit.fail(new FormValidationError({ errors: [] }));
650
+ }
651
+ decoded = exit.value;
652
+ }
653
+ const target = this.options.submit;
654
+ if (target === undefined) {
655
+ this.#succeeded(session, encoded, decoded, decoded);
656
+ return Exit.succeed(decoded);
657
+ }
658
+ const atom = target.atom();
659
+ const value = target.payload ? target.payload(decoded, args) : decoded;
660
+ this.#update((latest) => ({
661
+ ...latest,
662
+ submitResult: AsyncResult.waiting(latest.submitResult),
663
+ submitting: true,
664
+ }));
665
+ // oxlint-disable-next-line effecttsgo/global-date -- compared with an AsyncResult's timestamp, which Effect takes from Date.now
666
+ const start = Date.now();
667
+ this.registry.set(atom, value);
668
+ // The wait holds the atom, so the call finishes after the component is gone, unless the
669
+ // registry itself is disposed of, which ends it as interrupted.
670
+ const controller = new AbortController();
671
+ const cancel = onDispose(this.registry, () => {
672
+ controller.abort();
673
+ });
674
+ let exit;
675
+ try {
676
+ exit = await awaitWrite(this.registry, atom, start, controller.signal);
677
+ }
678
+ finally {
679
+ cancel();
680
+ }
681
+ if (session !== this.#session) {
682
+ return exit;
683
+ }
684
+ if (Exit.isSuccess(exit)) {
685
+ this.#succeeded(session, encoded, decoded, exit.value);
686
+ return exit;
687
+ }
688
+ const error = Cause.findErrorOption(exit.cause);
689
+ const claimed = Option.isSome(error) && this.options.mapError
690
+ ? this.options.mapError(error.value)
691
+ : undefined;
692
+ if (claimed !== undefined) {
693
+ this.setErrors(claimed, "submit");
694
+ }
695
+ this.#update((latest) => ({
696
+ ...latest,
697
+ submitResult: claimed === undefined
698
+ ? AsyncResult.failure(exit.cause)
699
+ : AsyncResult.initial(),
700
+ submitting: false,
701
+ }));
702
+ if (claimed !== undefined) {
703
+ // Server errors show at once, as after a failed check.
704
+ this.#update((latest) => ({
705
+ ...latest,
706
+ shown: new Set([...latest.shown, ...latest.external.keys()]),
707
+ }));
708
+ }
709
+ return exit;
710
+ }
711
+ #succeeded(session, encoded, decoded, value) {
712
+ if (session !== this.#session) {
713
+ return;
714
+ }
715
+ const lastSubmitted = Option.some({ decoded, encoded });
716
+ this.#update((latest) => ({
717
+ ...latest,
718
+ initial: this.options.resetOnSuccess === true ? latest.initial : encoded,
719
+ lastSubmitted,
720
+ submitResult: AsyncResult.success(value),
721
+ submitting: false,
722
+ }));
723
+ if (this.options.resetOnSuccess === true) {
724
+ this.#restart(this.#original, { keepSubmit: true });
725
+ }
726
+ }
727
+ #autoSubmit() {
728
+ if (this.options.autoSubmit !== true || this.#disposed) {
729
+ return;
730
+ }
731
+ if (this.#inflight !== undefined) {
732
+ this.#queued = true;
733
+ return;
734
+ }
735
+ const state = this.#current();
736
+ const baseline = Option.match(state.lastSubmitted, {
737
+ onNone: () => state.initial,
738
+ onSome: ({ encoded }) => encoded,
739
+ });
740
+ const values = this.#currentValues();
741
+ // Only edits: not the values a reset (as after resetOnSuccess) brought back.
742
+ if (state.issues.size === 0 &&
743
+ this.#isCurrent() &&
744
+ !equals(values, baseline) &&
745
+ !equals(values, state.initial)) {
746
+ void this.submit();
747
+ }
748
+ }
749
+ // ── lookups for fields ─────────────────────────────────────────────────────────────────────
750
+ /** The error to show on a path: from outside the Schema first, then the Schema's once revealed. */
751
+ errorAt(path) {
752
+ const { state } = this;
753
+ const id = pathId(path);
754
+ const external = state.external.get(id);
755
+ if (external !== undefined) {
756
+ return external.message;
757
+ }
758
+ return state.revealed.has(id) ? state.issues.get(id) : undefined;
759
+ }
760
+ indexPath(path) {
761
+ return toIndexPath(this.state.keys, path);
762
+ }
763
+ /** Resolves `relative`, an index path below `base`, to a key path. */
764
+ resolve(base, relative) {
765
+ const { keys } = this.state;
766
+ const out = [...base];
767
+ for (const segment of toPath(relative)) {
768
+ if (typeof segment === "number") {
769
+ const key = keys.get(pathId(out))?.[segment];
770
+ if (key === undefined) {
771
+ throw new RangeError(`effect-atom-svelte: the form has no item at index ${segment} of ${JSON.stringify(this.indexPath(out) ?? out)}`);
772
+ }
773
+ out.push(key);
774
+ }
775
+ else {
776
+ out.push(segment);
777
+ }
778
+ }
779
+ return out;
780
+ }
781
+ elementId(path) {
782
+ return path.length === 0
783
+ ? this.options.id
784
+ : `${this.options.id}-${idSuffix(path)}`;
785
+ }
786
+ constraints(path) {
787
+ const schemaPath = path.map((segment) => typeof segment === "number" ? 0 : segment);
788
+ const id = pathId(schemaPath);
789
+ let found = this.#constraints.get(id);
790
+ if (found === undefined) {
791
+ found = constraintsAt(this.definition.schema.ast, schemaPath);
792
+ this.#constraints.set(id, found);
793
+ }
794
+ return found;
795
+ }
796
+ field(path) {
797
+ const id = pathId(path);
798
+ let field = this.#fields.get(id);
799
+ if (field === undefined) {
800
+ field = new FieldImpl(this, path);
801
+ this.#fields.set(id, field);
802
+ }
803
+ return field;
804
+ }
805
+ group(path) {
806
+ const id = pathId(path);
807
+ let group = this.#groups.get(id);
808
+ if (group === undefined) {
809
+ group = new GroupImpl(this, path);
810
+ this.#groups.set(id, group);
811
+ }
812
+ return group;
813
+ }
814
+ array(path) {
815
+ const id = pathId(path);
816
+ let array = this.#arrays.get(id);
817
+ if (array === undefined) {
818
+ array = new ArrayImpl(this, path);
819
+ this.#arrays.set(id, array);
820
+ }
821
+ return array;
822
+ }
823
+ // ── lifetime ────────────────────────────────────────────────────────────────────────────────
824
+ #disposeAfterSubmit = false;
825
+ /** Releases the instance's atoms; a submit in flight keeps them until it settles. */
826
+ dispose() {
827
+ if (this.#disposed) {
828
+ return;
829
+ }
830
+ if (this.#inflight !== undefined) {
831
+ this.#disposeAfterSubmit = true;
832
+ return;
833
+ }
834
+ this.#disposed = true;
835
+ clearTimeout(this.#timer);
836
+ this.#release();
837
+ for (const release of this.#releases) {
838
+ release();
839
+ }
840
+ this.#releases = [];
841
+ }
842
+ }