remix-validated-form 4.6.12 → 5.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remix-validated-form",
3
- "version": "4.6.12",
3
+ "version": "5.0.0",
4
4
  "description": "Form component and utils for easy form validation in remix",
5
5
  "main": "./dist/index.cjs.js",
6
6
  "module": "./dist/index.esm.js",
@@ -33,14 +33,14 @@
33
33
  "validation"
34
34
  ],
35
35
  "peerDependencies": {
36
- "@remix-run/react": "1.x",
36
+ "@remix-run/react": ">= 1.15.0",
37
37
  "@remix-run/server-runtime": "1.x",
38
38
  "react": "^17.0.2 || ^18.0.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@remix-run/node": "^1.9.0",
42
- "@remix-run/react": "^1.9.0",
43
- "@remix-run/server-runtime": "^1.9.0",
41
+ "@remix-run/node": "^1.16.1",
42
+ "@remix-run/react": "^1.16.1",
43
+ "@remix-run/server-runtime": "^1.16.1",
44
44
  "@testing-library/react": "^13.3.0",
45
45
  "@types/lodash.get": "^4.4.7",
46
46
  "@types/react": "^18.0.9",
@@ -55,6 +55,7 @@
55
55
  "dependencies": {
56
56
  "immer": "^9.0.12",
57
57
  "lodash.get": "^4.4.2",
58
+ "nanoid": "3.3.6",
58
59
  "remeda": "^1.2.0",
59
60
  "tiny-invariant": "^1.2.0",
60
61
  "zustand": "^4.3.0"
@@ -3,6 +3,8 @@ import {
3
3
  Form as RemixForm,
4
4
  FormMethod,
5
5
  useSubmit,
6
+ SubmitOptions,
7
+ FormEncType,
6
8
  } from "@remix-run/react";
7
9
  import React, {
8
10
  ComponentProps,
@@ -41,7 +43,24 @@ import {
41
43
  } from "./internal/util";
42
44
  import { FieldErrors, Validator } from "./validation/types";
43
45
 
44
- export type FormProps<DataType> = {
46
+ type SubactionData<
47
+ DataType,
48
+ Subaction extends string | undefined
49
+ > = DataType & { subaction: Subaction };
50
+
51
+ // Not all validation libraries support encoding a literal value in the schema type (e.g. yup).
52
+ // This condition here allows us to provide strictness for users who are using a validation library that does support it,
53
+ // but also allows us to support users who are using a validation library that doesn't support it.
54
+ type DefaultValuesForSubaction<
55
+ DataType,
56
+ Subaction extends string | undefined
57
+ > = Subaction extends string // Not all validation libraries support encoding a literal value in the schema type.
58
+ ? SubactionData<DataType, Subaction> extends undefined
59
+ ? DataType
60
+ : SubactionData<DataType, Subaction>
61
+ : DataType;
62
+
63
+ export type FormProps<DataType, Subaction extends string | undefined> = {
45
64
  /**
46
65
  * A `Validator` object that describes how to validate the form.
47
66
  */
@@ -64,7 +83,7 @@ export type FormProps<DataType> = {
64
83
  * Accepts an object of default values for the form
65
84
  * that will automatically be propagated to the form fields via `useField`.
66
85
  */
67
- defaultValues?: Partial<DataType>;
86
+ defaultValues?: Partial<DefaultValuesForSubaction<DataType, Subaction>>;
68
87
  /**
69
88
  * A ref to the form element.
70
89
  */
@@ -74,7 +93,7 @@ export type FormProps<DataType> = {
74
93
  * Setting a value here will cause the form to be submitted with an extra `subaction` value.
75
94
  * This can be useful when there are multiple forms on the screen handled by the same action.
76
95
  */
77
- subaction?: string;
96
+ subaction?: Subaction;
78
97
  /**
79
98
  * Reset the form to the default values after the form has been successfully submitted.
80
99
  * This is useful if you want to submit the same form multiple times,
@@ -202,7 +221,7 @@ type HTMLFormSubmitter = HTMLButtonElement | HTMLInputElement;
202
221
  /**
203
222
  * The primary form component of `remix-validated-form`.
204
223
  */
205
- export function ValidatedForm<DataType>({
224
+ export function ValidatedForm<DataType, Subaction extends string | undefined>({
206
225
  validator,
207
226
  onSubmit,
208
227
  children,
@@ -217,8 +236,11 @@ export function ValidatedForm<DataType>({
217
236
  method,
218
237
  replace,
219
238
  id,
239
+ preventScrollReset,
240
+ relative,
241
+ encType,
220
242
  ...rest
221
- }: FormProps<DataType>) {
243
+ }: FormProps<DataType, Subaction>) {
222
244
  const formId = useFormId(id);
223
245
  const providedDefaultValues = useDeepEqualsMemo(unMemoizedDefaults);
224
246
  const contextValue = useMemo<InternalFormContextValue>(
@@ -321,12 +343,12 @@ export function ValidatedForm<DataType>({
321
343
  startSubmit();
322
344
  const submitter = nativeEvent.submitter as HTMLFormSubmitter | null;
323
345
  const formMethod = (submitter?.formMethod as FormMethod) || method;
324
- const formDataToValidate = getDataFromForm(target);
346
+ const formData = getDataFromForm(target);
325
347
  if (submitter?.name) {
326
- formDataToValidate.append(submitter.name, submitter.value);
348
+ formData.append(submitter.name, submitter.value);
327
349
  }
328
350
 
329
- const result = await validator.validate(formDataToValidate);
351
+ const result = await validator.validate(formData);
330
352
  if (result.error) {
331
353
  setFieldErrors(result.error.fieldErrors);
332
354
  endSubmit();
@@ -346,17 +368,22 @@ export function ValidatedForm<DataType>({
346
368
  return;
347
369
  }
348
370
 
371
+ const opts: SubmitOptions = {
372
+ method: formMethod,
373
+ replace,
374
+ preventScrollReset,
375
+ relative,
376
+ action,
377
+ encType: encType as FormEncType | undefined,
378
+ };
379
+
349
380
  // We deviate from the Remix code here a bit because of our async submit.
350
381
  // In Remix's `FormImpl`, they use `event.currentTarget` to get the form,
351
382
  // but we already have the form in `formRef.current` so we can just use that.
352
383
  // If we use `event.currentTarget` here, it will break because `currentTarget`
353
384
  // will have changed since the start of the submission.
354
- if (fetcher) fetcher.submit(submitter || target, { method: formMethod });
355
- else
356
- submit(submitter || target, {
357
- replace,
358
- method: formMethod,
359
- });
385
+ if (fetcher) fetcher.submit(formData, opts);
386
+ else submit(formData, opts);
360
387
  }
361
388
  };
362
389
 
@@ -367,7 +394,10 @@ export function ValidatedForm<DataType>({
367
394
  id={id}
368
395
  action={action}
369
396
  method={method}
397
+ encType={encType}
370
398
  replace={replace}
399
+ preventScrollReset={preventScrollReset}
400
+ relative={relative}
371
401
  onSubmit={(e) => {
372
402
  e.preventDefault();
373
403
  handleSubmit(
package/src/hooks.ts CHANGED
@@ -13,8 +13,8 @@ import {
13
13
  useInternalIsSubmitting,
14
14
  useInternalIsValid,
15
15
  useInternalHasBeenSubmitted,
16
- useValidateField,
17
16
  useRegisterReceiveFocus,
17
+ useSmartValidate,
18
18
  } from "./internal/hooks";
19
19
  import {
20
20
  useControllableValue,
@@ -23,7 +23,7 @@ import {
23
23
 
24
24
  /**
25
25
  * Returns whether or not the parent form is currently being submitted.
26
- * This is different from Remix's `useTransition().submission` in that it
26
+ * This is different from Remix's `useNavigation()` in that it
27
27
  * is aware of what form it's in and when _that_ form is being submitted.
28
28
  *
29
29
  * @param formId
@@ -106,7 +106,7 @@ export const useField = (
106
106
  const clearError = useClearError(formContext);
107
107
 
108
108
  const hasBeenSubmitted = useInternalHasBeenSubmitted(formContext.formId);
109
- const validateField = useValidateField(formContext.formId);
109
+ const smartValidate = useSmartValidate(formContext.formId);
110
110
  const registerReceiveFocus = useRegisterReceiveFocus(formContext.formId);
111
111
 
112
112
  useEffect(() => {
@@ -118,9 +118,7 @@ export const useField = (
118
118
  const helpers = {
119
119
  error,
120
120
  clearError: () => clearError(name),
121
- validate: () => {
122
- validateField(name);
123
- },
121
+ validate: () => smartValidate({ alwaysIncludeErrorsFromFields: [name] }),
124
122
  defaultValue,
125
123
  touched,
126
124
  setTouched,
@@ -144,7 +142,7 @@ export const useField = (
144
142
  name,
145
143
  hasBeenSubmitted,
146
144
  options?.validationBehavior,
147
- validateField,
145
+ smartValidate,
148
146
  ]);
149
147
 
150
148
  return field;
@@ -1,4 +1,4 @@
1
- import { useActionData, useMatches, useTransition } from "@remix-run/react";
1
+ import { useActionData, useMatches, useNavigation } from "@remix-run/react";
2
2
  import { useCallback, useContext } from "react";
3
3
  import { getPath } from "set-get";
4
4
  import invariant from "tiny-invariant";
@@ -107,10 +107,10 @@ export const useDefaultValuesForForm = (
107
107
  export const useHasActiveFormSubmit = ({
108
108
  fetcher,
109
109
  }: InternalFormContextValue): boolean => {
110
- const transition = useTransition();
110
+ let navigation = useNavigation();
111
111
  const hasActiveSubmission = fetcher
112
112
  ? fetcher.state === "submitting"
113
- : !!transition.submission;
113
+ : navigation.state === "submitting";
114
114
  return hasActiveSubmission;
115
115
  };
116
116
 
@@ -169,8 +169,8 @@ export const useInternalIsValid = (formId: InternalFormId) =>
169
169
  export const useInternalHasBeenSubmitted = (formId: InternalFormId) =>
170
170
  useFormStore(formId, (state) => state.hasBeenSubmitted);
171
171
 
172
- export const useValidateField = (formId: InternalFormId) =>
173
- useFormStore(formId, (state) => state.validateField);
172
+ export const useSmartValidate = (formId: InternalFormId) =>
173
+ useFormStore(formId, (state) => state.smartValidate);
174
174
 
175
175
  export const useValidate = (formId: InternalFormId) =>
176
176
  useFormStore(formId, (state) => state.validate);
@@ -20,6 +20,8 @@ export const getArray = (values: any, field: string): unknown[] => {
20
20
  return value;
21
21
  };
22
22
 
23
+ export const sparseCopy = <T>(array: T[]): T[] => array.slice();
24
+
23
25
  export const swap = (array: unknown[], indexA: number, indexB: number) => {
24
26
  const itemA = array[indexA];
25
27
  const itemB = array[indexB];
@@ -46,7 +48,7 @@ export const swap = (array: unknown[], indexA: number, indexB: number) => {
46
48
  function sparseSplice(
47
49
  array: unknown[],
48
50
  start: number,
49
- deleteCount: number,
51
+ deleteCount?: number,
50
52
  item?: unknown
51
53
  ) {
52
54
  // Inserting an item into an array won't behave as we need it to if the array isn't
@@ -56,8 +58,9 @@ function sparseSplice(
56
58
  }
57
59
 
58
60
  // If we just pass item in, it'll be undefined and splice will delete the item.
59
- if (arguments.length === 4) return array.splice(start, deleteCount, item);
60
- return array.splice(start, deleteCount);
61
+ if (arguments.length === 4) return array.splice(start, deleteCount!, item);
62
+ else if (arguments.length === 3) return array.splice(start, deleteCount);
63
+ return array.splice(start);
61
64
  }
62
65
 
63
66
  export const move = (array: unknown[], from: number, to: number) => {
@@ -69,6 +72,13 @@ export const insert = (array: unknown[], index: number, value: unknown) => {
69
72
  sparseSplice(array, index, 0, value);
70
73
  };
71
74
 
75
+ export const insertEmpty = (array: unknown[], index: number) => {
76
+ const tail = sparseSplice(array, index);
77
+ tail.forEach((item, i) => {
78
+ sparseSplice(array, index + i + 1, 0, item);
79
+ });
80
+ };
81
+
72
82
  export const remove = (array: unknown[], index: number) => {
73
83
  sparseSplice(array, index, 1);
74
84
  };
@@ -240,6 +250,34 @@ if (import.meta.vitest) {
240
250
  });
241
251
  });
242
252
 
253
+ describe("insertEmpty", () => {
254
+ it("should insert an empty item at a given index", () => {
255
+ const array = [1, 2, 3];
256
+ insertEmpty(array, 1);
257
+ // eslint-disable-next-line no-sparse-arrays
258
+ expect(array).toStrictEqual([1, , 2, 3]);
259
+ expect(array).not.toStrictEqual([1, undefined, 2, 3]);
260
+ });
261
+
262
+ it("should work with already sparse arrays", () => {
263
+ // eslint-disable-next-line no-sparse-arrays
264
+ const array = [, , 1, , 2, , 3];
265
+ insertEmpty(array, 3);
266
+ // eslint-disable-next-line no-sparse-arrays
267
+ expect(array).toStrictEqual([, , 1, , , 2, , 3]);
268
+ expect(array).not.toStrictEqual([
269
+ undefined,
270
+ undefined,
271
+ 1,
272
+ undefined,
273
+ undefined,
274
+ 2,
275
+ undefined,
276
+ 3,
277
+ ]);
278
+ });
279
+ });
280
+
243
281
  describe("remove", () => {
244
282
  it("should remove an item at a given index", () => {
245
283
  const array = [1, 2, 3];
@@ -22,6 +22,10 @@ export type SyncedFormProps = {
22
22
  validator: Validator<unknown>;
23
23
  };
24
24
 
25
+ export type SmartValidateOpts = {
26
+ alwaysIncludeErrorsFromFields?: string[];
27
+ };
28
+
25
29
  export type FormStoreState = {
26
30
  forms: { [formId: InternalFormId]: FormState };
27
31
  form: (formId: InternalFormId) => FormState;
@@ -49,8 +53,10 @@ export type FormState = {
49
53
  reset: () => void;
50
54
  syncFormProps: (props: SyncedFormProps) => void;
51
55
  setFormElement: (formElement: HTMLFormElement | null) => void;
52
- validateField: (fieldName: string) => Promise<string | null>;
53
56
  validate: () => Promise<ValidationResult<unknown>>;
57
+ smartValidate: (
58
+ opts?: SmartValidateOpts
59
+ ) => Promise<ValidationResult<unknown>>;
54
60
  resetFormElement: () => void;
55
61
  submit: () => void;
56
62
  getValues: () => FormData;
@@ -101,12 +107,15 @@ const defaultFormState: FormState = {
101
107
  reset: () => noOp,
102
108
  syncFormProps: noOp,
103
109
  setFormElement: noOp,
104
- validateField: async () => null,
105
110
 
106
111
  validate: async () => {
107
112
  throw new Error("Validate called before form was initialized.");
108
113
  },
109
114
 
115
+ smartValidate: async () => {
116
+ throw new Error("Validate called before form was initialized.");
117
+ },
118
+
110
119
  submit: async () => {
111
120
  throw new Error("Submit called before form was initialized.");
112
121
  },
@@ -210,7 +219,7 @@ const createFormState = (
210
219
  state.formElement = formElement as any;
211
220
  });
212
221
  },
213
- validateField: async (field: string) => {
222
+ validate: async () => {
214
223
  const formElement = get().formElement;
215
224
  invariant(
216
225
  formElement,
@@ -220,26 +229,15 @@ const createFormState = (
220
229
  const validator = get().formProps?.validator;
221
230
  invariant(
222
231
  validator,
223
- "Cannot validator. This is probably a bug in remix-validated-form."
232
+ "Cannot find validator. This is probably a bug in remix-validated-form."
224
233
  );
225
234
 
226
- await get().controlledFields.awaitValueUpdate?.(field);
227
-
228
- const { error } = await validator.validateField(
229
- new FormData(formElement),
230
- field
231
- );
232
-
233
- if (error) {
234
- get().setFieldError(field, error);
235
- return error;
236
- } else {
237
- get().clearFieldError(field);
238
- return null;
239
- }
235
+ const result = await validator.validate(new FormData(formElement));
236
+ if (result.error) get().setFieldErrors(result.error.fieldErrors);
237
+ return result;
240
238
  },
241
239
 
242
- validate: async () => {
240
+ smartValidate: async ({ alwaysIncludeErrorsFromFields = [] } = {}) => {
243
241
  const formElement = get().formElement;
244
242
  invariant(
245
243
  formElement,
@@ -249,12 +247,91 @@ const createFormState = (
249
247
  const validator = get().formProps?.validator;
250
248
  invariant(
251
249
  validator,
252
- "Cannot validator. This is probably a bug in remix-validated-form."
250
+ "Cannot find validator. This is probably a bug in remix-validated-form."
253
251
  );
254
252
 
255
- const result = await validator.validate(new FormData(formElement));
256
- if (result.error) get().setFieldErrors(result.error.fieldErrors);
257
- return result;
253
+ await Promise.all(
254
+ alwaysIncludeErrorsFromFields.map((field) =>
255
+ get().controlledFields.awaitValueUpdate?.(field)
256
+ )
257
+ );
258
+
259
+ const validationResult = await validator.validate(
260
+ new FormData(formElement)
261
+ );
262
+ if (!validationResult.error) {
263
+ // Only update the field errors if it hasn't changed
264
+ const hadErrors = Object.keys(get().fieldErrors).length > 0;
265
+ if (hadErrors) get().setFieldErrors({});
266
+ return validationResult;
267
+ }
268
+
269
+ const {
270
+ error: { fieldErrors },
271
+ } = validationResult;
272
+ const errorFields = new Set<string>();
273
+ const incomingErrors = new Set<string>();
274
+ const prevErrors = new Set<string>();
275
+
276
+ Object.keys(fieldErrors).forEach((field) => {
277
+ errorFields.add(field);
278
+ incomingErrors.add(field);
279
+ });
280
+
281
+ Object.keys(get().fieldErrors).forEach((field) => {
282
+ errorFields.add(field);
283
+ prevErrors.add(field);
284
+ });
285
+
286
+ const fieldsToUpdate = new Set<string>();
287
+ const fieldsToDelete = new Set<string>();
288
+
289
+ errorFields.forEach((field) => {
290
+ // If an error has been cleared, remove it.
291
+ if (!incomingErrors.has(field)) {
292
+ fieldsToDelete.add(field);
293
+ return;
294
+ }
295
+
296
+ // If an error has changed, we should update it.
297
+ if (prevErrors.has(field) && incomingErrors.has(field)) {
298
+ // Only update if the error has changed to avoid unnecessary rerenders
299
+ if (fieldErrors[field] !== get().fieldErrors[field])
300
+ fieldsToUpdate.add(field);
301
+ return;
302
+ }
303
+
304
+ // If the error is always included, then we should update it.
305
+ if (alwaysIncludeErrorsFromFields.includes(field)) {
306
+ fieldsToUpdate.add(field);
307
+ return;
308
+ }
309
+
310
+ // If the error is new, then only update if the field has been touched
311
+ // or if the form has been submitted
312
+ if (!prevErrors.has(field)) {
313
+ const fieldTouched = get().touchedFields[field];
314
+ const formHasBeenSubmitted = get().hasBeenSubmitted;
315
+ if (fieldTouched || formHasBeenSubmitted) fieldsToUpdate.add(field);
316
+ return;
317
+ }
318
+ });
319
+
320
+ if (fieldsToDelete.size === 0 && fieldsToUpdate.size === 0) {
321
+ return { ...validationResult, error: { fieldErrors: get().fieldErrors } };
322
+ }
323
+
324
+ set((state) => {
325
+ fieldsToDelete.forEach((field) => {
326
+ delete state.fieldErrors[field];
327
+ });
328
+
329
+ fieldsToUpdate.forEach((field) => {
330
+ state.fieldErrors[field] = fieldErrors[field];
331
+ });
332
+ });
333
+
334
+ return { ...validationResult, error: { fieldErrors: get().fieldErrors } };
258
335
  },
259
336
 
260
337
  submit: () => {
@@ -409,10 +486,10 @@ const createFormState = (
409
486
  );
410
487
  // Even though this is a new item, we need to push around other items.
411
488
  arrayUtil.mutateAsArray(fieldName, state.touchedFields, (array) =>
412
- arrayUtil.insert(array, index, false)
489
+ arrayUtil.insertEmpty(array, index)
413
490
  );
414
491
  arrayUtil.mutateAsArray(fieldName, state.fieldErrors, (array) =>
415
- arrayUtil.insert(array, index, undefined)
492
+ arrayUtil.insertEmpty(array, index)
416
493
  );
417
494
  });
418
495
  get().controlledFields.kickoffValueUpdate(fieldName);
@@ -458,10 +535,10 @@ const createFormState = (
458
535
  .getArray(state.currentDefaultValues, fieldName)
459
536
  .unshift(value);
460
537
  arrayUtil.mutateAsArray(fieldName, state.touchedFields, (array) =>
461
- array.unshift(false)
538
+ arrayUtil.insertEmpty(array, 0)
462
539
  );
463
540
  arrayUtil.mutateAsArray(fieldName, state.fieldErrors, (array) =>
464
- array.unshift(undefined)
541
+ arrayUtil.insertEmpty(array, 0)
465
542
  );
466
543
  });
467
544
  },
@@ -1,4 +1,5 @@
1
- import React, { useMemo } from "react";
1
+ import { nanoid } from "nanoid";
2
+ import React, { useMemo, useRef, useState } from "react";
2
3
  import { useCallback } from "react";
3
4
  import invariant from "tiny-invariant";
4
5
  import { InternalFormContextValue } from "../formContext";
@@ -7,8 +8,9 @@ import {
7
8
  useFieldError,
8
9
  useInternalFormContext,
9
10
  useInternalHasBeenSubmitted,
10
- useValidateField,
11
+ useSmartValidate,
11
12
  } from "../hooks";
13
+ import * as arrayUtil from "./arrayUtil";
12
14
  import { useRegisterControlledField } from "./controlledFields";
13
15
  import { useFormStore } from "./storeHooks";
14
16
 
@@ -19,6 +21,19 @@ export type FieldArrayValidationBehaviorOptions = {
19
21
  whenSubmitted: FieldArrayValidationBehavior;
20
22
  };
21
23
 
24
+ export type FieldArrayItem<T> = {
25
+ /**
26
+ * The default value of the item.
27
+ * This does not update as the field is changed by the user.
28
+ */
29
+ defaultValue: T;
30
+ /**
31
+ * A unique key for the item.
32
+ * Use this as the key prop when rendering the item.
33
+ */
34
+ key: string;
35
+ };
36
+
22
37
  const useInternalFieldArray = (
23
38
  context: InternalFormContextValue,
24
39
  field: string,
@@ -27,7 +42,7 @@ const useInternalFieldArray = (
27
42
  const value = useFieldDefaultValue(field, context);
28
43
  useRegisterControlledField(context, field);
29
44
  const hasBeenSubmitted = useInternalHasBeenSubmitted(context.formId);
30
- const validateField = useValidateField(context.formId);
45
+ const validateField = useSmartValidate(context.formId);
31
46
  const error = useFieldError(field, context);
32
47
 
33
48
  const resolvedValidationBehavior: FieldArrayValidationBehaviorOptions = {
@@ -42,7 +57,7 @@ const useInternalFieldArray = (
42
57
 
43
58
  const maybeValidate = useCallback(() => {
44
59
  if (behavior === "onChange") {
45
- validateField(field);
60
+ validateField({ alwaysIncludeErrorsFromFields: [field] });
46
61
  }
47
62
  }, [behavior, field, validateField]);
48
63
 
@@ -56,47 +71,74 @@ const useInternalFieldArray = (
56
71
  (state) => state.controlledFields.array
57
72
  );
58
73
 
74
+ const arrayValue = useMemo<unknown[]>(() => value ?? [], [value]);
75
+ const keyRef = useRef<string[]>([]);
76
+
77
+ // If the lengths don't match up it means one of two things
78
+ // 1. The array has been modified outside of this hook
79
+ // 2. We're initializing the array
80
+ if (keyRef.current.length !== arrayValue.length) {
81
+ keyRef.current = arrayValue.map(() => nanoid());
82
+ }
83
+
59
84
  const helpers = useMemo(
60
85
  () => ({
61
86
  push: (item: any) => {
62
87
  arr.push(field, item);
88
+ keyRef.current.push(nanoid());
63
89
  maybeValidate();
64
90
  },
65
91
  swap: (indexA: number, indexB: number) => {
66
92
  arr.swap(field, indexA, indexB);
93
+ arrayUtil.swap(keyRef.current, indexA, indexB);
67
94
  maybeValidate();
68
95
  },
69
96
  move: (from: number, to: number) => {
70
97
  arr.move(field, from, to);
98
+ arrayUtil.move(keyRef.current, from, to);
71
99
  maybeValidate();
72
100
  },
73
101
  insert: (index: number, value: any) => {
74
102
  arr.insert(field, index, value);
103
+ arrayUtil.insert(keyRef.current, index, nanoid());
75
104
  maybeValidate();
76
105
  },
77
106
  unshift: (value: any) => {
78
107
  arr.unshift(field, value);
108
+ keyRef.current.unshift(nanoid());
79
109
  maybeValidate();
80
110
  },
81
111
  remove: (index: number) => {
82
112
  arr.remove(field, index);
113
+ arrayUtil.remove(keyRef.current, index);
83
114
  maybeValidate();
84
115
  },
85
116
  pop: () => {
86
117
  arr.pop(field);
118
+ keyRef.current.pop();
87
119
  maybeValidate();
88
120
  },
89
121
  replace: (index: number, value: any) => {
90
122
  arr.replace(field, index, value);
123
+ keyRef.current[index] = nanoid();
91
124
  maybeValidate();
92
125
  },
93
126
  }),
94
127
  [arr, field, maybeValidate]
95
128
  );
96
129
 
97
- const arrayValue = useMemo(() => value ?? [], [value]);
98
-
99
- return [arrayValue, helpers, error] as const;
130
+ const valueWithKeys = useMemo(() => {
131
+ const result: { defaultValue: any; key: string }[] = [];
132
+ arrayValue.forEach((item, index) => {
133
+ result[index] = {
134
+ key: keyRef.current[index],
135
+ defaultValue: item,
136
+ };
137
+ });
138
+ return result;
139
+ }, [arrayValue]);
140
+
141
+ return [valueWithKeys, helpers, error] as const;
100
142
  };
101
143
 
102
144
  export type FieldArrayHelpers<Item = any> = {
@@ -122,29 +164,29 @@ export function useFieldArray<Item = any>(
122
164
  const context = useInternalFormContext(formId, "FieldArray");
123
165
 
124
166
  return useInternalFieldArray(context, name, validationBehavior) as [
125
- itemDefaults: Item[],
167
+ items: FieldArrayItem<Item>[],
126
168
  helpers: FieldArrayHelpers,
127
169
  error: string | undefined
128
170
  ];
129
171
  }
130
172
 
131
- export type FieldArrayProps = {
173
+ export type FieldArrayProps<Item> = {
132
174
  name: string;
133
175
  children: (
134
- itemDefaults: any[],
135
- helpers: FieldArrayHelpers,
176
+ items: FieldArrayItem<Item>[],
177
+ helpers: FieldArrayHelpers<Item>,
136
178
  error: string | undefined
137
179
  ) => React.ReactNode;
138
180
  formId?: string;
139
181
  validationBehavior?: FieldArrayValidationBehaviorOptions;
140
182
  };
141
183
 
142
- export const FieldArray = ({
184
+ export function FieldArray<Item = any>({
143
185
  name,
144
186
  children,
145
187
  formId,
146
188
  validationBehavior,
147
- }: FieldArrayProps) => {
189
+ }: FieldArrayProps<Item>) {
148
190
  const context = useInternalFormContext(formId, "FieldArray");
149
191
  const [value, helpers, error] = useInternalFieldArray(
150
192
  context,
@@ -152,4 +194,4 @@ export const FieldArray = ({
152
194
  validationBehavior
153
195
  );
154
196
  return <>{children(value, helpers, error)}</>;
155
- };
197
+ }