@conform-to/react 1.17.1 → 1.19.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.
@@ -1,45 +1,27 @@
1
1
  'use client';
2
2
  import { objectSpread2 as _objectSpread2, objectWithoutProperties as _objectWithoutProperties } from '../_virtual/_rollupPluginBabelHelpers.mjs';
3
- import { DEFAULT_INTENT_NAME, createGlobalFormsObserver, serialize, isFieldElement, deepEqual, change, focus, blur, getFormData, parseSubmission, report, createSubmitEvent } from '@conform-to/dom/future';
4
- import { createContext, useContext, useMemo, useRef, useId, useEffect, useSyncExternalStore, useCallback, useLayoutEffect, useState } from 'react';
5
- import { resolveStandardSchemaResult, resolveValidateResult, appendUniqueItem } from './util.mjs';
6
- import { isTouched, getFormMetadata, getFieldset, getField, initializeState, updateState } from './state.mjs';
3
+ import { createGlobalFormsObserver, isGlobalInstance, deepEqual, isFieldElement, change, focus, blur, getFormData, defaultSerialize, isPlainObject, normalizeFormError, dispatchInternalUpdateEvent, parseSubmission, report, requestSubmit } from '@conform-to/dom/future';
4
+ import { createContext, useRef, useContext, useMemo, useState, useEffect, useSyncExternalStore, useCallback, useLayoutEffect, forwardRef } from 'react';
5
+ import { appendUniqueItem, resolveValidateResult } from './util.mjs';
6
+ import { initializeState, updateState } from './state.mjs';
7
7
  import { deserializeIntent, applyIntent, intentHandlers, resolveIntent } from './intent.mjs';
8
- import { cleanupPreservedInputs, preserveInputs, focusFirstInvalidField, getFormElement, createIntentDispatcher, createDefaultSnapshot, getRadioGroupValue, getCheckboxGroupValue, getInputSnapshot, makeInputFocusable, initializeField, resetFormValue, updateFormValue, getSubmitEvent } from './dom.mjs';
8
+ import { cleanupPreservedInputs, preserveInputs, deriveDefaultPayload, resolveControlPayload, initializeField, getFormElement, resetFormValue, updateFormValue, getSubmitEvent } from './dom.mjs';
9
+ import { flushSync } from 'react-dom';
9
10
  import { jsx } from 'react/jsx-runtime';
10
11
 
11
- var _excluded = ["children"];
12
- // Static reset key for consistent hydration during Next.js prerendering
13
- // See: https://nextjs.org/docs/messages/next-prerender-current-time-client
12
+ var _excluded = ["name", "form", "defaultValue", "hidden"],
13
+ _excluded2 = ["defaultValue", "multiple", "hidden"],
14
+ _excluded3 = ["defaultValue", "hidden"],
15
+ _excluded4 = ["defaultValue", "value", "hidden"],
16
+ _excluded5 = ["defaultValue", "hidden"];
14
17
  var INITIAL_KEY = 'INITIAL_KEY';
15
- var GlobalFormOptionsContext = /*#__PURE__*/createContext({
16
- intentName: DEFAULT_INTENT_NAME,
17
- observer: createGlobalFormsObserver(),
18
- serialize,
19
- shouldValidate: 'onSubmit'
20
- });
21
- var FormContextContext = /*#__PURE__*/createContext([]);
22
-
23
- /**
24
- * Provides form context to child components.
25
- * Stacks contexts to support nested forms, with latest context taking priority.
26
- */
27
- function FormProvider(props) {
28
- var stack = useContext(FormContextContext);
29
- var value = useMemo(
30
- // Put the latest form context first to ensure that to be the first one found
31
- () => [props.context].concat(stack), [stack, props.context]);
32
- return /*#__PURE__*/jsx(FormContextContext.Provider, {
33
- value: value,
34
- children: props.children
35
- });
36
- }
18
+ var GlobalFormsObserverContext = /*#__PURE__*/createContext(createGlobalFormsObserver());
37
19
 
38
20
  /**
39
21
  * Preserves form field values when its contents are unmounted.
40
22
  * Useful for multi-step forms and virtualized lists.
41
23
  *
42
- * @see https://conform.guide/api/react/future/PreserveBoundary
24
+ * See https://conform.guide/api/react/future/PreserveBoundary
43
25
  */
44
26
  function PreserveBoundary(props) {
45
27
  // name is used as key so React properly unmounts/remounts when switching
@@ -76,30 +58,6 @@ function PreserveBoundaryImpl(props) {
76
58
  });
77
59
  }
78
60
 
