@zeno-lib/forms 0.7.0 → 0.8.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.
@@ -2,7 +2,7 @@
2
2
  import { applyValidationError } from "./lib/apply-validation-error.mjs";
3
3
  import { fieldContext, formContext } from "./lib/contexts.mjs";
4
4
  import { withDefaultProps } from "./lib/default-props.mjs";
5
- import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults.mjs";
5
+ import { deepMergeDefaults, extractZodDefaults, pickSchemaShape } from "./lib/schema-defaults.mjs";
6
6
  import { getRequiredPaths } from "./lib/schema-required.mjs";
7
7
  import { DEFAULT_VALIDATION_MODE, setFormZenoState } from "./lib/validation-modes.mjs";
8
8
  import { withEmptyStartValues } from "./lib/empty-value.mjs";
@@ -100,22 +100,32 @@ function createZenoForm(config) {
100
100
  resolvedValidationLogic = mode === "blur-then-change" ? blurThenChangeLogic : void 0;
101
101
  }
102
102
  const wrappedOnSubmit = userOnSubmit ? (async (props) => {
103
- const { submissionAttempts } = props.formApi.state;
103
+ const { formApi } = props;
104
+ const { submissionAttempts } = formApi.state;
105
+ let superseded = false;
106
+ const watch = formApi.store.subscribe(() => {
107
+ if (formApi.state.submissionAttempts !== submissionAttempts) superseded = true;
108
+ });
104
109
  try {
105
110
  await userOnSubmit(props);
106
111
  } catch (error) {
107
112
  if (error instanceof ValidationError) {
108
- applyValidationError(props.formApi, error);
113
+ applyValidationError(formApi, error);
109
114
  return;
110
115
  }
111
- const { state } = props.formApi;
112
- if (state.values === props.value && state.submissionAttempts === submissionAttempts) setSubmitError(props.formApi, error);
116
+ if (!superseded) setSubmitError(formApi, error);
113
117
  throw error;
118
+ } finally {
119
+ watch.unsubscribe();
114
120
  }
115
121
  }) : void 0;
116
122
  const requiredFields = useMemo(() => schema ? getRequiredPaths(schema) : /* @__PURE__ */ new Set(), [schema]);
117
123
  const schemaDefaults = useMemo(() => schema ? withEmptyStartValues(schema, extractZodDefaults(schema)) : void 0, [schema]);
118
- const mergedDefaultValues = useMemo(() => schemaDefaults ? deepMergeDefaults(schemaDefaults, userDefaultValues) : userDefaultValues, [schemaDefaults, userDefaultValues]);
124
+ const mergedDefaultValues = useMemo(() => schemaDefaults ? deepMergeDefaults(schemaDefaults, pickSchemaShape(schema, userDefaultValues)) : userDefaultValues, [
125
+ schema,
126
+ schemaDefaults,
127
+ userDefaultValues
128
+ ]);
119
129
  const rebased = useRebasedDefaultValues(mergedDefaultValues);
120
130
  const listeners = withMountListener(rest.listeners, rebased.onMount);
121
131
  const form = useAppForm({
@@ -8,6 +8,7 @@ type SchemaDef = {
8
8
  readonly type?: string;
9
9
  readonly innerType?: SchemaNode;
10
10
  readonly shape?: Record<string, SchemaNode>;
11
+ readonly catchall?: SchemaNode;
11
12
  readonly in?: SchemaNode;
12
13
  readonly element?: SchemaNode;
13
14
  readonly defaultValue?: unknown;
@@ -20,6 +21,7 @@ type ExtractResult = {
20
21
  };
21
22
  declare function extractZodDefaults(schema: SchemaNode): Record<string, unknown>;
22
23
  declare function extractZodDefaultAt(schema: SchemaNode, keys: readonly PropertyKey[]): ExtractResult;
24
+ declare function pickSchemaShape<T>(schema: SchemaNode, value: T): T;
23
25
  declare function deepMergeDefaults(schemaDefaults: Record<string, unknown>, userDefaults: Record<string, unknown> | undefined): Record<string, unknown>;
24
26
  //#endregion
25
- export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults };
27
+ export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults, pickSchemaShape };
@@ -92,6 +92,48 @@ function extractZodDefaultAt(schema, keys) {
92
92
  function isPlainObject(value) {
93
93
  return typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype;
94
94
  }
95
+ function acceptsOnlyShape(def) {
96
+ return def.catchall === void 0 || getDef(def.catchall)?.type === "never";
97
+ }
98
+ function pickShape(node, value) {
99
+ let current = node;
100
+ for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
101
+ const def = getDef(current);
102
+ switch (def?.type) {
103
+ case "object": {
104
+ const { shape } = def;
105
+ if (!(shape && isPlainObject(value) && acceptsOnlyShape(def))) return value;
106
+ const result = {};
107
+ for (const [key, field] of Object.entries(shape)) if (Object.hasOwn(value, key)) result[key] = pickShape(field, value[key]);
108
+ return result;
109
+ }
110
+ case "array": {
111
+ const { element } = def;
112
+ return element && Array.isArray(value) ? value.map((item) => pickShape(element, item)) : value;
113
+ }
114
+ case "default":
115
+ case "nonoptional":
116
+ case "nullable":
117
+ case "optional":
118
+ case "prefault":
119
+ case "readonly":
120
+ current = def.innerType;
121
+ continue;
122
+ case "pipe":
123
+ current = def.in;
124
+ continue;
125
+ default: return value;
126
+ }
127
+ }
128
+ return value;
129
+ }
130
+ function pickSchemaShape(schema, value) {
131
+ try {
132
+ return pickShape(schema, value);
133
+ } catch {
134
+ return value;
135
+ }
136
+ }
95
137
  function deepMergeDefaults(schemaDefaults, userDefaults) {
96
138
  if (!userDefaults) return schemaDefaults;
97
139
  const result = { ...schemaDefaults };
@@ -104,4 +146,4 @@ function deepMergeDefaults(schemaDefaults, userDefaults) {
104
146
  return result;
105
147
  }
106
148
  //#endregion
107
- export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults };
149
+ export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults, pickSchemaShape };
@@ -1,5 +1,6 @@
1
1
  import { applyValidationError } from "./apply-validation-error.mjs";
