@thomasfosterau/effect-forms-svelte 0.0.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.
@@ -0,0 +1,986 @@
1
+ import { validateData, validateField } from "./ClientValidate.js";
2
+ import { computeDataApplication, objectToFormData, parseActionResult, resultToUpdate } from "./EnhanceCore.js";
3
+ import { computeTainted, isAnyTainted, isTaintedAt, taintedLeafPaths } from "./Tainted.js";
4
+ import { dispatchChange } from "./Listeners.js";
5
+ import { Constraints, Errors, Field, Form, FormBuilder, Mode, Path, Validate } from "@thomasfosterau/effect-forms";
6
+ import * as Duration from "effect/Duration";
7
+ import * as Effect from "effect/Effect";
8
+ import * as Fiber from "effect/Fiber";
9
+ import * as Layer from "effect/Layer";
10
+ import * as ManagedRuntime from "effect/ManagedRuntime";
11
+ import { createAttachmentKey } from "svelte/attachments";
12
+ //#region src/FormStore.svelte.ts
13
+ /**
14
+ * @copyright Thomas Foster
15
+ *
16
+ * The reactive Svelte 5 form store.
17
+ *
18
+ * {@link createForm} binds a schema to a reactive, rune-backed {@link FormStore}.
19
+ * Give it either `{ schema, defaults }` (it wraps the core's `makeForm` to seed
20
+ * an empty form) or a server-produced {@link FormState} to hydrate from. The
21
+ * store exposes mutable `data` (bind inputs to it), typed `errors`
22
+ * (`form.errors.name`), `submitting` / `delayed` / `message` / `valid`, tainted
23
+ * tracking, and a spreadable {@link FormStore.props} for `<form {...form.props}>`
24
+ * that wires progressive-enhancement submission (a Svelte attachment).
25
+ *
26
+ * Submission is handled one of two ways: an `onSubmit` handler the page
27
+ * provides (full client-side / JS handling, with `cancel()` and an optional
28
+ * returned result), or — by default — a POST to the form's action (e.g. a
29
+ * derived SvelteKit action) whose result is applied back to the store.
30
+ *
31
+ * ## Validation engine v2
32
+ *
33
+ * `errors` merges two parallel trees (see {@link Errors.SourcedErrors}):
34
+ * `server` (the last submission's response) and `client` (browser-side
35
+ * validation — the schema, per-field validators, a builder's `refineEffect`
36
+ * refinements). A field's value changing clears *only that field's* server
37
+ * error — a `State.withError` uniqueness failure the user hasn't touched
38
+ * survives a client revalidation of some other field. `clientErrors` /
39
+ * `serverErrors` expose the per-source trees for a UI that wants to style
40
+ * them differently.
41
+ *
42
+ * `validators` (keyed by {@link Path.toString}) attach per-field sync/async
43
+ * validators (the `FieldValidators` shape — TanStack Form's `onChange(Async)`
44
+ * / `onBlur(Async)` vocabulary). Async validators run as Effect fibers: a new
45
+ * value interrupts the field's in-flight fiber before forking the next run —
46
+ * debounce + "latest wins" cancellation fall out of that alone (see
47
+ * {@link Validate.runAsyncFieldValidator}). `isValidating` /
48
+ * {@link FormStore.isValidatingAt} reflect any fiber still running; supply
49
+ * `layer` when a validator (or a builder's `refineEffect`) needs services.
50
+ *
51
+ * `Mode.RevalidateMode` (`options.mode.revalidate`, default `"onChange"`)
52
+ * governs what happens *after* a failed post in `onSubmit` mode: fixing a
53
+ * field re-validates it (clearing its error) without a resubmit.
54
+ *
55
+ * @module effect-forms-svelte/FormStore
56
+ */
57
+ const { clearSourceAt, emptySourced, hasErrors, mergeSourced, noErrors, replaceAt, setSourceAt } = Errors;
58
+ const { getAt, setAt } = Path;
59
+ const makeForm = Form.make;
60
+ /**
61
+ * A typed, reactive handle to one field, returned by {@link FormStore.field}.
62
+ * Bundles everything a single input needs — `bind:value={field.value}`,
63
+ * `name={field.name}`, `onblur={field.handleBlur}`, `{...field.constraints}`,
64
+ * `field.visibleErrors`, `field.meta` — instead of wiring each by hand against
65
+ * an untyped {@link FormPath}. See {@link Path.FieldPath} for how the path is
66
+ * type-checked.
67
+ */
68
+ var FieldHandle = class {
69
+ #store;
70
+ #path;
71
+ /** The path in flat field-name notation — use as the input's `name` attribute. */
72
+ name;
73
+ constructor(store, path) {
74
+ this.#store = store;
75
+ this.#path = path;
76
+ this.name = Path.toString(path);
77
+ }
78
+ /** The field's current value. Assignable — `bind:value={field.value}` works. */
79
+ get value() {
80
+ return this.#store.valueAt(this.#path);
81
+ }
82
+ set value(next) {
83
+ this.#store.setValue(this.#path, next);
84
+ }
85
+ /** All messages currently attached to this field. */
86
+ get errors() {
87
+ return this.#store.errorsAt(this.#path);
88
+ }
89
+ /** This field's messages, gated by the form's validation mode (see {@link FormStore.shouldShowError}). */
90
+ get visibleErrors() {
91
+ return this.#store.visibleErrorsAt(this.#path);
92
+ }
93
+ /** Native validation attributes for this field — spread onto the `<input>`. */
94
+ get constraints() {
95
+ return this.#store.constraintAt(this.#path);
96
+ }
97
+ /** This field's {@link FieldMeta}. */
98
+ get meta() {
99
+ const isBlurred = this.#store.isTouchedAt(this.#path);
100
+ const isDirty = this.#store.isDirtyAt(this.#path);
101
+ return {
102
+ isTouched: isBlurred || isDirty,
103
+ isBlurred,
104
+ isDirty,
105
+ isDefaultValue: this.#store.isDefaultValueAt(this.#path)
106
+ };
107
+ }
108
+ /** Mark this field blurred — see {@link FormStore.handleBlur}. */
109
+ handleBlur = () => {
110
+ this.#store.handleBlur(this.#path);
111
+ };
112
+ };
113
+ const defaultSubmit = async (body, action) => {
114
+ const response = await fetch(action, {
115
+ method: "POST",
116
+ body,
117
+ headers: { accept: "application/json" }
118
+ });
119
+ let parsed;
120
+ try {
121
+ parsed = await response.json();
122
+ } catch {
123
+ return {
124
+ _tag: "Error",
125
+ status: response.status,
126
+ message: response.statusText || "Request failed"
127
+ };
128
+ }
129
+ return parseActionResult(parsed, response.status);
130
+ };
131
+ /** Whether `index` is a valid position in an array of `length`. */
132
+ const inRange = (index, length) => index >= 0 && index < length;
133
+ /** A reactive form, returned by {@link createForm}. */
134
+ var FormStore = class {
135
+ /** The reactive form data — bind inputs to its fields. */
136
+ data = $state();
137
+ /** The current status message, if any. */
138
+ message = $state();
139
+ /** Native input constraints derived server-side. */
140
+ constraints = $state({});
141
+ /** Whether a submission is in flight. */
142
+ submitting = $state(false);
143
+ /** Whether the in-flight submission has passed `delayMs`. */
144
+ delayed = $state(false);
145
+ /** Whether the current state came from a submission. */
146
+ posted = $state(false);
147
+ /** Whether the last validation/submit was valid. */
148
+ valid = $state(false);
149
+ /** How many times {@link FormStore.enhance}'s submit handler has actually attempted a submission. */
150
+ submissionAttempts = $state(0);
151
+ /** The values sent by the last submission (for revert / changed-since-submit). */
152
+ #lastSubmitted = $state();
153
+ /**
154
+ * Per-field blurred flags, keyed by {@link Path.toString}. Drives `onBlur`
155
+ * display (via {@link shouldShowError}) and {@link FieldMeta.isBlurred}.
156
+ */
157
+ #touched = $state({});
158
+ /**
159
+ * Per-field persistent dirty flags, keyed by {@link Path.toString}. Set once
160
+ * a field's value is changed away from its default via {@link setValue} or
161
+ * an array mutator (`append` / `removeItem` / `swap` / `move`), and never
162
+ * cleared except by {@link reset} — unlike {@link taintedAt}, a revert to the
163
+ * default value does not clear it. Backs {@link FieldMeta.isDirty}. Only
164
+ * tracks writes made through those methods — mutating `data` directly
165
+ * (`bind:value={form.data.x}`, bypassing {@link FormStore.field}) never marks
166
+ * a field dirty.
167
+ */
168
+ #dirtied = $state({});
169
+ /** Parallel error trees — see {@link Errors.SourcedErrors}. `errors` is their merged view. */
170
+ #sourced = $state(emptySourced);
171
+ /** Whether *any* validation (whole-form or per-field) has run yet — see {@link canSubmit}. */
172
+ #hasValidated = $state(false);
173
+ /** Per-field / whole-form ("") async-validation-in-flight flags, keyed by `"<path>:<trigger>"` or `""`. */
174
+ #validating = $state({});
175
+ /** Per-key run counter guarding `#validating` writes — see {@link FormStore.#runValidatorFiber}. */
176
+ #validatingGeneration = {};
177
+ /** The outcome of the last completed submission, for {@link isSubmitSuccessful}. */
178
+ #lastSubmitOutcome = $state(void 0);
179
+ id;
180
+ /** The tainted baseline `tainted`/`isTainted` compare against — rebased on every successful submit. */
181
+ #initial;
182
+ #options;
183
+ #mode;
184
+ #attachmentKey = createAttachmentKey();
185
+ #runtime;
186
+ #autoSubmitNode;
187
+ #validateTimer;
188
+ /** Per-field-listener debounce timers, keyed by `"change:<path>"` / `"blur:<path>"`. */
189
+ #listenerTimers = /* @__PURE__ */ new Map();
190
+ /** Per-field validators, keyed by {@link Path.toString}; seeded from `options.validators`. */
191
+ #fieldValidators;
192
+ /** In-flight async-validator fibers, keyed by `"<path>:<trigger>"` (fields) or `""` (whole-form `refineEffect`). */
193
+ #fibers = {};
194
+ /** The error paths the last `refineEffect` run set, so the next run can clear exactly those before merging fresh ones. */
195
+ #lastRefinementPaths = [];
196
+ /** The data snapshot the field-tracking watcher last diffed against. */
197
+ #previousData;
198
+ /** The current nested error tree (server errors merged with client errors), typed to the form data's shape. */
199
+ errors = $derived.by(() => mergeSourced(this.#sourced));
200
+ /** Client-side-sourced errors only (schema, per-field validators, `refineEffect`). */
201
+ clientErrors = $derived.by(() => this.#sourced.client);
202
+ /** Server-sourced errors only (the last submission's response). */
203
+ serverErrors = $derived.by(() => this.#sourced.server);
204
+ /** Whether any async field validator or `refineEffect` run is currently in flight. */
205
+ isValidating = $derived(Object.values(this.#validating).some(Boolean));
206
+ /** Whether the form has ever been submitted (attempted). */
207
+ isSubmitted = $derived(this.submissionAttempts > 0);
208
+ /** Whether the most recently *completed* submission succeeded. `false` before any submit completes. */
209
+ isSubmitSuccessful = $derived(this.#lastSubmitOutcome === "success");
210
+ /**
211
+ * Whether the submit button should be enabled: not mid-submit, not
212
+ * mid-validation, and either nothing has been validated yet (a pristine
213
+ * form isn't presumptively invalid — the server gets the first look) or the
214
+ * last validation run said valid.
215
+ */
216
+ canSubmit = $derived(!this.submitting && !this.isValidating && (!this.#hasValidated || this.valid));
217
+ /** The tainted-field tree, re-derived as `data` changes. */
218
+ tainted = $derived.by(() => computeTainted(this.#initial, this.data));
219
+ /** Whether any field has been changed since the form was seeded. */
220
+ isTainted = $derived(isAnyTainted(this.tainted));
221
+ /** The tree of fields changed since the last submission (empty before any submit). */
222
+ changedSinceSubmit = $derived.by(() => this.#lastSubmitted === void 0 ? {} : computeTainted(this.#lastSubmitted, this.data));
223
+ /** Whether anything has changed since the last submission. */
224
+ hasChangedSinceSubmit = $derived(isAnyTainted(this.changedSinceSubmit));
225
+ constructor(initial, options = {}) {
226
+ this.id = initial.id;
227
+ this.#initial = structuredClone(initial.data);
228
+ this.#previousData = structuredClone(initial.data);
229
+ this.#options = options;
230
+ this.#mode = Mode.parse(options.mode);
231
+ this.#runtime = ManagedRuntime.make(options.layer ?? Layer.empty);
232
+ this.#fieldValidators = { ...options.validators };
233
+ this.data = structuredClone(initial.data);
234
+ this.#sourced = {
235
+ client: noErrors,
236
+ server: initial.errors ?? noErrors
237
+ };
238
+ this.message = initial.message;
239
+ this.constraints = initial.constraints ?? (options.schema ? Constraints.constraintsFromSchema(options.schema) : {});
240
+ this.posted = initial.posted;
241
+ this.valid = initial.valid;
242
+ this.#hasValidated = initial.posted;
243
+ }
244
+ /**
245
+ * Spreadable attributes for the `<form>` element: `<form {...form.props}>`.
246
+ * Sets `method="post"` (and `action`, if configured) and attaches the
247
+ * progressive-enhancement submit handler via a Svelte attachment.
248
+ */
249
+ get props() {
250
+ return {
251
+ method: "post",
252
+ ...this.#options.action !== void 0 ? { action: this.#options.action } : {},
253
+ [this.#attachmentKey]: this.#enhanceAttachment
254
+ };
255
+ }
256
+ /** Whether the field at `path` (or anything beneath it) is tainted. */
257
+ taintedAt(path) {
258
+ return isTaintedAt(this.tainted, path);
259
+ }
260
+ /** The messages currently attached to `path` (for dynamic / nested paths). */
261
+ errorsAt(path) {
262
+ return Errors.getError(this.errors, path) ?? [];
263
+ }
264
+ /** Whether the async validator(s) at `path` are currently running. */
265
+ isValidatingAt(path) {
266
+ const key = Path.toString(path);
267
+ return this.#validating[`${key}:change`] === true || this.#validating[`${key}:blur`] === true;
268
+ }
269
+ /**
270
+ * Register (or replace) `path`'s validators — the option-driven equivalent
271
+ * of attaching `validators` at {@link createForm} time, for fields that
272
+ * don't exist until later (e.g. an appended array item).
273
+ */
274
+ setFieldValidators(path, validators) {
275
+ this.#fieldValidators = {
276
+ ...this.#fieldValidators,
277
+ [Path.toString(path)]: validators
278
+ };
279
+ }
280
+ /** Re-validate the whole form against the schema (if one was supplied), plus a builder's `refineEffect` refinements. */
281
+ validate() {
282
+ if (this.#options.schema) {
283
+ const clientErrors = validateData(this.#options.schema, this.data, this.#options.validationFormat);
284
+ this.#sourced = {
285
+ ...this.#sourced,
286
+ client: clientErrors
287
+ };
288
+ this.#hasValidated = true;
289
+ this.#recomputeValid();
290
+ }
291
+ this.#runRefineEffect();
292
+ return this.valid;
293
+ }
294
+ /** Validate a single field against the schema, returning that field's messages. */
295
+ validateField(path) {
296
+ if (!this.#options.schema) return this.errorsAt(path);
297
+ return validateField(this.#options.schema, this.data, path, this.#options.validationFormat);
298
+ }
299
+ /** Set the value at `path` (path-addressed, immutable). Bind directly to `data` otherwise. */
300
+ setValue(path, value) {
301
+ this.data = setAt(this.data, path, value);
302
+ this.#markDirty(path);
303
+ this.#previousData = this.#detachedData();
304
+ this.#onFieldChanged(path);
305
+ }
306
+ /** Read the value at `path`. */
307
+ valueAt(path) {
308
+ return getAt(this.data, path);
309
+ }
310
+ /** Mark the field at `path` touched (defaults to `true`) — drives `onBlur` display. */
311
+ setTouched(path, touched = true) {
312
+ this.#touched = {
313
+ ...this.#touched,
314
+ [Path.toString(path)]: touched
315
+ };
316
+ }
317
+ /** Whether the field at `path` has been touched. */
318
+ isTouchedAt(path) {
319
+ return this.#touched[Path.toString(path)] === true;
320
+ }
321
+ /**
322
+ * Once a change away from the default at `path` is seen (per the current
323
+ * `data`, against `taintedAt`), sticks until {@link reset}. Called after
324
+ * `data` has already been updated, by every method that writes it
325
+ * (`setValue` and the array mutators below) — writing `data` directly
326
+ * (`bind:value={form.data.x}`) bypasses this, same as it bypasses `tainted`
327
+ * recomputing off anything but `data` itself would.
328
+ */
329
+ #markDirty(path) {
330
+ const key = Path.toString(path);
331
+ if (!this.#dirtied[key] && this.taintedAt(path)) this.#dirtied = {
332
+ ...this.#dirtied,
333
+ [key]: true
334
+ };
335
+ }
336
+ /** Whether the field at `path` was ever changed away from its default — see {@link FieldMeta.isDirty}. */
337
+ isDirtyAt(path) {
338
+ return this.#dirtied[Path.toString(path)] === true;
339
+ }
340
+ /** Whether the field at `path` currently holds its default value — the negation of {@link taintedAt}. */
341
+ isDefaultValueAt(path) {
342
+ return !this.taintedAt(path);
343
+ }
344
+ /** The native validation attributes for `path` (a leaf of {@link constraints}). */
345
+ constraintAt(path) {
346
+ return Constraints.constraintAt(this.constraints, path);
347
+ }
348
+ /**
349
+ * A typed, reactive handle to the field at `path` — see {@link FieldHandle}.
350
+ * `path` is type-checked against the form data's shape (see {@link
351
+ * Path.FieldPath}); a typo'd segment or wrong-shaped path is a compile error.
352
+ */
353
+ field(path) {
354
+ return new FieldHandle(this, Path.fromString(path));
355
+ }
356
+ /**
357
+ * Handle a field losing focus: mark it touched, run its `onBlur` /
358
+ * `onBlurAsync` validators (or, once posted, the revalidate-on-blur mode),
359
+ * fire that field's `onBlur` listener (if any, debounced per its config),
360
+ * and — in `onBlur` validation mode — validate the whole form
361
+ * (auto-submitting if configured and something changed since the last
362
+ * submit).
363
+ */
364
+ handleBlur(path) {
365
+ this.setTouched(path, true);
366
+ this.#onFieldBlurred(path);
367
+ const key = Path.toString(path);
368
+ const field = this.#options.listeners?.fields?.[key];
369
+ if (field?.onBlur) {
370
+ const value = this.valueAt(path);
371
+ this.#scheduleListener(`blur:${key}`, field.debounce, () => field.onBlur?.({
372
+ path,
373
+ value
374
+ }));
375
+ }
376
+ if (this.#mode.validation === "onBlur") {
377
+ this.validate();
378
+ if (this.#mode.autoSubmit && this.valid && this.#hasUnsubmittedChanges()) this.#autoSubmit();
379
+ }
380
+ }
381
+ /** Run `run` now, or after `debounce` (per-key timer, replacing any pending run for that key). */
382
+ #scheduleListener(key, debounce, run) {
383
+ if (debounce === void 0) {
384
+ run();
385
+ return;
386
+ }
387
+ clearTimeout(this.#listenerTimers.get(key));
388
+ this.#listenerTimers.set(key, setTimeout(() => {
389
+ this.#listenerTimers.delete(key);
390
+ run();
391
+ }, Duration.toMillis(debounce)));
392
+ }
393
+ /**
394
+ * The listeners table to dispatch change events against, with each field's
395
+ * `onChange` wrapped in this store's own debounce timer — or `undefined` if
396
+ * nothing is configured to consume a change (an `onMount`-only config, say),
397
+ * so the deep watcher can skip diffing the data tree entirely. Built once
398
+ * per {@link enhance} call rather than per change, since the listener table
399
+ * itself doesn't change across the mount's lifetime.
400
+ */
401
+ #buildDispatchListeners() {
402
+ const listeners = this.#options.listeners;
403
+ if (!listeners || listeners.onChange === void 0 && !listeners.fields) return void 0;
404
+ if (!listeners.fields) return listeners;
405
+ const fields = {};
406
+ for (const [key, field] of Object.entries(listeners.fields)) fields[key] = field.onChange ? {
407
+ ...field,
408
+ onChange: (input) => this.#scheduleListener(`change:${key}`, field.debounce, () => field.onChange?.(input))
409
+ } : field;
410
+ return {
411
+ ...listeners,
412
+ fields
413
+ };
414
+ }
415
+ /**
416
+ * Whether there are edits worth auto-submitting: before any submit, any
417
+ * change from the seeded values; afterwards, any change since the last
418
+ * submission. Stops `autoSubmit` from re-posting server-echoed data in a loop.
419
+ */
420
+ #hasUnsubmittedChanges() {
421
+ return this.#lastSubmitted === void 0 ? this.isTainted : this.hasChangedSinceSubmit;
422
+ }
423
+ /**
424
+ * Whether an error at `path` should be shown yet, per the validation mode:
425
+ * always after a submit; in `onChange` once the field is tainted; in `onBlur`
426
+ * once it is touched; never in `onSubmit` before posting.
427
+ */
428
+ shouldShowError(path) {
429
+ if (this.posted) return true;
430
+ if (this.#mode.validation === "onChange") return this.taintedAt(path);
431
+ if (this.#mode.validation === "onBlur") return this.isTouchedAt(path);
432
+ return false;
433
+ }
434
+ /** The messages at `path`, but only when {@link shouldShowError} allows it. */
435
+ visibleErrorsAt(path) {
436
+ return this.shouldShowError(path) ? this.errorsAt(path) : [];
437
+ }
438
+ /** Read the array at `path` (empty when absent). */
439
+ #arrayAt(path) {
440
+ return getAt(this.data, path) ?? [];
441
+ }
442
+ /** Append an item to the array at `path` (a blank item is derived when omitted). */
443
+ append(path, value) {
444
+ const current = this.#arrayAt(path);
445
+ const item = value !== void 0 ? value : this.#defaultItem(path);
446
+ this.data = setAt(this.data, path, [...current, item]);
447
+ this.#markDirty(path);
448
+ this.#previousData = this.#detachedData();
449
+ this.#onFieldChanged(path);
450
+ }
451
+ /**
452
+ * Insert an item into the array at `path` at `index` (clamped to the
453
+ * array's bounds; a non-finite `index` inserts at the end), shifting later
454
+ * items — and their per-index touched flags / errors — up by one. A blank
455
+ * item is derived when `value` is omitted.
456
+ */
457
+ insertItem(path, index, value) {
458
+ const current = this.#arrayAt(path);
459
+ const at = Math.max(0, Math.min(Number.isFinite(index) ? index : current.length, current.length));
460
+ const item = value !== void 0 ? value : this.#defaultItem(path);
461
+ this.data = setAt(this.data, path, [
462
+ ...current.slice(0, at),
463
+ item,
464
+ ...current.slice(at)
465
+ ]);
466
+ this.#markDirty(path);
467
+ this.#remapArrayIndices(path, Path.insertAt(at));
468
+ }
469
+ /**
470
+ * Replace the item at `index` in the array at `path` in place. Drops that
471
+ * index's own touched flag / errors (they described the old value); other
472
+ * indices are untouched.
473
+ */
474
+ replaceItem(path, index, value) {
475
+ const current = this.#arrayAt(path);
476
+ if (!inRange(index, current.length)) return;
477
+ const next = [...current];
478
+ next[index] = value;
479
+ this.data = setAt(this.data, path, next);
480
+ this.#markDirty(path);
481
+ this.#remapArrayIndices(path, Path.replaceAt(index));
482
+ }
483
+ /** Remove every item from the array at `path`, dropping all of its per-index state. */
484
+ clearItems(path) {
485
+ this.data = setAt(this.data, path, []);
486
+ this.#markDirty(path);
487
+ this.#remapArrayIndices(path, Path.clearAll);
488
+ }
489
+ /**
490
+ * Remove the item at `index` from the array at `path`, shifting later
491
+ * items' per-index touched flags / errors down by one so they keep
492
+ * following their row.
493
+ */
494
+ removeItem(path, index) {
495
+ const current = this.#arrayAt(path);
496
+ if (!inRange(index, current.length)) return;
497
+ this.data = setAt(this.data, path, current.filter((_, i) => i !== index));
498
+ this.#markDirty(path);
499
+ this.#remapArrayIndices(path, Path.removeAt(index));
500
+ this.#previousData = this.#detachedData();
501
+ this.#onFieldChanged(path);
502
+ }
503
+ /** Swap two items in the array at `path`, swapping their per-index touched flags / errors too. */
504
+ swap(path, indexA, indexB) {
505
+ const current = this.#arrayAt(path);
506
+ if (!inRange(indexA, current.length) || !inRange(indexB, current.length) || indexA === indexB) return;
507
+ const next = [...current];
508
+ [next[indexA], next[indexB]] = [next[indexB], next[indexA]];
509
+ this.data = setAt(this.data, path, next);
510
+ this.#markDirty(path);
511
+ this.#remapArrayIndices(path, Path.swapAt(indexA, indexB));
512
+ this.#previousData = this.#detachedData();
513
+ this.#onFieldChanged(path);
514
+ }
515
+ /** Move the item at `from` to `to` within the array at `path`, carrying its per-index state along. */
516
+ move(path, from, to) {
517
+ const current = this.#arrayAt(path);
518
+ if (!inRange(from, current.length) || to < 0 || to >= current.length || from === to) return;
519
+ const next = [...current];
520
+ const [item] = next.splice(from, 1);
521
+ next.splice(to, 0, item);
522
+ this.data = setAt(this.data, path, next);
523
+ this.#markDirty(path);
524
+ this.#remapArrayIndices(path, Path.moveAt(from, to));
525
+ this.#previousData = this.#detachedData();
526
+ this.#onFieldChanged(path);
527
+ }
528
+ /**
529
+ * Remap the per-index touched flags and errors under the array at `path`
530
+ * through `map`, so state recorded against a row follows it through an
531
+ * index-shifting mutation. See {@link Path.IndexMap}.
532
+ */
533
+ #remapArrayIndices(path, map) {
534
+ const sourced = this.#sourcedSnapshot();
535
+ this.#sourced = {
536
+ client: Errors.remapIndices(sourced.client, path, map),
537
+ server: Errors.remapIndices(sourced.server, path, map)
538
+ };
539
+ this.#touched = Path.remapKeyedRecord(this.#touched, path, map);
540
+ }
541
+ /** Revert `data` to the values sent by the last submission (no-op before any submit). */
542
+ revertToLastSubmit() {
543
+ if (this.#lastSubmitted === void 0) return;
544
+ this.data = structuredClone($state.snapshot(this.#lastSubmitted));
545
+ this.#previousData = this.#detachedData();
546
+ this.#sourced = emptySourced;
547
+ }
548
+ /**
549
+ * Derive a blank item for the array field at `path`, at any nesting depth
550
+ * (e.g. `authors[0].emails`) — by walking the bound schema's AST there when
551
+ * one is available, falling back to a top-level {@link FormOptions.fields}
552
+ * lookup, else `""`.
553
+ */
554
+ #defaultItem(path) {
555
+ if (this.#options.schema) return Field.defaultItemAt(this.#options.schema, path);
556
+ const fields = this.#options.fields;
557
+ if (fields && path.length === 1 && typeof path[0] === "string") {
558
+ const def = fields[path[0]];
559
+ if (def && Field.isArrayFieldDef(def)) return Field.defaultValue(def.itemSchema);
560
+ }
561
+ return "";
562
+ }
563
+ /** Submit via the enhanced node, if one is mounted and not already in flight. */
564
+ #autoSubmit() {
565
+ if (this.submitting || this.#autoSubmitNode === void 0) return;
566
+ this.#runSubmit(this.#autoSubmitNode);
567
+ }
568
+ /** Debounced (in `onChange`) whole-form validation, auto-submitting when valid. */
569
+ #scheduleValidation() {
570
+ const run = () => {
571
+ this.validate();
572
+ if (this.#mode.autoSubmit && this.valid && this.#hasUnsubmittedChanges()) this.#autoSubmit();
573
+ };
574
+ const ms = this.#mode.debounce;
575
+ if (ms !== null && ms > 0) {
576
+ clearTimeout(this.#validateTimer);
577
+ this.#validateTimer = setTimeout(run, ms);
578
+ } else run();
579
+ }
580
+ #recomputeValid() {
581
+ this.valid = !hasErrors(mergeSourced(this.#sourced));
582
+ }
583
+ /**
584
+ * A plain (non-`$state`-proxy) snapshot of `#sourced`. `clearSourceAt` /
585
+ * `setSourceAt` / `replaceAt` in the framework-neutral core `structuredClone`
586
+ * their `errors` argument — which throws `DataCloneError` on a live Svelte
587
+ * `$state` proxy — so anywhere `#sourced` (or one of its two branches) feeds
588
+ * one of those calls, snapshot it first.
589
+ */
590
+ #sourcedSnapshot() {
591
+ return $state.snapshot(this.#sourced);
592
+ }
593
+ /** Whether {@link Mode.ParsedMode.revalidate} names this field trigger. */
594
+ #revalidatesOn(trigger) {
595
+ if (this.#mode.revalidate === "never") return false;
596
+ return this.#mode.revalidate === "onChange" ? trigger === "change" : trigger === "blur";
597
+ }
598
+ /** Interrupt (fire-and-forget) any fiber running under `key`, if one is in flight. */
599
+ #interruptFiber(key) {
600
+ const previous = this.#fibers[key];
601
+ if (previous) Effect.runFork(Fiber.interrupt(previous));
602
+ delete this.#fibers[key];
603
+ }
604
+ /**
605
+ * Fork `effect` under `key` as an interrupt-on-supersede fiber, toggling
606
+ * `#validating[key]` for its duration: any fiber already running under
607
+ * `key` is interrupted first (this — not a timer — is what gives "latest
608
+ * wins"; see {@link Validate.runAsyncFieldValidator}). The old and new
609
+ * fiber's `#validating` writes are guarded by a per-key generation counter
610
+ * — `Effect.runFork(Fiber.interrupt(previous))` is fire-and-forget, so the
611
+ * *old* fiber's `Effect.ensuring` finalizer can run after the *new* one has
612
+ * already flipped `#validating[key]` back to `true`; without the guard that
613
+ * finalizer would clear it while the new run is still genuinely in flight.
614
+ */
615
+ #runValidatorFiber(key, effect) {
616
+ this.#interruptFiber(key);
617
+ const generation = (this.#validatingGeneration[key] ?? 0) + 1;
618
+ this.#validatingGeneration[key] = generation;
619
+ this.#validating = {
620
+ ...this.#validating,
621
+ [key]: true
622
+ };
623
+ const program = effect.pipe(Effect.ensuring(Effect.sync(() => {
624
+ if (this.#validatingGeneration[key] === generation) this.#validating = {
625
+ ...this.#validating,
626
+ [key]: false
627
+ };
628
+ })));
629
+ this.#fibers[key] = this.#runtime.runFork(program);
630
+ }
631
+ /**
632
+ * Handle a field's value actually changing: the field's server-sourced
633
+ * error (if any) is superseded — clear it — then run whichever validators
634
+ * apply for the `"change"` trigger.
635
+ */
636
+ #onFieldChanged(path) {
637
+ this.#sourced = clearSourceAt(this.#sourcedSnapshot(), "server", path);
638
+ this.#runFieldTrigger(path, "change");
639
+ this.#recomputeValid();
640
+ }
641
+ /** Handle a field losing focus: run whichever validators apply for the `"blur"` trigger. */
642
+ #onFieldBlurred(path) {
643
+ this.#runFieldTrigger(path, "blur");
644
+ this.#recomputeValid();
645
+ }
646
+ /**
647
+ * Run `path`'s sync + async validators for `trigger` (resolved by {@link
648
+ * Validate.resolveFieldTrigger}). When neither is registered, falls back to
649
+ * a whole-schema {@link validateField} re-run once the form has been
650
+ * posted and {@link Mode.ParsedMode.revalidate} names this trigger — the
651
+ * "fixing a field clears its error without a resubmit" behaviour.
652
+ */
653
+ #runFieldTrigger(path, trigger) {
654
+ const validators = this.#fieldValidators[Path.toString(path)];
655
+ const value = getAt(this.data, path);
656
+ const resolved = Validate.resolveFieldTrigger(validators, trigger);
657
+ if (resolved.sync) this.#applySyncFieldValidator(path, resolved.sync, value);
658
+ else if (this.#shouldFallBackToRevalidate(trigger)) this.#applyRevalidateFallback(path);
659
+ if (resolved.async) {
660
+ this.#hasValidated = true;
661
+ this.#runAsyncFieldValidator(path, trigger, resolved.async, value, resolved.asyncDebounceMs);
662
+ }
663
+ }
664
+ /** Run a field's registered sync validator and set its client messages. */
665
+ #applySyncFieldValidator(path, validator, value) {
666
+ this.#setClientMessagesAt(path, Validate.runSyncFieldValidator(validator, value));
667
+ this.#hasValidated = true;
668
+ }
669
+ /** Whether, absent a registered validator, a whole-schema fallback revalidation applies for `trigger`. */
670
+ #shouldFallBackToRevalidate(trigger) {
671
+ return this.posted && this.#revalidatesOn(trigger) && this.#options.schema !== void 0;
672
+ }
673
+ /** Re-run the whole-schema client validation and adopt just `path`'s messages. */
674
+ #applyRevalidateFallback(path) {
675
+ this.#sourced = setSourceAt(this.#sourcedSnapshot(), "client", path, this.validateField(path));
676
+ this.#hasValidated = true;
677
+ }
678
+ /** Replace `path`'s client messages with `issues`' messages (folding {@link FormIssue}s at the field's own path). */
679
+ #setClientMessagesAt(path, issues) {
680
+ this.#sourced = setSourceAt(this.#sourcedSnapshot(), "client", path, issues.map((issue) => issue.message));
681
+ }
682
+ /**
683
+ * Run `path`'s `trigger` async validator as an interrupt-on-supersede
684
+ * fiber (via {@link FormStore.#runValidatorFiber}) — the result (unless
685
+ * the fiber is itself later interrupted) replaces the field's client
686
+ * messages.
687
+ */
688
+ #runAsyncFieldValidator(path, trigger, validator, value, debounceMs) {
689
+ const fiberKey = `${Path.toString(path)}:${trigger}`;
690
+ this.#runValidatorFiber(fiberKey, Validate.runAsyncFieldValidator(validator, value, debounceMs).pipe(Effect.tap((issues) => Effect.sync(() => {
691
+ this.#setClientMessagesAt(path, issues);
692
+ this.#recomputeValid();
693
+ }))));
694
+ }
695
+ /**
696
+ * Run the builder's `refineEffect` refinements (client-side, when the form
697
+ * was seeded from a {@link FormBuilder}) as a whole-form async validator —
698
+ * interrupt-on-supersede, same as a field's async validator (see {@link
699
+ * FormStore.#runValidatorFiber}). The previous run's issue *paths* are
700
+ * cleared before the fresh ones are merged in, so a superseded run can't
701
+ * leave stale messages behind without disturbing schema-derived client
702
+ * errors set by {@link validate} in between.
703
+ */
704
+ #runRefineEffect() {
705
+ const builder = this.#options.builder;
706
+ if (!builder || FormBuilder.asyncRefinements(builder).length === 0) return;
707
+ this.#runValidatorFiber("", FormBuilder.runAsyncRefinements(builder, this.data).pipe(Effect.tap((issues) => Effect.sync(() => {
708
+ let client = this.#sourcedSnapshot().client;
709
+ for (const path of this.#lastRefinementPaths) client = replaceAt(client, path, []);
710
+ client = Errors.mergeErrors(client, Errors.issuesToErrors(issues));
711
+ this.#lastRefinementPaths = issues.map((issue) => issue.path);
712
+ this.#sourced = {
713
+ ...this.#sourced,
714
+ client
715
+ };
716
+ this.#recomputeValid();
717
+ }))));
718
+ }
719
+ /**
720
+ * Reset `data`, `errors`, and `message` back to the tainted baseline. Pass
721
+ * `values` to re-seed that baseline first (e.g. adopting a different
722
+ * entity's values) instead of returning to the original seed.
723
+ */
724
+ reset(values) {
725
+ if (values !== void 0) this.#initial = structuredClone(values);
726
+ for (const key of Object.keys(this.#fibers)) this.#interruptFiber(key);
727
+ this.data = structuredClone(this.#initial);
728
+ this.#previousData = this.#detachedData();
729
+ this.#sourced = emptySourced;
730
+ this.message = void 0;
731
+ this.valid = false;
732
+ this.posted = false;
733
+ this.#touched = {};
734
+ this.#dirtied = {};
735
+ this.#lastSubmitted = void 0;
736
+ this.#hasValidated = false;
737
+ this.#validating = {};
738
+ this.#lastRefinementPaths = [];
739
+ this.submissionAttempts = 0;
740
+ this.#lastSubmitOutcome = void 0;
741
+ }
742
+ /** A serialisable snapshot for SvelteKit's `snapshot` capture/restore. */
743
+ capture() {
744
+ return {
745
+ id: this.id,
746
+ valid: this.valid,
747
+ posted: this.posted,
748
+ data: this.#detachedData(),
749
+ errors: this.errors,
750
+ message: this.message,
751
+ constraints: this.constraints
752
+ };
753
+ }
754
+ /**
755
+ * An independent deep copy of `data`, detached from the live `$state`
756
+ * proxy. `structuredClone` can't clone the proxy directly (Node's
757
+ * structured-clone algorithm rejects it); take a plain snapshot first, per
758
+ * Svelte's own guidance for this exact case.
759
+ */
760
+ #detachedData() {
761
+ return structuredClone($state.snapshot(this.data));
762
+ }
763
+ /** Restore from a {@link capture} snapshot (its merged tree becomes the server tree; the client tree clears). */
764
+ restore(snapshot) {
765
+ this.data = structuredClone(snapshot.data);
766
+ this.#previousData = this.#detachedData();
767
+ this.#sourced = {
768
+ client: noErrors,
769
+ server: snapshot.errors
770
+ };
771
+ this.message = snapshot.message;
772
+ this.valid = snapshot.valid;
773
+ this.posted = snapshot.posted;
774
+ this.constraints = snapshot.constraints ?? {};
775
+ this.#hasValidated = snapshot.posted;
776
+ }
777
+ /**
778
+ * Apply server-sourced `data` + `errors` directly, bypassing a submit
779
+ * round-trip — for glue code that repopulates the store from a derived
780
+ * result it received some other way (e.g.
781
+ * {@link module:effect-forms-svelte/SvelteKit}'s `applyActionResult`, from
782
+ * a SvelteKit form action's failure). Sets `posted` so
783
+ * {@link shouldShowError} shows the new errors immediately; `valid` is
784
+ * always `false` (the caller only reaches for this on a failure result).
785
+ */
786
+ setServerState(data, errors) {
787
+ this.data = data;
788
+ this.#previousData = structuredClone(data);
789
+ this.#sourced = {
790
+ client: noErrors,
791
+ server: errors
792
+ };
793
+ this.valid = false;
794
+ this.posted = true;
795
+ this.#hasValidated = true;
796
+ }
797
+ #applyUpdate(update, result) {
798
+ this.#options.onUpdate?.({
799
+ update,
800
+ result
801
+ });
802
+ if (update.redirect !== void 0) return this.#applyRedirect(update.redirect);
803
+ if (update.error !== void 0) return this.#applyError(update.error);
804
+ if (update.data !== void 0) this.#applySubmittedData(update.data, result);
805
+ this.#sourced = {
806
+ client: noErrors,
807
+ server: update.errors ?? noErrors
808
+ };
809
+ this.#hasValidated = true;
810
+ this.message = update.message;
811
+ if (update.valid !== void 0) this.valid = update.valid;
812
+ this.#lastSubmitOutcome = this.valid ? "success" : "failure";
813
+ this.posted = true;
814
+ this.#options.onUpdated?.({ form: {
815
+ data: this.data,
816
+ errors: this.errors,
817
+ valid: this.valid
818
+ } });
819
+ }
820
+ #applyRedirect(location) {
821
+ this.#lastSubmitOutcome = "success";
822
+ if (this.#options.onRedirect) this.#options.onRedirect({ location });
823
+ else if (typeof window !== "undefined") window.location.assign(location);
824
+ }
825
+ #applyError(error) {
826
+ this.#lastSubmitOutcome = "failure";
827
+ this.#options.onError?.({ error });
828
+ }
829
+ /**
830
+ * Adopt (or discard) the submission's echoed data via {@link
831
+ * computeDataApplication} and rebase the tainted baseline. An
832
+ * auto-submitting (live-save) form keeps its local data as the source of
833
+ * truth: adopting the server's echo would discard keystrokes made during
834
+ * the round-trip and, when the echo isn't byte-identical (a trimmed or
835
+ * server-stamped field), retrigger the change watcher — an extra submit,
836
+ * or an endless loop for a non-idempotent echo. On a successful submit the
837
+ * tainted baseline still rebases to whatever `data` ends up holding, so
838
+ * `isTainted` (and the `beforeunload` guard it drives) reflects what was
839
+ * actually just saved, not the original seed.
840
+ */
841
+ #applySubmittedData(data, result) {
842
+ const applied = computeDataApplication({
843
+ isSuccess: result._tag === "Success",
844
+ appliedData: data,
845
+ initial: this.#initial,
846
+ submittedData: $state.snapshot(this.#lastSubmitted ?? this.data),
847
+ resetForm: this.#options.resetForm ?? false,
848
+ autoSubmit: this.#mode.autoSubmit
849
+ });
850
+ if (applied.data !== void 0) this.data = structuredClone(applied.data);
851
+ if (applied.rebaseTo !== void 0) {
852
+ this.#initial = structuredClone(applied.rebaseTo);
853
+ this.#touched = {};
854
+ }
855
+ this.#previousData = this.#detachedData();
856
+ }
857
+ async #runSubmit(node, submitter = null) {
858
+ const formData = new FormData(node, submitter);
859
+ let cancelled = false;
860
+ let resolved;
861
+ await this.#options.onSubmit?.({
862
+ data: this.data,
863
+ formData,
864
+ form: node,
865
+ submitter,
866
+ cancel: () => {
867
+ cancelled = true;
868
+ },
869
+ resolve: (result) => {
870
+ resolved = result;
871
+ }
872
+ });
873
+ if (cancelled) return;
874
+ this.#lastSubmitted = this.#detachedData();
875
+ this.submissionAttempts++;
876
+ this.submitting = true;
877
+ const delayMs = this.#options.delayMs ?? 500;
878
+ const delayTimer = setTimeout(() => {
879
+ this.delayed = true;
880
+ }, delayMs);
881
+ try {
882
+ const submit = this.#options.submit ?? defaultSubmit;
883
+ const action = node.action || (typeof location !== "undefined" ? location.href : "");
884
+ let result;
885
+ if (resolved !== void 0) result = resolved;
886
+ else if (this.#options.handler) result = await this.#options.handler(this.data);
887
+ else result = await submit(formData, action, node);
888
+ this.#options.onResult?.({ result });
889
+ this.#applyUpdate(resultToUpdate(result), result);
890
+ } catch (cause) {
891
+ const message = cause instanceof Error ? cause.message : "Submission failed";
892
+ this.#lastSubmitOutcome = "failure";
893
+ this.#options.onError?.({ error: message });
894
+ } finally {
895
+ clearTimeout(delayTimer);
896
+ this.submitting = false;
897
+ this.delayed = false;
898
+ }
899
+ }
900
+ /**
901
+ * Progressive-enhancement form action. Use as `<form use:form.enhance>` (or
902
+ * spread {@link FormStore.props}). Intercepts submit, runs the `onSubmit`
903
+ * handler, then sends the body and applies the result. Without JavaScript the
904
+ * form posts normally.
905
+ */
906
+ enhance = (node) => {
907
+ const handleSubmit = (event) => {
908
+ event.preventDefault();
909
+ this.#runSubmit(node, event.submitter);
910
+ };
911
+ node.addEventListener("submit", handleSubmit);
912
+ const beforeUnload = (event) => {
913
+ if (this.#options.taintedMessage && this.isTainted) event.preventDefault();
914
+ };
915
+ if (this.#options.taintedMessage && typeof window !== "undefined") window.addEventListener("beforeunload", beforeUnload);
916
+ this.#autoSubmitNode = node;
917
+ this.#options.listeners?.onMount?.({ data: $state.snapshot(this.data) });
918
+ const dispatchListeners = this.#buildDispatchListeners();
919
+ let stopWatching;
920
+ if (this.#mode.validation === "onChange" || dispatchListeners !== void 0) {
921
+ let previous = $state.snapshot(this.data);
922
+ stopWatching = $effect.root(() => {
923
+ $effect(() => {
924
+ const snapshot = $state.snapshot(this.data);
925
+ if (this.#mode.validation === "onChange" && (this.isTainted || this.posted)) this.#scheduleValidation();
926
+ if (dispatchListeners !== void 0) dispatchChange(previous, snapshot, dispatchListeners);
927
+ previous = snapshot;
928
+ });
929
+ });
930
+ }
931
+ const stopFieldWatching = $effect.root(() => {
932
+ $effect(() => {
933
+ const snapshot = $state.snapshot(this.data);
934
+ const changed = taintedLeafPaths(computeTainted(this.#previousData, snapshot));
935
+ this.#previousData = snapshot;
936
+ for (const path of changed) this.#onFieldChanged(path);
937
+ });
938
+ });
939
+ return { destroy: () => {
940
+ node.removeEventListener("submit", handleSubmit);
941
+ if (typeof window !== "undefined") window.removeEventListener("beforeunload", beforeUnload);
942
+ stopWatching?.();
943
+ stopFieldWatching?.();
944
+ clearTimeout(this.#validateTimer);
945
+ for (const timer of this.#listenerTimers.values()) clearTimeout(timer);
946
+ this.#listenerTimers.clear();
947
+ for (const key of Object.keys(this.#fibers)) this.#interruptFiber(key);
948
+ if (this.#autoSubmitNode === node) this.#autoSubmitNode = void 0;
949
+ } };
950
+ };
951
+ /** Attachment form of {@link FormStore.enhance} for {@link FormStore.props}. */
952
+ #enhanceAttachment = (node) => this.enhance(node).destroy;
953
+ /** Build a {@link FormData} body from the current data (SPA / manual submit). */
954
+ toFormData() {
955
+ return objectToFormData(this.data);
956
+ }
957
+ };
958
+ function createForm(source, options = {}) {
959
+ if (FormBuilder.isFormBuilder(source)) {
960
+ const schema = FormBuilder.buildSchema(source);
961
+ return new FormStore(Form.fromBuilder(source, {
962
+ ...options.defaults !== void 0 ? { defaults: options.defaults } : {},
963
+ ...options.id !== void 0 ? { id: options.id } : {}
964
+ }).empty(), {
965
+ ...options,
966
+ schema,
967
+ fields: source.fields,
968
+ builder: source
969
+ });
970
+ }
971
+ if ("defaults" in source && "schema" in source) {
972
+ const seed = source;
973
+ return new FormStore(makeForm(seed.schema, {
974
+ defaults: seed.defaults,
975
+ ...seed.id !== void 0 ? { id: seed.id } : {}
976
+ }).empty(), {
977
+ ...options,
978
+ schema: seed.schema
979
+ });
980
+ }
981
+ return new FormStore(source, options);
982
+ }
983
+ //#endregion
984
+ export { FieldHandle, FormStore, createForm };
985
+
986
+ //# sourceMappingURL=FormStore.svelte.js.map