79
- /**
80
- * @deprecated Replaced by the `configureForms` factory API. This will be removed in the next minor version. If you are not ready to migrate, please pin to `v1.16.0`.
81
- */
82
- function FormOptionsProvider(props) {
83
- var {
84
- children
85
- } = props,
86
- providedOptions = _objectWithoutProperties(props, _excluded);
87
- var defaultOptions = useContext(GlobalFormOptionsContext);
88
- var options = useMemo(() => _objectSpread2(_objectSpread2({}, defaultOptions), providedOptions), [defaultOptions, providedOptions]);
89
- return /*#__PURE__*/jsx(GlobalFormOptionsContext.Provider, {
90
- value: options,
91
- children: children
92
- });
93
- }
94
- function useFormContext(formId) {
95
- var contexts = useContext(FormContextContext);
96
- var context = formId ? contexts.find(context => formId === context.formId) : contexts[0];
97
- if (!context) {
98
- throw new Error('No form context found; Have you render a <FormProvider /> with the corresponding form context?');
99
- }
100
- return context;
101
- }
102
-
103
61
  /**
104
62
  * Core form hook that manages form state, validation, and submission.
105
63
  * Handles both sync and async validation, intent dispatching, and DOM updates.
@@ -142,16 +100,23 @@ function useConform(formRef, options) {
142
100
  var abortControllerRef = useRef(null);
143
101
  var handleSubmission = useCallback(function (type, result) {
144
102
  var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : optionsRef.current;
103
+ var normalizedResult = !result.error ? result : _objectSpread2(_objectSpread2({}, result), {}, {
104
+ error: normalizeFormError(result.error)
105
+ });
145
106
  var intent = result.submission.intent ? deserializeIntent(result.submission.intent) : null;
146
- var finalResult = applyIntent(result, intent, {
107
+ var finalResult = applyIntent(normalizedResult, intent, {
147
108
  handlers: intentHandlers
148
109
  });
110
+ var formElement = getFormElement(formRef);
111
+ if (formElement && (finalResult.reset || typeof finalResult.targetValue !== 'undefined')) {
112
+ dispatchInternalUpdateEvent(formElement);
113
+ }
149
114
  setState(state => updateState(state, _objectSpread2(_objectSpread2({}, finalResult), {}, {
150
115
  type,
151
116
  intent,
152
117
  ctx: {
153
118
  handlers: intentHandlers,
154
- cancelled: finalResult !== result,
119
+ cancelled: finalResult !== normalizedResult,
155
120
  reset(defaultValue) {
156
121
  return initializeState({
157
122
  defaultValue: defaultValue !== null && defaultValue !== void 0 ? defaultValue : options.defaultValue
@@ -161,12 +126,11 @@ function useConform(formRef, options) {
161
126
  })));
162
127
 
163
128
  // TODO: move on error handler to a new effect
164
- var formElement = getFormElement(formRef);
165
- if (formElement && result.error) {
129
+ if (formElement && finalResult.error) {
166
130
  var _optionsRef$current$o, _optionsRef$current;
167
131
  (_optionsRef$current$o = (_optionsRef$current = optionsRef.current).onError) === null || _optionsRef$current$o === void 0 || _optionsRef$current$o.call(_optionsRef$current, {
168
132
  formElement,
169
- error: result.error,
133
+ error: finalResult.error,
170
134
  intent
171
135
  });
172
136
  }
@@ -174,6 +138,10 @@ function useConform(formRef, options) {
174
138
  }, [formRef, optionsRef]);
175
139
  if (options.key !== keyRef.current) {
176
140
  keyRef.current = options.key;
141
+ var formElement = getFormElement(formRef);
142
+ if (formElement) {
143
+ dispatchInternalUpdateEvent(formElement);
144
+ }
177
145
  setState(initializeState({
178
146
  defaultValue: options.defaultValue
179
147
  }));
@@ -200,52 +168,56 @@ function useConform(formRef, options) {
200
168
  }, [formRef, state.resetKey, state.defaultValue, optionsRef]);
201
169
  useSafeLayoutEffect(() => {
202
170
  if (state.targetValue) {
203
- var formElement = getFormElement(formRef);
204
- if (!formElement) {
171
+ var _formElement = getFormElement(formRef);
172
+ if (!_formElement) {
205
173
  // eslint-disable-next-line no-console
206
174
  console.error('Failed to update form value; No form element found');
207
175
  return;
208
176
  }
209
- updateFormValue(formElement, state.targetValue, optionsRef.current.serialize);
177
+ updateFormValue(_formElement, state.targetValue, optionsRef.current.serialize);
210
178
  }
211
179
  pendingValueRef.current = undefined;
212
180
  }, [formRef, state.targetValue, optionsRef]);
213
181
  var handleSubmit = useCallback(event => {
214
- var _abortControllerRef$c2, _lastAsyncResultRef$c;
182
+ var _abortControllerRef$c2;
215
183
  var abortController = new AbortController();
216
184
 
217
185
  // Keep track of the abort controller so we can cancel the previous request if a new one is made
218
186
  (_abortControllerRef$c2 = abortControllerRef.current) === null || _abortControllerRef$c2 === void 0 || _abortControllerRef$c2.abort('A new submission is made');
219
187
  abortControllerRef.current = abortController;
220
- var formData;
221
188
  var result;
222
189
  var resolvedValue;
190
+ var formElement = event.currentTarget;
191
+ var submitEvent = getSubmitEvent(event);
192
+ var formData = getFormData(formElement, submitEvent.submitter);
193
+ var submission = parseSubmission(formData, {
194
+ intentName: optionsRef.current.intentName
195
+ });
223
196
 
224
- // The form might be re-submitted manually if there was an async validation
225
- if (event.nativeEvent === ((_lastAsyncResultRef$c = lastAsyncResultRef.current) === null || _lastAsyncResultRef$c === void 0 ? void 0 : _lastAsyncResultRef$c.event)) {
226
- formData = lastAsyncResultRef.current.formData;
227
- result = lastAsyncResultRef.current.result;
228
- resolvedValue = lastAsyncResultRef.current.resolvedValue;
229
- } else {
230
- var _optionsRef$current$o2, _optionsRef$current2;
231
- var formElement = event.currentTarget;
232
- var submitEvent = getSubmitEvent(event);
233
- formData = getFormData(formElement, submitEvent.submitter);
234
- var submission = parseSubmission(formData, {
235
- intentName: optionsRef.current.intentName
236
- });
237
-
238
- // Patch missing fields in the submission object
239
- for (var element of formElement.elements) {
240
- if (isFieldElement(element) && element.name) {
241
- submission.fields = appendUniqueItem(submission.fields, element.name);
242
- }
197
+ // Patch missing fields in the submission object
198
+ for (var element of formElement.elements) {
199
+ if (isFieldElement(element) && element.name) {
200
+ submission.fields = appendUniqueItem(submission.fields, element.name);
243
201
  }
202
+ }
244
203
 
245
- // Override submission value if the pending value is not applied yet (i.e. batch updates)
246
- if (pendingValueRef.current !== undefined) {
247
- submission.payload = pendingValueRef.current;
248
- }
204
+ // Override submission value if the pending value is not applied yet (i.e. batch updates)
205
+ if (pendingValueRef.current !== undefined) {
206
+ submission.payload = pendingValueRef.current;
207
+ }
208
+ var lastAsyncResult = lastAsyncResultRef.current;
209
+
210
+ // Clear the last async result so it won't affect the next submission
211
+ lastAsyncResultRef.current = null;
212
+ if (lastAsyncResult &&
213
+ // Only default submission will be re-submitted after async validation
214
+ !submission.intent &&
215
+ // Ensure the submission payload is the same as the one being validated
216
+ deepEqual(submission.payload, lastAsyncResult.result.submission.payload)) {
217
+ result = lastAsyncResult.result;
218
+ resolvedValue = lastAsyncResult.resolvedValue;
219
+ } else {
220
+ var _optionsRef$current$o2, _optionsRef$current2;
249
221
  var value = resolveIntent(submission);
250
222
  var submissionResult = report(submission, {
251
223
  keepFiles: true,
@@ -256,7 +228,7 @@ function useConform(formRef, options) {
256
228
  value !== undefined ? (_optionsRef$current$o2 = (_optionsRef$current2 = optionsRef.current).onValidate) === null || _optionsRef$current$o2 === void 0 ? void 0 : _optionsRef$current$o2.call(_optionsRef$current2, {
257
229
  payload: value,
258
230
  error: {
259
- formErrors: [],
231
+ formErrors: null,
260
232
  fieldErrors: {}
261
233
  },
262
234
  intent: submission.intent ? deserializeIntent(submission.intent) : null,
@@ -290,16 +262,20 @@ function useConform(formRef, options) {
290
262
 
291
263
  // If the form is meant to be submitted and there is no error
292
264
  if (submissionResult.error === null && !submission.intent) {
293
- var _event = createSubmitEvent(submitEvent.submitter);
294
-
295
- // Keep track of the submit event so we can skip validation on the next submit
296
- lastAsyncResultRef.current = {
297
- event: _event,
298
- formData,
299
- resolvedValue: value,
300
- result: submissionResult
301
- };
302
- formElement.dispatchEvent(_event);
265
+ // Keep track of the validated payload and resume submission on the next task.
266
+ // Calling requestSubmit() directly from the async callback, or from a
267
+ // microtask, can still be ignored before the native submission lifecycle
268
+ // has fully settled.
269
+ setTimeout(() => {
270
+ if (abortController.signal.aborted) {
271
+ return;
272
+ }
273
+ lastAsyncResultRef.current = {
274
+ resolvedValue: value,
275
+ result: submissionResult
276
+ };
277
+ requestSubmit(formElement, submitEvent.submitter);
278
+ }, 0);
303
279
  }
304
280
  }
305
281
  });
@@ -345,287 +321,20 @@ function useConform(formRef, options) {
345
321
  return [state, handleSubmit];
346
322
  }
347
323
 
348
- /**
349
- * The main React hook for form management. Handles form state, validation, and submission
350
- * while providing access to form metadata, field objects, and form actions.
351
- *
352
- * It can be called in two ways:
353
- * - **Schema first**: Pass a schema as the first argument for automatic validation with type inference
354
- * - **Manual configuration**: Pass options with custom `onValidate` handler for manual validation
355
- *
356
- * @see https://conform.guide/api/react/future/useForm
357
- * @example Schema first setup with zod:
358
- *
359
- * ```tsx
360
- * const { form, fields } = useForm(zodSchema, {
361
- * lastResult,
362
- * shouldValidate: 'onBlur',
363
- * });
364
- *
365
- * return (
366
- * <form {...form.props}>
367
- * <input name={fields.email.name} defaultValue={fields.email.defaultValue} />
368
- * <div>{fields.email.errors}</div>
369
- * </form>
370
- * );
371
- * ```
372
- *
373
- * @example Manual configuration setup with custom validation:
374
- *
375
- * ```tsx
376
- * const { form, fields } = useForm({
377
- * onValidate({ payload, error }) {
378
- * if (!payload.email) {
379
- * error.fieldErrors.email = ['Required'];
380
- * }
381
- * return error;
382
- * }
383
- * });
384
- *
385
- * return (
386
- * <form {...form.props}>
387
- * <input name={fields.email.name} defaultValue={fields.email.defaultValue} />
388
- * <div>{fields.email.errors}</div>
389
- * </form>
390
- * );
391
- * ```
392
- */
393
-
394
- /**
395
- * @deprecated Use `useForm(schema, options)` instead for better type inference.
396
- */
397
-
398
- function useForm(schemaOrOptions, maybeOptions) {
399
- var _options$onError;
400
- var schema;
401
- var options;
402
- if (maybeOptions) {
403
- schema = schemaOrOptions;
404
- options = maybeOptions;
405
- } else {
406
- var fullOptions = schemaOrOptions;
407
- options = fullOptions;
408
- schema = fullOptions.schema;
409
- }
410
- var {
411
- id,
412
- constraint
413
- } = options;
414
- var globalOptions = useContext(GlobalFormOptionsContext);
415
- var optionsRef = useLatest(options);
416
- var globalOptionsRef = useLatest(globalOptions);
417
- var fallbackId = useId();
418
- var formId = id !== null && id !== void 0 ? id : "form-".concat(fallbackId);
419
- var [state, handleSubmit] = useConform(formId, _objectSpread2(_objectSpread2({}, options), {}, {
420
- serialize: globalOptions.serialize,
421
- intentName: globalOptions.intentName,
422
- onError: (_options$onError = options.onError) !== null && _options$onError !== void 0 ? _options$onError : focusFirstInvalidField,
423
- onValidate(ctx) {
424
- var _options$onValidate, _options$onValidate2, _options;
425
- if (schema) {
426
- var standardResult = schema['~standard'].validate(ctx.payload);
427
- if (standardResult instanceof Promise) {
428
- return standardResult.then(actualStandardResult => {
429
- if (typeof options.onValidate === 'function') {
430
- throw new Error('The "onValidate" handler is not supported when used with asynchronous schema validation.');
431
- }
432
- return resolveStandardSchemaResult(actualStandardResult);
433
- });
434
- }
435
- var resolvedResult = resolveStandardSchemaResult(standardResult);
436
- if (!options.onValidate) {
437
- return resolvedResult;
438
- }
439
-
440
- // Update the schema error in the context
441
- if (resolvedResult.error) {
442
- ctx.error = resolvedResult.error;
443
- }
444
- ctx.schemaValue = resolvedResult.value;
445
- var validateResult = resolveValidateResult(options.onValidate(ctx));
446
- if (validateResult.syncResult) {
447
- var _validateResult$syncR, _validateResult$syncR2;
448
- (_validateResult$syncR2 = (_validateResult$syncR = validateResult.syncResult).value) !== null && _validateResult$syncR2 !== void 0 ? _validateResult$syncR2 : _validateResult$syncR.value = resolvedResult.value;
449
- }
450
- if (validateResult.asyncResult) {
451
- validateResult.asyncResult = validateResult.asyncResult.then(result => {
452
- var _result$value;
453
- (_result$value = result.value) !== null && _result$value !== void 0 ? _result$value : result.value = resolvedResult.value;
454
- return result;
455
- });
456
- }
457
- return [validateResult.syncResult, validateResult.asyncResult];
458
- }
459
- return (_options$onValidate = (_options$onValidate2 = (_options = options).onValidate) === null || _options$onValidate2 === void 0 ? void 0 : _options$onValidate2.call(_options, ctx)) !== null && _options$onValidate !== void 0 ? _options$onValidate : {
460
- // To avoid conform falling back to server validation,
461
- // if neither schema nor validation handler is provided,
462
- // we just treat it as a valid client submission
463
- error: null
464
- };
465
- }
466
- }));
467
- var intent = useIntent(formId);
468
- var context = useMemo(() => ({
469
- formId,
470
- state,
471
- constraint: constraint !== null && constraint !== void 0 ? constraint : null,
472
- handleSubmit,
473
- handleInput(event) {
474
- var _optionsRef$current$o4, _optionsRef$current4, _globalOptionsRef$cur;
475
- if (!isFieldElement(event.target) || event.target.name === '' || event.target.form === null || event.target.form !== getFormElement(formId)) {
476
- return;
477
- }
478
- (_optionsRef$current$o4 = (_optionsRef$current4 = optionsRef.current).onInput) === null || _optionsRef$current$o4 === void 0 || _optionsRef$current$o4.call(_optionsRef$current4, _objectSpread2(_objectSpread2({}, event), {}, {
479
- target: event.target,
480
- currentTarget: event.target.form
481
- }));
482
- if (event.defaultPrevented) {
483
- return;
484
- }
485
- var {
486
- shouldValidate = globalOptionsRef.current.shouldValidate,
487
- shouldRevalidate = (_globalOptionsRef$cur = globalOptionsRef.current.shouldRevalidate) !== null && _globalOptionsRef$cur !== void 0 ? _globalOptionsRef$cur : shouldValidate
488
- } = optionsRef.current;
489
- if (isTouched(state, event.target.name) ? shouldRevalidate === 'onInput' : shouldValidate === 'onInput') {
490
- intent.validate(event.target.name);
491
- }
492
- },
493
- handleBlur(event) {
494
- var _optionsRef$current$o5, _optionsRef$current5, _globalOptionsRef$cur2;
495
- if (!isFieldElement(event.target) || event.target.name === '' || event.target.form === null || event.target.form !== getFormElement(formId)) {
496
- return;
497
- }
498
- (_optionsRef$current$o5 = (_optionsRef$current5 = optionsRef.current).onBlur) === null || _optionsRef$current$o5 === void 0 || _optionsRef$current$o5.call(_optionsRef$current5, _objectSpread2(_objectSpread2({}, event), {}, {
499
- target: event.target,
500
- currentTarget: event.target.form
501
- }));
502
- if (event.defaultPrevented) {
503
- return;
504
- }
505
- var {
506
- shouldValidate = globalOptionsRef.current.shouldValidate,
507
- shouldRevalidate = (_globalOptionsRef$cur2 = globalOptionsRef.current.shouldRevalidate) !== null && _globalOptionsRef$cur2 !== void 0 ? _globalOptionsRef$cur2 : shouldValidate
508
- } = optionsRef.current;
509
- if (isTouched(state, event.target.name) ? shouldRevalidate === 'onBlur' : shouldValidate === 'onBlur') {
510
- intent.validate(event.target.name);
511
- }
512
- }
513
- }), [formId, state, constraint, handleSubmit, intent, optionsRef, globalOptionsRef]);
514
- var form = useMemo(() => getFormMetadata(context, {
515
- serialize: globalOptions.serialize,
516
- extendFieldMetadata: globalOptions.defineCustomMetadata
517
- }), [context, globalOptions.serialize, globalOptions.defineCustomMetadata]);
518
- var fields = useMemo(() => getFieldset(context, {
519
- serialize: globalOptions.serialize,
520
- extendFieldMetadata: globalOptions.defineCustomMetadata
521
- }), [context, globalOptions.serialize, globalOptions.defineCustomMetadata]);
522
- return {
523
- form,
524
- fields,
525
- intent
526
- };
527
- }
528
-
529
- /**
530
- * A React hook that provides access to form-level metadata and state.
531
- * Requires `FormProvider` context when used in child components.
532
- *
533
- * @see https://conform.guide/api/react/future/useFormMetadata
534
- * @example
535
- * ```tsx
536
- * function ErrorSummary() {
537
- * const form = useFormMetadata();
538
- *
539
- * if (form.valid) return null;
540
- *
541
- * return (
542
- * <div>Please fix {Object.keys(form.fieldErrors).length} errors</div>
543
- * );
544
- * }
545
- * ```
546
- */
547
- function useFormMetadata() {
548
- var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
549
- var globalOptions = useContext(GlobalFormOptionsContext);
550
- var context = useFormContext(options.formId);
551
- var formMetadata = useMemo(() => getFormMetadata(context, {
552
- serialize: globalOptions.serialize,
553
- extendFieldMetadata: globalOptions.defineCustomMetadata
554
- }), [context, globalOptions.serialize, globalOptions.defineCustomMetadata]);
555
- return formMetadata;
556
- }
557
-
558
- /**
559
- * A React hook that provides access to a specific field's metadata and state.
560
- * Requires `FormProvider` context when used in child components.
561
- *
562
- * @see https://conform.guide/api/react/future/useField
563
- * @example
564
- * ```tsx
565
- * function FormField({ name, label }) {
566
- * const field = useField(name);
567
- *
568
- * return (
569
- * <div>
570
- * <label htmlFor={field.id}>{label}</label>
571
- * <input id={field.id} name={field.name} defaultValue={field.defaultValue} />
572
- * {field.errors && <div>{field.errors.join(', ')}</div>}
573
- * </div>
574
- * );
575
- * }
576
- * ```
577
- */
578
- function useField(name) {
579
- var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
580
- var globalOptions = useContext(GlobalFormOptionsContext);
581
- var context = useFormContext(options.formId);
582
- var field = useMemo(() => getField(context, {
583
- name,
584
- serialize: globalOptions.serialize,
585
- extendFieldMetadata: globalOptions.defineCustomMetadata
586
- }), [context, name, globalOptions.serialize, globalOptions.defineCustomMetadata]);
587
- return field;
588
- }
589
-
590
- /**
591
- * A React hook that provides an intent dispatcher for programmatic form actions.
592
- * Intent dispatchers allow you to trigger form operations like validation, field updates,
593
- * and array manipulations without manual form submission.
594
- *
595
- * @see https://conform.guide/api/react/future/useIntent
596
- * @example
597
- * ```tsx
598
- * function ResetButton() {
599
- * const buttonRef = useRef<HTMLButtonElement>(null);
600
- * const intent = useIntent(buttonRef);
601
- *
602
- * return (
603
- * <button type="button" ref={buttonRef} onClick={() => intent.reset()}>
604
- * Reset Form
605
- * </button>
606
- * );
607
- * }
608
- * ```
609
- */
610
- function useIntent(formRef) {
611
- var globalOptions = useContext(GlobalFormOptionsContext);
612
- return useMemo(() => createIntentDispatcher(() => getFormElement(formRef), globalOptions.intentName), [formRef, globalOptions.intentName]);
613
- }
614
-
615
324
  /**
616
325
  * A React hook that lets you sync the state of an input and dispatch native form events from it.
617
- * This is useful when emulating native input behavior — typically by rendering a hidden base input
326
+ * This is useful when emulating native input behavior — typically by rendering a hidden base control
618
327
  * and syncing it with a custom input.
619
328
  *
620
- * @example
329
+ * **Example:**
621
330
  * ```ts
622
331
  * const control = useControl(options);
623
332
  * ```
624
333
  */