2
2
  import { toActionError } from "./action-result.mjs";
3
+ import { pickSchemaShape } from "./schema-defaults.mjs";
3
4
  import { getFormSchema } from "./validation-modes.mjs";
4
5
  import { ValidationError } from "./validation-error.mjs";
5
6
  //#region src/lib/submit-action.ts
@@ -36,10 +37,14 @@ async function submitAction(props, action, options = {}) {
36
37
  return result;
37
38
  }
38
39
  const { reset } = options;
39
- if (reset === true) formApi.reset(result.data);
40
+ if (reset === true) formApi.reset(toFormValues(formApi, result.data));
40
41
  else if (reset === "values") formApi.reset(value);
41
- else if (typeof reset === "function") formApi.reset(reset(result.data));
42
+ else if (typeof reset === "function") formApi.reset(toFormValues(formApi, reset(result.data)));
42
43
  return result;
43
44
  }
45
+ function toFormValues(formApi, data) {
46
+ const schema = getFormSchema(formApi);
47
+ return schema ? pickSchemaShape(schema, data) : data;
48
+ }
44
49
  //#endregion
45
50
  export { applyActionError, submitAction };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeno-lib/forms",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "repository": {
5
5
  "url": "https://github.com/zeno-lib/zeno.git",
6
6
  "directory": "packages/forms"
@@ -37,7 +37,7 @@
37
37
  "@types/react": "19.3.0",
38
38
  "@types/react-dom": "19.3.0",
39
39
  "@zeno-lib/test": "^0.1.1",
40
- "@zeno-lib/typescript": "^1.1.0",
40
+ "@zeno-lib/typescript": "^1.2.0",
41
41
  "@zeno-lib/ui": "^0.0.1",
42
42
  "lucide-react": "^1.52.0",
43
43
  "tsdown": "^0.23.0",
@@ -16,7 +16,11 @@ import { fieldContext, formContext } from "./lib/contexts"
16
16
  import { withDefaultProps } from "./lib/default-props"
17
17
  import { withEmptyStartValues } from "./lib/empty-value"
18
18
  import { useDisabledOption } from "./lib/form-disabled"
