@holo-js/forms 0.2.6 → 0.3.1

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.
@@ -27,6 +27,7 @@ interface UseFormOptions<TData, TSuccess = unknown> {
27
27
  readonly initialState?: SerializedFormSubmission<TData> | FormFailurePayload<TData> | null;
28
28
  readonly submitter?: (context: ClientSubmitContext<TData>) => Promise<ClientSubmitResult<TData, TSuccess> | void> | ClientSubmitResult<TData, TSuccess> | void;
29
29
  }
30
+ declare function markClientSubmitControlFlowError(error: unknown): unknown;
30
31
  interface FormFieldState<TValue> {
31
32
  value: TValue;
32
33
  readonly errors: readonly string[];
@@ -65,4 +66,4 @@ declare function runWithBrowserFormElement<TData, TSuccess>(formClient: Pick<Use
65
66
  */
66
67
  declare function createFormClient<TSchema extends FormSchema, TSuccess = unknown>(schemaDefinition: TSchema, options?: UseFormOptions<InferFormData<TSchema>, TSuccess>): UseFormResult<InferFormData<TSchema>, TSuccess, InferFormFieldTree<TSchema>>;
67
68
 
68
- export { type ClientSubmitContext, type ClientSubmitResult, type FormFieldState, type FormFieldTree, type InferFormFieldTree, type UseFormOptions, type UseFormResult, type ValidateOnMode, createFormClient, runWithBrowserFormElement };
69
+ export { type ClientSubmitContext, type ClientSubmitResult, type FormFieldState, type FormFieldTree, type InferFormFieldTree, type UseFormOptions, type UseFormResult, type ValidateOnMode, createFormClient, markClientSubmitControlFlowError, runWithBrowserFormElement };
@@ -133,11 +133,139 @@ function validationExceptionToFailure(error, fallbackValues = {}) {
133
133
  return void 0;
134
134
  }
135
135
 
136
+ // src/internal/formValues.ts
137
+ function isPlainFormObject(value) {
138
+ return !!value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof Blob);
139
+ }
140
+ function isSchemaFieldLike(value) {
141
+ return !!value && typeof value === "object" && value.kind === "field" && !!value.definition && typeof value.definition === "object";
142
+ }
143
+ function areFormValuesEqual(left, right) {
144
+ if (Object.is(left, right)) return true;
145
+ if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
146
+ if (left instanceof Blob || right instanceof Blob) return false;
147
+ if (Array.isArray(left) && Array.isArray(right)) {
148
+ return left.length === right.length && left.every((value, index) => areFormValuesEqual(value, right[index]));
149
+ }
150
+ if (isPlainFormObject(left) && isPlainFormObject(right)) {
151
+ const leftKeys = Object.keys(left);
152
+ const rightKeys = Object.keys(right);
153
+ return leftKeys.length === rightKeys.length && leftKeys.every((key) => key in right && areFormValuesEqual(left[key], right[key]));
154
+ }
155
+ return false;
156
+ }
157
+ function cloneFormValue(value) {
158
+ if (Array.isArray(value)) return value.map((item) => cloneFormValue(item));
159
+ if (value instanceof Date) return new Date(value.getTime());
160
+ if (isPlainFormObject(value)) {
161
+ return Object.fromEntries(
162
+ Object.entries(value).map(([key, entry]) => [key, cloneFormValue(entry)])
163
+ );
164
+ }
165
+ return value;
166
+ }
167
+ function isFormLeafValue(value) {
168
+ return Array.isArray(value) || value instanceof Date || value instanceof Blob || isSchemaFieldLike(value) || !isPlainFormObject(value);
169
+ }
170
+ function normalizeFormObject(value) {
171
+ return isPlainFormObject(value) ? cloneFormValue(value) : {};
172
+ }
173
+ function mergeFormValues(base, override) {
174
+ if (!override || !isPlainFormObject(override)) return cloneFormValue(base);
175
+ const output = cloneFormValue(base);
176
+ for (const [key, value] of Object.entries(override)) {
177
+ if (typeof value === "undefined" && key in output) continue;
178
+ if (isPlainFormObject(value) && isPlainFormObject(output[key])) {
179
+ output[key] = mergeFormValues(output[key], value);
180
+ continue;
181
+ }
182
+ output[key] = cloneFormValue(value);
183
+ }
184
+ return output;
185
+ }
186
+ function splitFormPath(path) {
187
+ return path.split(".").map((part) => part.trim()).filter(Boolean);
188
+ }
189
+ function getFormValueAtPath(root, path) {
190
+ let cursor = root;
191
+ for (const part of splitFormPath(path)) {
192
+ if (!isPlainFormObject(cursor) && !Array.isArray(cursor)) return void 0;
193
+ cursor = cursor[part];
194
+ }
195
+ return cursor;
196
+ }
197
+ function setFormValueAtPath(root, path, value) {
198
+ const parts = splitFormPath(path);
199
+ let cursor = root;
200
+ for (const [index, part] of parts.entries()) {
201
+ const last = index === parts.length - 1;
202
+ const nextPart = parts[index + 1];
203
+ if (!nextPart && !last) return;
204
+ if (Array.isArray(cursor)) {
205
+ const offset = Number(part);
206
+ if (!Number.isInteger(offset) || offset < 0) return;
207
+ if (last) {
208
+ cursor[offset] = value;
209
+ return;
210
+ }
211
+ const existing2 = cursor[offset];
212
+ if (!isPlainFormObject(existing2) && !Array.isArray(existing2)) cursor[offset] = /^\d+$/.test(nextPart) ? [] : {};
213
+ cursor = cursor[offset];
214
+ continue;
215
+ }
216
+ if (last) {
217
+ cursor[part] = value;
218
+ return;
219
+ }
220
+ const existing = cursor[part];
221
+ if (!isPlainFormObject(existing) && !Array.isArray(existing)) cursor[part] = /^\d+$/.test(nextPart) ? [] : {};
222
+ cursor = cursor[part];
223
+ }
224
+ }
225
+ function flattenFormLeafPaths(value, prefix = "") {
226
+ if (isFormLeafValue(value)) return [prefix].filter(Boolean);
227
+ return Object.entries(value).flatMap(([key, nested]) => {
228
+ return flattenFormLeafPaths(nested, prefix ? `${prefix}.${key}` : key);
229
+ });
230
+ }
231
+ function buildFormData(value, path = "", formData = new FormData()) {
232
+ if (typeof value === "undefined") return formData;
233
+ if (value instanceof Date) {
234
+ formData.append(path, value.toISOString());
235
+ return formData;
236
+ }
237
+ if (value instanceof Blob) {
238
+ formData.append(path, value);
239
+ return formData;
240
+ }
241
+ if (Array.isArray(value)) {
242
+ for (const item of value) buildFormData(item, `${path}[]`, formData);
243
+ return formData;
244
+ }
245
+ if (isPlainFormObject(value)) {
246
+ for (const [key, nested] of Object.entries(value)) buildFormData(nested, path ? `${path}.${key}` : key, formData);
247
+ return formData;
248
+ }
249
+ formData.append(path, String(value));
250
+ return formData;
251
+ }
252
+
136
253
  // src/internal/client.ts
137
254
  var browserFormSubmitSymbol = /* @__PURE__ */ Symbol("browserFormSubmit");
255
+ var clientSubmitControlFlowErrors = /* @__PURE__ */ new WeakSet();
138
256
  function isBrowserFormSubmitter(value) {
139
257
  return browserFormSubmitSymbol in value;
140
258
  }
259
+ function markClientSubmitControlFlowError(error) {
260
+ if (!error || typeof error !== "object" && typeof error !== "function") {
261
+ return error;
262
+ }
263
+ clientSubmitControlFlowErrors.add(error);
264
+ return error;
265
+ }
266
+ function isClientSubmitControlFlowError(error) {
267
+ return !!error && (typeof error === "object" || typeof error === "function") && clientSubmitControlFlowErrors.has(error);
268
+ }
141
269
  var transportFailureMessage = "Unable to submit the form right now. Please try again.";
142
270
  function serializeSubmissionState(valid, values, errors, schemaDefinition) {
143
271
  return Object.freeze({
@@ -232,152 +360,6 @@ async function validateClientValues(values, schemaDefinition) {
232
360
  result.errors.flatten()
233
361
  );
234
362
  }
235
- function isPlainObject2(value) {
236
- return !!value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof Blob);
237
- }
238
- function isSchemaFieldLike(value) {
239
- return !!value && typeof value === "object" && value.kind === "field" && !!value.definition && typeof value.definition === "object";
240
- }
241
- function areEqual(left, right) {
242
- if (Object.is(left, right)) {
243
- return true;
244
- }
245
- if (left instanceof Date && right instanceof Date) {
246
- return left.getTime() === right.getTime();
247
- }
248
- if (left instanceof Blob || right instanceof Blob) {
249
- return false;
250
- }
251
- if (Array.isArray(left) && Array.isArray(right)) {
252
- return left.length === right.length && left.every((value, index) => areEqual(value, right[index]));
253
- }
254
- if (isPlainObject2(left) && isPlainObject2(right)) {
255
- const leftKeys = Object.keys(left);
256
- const rightKeys = Object.keys(right);
257
- return leftKeys.length === rightKeys.length && leftKeys.every((key) => key in right && areEqual(left[key], right[key]));
258
- }
259
- return false;
260
- }
261
- function cloneValue(value) {
262
- if (Array.isArray(value)) {
263
- return value.map((item) => cloneValue(item));
264
- }
265
- if (value instanceof Date) {
266
- return new Date(value.getTime());
267
- }
268
- if (isPlainObject2(value)) {
269
- return Object.fromEntries(
270
- Object.entries(value).map(([key, entry]) => [key, cloneValue(entry)])
271
- );
272
- }
273
- return value;
274
- }
275
- function isLeafValue(value) {
276
- return Array.isArray(value) || value instanceof Date || value instanceof Blob || isSchemaFieldLike(value) || !isPlainObject2(value);
277
- }
278
- function normalizeObject(value) {
279
- return isPlainObject2(value) ? cloneValue(value) : {};
280
- }
281
- function mergeValues(base, override) {
282
- if (!override || !isPlainObject2(override)) {
283
- return cloneValue(base);
284
- }
285
- const output = cloneValue(base);
286
- for (const [key, value] of Object.entries(override)) {
287
- if (typeof value === "undefined" && key in output) {
288
- continue;
289
- }
290
- if (isPlainObject2(value) && isPlainObject2(output[key])) {
291
- output[key] = mergeValues(output[key], value);
292
- continue;
293
- }
294
- output[key] = cloneValue(value);
295
- }
296
- return output;
297
- }
298
- function splitPath(path) {
299
- return path.split(".").map((part) => part.trim()).filter(Boolean);
300
- }
301
- function getValueAtPath(root, path) {
302
- let cursor = root;
303
- for (const part of splitPath(path)) {
304
- if (!isPlainObject2(cursor) && !Array.isArray(cursor)) {
305
- return void 0;
306
- }
307
- cursor = cursor[part];
308
- }
309
- return cursor;
310
- }
311
- function setValueAtPath(root, path, value) {
312
- const parts = splitPath(path);
313
- let cursor = root;
314
- for (const [index, part] of parts.entries()) {
315
- const last = index === parts.length - 1;
316
- const nextPart = parts[index + 1];
317
- if (Array.isArray(cursor)) {
318
- const offset = Number(part);
319
- if (!Number.isInteger(offset) || offset < 0) {
320
- return;
321
- }
322
- if (last) {
323
- cursor[offset] = value;
324
- return;
325
- }
326
- const existing2 = cursor[offset];
327
- if (!isPlainObject2(existing2) && !Array.isArray(existing2)) {
328
- cursor[offset] = /^\d+$/.test(nextPart) ? [] : {};
329
- }
330
- cursor = cursor[offset];
331
- continue;
332
- }
333
- if (last) {
334
- cursor[part] = value;
335
- return;
336
- }
337
- const existing = cursor[part];
338
- if (!isPlainObject2(existing) && !Array.isArray(existing)) {
339
- cursor[part] = /^\d+$/.test(nextPart) ? [] : {};
340
- }
341
- cursor = cursor[part];
342
- }
343
- }
344
- function flattenLeafPaths(value, prefix = "") {
345
- if (isLeafValue(value)) {
346
- return [prefix].filter(Boolean);
347
- }
348
- return Object.entries(value).flatMap(([key, nested]) => {
349
- const next = prefix ? `${prefix}.${key}` : key;
350
- return flattenLeafPaths(nested, next);
351
- });
352
- }
353
- function buildFormData(value, path = "", formData = new FormData()) {
354
- if (typeof value === "undefined") {
355
- return formData;
356
- }
357
- if (value instanceof Date) {
358
- formData.append(path, value.toISOString());
359
- return formData;
360
- }
361
- if (value instanceof Blob) {
362
- formData.append(path, value);
363
- return formData;
364
- }
365
- if (Array.isArray(value)) {
366
- for (const item of value) {
367
- buildFormData(item, `${path}[]`, formData);
368
- }
369
- return formData;
370
- }
371
- if (isPlainObject2(value)) {
372
- for (const [key, nested] of Object.entries(value)) {
373
- const next = path ? `${path}.${key}` : key;
374
- buildFormData(nested, next, formData);
375
- }
376
- return formData;
377
- }
378
- formData.append(path, String(value));
379
- return formData;
380
- }
381
363
  function getActiveElementBrowserForm() {
382
364
  const activeElement = globalThis.document?.activeElement;
383
365
  return activeElement?.form ?? void 0;
@@ -432,11 +414,11 @@ function replaceErrorsForPath(flattenedErrors, path, nextErrors) {
432
414
  return merged;
433
415
  }
434
416
  function buildFieldsTree(state, schemaDefinition, source, validateOn, prefix = "") {
435
- if (isLeafValue(source)) {
417
+ if (isFormLeafValue(source)) {
436
418
  const path = prefix;
437
419
  return Object.freeze({
438
420
  get value() {
439
- return getValueAtPath(state.values, path);
421
+ return getFormValueAtPath(state.values, path);
440
422
  },
441
423
  set value(next) {
442
424
  void this.set(next);
@@ -451,9 +433,9 @@ function buildFieldsTree(state, schemaDefinition, source, validateOn, prefix = "
451
433
  return state.dirty.has(path);
452
434
  },
453
435
  async set(value) {
454
- setValueAtPath(state.values, path, value);
436
+ setFormValueAtPath(state.values, path, value);
455
437
  state.touched.add(path);
456
- if (!areEqual(getValueAtPath(state.initialValues, path), value)) {
438
+ if (!areFormValuesEqual(getFormValueAtPath(state.initialValues, path), value)) {
457
439
  state.dirty.add(path);
458
440
  } else {
459
441
  state.dirty.delete(path);
@@ -461,7 +443,7 @@ function buildFieldsTree(state, schemaDefinition, source, validateOn, prefix = "
461
443
  if (validateOn === "change") {
462
444
  const sequence = nextValidationSequence(state);
463
445
  const submission = await validateClientValues(
464
- cloneValue(state.values),
446
+ cloneFormValue(state.values),
465
447
  schemaDefinition
466
448
  );
467
449
  if (isLatestValidation(state, sequence)) {
@@ -478,7 +460,7 @@ function buildFieldsTree(state, schemaDefinition, source, validateOn, prefix = "
478
460
  if (validateOn === "blur") {
479
461
  const sequence = nextValidationSequence(state);
480
462
  const submission = await validateClientValues(
481
- cloneValue(state.values),
463
+ cloneFormValue(state.values),
482
464
  schemaDefinition
483
465
  );
484
466
  if (isLatestValidation(state, sequence)) {
@@ -493,7 +475,7 @@ function buildFieldsTree(state, schemaDefinition, source, validateOn, prefix = "
493
475
  },
494
476
  async validate() {
495
477
  const sequence = nextValidationSequence(state);
496
- const submission = await validateClientValues(cloneValue(state.values), schemaDefinition);
478
+ const submission = await validateClientValues(cloneFormValue(state.values), schemaDefinition);
497
479
  if (isLatestValidation(state, sequence)) {
498
480
  state.flattenedErrors = submission.errors.flatten();
499
481
  notifyListeners(state);
@@ -515,13 +497,13 @@ function isSerializedSubmission(value) {
515
497
  return "submitted" in value && "errors" in value && "values" in value && !("ok" in value);
516
498
  }
517
499
  function isFormFailurePayload2(value) {
518
- return isPlainObject2(value) && value.ok === false && typeof value.status === "number" && value.valid === false && isPlainObject2(value.values) && isPlainObject2(value.errors);
500
+ return isPlainFormObject(value) && value.ok === false && typeof value.status === "number" && value.valid === false && isPlainFormObject(value.values) && isPlainFormObject(value.errors);
519
501
  }
520
502
  function isSvelteKitActionFailureResult(value) {
521
- return isPlainObject2(value) && value.type === "failure" && typeof value.status === "number" && typeof value.data === "string";
503
+ return isPlainFormObject(value) && value.type === "failure" && typeof value.status === "number" && typeof value.data === "string";
522
504
  }
523
505
  function isSvelteKitActionErrorResult(value) {
524
- return isPlainObject2(value) && value.type === "error" && isPlainObject2(value.error);
506
+ return isPlainFormObject(value) && value.type === "error" && isPlainFormObject(value.error);
525
507
  }
526
508
  function parseJsonObject(value) {
527
509
  try {
@@ -648,13 +630,13 @@ function isSafeMethod(method) {
648
630
  }
649
631
  function createFormClient(schemaDefinition, options = {}) {
650
632
  const initialState = options.initialState ?? void 0;
651
- const initialValues = mergeValues(
652
- normalizeObject(options.initialValues),
653
- normalizeObject(initialState?.values)
633
+ const initialValues = mergeFormValues(
634
+ normalizeFormObject(options.initialValues),
635
+ normalizeFormObject(initialState?.values)
654
636
  );
655
637
  const state = {
656
- values: cloneValue(initialValues),
657
- initialValues: cloneValue(initialValues),
638
+ values: cloneFormValue(initialValues),
639
+ initialValues: cloneFormValue(initialValues),
658
640
  flattenedErrors: { ...initialState?.errors ?? {} },
659
641
  touched: /* @__PURE__ */ new Set(),
660
642
  dirty: /* @__PURE__ */ new Set(),
@@ -664,7 +646,7 @@ function createFormClient(schemaDefinition, options = {}) {
664
646
  validationSequence: 0
665
647
  };
666
648
  const validateOn = options.validateOn ?? "submit";
667
- const fieldPaths = flattenLeafPaths(schemaDefinition.fields);
649
+ const fieldPaths = flattenFormLeafPaths(schemaDefinition.fields);
668
650
  const fields = buildFieldsTree(
669
651
  state,
670
652
  schemaDefinition,
@@ -672,13 +654,13 @@ function createFormClient(schemaDefinition, options = {}) {
672
654
  validateOn
673
655
  );
674
656
  async function runValidation() {
675
- return await runValidationForInput(cloneValue(state.values));
657
+ return await runValidationForInput(cloneFormValue(state.values));
676
658
  }
677
659
  async function runValidationForInput(input) {
678
660
  const sequence = nextValidationSequence(state);
679
661
  const submission = await validateClientValues(input, schemaDefinition);
680
662
  if (isLatestValidation(state, sequence)) {
681
- state.values = mergeValues(
663
+ state.values = mergeFormValues(
682
664
  state.values,
683
665
  submission.valid ? submission.data : submission.values
684
666
  );
@@ -704,7 +686,7 @@ function createFormClient(schemaDefinition, options = {}) {
704
686
  ...normalized,
705
687
  values: sanitizeFlashedInput(normalized.values, schemaDefinition)
706
688
  };
707
- state.values = mergeValues(state.values, sanitized.values);
689
+ state.values = mergeFormValues(state.values, sanitized.values);
708
690
  clearSensitiveInputValues(state.values, schemaDefinition);
709
691
  state.flattenedErrors = sanitized.errors;
710
692
  state.lastSubmission = sanitized;
@@ -712,7 +694,7 @@ function createFormClient(schemaDefinition, options = {}) {
712
694
  return sanitized;
713
695
  }
714
696
  const normalizedSubmission = normalized;
715
- state.values = mergeValues(state.values, normalizedSubmission.values);
697
+ state.values = mergeFormValues(state.values, normalizedSubmission.values);
716
698
  clearSensitiveInputValues(state.values, schemaDefinition);
717
699
  state.flattenedErrors = normalizedSubmission.errors.flatten();
718
700
  state.lastSubmission = normalizedSubmission.valid ? void 0 : normalizedSubmission.fail();
@@ -748,6 +730,9 @@ function createFormClient(schemaDefinition, options = {}) {
748
730
  formData
749
731
  });
750
732
  } catch (error) {
733
+ if (isClientSubmitControlFlowError(error)) {
734
+ throw error;
735
+ }
751
736
  return applyServerState(
752
737
  validationExceptionToFailure(error, state.values) ?? createTransportFailure(state.values)
753
738
  );
@@ -799,9 +784,9 @@ function createFormClient(schemaDefinition, options = {}) {
799
784
  return submit(form);
800
785
  },
801
786
  reset(values) {
802
- const next = mergeValues(state.initialValues, values);
803
- state.values = cloneValue(next);
804
- state.initialValues = cloneValue(next);
787
+ const next = mergeFormValues(state.initialValues, values);
788
+ state.values = cloneFormValue(next);
789
+ state.initialValues = cloneFormValue(next);
805
790
  state.flattenedErrors = {};
806
791
  state.touched.clear();
807
792
  state.dirty.clear();
@@ -809,9 +794,9 @@ function createFormClient(schemaDefinition, options = {}) {
809
794
  notifyListeners(state);
810
795
  },
811
796
  async setValue(path, value) {
812
- setValueAtPath(state.values, path, value);
797
+ setFormValueAtPath(state.values, path, value);
813
798
  state.touched.add(path);
814
- if (!areEqual(getValueAtPath(state.initialValues, path), value)) {
799
+ if (!areFormValuesEqual(getFormValueAtPath(state.initialValues, path), value)) {
815
800
  state.dirty.add(path);
816
801
  } else {
817
802
  state.dirty.delete(path);
@@ -829,5 +814,6 @@ function createFormClient(schemaDefinition, options = {}) {
829
814
  }
830
815
  export {
831
816
  createFormClient,
817
+ markClientSubmitControlFlowError,
832
818
  runWithBrowserFormElement
833
819
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holo-js/forms",
3
- "version": "0.2.6",
3
+ "version": "0.3.1",
4
4
  "description": "Holo-JS Framework - forms-first validation and submission contracts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,10 +33,10 @@
33
33
  "test": "vitest --run"
34
34
  },
35
35
  "dependencies": {
36
- "@holo-js/validation": "^0.2.6"
36
+ "@holo-js/validation": "^0.3.1"
37
37
  },
38
38
  "peerDependencies": {
39
- "@holo-js/security": "^0.2.6"
39
+ "@holo-js/security": "^0.3.1"
40
40
  },
41
41
  "peerDependenciesMeta": {
42
42
  "@holo-js/security": {