625
- function useControl(options) {
626
- var {
627
- observer
628
- } = useContext(GlobalFormOptionsContext);
334
+
335
+ function useControl() {
336
+ var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
337
+ var observer = useContext(GlobalFormsObserverContext);
629
338
  var inputRef = useRef(null);
630
339
  var formRef = useMemo(() => ({
631
340
  get current() {
@@ -637,29 +346,40 @@ function useControl(options) {
637
346
  return Array.isArray(input) ? (_input$0$form = (_input$ = input[0]) === null || _input$ === void 0 ? void 0 : _input$.form) !== null && _input$0$form !== void 0 ? _input$0$form : null : input.form;
638
347
  }
639
348
  }), []);
349
+ var [defaultValue, setDefaultValue] = useState(() => deriveDefaultPayload(options));
350
+ var pendingDefaultValueSyncRef = useRef(false);
351
+
352
+ /**
353
+ * Keep defaultValue in sync with external option updates during render.
354
+ * This is required for structural controls where hidden descendants must be
355
+ * rendered in the same cycle as form state updates (e.g. update intents).
356
+ */
357
+ if (pendingDefaultValueSyncRef.current && inputRef.current && isGlobalInstance(inputRef.current, 'HTMLFieldSetElement')) {
358
+ pendingDefaultValueSyncRef.current = false;
359
+ setDefaultValue(() => deriveDefaultPayload(options));
360
+ }
640
361
  var eventDispatched = useRef({});
641
- var defaultSnapshot = createDefaultSnapshot(options === null || options === void 0 ? void 0 : options.defaultValue, options === null || options === void 0 ? void 0 : options.defaultChecked, options === null || options === void 0 ? void 0 : options.value);
642
- var snapshotRef = useRef(defaultSnapshot);
362
+ var snapshotRef = useRef(defaultValue);
643
363
  var optionsRef = useRef(options);
644
364
  useEffect(() => {
645
365
  optionsRef.current = options;
646
366
  });
647
-
648
- // This is necessary to ensure that input is re-registered
649
- // if the onFocus handler changes
650
- var shouldHandleFocus = typeof (options === null || options === void 0 ? void 0 : options.onFocus) === 'function';
651
- var snapshot = useSyncExternalStore(useCallback(callback => observer.onFieldUpdate(event => {
367
+ useEffect(() => observer.onInternalUpdate(event => {
368
+ var input = inputRef.current;
369
+ if (input && input instanceof HTMLFieldSetElement && event.target === input.form) {
370
+ pendingDefaultValueSyncRef.current = true;
371
+ }
372
+ }), [observer]);
373
+ var payloadSnapshot = useSyncExternalStore(useCallback(callback => observer.onFieldUpdate(event => {
374
+ var _inputRef$current;
652
375
  var input = event.target;
653
- if (Array.isArray(inputRef.current) ? inputRef.current.some(item => item === input) : inputRef.current === input) {
376
+ if (Array.isArray(inputRef.current) ? inputRef.current.some(item => item === input) : (_inputRef$current = inputRef.current) === null || _inputRef$current === void 0 ? void 0 : _inputRef$current.contains(input)) {
654
377
  callback();
655
378
  }
656
379
  }), [observer]), () => {
657
380
  var input = inputRef.current;
658
381
  var prev = snapshotRef.current;
659
- var next = !input ? defaultSnapshot : Array.isArray(input) ? {
660
- value: getRadioGroupValue(input),
661
- options: getCheckboxGroupValue(input)
662
- } : getInputSnapshot(input);
382
+ var next = input ? resolveControlPayload(input) : defaultValue;
663
383
  if (deepEqual(prev, next)) {
664
384
  return prev;
665
385
  }
@@ -669,7 +389,8 @@ function useControl(options) {
669
389
  useEffect(() => {
670
390
  var createEventListener = listener => {
671
391
  return event => {
672
- if (Array.isArray(inputRef.current) ? inputRef.current.some(item => item === event.target) : inputRef.current === event.target) {
392
+ var _inputRef$current2;
393
+ if (Array.isArray(inputRef.current) ? inputRef.current.some(item => item === event.target) : event.target instanceof Node && ((_inputRef$current2 = inputRef.current) === null || _inputRef$current2 === void 0 ? void 0 : _inputRef$current2.contains(event.target))) {
673
394
  var timer = eventDispatched.current[listener];
674
395
  if (timer) {
675
396
  clearTimeout(timer);
@@ -678,8 +399,8 @@ function useControl(options) {
678
399
  eventDispatched.current[listener] = undefined;
679
400
  });
680
401
  if (listener === 'focus') {
681
- var _optionsRef$current6, _optionsRef$current6$;
682
- (_optionsRef$current6 = optionsRef.current) === null || _optionsRef$current6 === void 0 || (_optionsRef$current6$ = _optionsRef$current6.onFocus) === null || _optionsRef$current6$ === void 0 || _optionsRef$current6$.call(_optionsRef$current6);
402
+ var _optionsRef$current4, _optionsRef$current4$;
403
+ (_optionsRef$current4 = optionsRef.current) === null || _optionsRef$current4 === void 0 || (_optionsRef$current4$ = _optionsRef$current4.onFocus) === null || _optionsRef$current4$ === void 0 || _optionsRef$current4$.call(_optionsRef$current4);
683
404
  }
684
405
  }
685
406
  };
@@ -697,10 +418,62 @@ function useControl(options) {
697
418
  };
698
419
  }, []);
699
420
  return {
700
- value: snapshot.value,
701
- checked: snapshot.checked,
702
- options: snapshot.options,
703
- files: snapshot.files,
421
+ defaultValue,
422
+ get payload() {
423
+ if (payloadSnapshot != null && 'parse' in options) {
424
+ try {
425
+ return options.parse(payloadSnapshot);
426
+ } catch (error) {
427
+ var payloadText = '';
428
+ try {
429
+ payloadText = JSON.stringify(payloadSnapshot, null, 2);
430
+ } catch (_unused) {
431
+ payloadText = '<unserializable payload>';
432
+ }
433
+ throw new Error("Failed to parse the payload. Received ".concat(payloadText, "."), {
434
+ cause: error
435
+ });
436
+ }
437
+ }
438
+ return payloadSnapshot;
439
+ },
440
+ get value() {
441
+ if (payloadSnapshot === null) {
442
+ return '';
443
+ }
444
+ if (typeof payloadSnapshot === 'string') {
445
+ return payloadSnapshot;
446
+ }
447
+ return undefined;
448
+ },
449
+ get checked() {
450
+ if (payloadSnapshot === null) {
451
+ return false;
452
+ }
453
+ var value = 'value' in options && options.value ? options.value : 'on';
454
+ if (payloadSnapshot === value) {
455
+ return true;
456
+ }
457
+ return undefined;
458
+ },
459
+ get options() {
460
+ if (payloadSnapshot === null) {
461
+ return [];
462
+ }
463
+ if (Array.isArray(payloadSnapshot) && payloadSnapshot.every(item => typeof item === 'string')) {
464
+ return payloadSnapshot;
465
+ }
466
+ return undefined;
467
+ },
468
+ get files() {
469
+ if (payloadSnapshot === null) {
470
+ return [];
471
+ }
472
+ if (Array.isArray(payloadSnapshot) && payloadSnapshot.every(item => isGlobalInstance(item, 'File'))) {
473
+ return payloadSnapshot;
474
+ }
475
+ return undefined;
476
+ },
704
477
  formRef,
705
478
  register: useCallback(element => {
706
479
  if (!element) {
@@ -714,17 +487,16 @@ function useControl(options) {
714
487
  element.hidden = true;
715
488
  element.removeAttribute('type');
716
489
  }
717
- if (shouldHandleFocus) {
718
- makeInputFocusable(element);
719
- }
720
490
  if (element.type === 'checkbox' || element.type === 'radio') {
721
- var _optionsRef$current$v, _optionsRef$current7;
722
491
  // React set the value as empty string incorrectly when the value is undefined
723
492
  // This make sure the checkbox value falls back to the default value "on" properly
724
- // @see https://github.com/facebook/react/issues/17590
725
- element.value = (_optionsRef$current$v = (_optionsRef$current7 = optionsRef.current) === null || _optionsRef$current7 === void 0 ? void 0 : _optionsRef$current7.value) !== null && _optionsRef$current$v !== void 0 ? _optionsRef$current$v : 'on';
493
+ // See https://github.com/facebook/react/issues/17590
494
+ var value = 'value' in optionsRef.current && optionsRef.current.value ? optionsRef.current.value : 'on';
495
+ element.value = value;
726
496
  }
727
497
  initializeField(element, optionsRef.current);
498
+ } else if (element instanceof HTMLFieldSetElement) {
499
+ inputRef.current = element;
728
500
  } else {
729
501
  var _inputs$0$name, _inputs$, _inputs$0$type, _inputs$2;
730
502
  var inputs = Array.from(element);
@@ -734,39 +506,36 @@ function useControl(options) {
734
506
  throw new Error('You can only register a checkbox or radio group with the same name');
735
507
  }
736
508
  inputRef.current = inputs;
737
- for (var input of inputs) {
738
- var _optionsRef$current8;
739
- if (shouldHandleFocus) {
740
- makeInputFocusable(input);
509
+ if ('defaultValue' in optionsRef.current) {
510
+ for (var input of inputs) {
511
+ var _optionsRef$current5;
512
+ initializeField(input, {
513
+ // We will not be uitlizing defaultChecked / value on checkbox / radio group
514
+ defaultValue: (_optionsRef$current5 = optionsRef.current) === null || _optionsRef$current5 === void 0 ? void 0 : _optionsRef$current5.defaultValue
515
+ });
741
516
  }
742
- initializeField(input, {
743
- // We will not be uitlizing defaultChecked / value on checkbox / radio group
744
- defaultValue: (_optionsRef$current8 = optionsRef.current) === null || _optionsRef$current8 === void 0 ? void 0 : _optionsRef$current8.defaultValue
745
- });
746
517
  }
747
518
  }
748
- }, [shouldHandleFocus]),
519
+ }, []),
749
520
  change: useCallback(value => {
750
521
  if (!eventDispatched.current.change) {
751
- var _inputRef$current;
752
- var element = Array.isArray(inputRef.current) ? (_inputRef$current = inputRef.current) === null || _inputRef$current === void 0 ? void 0 : _inputRef$current.find(input => {
753
- var wasChecked = input.checked;
754
- var isChecked = Array.isArray(value) ? value.some(item => item === input.value) : input.value === value;
755
- switch (input.type) {
756
- case 'checkbox':
757
- // We assume that only one checkbox can be checked at a time
758
- // So we will pick the first element with checked state changed
759
- return wasChecked !== isChecked;
760
- case 'radio':
761
- // We cannot uncheck a radio button
762
- // So we will pick the first element that should be checked
763
- return isChecked;
764
- default:
765
- return false;
766
- }
767
- }) : inputRef.current;
522
+ var element = inputRef.current;
523
+ var isFieldset = element instanceof HTMLFieldSetElement;
524
+ var serializedValue = value == null ? value : 'serialize' in optionsRef.current && optionsRef.current.serialize ? optionsRef.current.serialize(value) : value;
525
+ if (isFieldset) {
526
+ // Fieldset mode renders hidden descendant inputs from defaultValue.
527
+ // Flush this update before dispatching events so listeners see the
528
+ // latest form structure in the same input/change cycle.
529
+ flushSync(() => {
530
+ setDefaultValue(serializedValue);
531
+ });
532
+ }
768
533
  if (element) {
769
- change(element, typeof value === 'boolean' ? value ? element.value : null : value);
534
+ change(element, serializedValue, {
535
+ // Sometimes no change is made on the inputs but done through DOM mutation.
536
+ // But we still want to dispatch the event to notify listeners.
537
+ forceDispatch: isFieldset
538
+ });
770
539
  }
771
540
  }
772
541
  if (eventDispatched.current.change) {
@@ -808,8 +577,9 @@ function useControl(options) {
808
577
  * Returns `undefined` when the form element is not available (e.g., on SSR or initial client render),
809
578
  * unless a `fallback` is provided.
810
579
  *
811
- * @see https://conform.guide/api/react/future/useFormData
812
- * @example
580
+ * See https://conform.guide/api/react/future/useFormData
581
+ *
582
+ * **Example:**
813
583
  * ```ts
814
584
  * const value = useFormData(
815
585
  * formRef,
@@ -819,9 +589,7 @@ function useControl(options) {
819
589
  */
820
590
 
821
591
  function useFormData(formRef, select, options) {
822
- var {
823
- observer
824
- } = useContext(GlobalFormOptionsContext);
592
+ var observer = useContext(GlobalFormsObserverContext);
825
593
  var valueRef = useRef();
826
594
  var formDataRef = useRef();
827
595
  var value = useSyncExternalStore(useCallback(callback => {
@@ -877,4 +645,146 @@ function useLatest(value) {
877
645
  return ref;
878
646
  }
879
647
 
880
- export { FormContextContext, FormOptionsProvider, FormProvider, GlobalFormOptionsContext, INITIAL_KEY, PreserveBoundary, useConform, useControl, useField, useForm, useFormContext, useFormData, useFormMetadata, useIntent, useLatest, useSafeLayoutEffect };
648
+ /**
649
+ * A component that renders hidden base control(s) based on the shape of defaultValue.
650
+ * Used with useControl to sync complex values with form data.
651
+ *
652
+ * **Example:**
653
+ * ```tsx
654
+ * const control = useControl<{ street: string; city: string }>({
655
+ * defaultValue: { street: '123 Main St', city: 'Anytown' },
656
+ * parse(payload) {
657
+ * if (
658
+ * typeof payload === 'object' &&
659
+ * payload !== null &&
660
+ * 'street' in payload &&
661
+ * 'city' in payload &&
662
+ * typeof payload.street === 'string' &&
663
+ * typeof payload.city === 'string'
664
+ * ) {
665
+ * return payload;
666
+ * }
667
+ *
668
+ * throw new Error('Unexpected payload shape');
669
+ * },
670
+ * });
671
+ *
672
+ * <BaseControl
673
+ * type="fieldset"
674
+ * name="address"
675
+ * ref={control.register}
676
+ * defaultValue={control.defaultValue}
677
+ * />
678
+ * ```
679
+ */
680
+ var BaseControl = /*#__PURE__*/forwardRef(function BaseControl(props, ref) {
681
+ function formatValue(value) {
682
+ var serialized = defaultSerialize(value);
683
+ if (typeof serialized === 'string') {
684
+ return serialized;
685
+ }
686
+
687
+ // null, undefined, File, or array - fallback to empty string
688
+ return '';
689
+ }
690
+ function renderInput(name, value, form) {
691
+ if (Array.isArray(value)) {
692
+ return value.map((item, index) => renderInput("".concat(name, "[").concat(index, "]"), item, form));
693
+ }
694
+ if (isPlainObject(value)) {
695
+ return Object.entries(value).map(_ref5 => {
696
+ var [key, val] = _ref5;
697
+ return renderInput("".concat(name, ".").concat(key), val, form);
698
+ });
699
+ }
700
+ return /*#__PURE__*/jsx("input", {
701
+ name: name,
702
+ defaultValue: formatValue(value),
703
+ form: form
704
+ }, name);
705
+ }
706
+ if (props.type === 'fieldset') {
707
+ var {
708
+ name,
709
+ form,
710
+ defaultValue: _defaultValue,
711
+ hidden: _hidden = true
712
+ } = props,
713
+ fieldsetProps = _objectWithoutProperties(props, _excluded);
714
+ return /*#__PURE__*/jsx("fieldset", _objectSpread2(_objectSpread2({}, fieldsetProps), {}, {
715
+ ref: ref,
716
+ name: name,
717
+ form: form,
718
+ hidden: _hidden,
719
+ children: _defaultValue != null ? renderInput(name, _defaultValue, form) : null
720
+ }));
721
+ }
722
+ if (props.type === 'select') {
723
+ var {
724
+ defaultValue: _defaultValue2,
725
+ multiple = Array.isArray(_defaultValue2),
726
+ hidden: _hidden2 = true
727
+ } = props,
728
+ selectProps = _objectWithoutProperties(props, _excluded2);
729
+ if (multiple) {
730
+ var defaultOptions = Array.isArray(_defaultValue2) ? _defaultValue2.map(formatValue) : [formatValue(_defaultValue2)];
731
+ return /*#__PURE__*/jsx("select", _objectSpread2(_objectSpread2({}, selectProps), {}, {
732
+ ref: ref,
733
+ defaultValue: defaultOptions,
734
+ hidden: _hidden2,
735
+ multiple: true,
736
+ children: defaultOptions.map((option, index) => /*#__PURE__*/jsx("option", {
737
+ value: option,
738
+ children: option
739
+ }, index))
740
+ }));
741
+ }
742
+ var defaultOption = formatValue(_defaultValue2);
743
+ return /*#__PURE__*/jsx("select", _objectSpread2(_objectSpread2({}, selectProps), {}, {
744
+ ref: ref,
745
+ defaultValue: defaultOption,
746
+ hidden: _hidden2,
747
+ children: [defaultOption].map((option, index) => /*#__PURE__*/jsx("option", {
748
+ value: option,
749
+ children: option
750
+ }, index))
751
+ }));
752
+ }
753
+ if (props.type === 'textarea') {
754
+ var {
755
+ defaultValue: _defaultValue3,
756
+ hidden: _hidden3 = true
757
+ } = props,
758
+ textareaProps = _objectWithoutProperties(props, _excluded3);
759
+ return /*#__PURE__*/jsx("textarea", _objectSpread2(_objectSpread2({}, textareaProps), {}, {
760
+ defaultValue: formatValue(_defaultValue3),
761
+ ref: ref,
762
+ hidden: _hidden3
763
+ }));
764
+ }
765
+ if (props.type === 'checkbox' || props.type === 'radio') {
766
+ var {
767
+ defaultValue: _defaultValue4 = 'on',
768
+ value = _defaultValue4,
769
+ hidden: _hidden4 = true
770
+ } = props,
771
+ _inputProps = _objectWithoutProperties(props, _excluded4);
772
+ return /*#__PURE__*/jsx("input", _objectSpread2(_objectSpread2({}, _inputProps), {}, {
773
+ ref: ref,
774
+ value: value,
775
+ hidden: _hidden4
776
+ }));
777
+ }
778
+ var {
779
+ defaultValue,
780
+ hidden = true
781
+ } = props,
782
+ inputProps = _objectWithoutProperties(props, _excluded5);
783
+ return /*#__PURE__*/jsx("input", _objectSpread2(_objectSpread2({}, inputProps), {}, {
784
+ ref: ref,
785
+ defaultValue: defaultValue !== undefined ? formatValue(defaultValue) : undefined,
786
+ hidden: hidden
787
+ }));
788
+ });
789
+
790
+ export { BaseControl, GlobalFormsObserverContext, INITIAL_KEY, PreserveBoundary, useConform, useControl, useFormData, useLatest, useSafeLayoutEffect };