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