@zeno-lib/forms 0.7.1 → 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.1",
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
@@ -178,6 +178,40 @@ describe("FormDialog", () => {
178
178
  expect(name.value).toBe("Ada")
179
179
  })
180
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
+
181
215
  test("closes without asking when nothing changed", async () => {
182
216
  const user = userEvent.setup()
183
217
  render(<Harness />)
@@ -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({