19
- import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults"
19
+ import {
20
+ deepMergeDefaults,
21
+ extractZodDefaults,
22
+ pickSchemaShape,
23
+ } from "./lib/schema-defaults"
20
24
  import {
21
25
  getRequiredPaths,
22
26
  type StandardSchemaLike,
@@ -531,7 +535,17 @@ export function createZenoForm<
531
535
 
532
536
  const wrappedOnSubmit = userOnSubmit
533
537
  ? ((async (props: { formApi: AnyFormApi; value: TFormData }) => {
534
- const { submissionAttempts } = props.formApi.state
538
+ const { formApi } = props
539
+ const { submissionAttempts } = formApi.state
540
+ // A reset (a dialog closed meanwhile) or a newer submit while this
541
+ // one runs makes its failure stale, even once a later submit brings
542
+ // the count back. An edit doesn't: the save still didn't happen.
543
+ let superseded = false
544
+ const watch = formApi.store.subscribe(() => {
545
+ if (formApi.state.submissionAttempts !== submissionAttempts) {
546
+ superseded = true
547
+ }
548
+ })
535
549
  try {
536
550
  await (
537
551
  userOnSubmit as unknown as (
@@ -540,20 +554,15 @@ export function createZenoForm<
540
554
  )(props)
541
555
  } catch (error) {
542
556
  if (error instanceof ValidationError) {
543
- applyValidationError(props.formApi, error)
557
+ applyValidationError(formApi, error)
544
558
  return
545
559
  }
546
- // Kept for `FormError`, unless an edit, a reset (a dialog closed
547
- // meanwhile) or a newer submit came first: then it is about
548
- // values no longer on screen.
549
- const { state } = props.formApi
550
- if (
551
- state.values === props.value &&
552
- state.submissionAttempts === submissionAttempts
553
- ) {
554
- setSubmitError(props.formApi, error)
560
+ if (!superseded) {
561
+ setSubmitError(formApi, error)
555
562
  }
556
563
  throw error
564
+ } finally {
565
+ watch.unsubscribe()
557
566
  }
558
567
  }) as typeof userOnSubmit)
559
568
  : undefined
@@ -579,15 +588,21 @@ export function createZenoForm<
579
588
  [schema]
580
589
  )
581
590
 
591
+ // The caller's values keep only the keys the schema describes, so a
592
+ // record as read (`id`, `createdAt`, …) doesn't fail a `.strict()` schema
593
+ // on submit. The schema's own defaults then fill the keys left out.
582
594
  const mergedDefaultValues = useMemo(
583
595
  () =>
584
596
  schemaDefaults
585
597
  ? (deepMergeDefaults(
586
598
  schemaDefaults,
587
- userDefaultValues as Record<string, unknown> | undefined
599
+ pickSchemaShape(
600
+ schema as Parameters<typeof pickSchemaShape>[0],
601
+ userDefaultValues as Record<string, unknown> | undefined
602
+ )
588
603
  ) as typeof userDefaultValues)
589
604
  : userDefaultValues,
590
- [schemaDefaults, userDefaultValues]
605
+ [schema, schemaDefaults, userDefaultValues]
591
606
  )
592
607
 
593
608
  // Keep `formApi.reset(values)` from being undone by the next render; see
@@ -18,7 +18,7 @@ import {
18
18
  } from "./form-dialog"
19
19
  import type { ActionResult } from "./lib/action-result"
20
20
  import { submitAction } from "./lib/submit-action"
21
- import { useFormDialog } from "./lib/use-form-dialog"
21
+ import { type FormDialogController, useFormDialog } from "./lib/use-form-dialog"
22
22
 
23
23
  const NAME = /Name/
24
24
  const EMAIL = /Email/
@@ -54,8 +54,10 @@ function Harness({
54
54
  children,
55
55
  onSubmit = vi.fn(),
56
56
  closeOnSubmit,
57
+ controller,
57
58
  disabled,
58
59
  discardPrompt,
60
+ formDisabled,
59
61
  saveFromPrompt,
60
62
  submitErrorMessage,
61
63
  submitLabel,
@@ -65,15 +67,23 @@ function Harness({
65
67
  children?: ReactNode
66
68
  onSubmit?: (values: Values) => unknown
67
69
  closeOnSubmit?: boolean
70
+ /** Gets the dialog's controller, to close it from outside. */
71
+ controller?: { current?: FormDialogController<Values> }
68
72
  disabled?: boolean
69
73
  discardPrompt?: DiscardPromptText
74
+ /** Disable the form through `useForm({ disabled })`. */
75
+ formDisabled?: boolean
70
76
  saveFromPrompt?: boolean
71
77
  submitErrorMessage?: (error: unknown) => ReactNode
72
78
  submitLabel?: ReactNode
73
79
  }) {
74
80
  const dialog = useFormDialog({ defaultValues: BLANK })
81
+ if (controller) {
82
+ controller.current = dialog
83
+ }
75
84
  const form = useForm({
76
85
  defaultValues: dialog.defaultValues,
86
+ disabled: formDisabled,
77
87
  onSubmit: (submit) =>
78
88
  action ? submitAction(submit, action) : onSubmit(submit.value),
79
89
  schema,
@@ -119,6 +129,13 @@ function dropFocus() {
119
129
  sink.remove()
120
130
  }
121
131
 
132
+ // Whether leaving the page now would make the browser ask first.
133
+ function warnsOnUnload(): boolean {
134
+ const event = new Event("beforeunload", { cancelable: true })
135
+ window.dispatchEvent(event)
136
+ return event.defaultPrevented
137
+ }
138
+
122
139
  // The alerts added where assistive tech could see them. One added under an
123
140
  // `aria-hidden` ancestor (the dialog, while the leave prompt is open) is never
124
141
  // announced, not even once that ancestor is shown again.
@@ -161,6 +178,40 @@ describe("FormDialog", () => {
161
178
  expect(name.value).toBe("Ada")
162
179
  })
163
180
 
181
+ test("an edit opened on a record with keys the schema lacks saves to a .strict() schema", async () => {
182
+ const user = userEvent.setup()
183
+ const strictSchema = schema.strict()
184
+ const record = { ...ROW, createdAt: "2026-01-01", id: 7 }
185
+ const onSubmit = vi.fn()
186
+ function StrictHarness() {
187
+ const dialog = useFormDialog<Values>({ defaultValues: BLANK })
188
+ const form = useForm({
189
+ defaultValues: dialog.defaultValues,
190
+ onSubmit: ({ value }) => onSubmit(value),
191
+ schema: strictSchema,
192
+ })
193
+ const { InputField } = form
194
+ return (
195
+ <>
196
+ <button
197
+ onClick={() => dialog.open({ defaultValues: record })}
198
+ type="button"
199
+ >
200
+ Edit
201
+ </button>
202
+ <FormDialog dialog={dialog} form={form} title="Person">
203
+ <InputField label="Name" name="name" />
204
+ </FormDialog>
205
+ </>
206
+ )
207
+ }
208
+ render(<StrictHarness />)
209
+ await user.click(screen.getByRole("button", { name: "Edit" }))
210
+ await screen.findByLabelText(NAME)
211
+ await user.click(screen.getByRole("button", { name: "Save" }))
212
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(ROW))
213
+ })
214
+
164
215
  test("closes without asking when nothing changed", async () => {
165
216
  const user = userEvent.setup()
166
217
  render(<Harness />)
@@ -277,6 +328,110 @@ describe("FormDialog", () => {
277
328
  expect(screen.getByRole("dialog")).toBeTruthy()
278
329
  })
279
330
 
331
+ for (const closeOnSubmit of [true, false]) {
332
+ test(`a leave asked for while a Save runs goes through once it saves (closeOnSubmit: ${closeOnSubmit})`, async () => {
333
+ const user = userEvent.setup()
334
+ let finishSave: () => void = () => undefined
335
+ const onSubmit = vi.fn(
336
+ () =>
337
+ new Promise<void>((resolve) => {
338
+ finishSave = resolve
339
+ })
340
+ )
341
+ render(<Harness closeOnSubmit={closeOnSubmit} onSubmit={onSubmit} />)
342
+ const name = await openEdit(user)
343
+ await user.type(name, "!")
344
+ await user.click(screen.getByRole("button", { name: "Save" }))
345
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
346
+ await user.keyboard("{Escape}")
347
+ await screen.findByRole("alertdialog")
348
+
349
+ finishSave()
350
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
351
+ expect(screen.queryByRole("alertdialog")).toBeNull()
352
+ })
353
+ }
354
+
355
+ test("an edit made while a Save runs keeps the leave prompt asking once it saves", async () => {
356
+ const user = userEvent.setup()
357
+ let finishSave: () => void = () => undefined
358
+ const onSubmit = vi.fn(
359
+ () =>
360
+ new Promise<void>((resolve) => {
361
+ finishSave = resolve
362
+ })
363
+ )
364
+ render(<Harness onSubmit={onSubmit} />)
365
+ const name = await openEdit(user)
366
+ await user.type(name, "!")
367
+ await user.click(screen.getByRole("button", { name: "Save" }))
368
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
369
+ await user.type(name, "?")
370
+ await user.keyboard("{Escape}")
371
+ const prompt = await screen.findByRole("alertdialog")
372
+
373
+ finishSave()
374
+ await waitFor(() =>
375
+ expect(screen.queryByRole("status", { hidden: true })).toBeNull()
376
+ )
377
+ expect(screen.getByRole("alertdialog")).toBe(prompt)
378
+ await user.click(
379
+ within(prompt).getByRole("button", { name: "Keep editing" })
380
+ )
381
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
382
+ expect(name.value).toBe("Ada!?")
383
+ })
384
+
385
+ test("a Save that fails while the leave prompt asks focuses the invalid field once it closes", async () => {
386
+ const user = userEvent.setup()
387
+ let finishSave: (result: ActionResult<never>) => void = () => undefined
388
+ const action = vi.fn(
389
+ () =>
390
+ new Promise<ActionResult<never>>((resolve) => {
391
+ finishSave = resolve
392
+ })
393
+ )
394
+ render(<Harness action={action} />)
395
+ const name = await openEdit(user)
396
+ await user.type(name, "!")
397
+ await user.click(screen.getByRole("button", { name: "Save" }))
398
+ await waitFor(() => expect(action).toHaveBeenCalled())
399
+ await user.keyboard("{Escape}")
400
+ const prompt = await screen.findByRole("alertdialog")
401
+ await waitFor(() =>
402
+ expect(prompt.contains(document.activeElement)).toBe(true)
403
+ )
404
+
405
+ finishSave({
406
+ error: { fieldErrors: { email: ["Already taken"] }, formErrors: [] },
407
+ ok: false,
408
+ })
409
+ const email = screen.getByLabelText(EMAIL)
410
+ await waitFor(() => expect(email.getAttribute("aria-invalid")).toBe("true"))
411
+ expect(prompt.contains(document.activeElement)).toBe(true)
412
+ await user.click(
413
+ within(prompt).getByRole("button", { name: "Keep editing" })
414
+ )
415
+ await waitFor(() => expect(document.activeElement).toBe(email))
416
+ })
417
+
418
+ test("closing the dialog from outside closes its leave prompt for good", async () => {
419
+ const user = userEvent.setup()
420
+ const controller: { current?: FormDialogController<Values> } = {}
421
+ render(<Harness controller={controller} />)
422
+ const name = await openEdit(user)
423
+ await user.type(name, "!")
424
+ await user.keyboard("{Escape}")
425
+ await screen.findByRole("alertdialog")
426
+
427
+ act(() => controller.current?.close())
428
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
429
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
430
+ await user.click(screen.getByRole("button", { name: "Edit" }))
431
+ await screen.findByRole("dialog")
432
+ expect(screen.queryByRole("alertdialog")).toBeNull()
433
+ })
434
+
280
435
  test("a field error from the server focuses that field", async () => {
281
436
  const user = userEvent.setup()
282
437
  const action = vi.fn(() =>
@@ -583,17 +738,46 @@ describe("FormDialog", () => {
583
738
  expect(screen.getByRole("dialog")).toBeTruthy()
584
739
  })
585
740
 
586
- test("disabled also turns off Save in the leave prompt", async () => {
741
+ for (const via of ["disabled", "useForm({ disabled })"]) {
742
+ test(`with ${via}, a changed dialog still asks and warns, without offering Save`, async () => {
743
+ const user = userEvent.setup()
744
+ const { rerender } = render(<Harness saveFromPrompt />)
745
+ const name = await openEdit(user)
746
+ await user.type(name, "!")
747
+
748
+ rerender(
749
+ <Harness
750
+ disabled={via === "disabled"}
751
+ formDisabled={via !== "disabled"}
752
+ saveFromPrompt
753
+ />
754
+ )
755
+ expect(warnsOnUnload()).toBe(true)
756
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
757
+ const prompt = await screen.findByRole("alertdialog")
758
+ expect(within(prompt).getByText("Discard changes?")).toBeTruthy()
759
+ expect(within(prompt).queryByRole("button", { name: "Save" })).toBeNull()
760
+ await user.click(within(prompt).getByRole("button", { name: "Discard" }))
761
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
762
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
763
+ })
764
+ }
765
+
766
+ test("disabled while the leave prompt asks keeps it, with its Save off", async () => {
587
767
  const user = userEvent.setup()
588
768
  const onSubmit = vi.fn()
589
769
  const { rerender } = render(<Harness onSubmit={onSubmit} saveFromPrompt />)
590
770
  const name = await openEdit(user)
591
771
  await user.type(name, "!")
592
- rerender(<Harness disabled onSubmit={onSubmit} saveFromPrompt />)
593
772
  await user.click(screen.getByRole("button", { name: "Cancel" }))
594
773
  const prompt = await screen.findByRole("alertdialog")
774
+
775
+ rerender(<Harness disabled onSubmit={onSubmit} saveFromPrompt />)
595
776
  await user.click(within(prompt).getByRole("button", { name: "Save" }))
596
777
  expect(onSubmit).not.toHaveBeenCalled()
778
+ expect(screen.getByRole("alertdialog")).toBe(prompt)
779
+ await user.click(within(prompt).getByRole("button", { name: "Discard" }))
780
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
597
781
  })
598
782
 
599
783
  test("Save in the leave prompt doesn't submit again while a submit runs", async () => {
@@ -110,7 +110,11 @@ type FormDialogProps<TValues> = {
110
110
  cancelLabel?: ReactNode
111
111
  /** Extra footer content, rendered before Cancel / Save. */
112
112
  footer?: ReactNode
113
- /** Close after a successful submit. Defaults to `true`. */
113
+ /**
114
+ * Close after a successful submit. Defaults to `true`. A close the user
115
+ * asked for while the submit ran goes through once it succeeds either way,
116
+ * unless they edited a field since.
117
+ */
114
118
  closeOnSubmit?: boolean
115
119
  /**
116
120
  * Shown above the footer when the submit handler throws. Defaults to the
@@ -127,13 +131,15 @@ type FormDialogProps<TValues> = {
127
131
  discardPrompt?: DiscardPromptText
128
132
  /**
129
133
  * Offer Save in the discard prompt, next to Discard: it submits, and closes
130
- * the dialog if the submit succeeds. Defaults to `false`.
134
+ * the dialog if the submit succeeds. Not in a prompt that opens while the
135
+ * dialog is disabled. Defaults to `false`.
131
136
  */
132
137
  saveFromPrompt?: boolean
133
138
  /**
134
139
  * Disable every field, Save and ⌘/Ctrl+Enter, as `useForm({ disabled })`
135
140
  * does. A `<fieldset disabled>` also disables native controls of your own.
136
- * Defaults to `false`.
141
+ * A discard prompt that opens while disabled doesn't offer Save. Defaults
142
+ * to `false`.
137
143
  */
138
144
  disabled?: boolean
139
145
  /** Class for the dialog popup (e.g. a wider `sm:max-w-lg`). */
@@ -257,8 +263,8 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
257
263
  useIsFormDisabled(form as unknown as AnyFormApi) || disabled
258
264
  const popupRef = useRef<HTMLDivElement>(null)
259
265
  const formRef = useRef<HTMLFormElement>(null)
260
- // Set when a Save from the discard prompt fails, so the prompt hands focus
261
- // to the first invalid field as it closes.
266
+ // Set when a submit fails while the discard prompt is open, so the prompt
267
+ // hands focus to the first invalid field as it closes.
262
268
  const focusInvalidOnPromptClose = useRef<boolean>(false)
263
269
  // The open prompt hides the dialog from assistive tech, so a form-level
264
270
  // error that shows up meanwhile (a Save from the prompt, or a footer Save
@@ -266,9 +272,13 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
266
272
  // the prompt has closed, which announces it.
267
273
  const announceOnPromptClosed = useRef<boolean>(false)
268
274
  const [formErrorKey, setFormErrorKey] = useState(0)
275
+ // Whether the discard prompt offers Save, set as it opens: a disabled
276
+ // dialog can't save. Kept while it's open, so the prompt doesn't change
277
+ // under the user; one disabled meanwhile only turns its Save off.
278
+ const [promptOffersSave, setPromptOffersSave] = useState(false)
269
279
  const { close, defaultValues, focus, isOpen, open, session } = dialog
270
280
  const DialogFormError = form.FormError ?? FormError
271
- const promptCopy = getPromptCopy(discardPrompt, saveFromPrompt)
281
+ const promptCopy = getPromptCopy(discardPrompt, promptOffersSave)
272
282
  // Read after a submit's await: the dialog may have closed, or reopened on
273
283
  // another record, while a slow save was in flight.
274
284
  const latest = useRef({ isOpen, session })
@@ -313,6 +323,12 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
313
323
  }
314
324
  }, [session])
315
325
 
326
+ // A held leave belongs to the session it was asked in. Drop it when that
327
+ // session ends some other way (`dialog.close()`, a submit that closes it,
328
+ // `dialog.open()` on another record), so the prompt never outlives it.
329
+ // biome-ignore lint/correctness/useExhaustiveDependencies: on every session change.
330
+ useLayoutEffect(() => cancelLeave, [isOpen, session])
331
+
316
332
  // Every way to submit (footer Save, ⌘/Ctrl+Enter, the prompt's Save) checks
317
333
  // this first. TanStack only refuses a second `handleSubmit` while one runs on
318
334
  // the very first attempt, and `isOpen` is false through the exit animation,
@@ -342,10 +358,25 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
342
358
  }
343
359
  const node = event.currentTarget
344
360
  const focused = node.ownerDocument.activeElement
361
+ // The fields stay editable while the submit runs. TanStack replaces
362
+ // `values` on every edit, so the same object means none came after.
363
+ const submitted = form.state.values
345
364
  const saved = await submit()
346
365
  if (!latest.current.isOpen || latest.current.session !== session) {
347
366
  return // the session this submit belongs to is over
348
367
  }
368
+ if (isConfirmingRef.current) {
369
+ // The user asked to leave while the submit ran. If it saved every
370
+ // change, that leave goes through, `closeOnSubmit` or not. After a
371
+ // failure or a later edit, the prompt waits for the user's answer, and
372
+ // it traps focus, so it moves focus to an invalid field as it closes.
373
+ if (saved && form.state.values === submitted) {
374
+ confirmLeave()
375
+ } else {
376
+ focusInvalidOnPromptClose.current = !saved
377
+ }
378
+ return
379
+ }
349
380
  if (saved) {
350
381
  if (closeOnSubmit) {
351
382
  // Close directly, past the guard: the changes are saved. The form is
@@ -392,6 +423,7 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
392
423
  if (next) {
393
424
  open()
394
425
  } else {
426
+ setPromptOffersSave(saveFromPrompt && !isDisabled)
395
427
  requestLeave(close)
396
428
  }
397
429
  }}
@@ -480,21 +512,24 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
480
512
  setFormErrorKey((key) => key + 1)
481
513
  }
482
514
  }}
483
- open={isConfirming}
515
+ // Closes in the same render as the dialog.
516
+ open={isConfirming && isOpen}
484
517
  >
485
518
  <AlertDialogContent
486
519
  // The prompt traps focus while open, so a failed Save's invalid
487
520
  // field gets focus as the prompt closes, not from `submit`.
488
521
  finalFocus={() => {
489
- if (!focusInvalidOnPromptClose.current) {
522
+ const focusInvalid = focusInvalidOnPromptClose.current
523
+ focusInvalidOnPromptClose.current = false
524
+ // After Discard, the closing dialog hands focus back itself.
525
+ if (!(focusInvalid && latest.current.isOpen)) {
490
526
  return true
491
527
  }
492
- focusInvalidOnPromptClose.current = false
493
528
  // Base UI focuses what this returns too; focusing here first
494
529
  // finds a field that takes focus, skipping disabled or hidden ones.
495
530
  return focusFirstInvalid(formRef.current) ?? true
496
531
  }}
497
- size={saveFromPrompt ? "default" : "sm"}
532
+ size={promptOffersSave ? "default" : "sm"}
498
533
  >
499
534
  <AlertDialogHeader>
500
535
  <AlertDialogTitle>{promptCopy.title}</AlertDialogTitle>
@@ -513,7 +548,7 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
513
548
  >
514
549
  {discardPrompt?.confirmLabel ?? "Discard"}
515
550
  </AlertDialogAction>
516
- {saveFromPrompt && (
551
+ {promptOffersSave && (
517
552
  <AlertDialogAction
518
553
  disabled={isDisabled || isSaving || isSubmitting}
519
554
  onClick={saveAndLeave}
@@ -136,6 +136,53 @@ describe("<FormError> with <Form>", () => {
136
136
  expect(screen.queryByRole("alert")).toBeNull()
137
137
  })
138
138
 
139
+ test("a submit that throws after an edit made while it ran still shows", async () => {
140
+ const user = userEvent.setup()
141
+ let rejectSubmit: (error: Error) => void = () => undefined
142
+ const onSubmit = vi.fn(
143
+ () =>
144
+ new Promise((_, reject) => {
145
+ rejectSubmit = reject
146
+ })
147
+ )
148
+ render(<Profile onSubmit={onSubmit} />)
149
+ await user.click(screen.getByRole("button", SUBMIT))
150
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
151
+
152
+ await user.type(screen.getByLabelText(NAME), "!")
153
+ rejectSubmit(new Error("offline"))
154
+ expect(await screen.findByRole("alert")).toBeTruthy()
155
+
156
+ // The next edit clears it, as after any thrown submit.
157
+ await user.type(screen.getByLabelText(NAME), "?")
158
+ expect(screen.queryByRole("alert")).toBeNull()
159
+ })
160
+
161
+ test("a reset while a submit runs drops its error, even after a new submit", async () => {
162
+ const user = userEvent.setup()
163
+ let rejectFirst: (error: Error) => void = () => undefined
164
+ const onSubmit = vi
165
+ .fn<(values: Values) => unknown>()
166
+ .mockImplementationOnce(
167
+ () =>
168
+ new Promise((_, reject) => {
169
+ rejectFirst = reject
170
+ })
171
+ )
172
+ .mockImplementationOnce(() => new Promise(() => undefined))
173
+ render(<Profile onSubmit={onSubmit} />)
174
+ await user.click(screen.getByRole("button", SUBMIT))
175
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
176
+
177
+ await user.click(screen.getByRole("button", { name: "Reset" }))
178
+ // Back to the first submit's attempt count, still running.
179
+ await user.click(screen.getByRole("button", SUBMIT))
180
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2))
181
+ rejectFirst(new Error("offline"))
182
+ await new Promise((resolve) => setTimeout(resolve, 0))
183
+ expect(screen.queryByRole("alert")).toBeNull()
184
+ })
185
+
139
186
  test("a reset drops a thrown submit's error, even when it restores the same values", async () => {
140
187
  const user = userEvent.setup()
141
188
  let resolveRetry: () => void = () => undefined
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, test } from "vitest"
2
2
  import { z } from "zod"
3
- import { deepMergeDefaults, extractZodDefaults } from "./schema-defaults"
3
+ import {
4
+ deepMergeDefaults,
5
+ extractZodDefaults,
6
+ pickSchemaShape,
7
+ } from "./schema-defaults"
4
8
 
5
9
  describe("extractZodDefaults — primitives", () => {
6
10
  test("z.string() → ''", () => {
@@ -192,3 +196,126 @@ describe("deepMergeDefaults", () => {
192
196
  expect(deepMergeDefaults({ x: { a: 1 } }, { x: null })).toEqual({ x: null })
193
197
  })
194
198
  })
199
+
200
+ describe("pickSchemaShape", () => {
201
+ test("keeps only the keys the schema describes, at every depth", () => {
202
+ const schema = z
203
+ .object({
204
+ address: z.object({ city: z.string() }).strict(),
205
+ name: z.string(),
206
+ })
207
+ .strict()
208
+ const record = {
209
+ address: { city: "Geneva", geocodedAt: "2026-01-01" },
210
+ createdAt: new Date(),
211
+ id: 7,
212
+ name: "Ada",
213
+ }
214
+ expect(pickSchemaShape(schema, record)).toEqual({
215
+ address: { city: "Geneva" },
216
+ name: "Ada",
217
+ })
218
+ // The record is copied, never trimmed in place.
219
+ expect(record).toHaveProperty("id", 7)
220
+ })
221
+
222
+ test("maps an array's element over every row", () => {
223
+ const schema = z.object({
224
+ lines: z.array(z.object({ amount: z.number(), label: z.string() })),
225
+ })
226
+ expect(
227
+ pickSchemaShape(schema, {
228
+ lines: [
229
+ { amount: 1, id: 10, label: "Design" },
230
+ { amount: 2, id: 11, label: "Build" },
231
+ ],
232
+ })
233
+ ).toEqual({
234
+ lines: [
235
+ { amount: 1, label: "Design" },
236
+ { amount: 2, label: "Build" },
237
+ ],
238
+ })
239
+ })
240
+
241
+ test("unwraps optional, nullable and default, keeping null and absent keys", () => {
242
+ const address = z.object({ city: z.string() })
243
+ const schema = z.object({
244
+ billing: address.nullable(),
245
+ shipping: address.optional(),
246
+ tags: z.array(z.object({ label: z.string() })).default([]),
247
+ work: address.nullable(),
248
+ })
249
+ const picked = pickSchemaShape(schema, {
250
+ billing: null,
251
+ tags: [{ id: 1, label: "vip" }],
252
+ work: { city: "Bern", id: 3 },
253
+ })
254
+ expect(picked).toEqual({
255
+ billing: null,
256
+ tags: [{ label: "vip" }],
257
+ work: { city: "Bern" },
258
+ })
259
+ expect(picked).not.toHaveProperty("shipping")
260
+ })
261
+
262
+ test("reads a pipe or a transform through its input", () => {
263
+ const schema = z
264
+ .object({
265
+ amount: z.string().transform(Number),
266
+ period: z
267
+ .object({ from: z.string(), to: z.string() })
268
+ .transform(({ from, to }) => `${from}/${to}`),
269
+ })
270
+ .transform((invoice) => ({ ...invoice, total: invoice.amount }))
271
+ expect(
272
+ pickSchemaShape(schema, {
273
+ amount: "12",
274
+ id: 4,
275
+ period: { from: "2026-01", id: 9, to: "2026-02" },
276
+ })
277
+ ).toEqual({ amount: "12", period: { from: "2026-01", to: "2026-02" } })
278
+ })
279
+
280
+ test("keeps as is what the schema doesn't describe for certain", () => {
281
+ const schema = z.object({
282
+ contact: z.union([
283
+ z.object({ email: z.string() }),
284
+ z.object({ phone: z.string() }),
285
+ ]),
286
+ extra: z.object({ a: z.string() }).catchall(z.string()),
287
+ labels: z.record(z.string(), z.object({ text: z.string() })),
288
+ loose: z.looseObject({ a: z.string() }),
289
+ meta: z.unknown(),
290
+ profile: z.object({ nick: z.string() }),
291
+ })
292
+ const value = {
293
+ contact: { email: "ada@example.com", id: 1 },
294
+ extra: { a: "x", b: "y" },
295
+ labels: { en: { id: 2, text: "Hello" } },
296
+ loose: { a: "x", b: "y" },
297
+ meta: { anything: true },
298
+ profile: "not an object",
299
+ }
300
+ expect(pickSchemaShape(schema, value)).toEqual(value)
301
+ })
302
+
303
+ test("leaves a value alone without a Zod schema", () => {
304
+ const value = { id: 1, name: "Ada" }
305
+ expect(
306
+ pickSchemaShape({} as Parameters<typeof pickSchemaShape>[0], value)
307
+ ).toBe(value)
308
+ })
309
+
310
+ test("leaves a missing key absent, for the schema default to fill", () => {
311
+ const schema = z.object({
312
+ name: z.string(),
313
+ role: z.string().default("member"),
314
+ })
315
+ const picked = pickSchemaShape(schema, { id: 7, name: "Ada" })
316
+ expect(deepMergeDefaults(extractZodDefaults(schema), picked)).toEqual({
317
+ name: "Ada",
318
+ role: "member",
319
+ })
320
+ })
321
+ })
@@ -1,4 +1,5 @@
1
- // Extract default values from a Zod schema for `useForm`.
1
+ // Extract default values from a Zod schema for `useForm`, and keep the values
2
+ // a caller passes to the schema's shape (`pickSchemaShape`, below).
2
3
  //
3
4
  // Tier A+ semantics:
4
5
  // - `.default(...)` / `.prefault(...)` values flow through.
@@ -23,6 +24,7 @@ type SchemaDef = {
23
24
  readonly type?: string
24
25
  readonly innerType?: SchemaNode
25
26
  readonly shape?: Record<string, SchemaNode>
27
+ readonly catchall?: SchemaNode
26
28
  readonly in?: SchemaNode
27
29
  readonly element?: SchemaNode
28
30
  readonly defaultValue?: unknown
@@ -148,6 +150,71 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
148
150
  )
149
151
  }
150
152
 
153
+ // `.strict()` sets `catchall` to `z.never()`; a plain `z.object` leaves it
154
+ // unset. Either way the schema accepts no key outside its `shape`.
155
+ function acceptsOnlyShape(def: SchemaDef): boolean {
156
+ return def.catchall === undefined || getDef(def.catchall)?.type === "never"
157
+ }
158
+
159
+ function pickShape(node: SchemaNode, value: unknown): unknown {
160
+ let current: SchemaNode | undefined = node
161
+ for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
162
+ const def: SchemaDef | undefined = getDef(current)
163
+ switch (def?.type) {
164
+ case "object": {
165
+ const { shape } = def
166
+ if (!(shape && isPlainObject(value) && acceptsOnlyShape(def))) {
167
+ return value
168
+ }
169
+ const result: Record<string, unknown> = {}
170
+ for (const [key, field] of Object.entries(shape)) {
171
+ if (Object.hasOwn(value, key)) {
172
+ result[key] = pickShape(field, value[key])
173
+ }
174
+ }
175
+ return result
176
+ }
177
+ case "array": {
178
+ const { element } = def
179
+ return element && Array.isArray(value)
180
+ ? value.map((item) => pickShape(element, item))
181
+ : value
182
+ }
183
+ case "default":
184
+ case "nonoptional":
185
+ case "nullable":
186
+ case "optional":
187
+ case "prefault":
188
+ case "readonly":
189
+ current = def.innerType
190
+ continue
191
+ case "pipe":
192
+ current = def.in
193
+ continue
194
+ default:
195
+ return value
196
+ }
197
+ }
198
+ return value
199
+ }
200
+
201
+ // `value` with only the keys `schema` describes, so a form can start from a
202
+ // record as read (`{ id, createdAt, … }`) without a `.strict()` schema
203
+ // rejecting the rest on every submit. An object keeps the keys in its
204
+ // `shape`, an array maps its `element` over every item, and the wrappers
205
+ // unwrap as in `extractDefaultForField`. Where the shape isn't certain (a
206
+ // union, a record, a loose or catchall object, `any`, a non-Zod schema) or the
207
+ // value doesn't fit it (`null`, a string where an object is expected), the
208
+ // value is kept as is. A key the value lacks stays absent: filling it is
209
+ // `extractZodDefaults`' job.
210
+ function pickSchemaShape<T>(schema: SchemaNode, value: T): T {
211
+ try {
212
+ return pickShape(schema, value) as T
213
+ } catch {
214
+ return value
215
+ }
216
+ }
217
+
151
218
  // User-supplied values always win on conflict. Plain objects on both sides
152
219
  // recurse so nested `z.object(...)` defaults aren't clobbered by partial user
153
220
  // objects. Arrays, dates, class instances are treated atomically (user value
@@ -173,4 +240,9 @@ function deepMergeDefaults(
173
240
  return result
174
241
  }
175
242
 
176
- export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults }
243
+ export {
244
+ deepMergeDefaults,
245
+ extractZodDefaultAt,
246
+ extractZodDefaults,
247
+ pickSchemaShape,
248
+ }
@@ -7,6 +7,7 @@ import {
7
7
  toActionError,
8
8
  } from "./action-result"
9
9
  import { applyValidationError } from "./apply-validation-error"
10
+ import { pickSchemaShape } from "./schema-defaults"
10
11
  import { ValidationError } from "./validation-error"
11
12
  import { getFormSchema } from "./validation-modes"
12
13
 
@@ -179,14 +180,28 @@ async function submitAction(
179
180
 
180
181
  const { reset } = options
181
182
  if (reset === true) {
182
- formApi.reset(result.data)
183
+ formApi.reset(toFormValues(formApi, result.data))
183
184
  } else if (reset === "values") {
184
185
  formApi.reset(value)
185
186
  } else if (typeof reset === "function") {
186
- formApi.reset((reset as (data: unknown) => unknown)(result.data))
187
+ formApi.reset(
188
+ toFormValues(formApi, (reset as (data: unknown) => unknown)(result.data))
189
+ )
187
190
  }
188
191
  return result
189
192
  }
190
193
 
194
+ // What the action returned, kept to the keys the form's own schema describes
195
+ // (not `options.schema`, which is the action's), so a saved record's `id` or
196
+ // `updatedAt` doesn't become a form value that a `.strict()` schema rejects on
197
+ // the next submit. The submitted values need none of this: they already have
198
+ // the form's shape.
199
+ function toFormValues(formApi: FormApiLike, data: unknown): unknown {
200
+ const schema = getFormSchema(formApi as AnyFormApi)
201
+ return schema
202
+ ? pickSchemaShape(schema as Parameters<typeof pickSchemaShape>[0], data)
203
+ : data
204
+ }
205
+
191
206
  export type { FormApiLike, SubmitActionOptions, SubmitActionSchema }
192
207
  export { applyActionError, submitAction }
@@ -508,6 +508,51 @@ describe("submitAction — success", () => {
508
508
  expect(form.state.isDirty).toBe(false)
509
509
  })
510
510
 
511
+ // An action that returns the saved row, with keys the form doesn't edit.
512
+ type SavedProfile = Profile & { id: number; updatedAt: string }
513
+ const resets = {
514
+ "a reset function": (saved: SavedProfile): Profile => saved,
515
+ "reset: true": true,
516
+ } as const
517
+
518
+ for (const [label, reset] of Object.entries(resets)) {
519
+ test(`${label} rebases on the saved row's form keys only, so the next save passes a .strict() schema`, async () => {
520
+ const user = userEvent.setup()
521
+ const save = vi.fn((input: Profile) =>
522
+ Promise.resolve<ActionResult<SavedProfile>>({
523
+ data: { ...input, id: 7, updatedAt: "2026-01-01" },
524
+ ok: true,
525
+ })
526
+ )
527
+ function Harness() {
528
+ const form = useForm({
529
+ defaultValues: { email: "ada@example.com", name: "Ada" },
530
+ onSubmit: (submit) => submitAction(submit, save, { reset }),
531
+ schema: profileSchema.strict(),
532
+ })
533
+ const { SubmitButton } = form
534
+ return (
535
+ <FormProvider form={form}>
536
+ <Form>
537
+ <SubmitButton>Submit</SubmitButton>
538
+ </Form>
539
+ </FormProvider>
540
+ )
541
+ }
542
+ render(<Harness />)
543
+
544
+ await user.click(screen.getByRole("button", SUBMIT))
545
+ await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1))
546
+ await user.click(screen.getByRole("button", SUBMIT))
547
+
548
+ await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2))
549
+ expect(save).toHaveBeenLastCalledWith({
550
+ email: "ada@example.com",
551
+ name: "Ada",
552
+ })
553
+ })
554
+ }
555
+
511
556
  test('reset: "values" rebases the form on the submitted values', async () => {
512
557
  const user = userEvent.setup()
513
558
  const formRef: { current: unknown } = { current: undefined }
@@ -48,6 +48,29 @@ test("schema path: defaultValues is PartialFormData (deep partial for plain obje
48
48
  })
49
49
  })
50
50
 
51
+ test("schema path: defaultValues takes a record with keys the schema lacks", () => {
52
+ // A row as read: `useForm` keeps the keys the schema describes at runtime,
53
+ // and the form stays typed from the schema.
54
+ const invoiceSchema = z
55
+ .object({
56
+ lines: z.array(z.object({ label: z.string() }).strict()),
57
+ number: z.string(),
58
+ })
59
+ .strict()
60
+ const row = {
61
+ createdAt: new Date(),
62
+ id: 7,
63
+ lines: [{ id: 1, invoiceId: 7, label: "Design" }],
64
+ number: "2026-001",
65
+ }
66
+ const form = useForm({
67
+ defaultValues: row,
68
+ onSubmit: () => undefined,
69
+ schema: invoiceSchema,
70
+ })
71
+ expectTypeOf(form.state.values).toEqualTypeOf<z.input<typeof invoiceSchema>>()
72
+ })
73
+
51
74
  test("schema path: validationLogic is forbidden (type never)", () => {
52
75
  // Error lands on the overall call argument, not on `validationLogic`:
53
76
  // `validationLogic: never` makes the whole options object incompatible.
@@ -62,6 +62,52 @@ describe("useForm — schema-derived defaults", () => {
62
62
  })
63
63
  })
64
64
 
65
+ describe("useForm — defaultValues from a record", () => {
66
+ test("an edit form opened with a record as read submits to a .strict() schema", async () => {
67
+ const user = userEvent.setup()
68
+ const schema = z
69
+ .object({
70
+ address: z.object({ city: z.string() }).strict(),
71
+ email: z.email(),
72
+ lines: z.array(z.object({ label: z.string() }).strict()),
73
+ })
74
+ .strict()
75
+ const record = {
76
+ address: { city: "Geneva", updatedAt: "2026-01-01" },
77
+ createdAt: new Date("2026-01-01"),
78
+ email: "ada@example.com",
79
+ id: 7,
80
+ lines: [{ id: 1, label: "Design" }],
81
+ }
82
+ const onSubmit = vi.fn()
83
+ function Harness() {
84
+ const form = useForm({
85
+ defaultValues: record,
86
+ onSubmit: ({ value }) => onSubmit(value),
87
+ schema,
88
+ })
89
+ const { EmailField, SubmitButton } = form
90
+ return (
91
+ <FormProvider form={form}>
92
+ <Form>
93
+ <EmailField label="Email" name="email" />
94
+ <SubmitButton>Submit</SubmitButton>
95
+ </Form>
96
+ </FormProvider>
97
+ )
98
+ }
99
+ render(<Harness />)
100
+ await user.click(screen.getByRole("button", { name: "Submit" }))
101
+ await vi.waitFor(() =>
102
+ expect(onSubmit).toHaveBeenCalledWith({
103
+ address: { city: "Geneva" },
104
+ email: "ada@example.com",
105
+ lines: [{ label: "Design" }],
106
+ })
107
+ )
108
+ })
109
+ })
110
+
65
111
  describe("useForm — required indicator", () => {
66
112
  test("`*` rendered next to required field labels (schema-derived)", () => {
67
113
  const schema = z.object({