From 7d2a279331cdf98af4d2f8c786a2363e8727b0de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 14:14:33 +0000 Subject: [PATCH 1/2] Fix FieldArray type errors from react-hook-form 7.82 upgrade react-hook-form 7.82 introduced a FieldArray component whose root export shadows the FieldArray type, breaking 'import type { FieldArray }'. Extract the duplicated useFieldArray/watch/merge boilerplate from the four array-input components into a shared useControlledFieldArray hook, with a FieldArrayItem type reconstructing the no-longer-importable FieldArray type. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UAZSFDb2GcgSmp5uXhy4w8 --- .../parts/attendee-stay-input.tsx | 26 ++++------ .../parts/flight-path-input.tsx | 31 ++++------- .../common/inputs/charge-spread-input.tsx | 27 ++++------ .../common/inputs/string-array-input.tsx | 31 ++++------- .../src/hooks/use-controlled-field-array.ts | 51 +++++++++++++++++++ 5 files changed, 91 insertions(+), 75 deletions(-) create mode 100644 packages/client/src/hooks/use-controlled-field-array.ts diff --git a/packages/client/src/components/common/business-trip-report/parts/attendee-stay-input.tsx b/packages/client/src/components/common/business-trip-report/parts/attendee-stay-input.tsx index 811080d089..17fa19295b 100644 --- a/packages/client/src/components/common/business-trip-report/parts/attendee-stay-input.tsx +++ b/packages/client/src/components/common/business-trip-report/parts/attendee-stay-input.tsx @@ -2,9 +2,7 @@ import { useEffect, useState, type ReactElement } from 'react'; import { ListPlus, Trash2 } from 'lucide-react'; import { Controller, - useFieldArray, type ArrayPath, - type FieldArray, type FieldValues, type Path, type UseFormReturn, @@ -12,6 +10,10 @@ import { import { useQuery } from 'urql'; import { NumberInput, Select } from '@mantine/core'; import { AttendeesByBusinessTripDocument } from '../../../../gql/graphql.js'; +import { + useControlledFieldArray, + type FieldArrayItem, +} from '../../../../hooks/use-controlled-field-array.js'; import { Button } from '../../../ui/button.js'; type Props = { @@ -32,22 +34,14 @@ export function AttendeesStayInput({ fetchingAttendees = false, businessTripId, }: Props): ReactElement { - const { control, watch, trigger } = formManager; - const { fields, append, remove } = useFieldArray({ - control, - name: attendeesStayPath, - }); + const { control, trigger } = formManager; + const { controlledFields, append, remove } = useControlledFieldArray( + formManager, + attendeesStayPath, + ); const [fetching, setFetching] = useState(fetchingAttendees); const [attendees, setAttendees] = useState(attendeesData ?? []); - const watchFieldArray = watch(attendeesStayPath as Path); - const controlledFields = fields.map((field, index) => { - return { - ...field, - ...watchFieldArray[index], - }; - }); - useEffect(() => { if (attendeesData) { setAttendees(attendeesData); @@ -169,7 +163,7 @@ export function AttendeesStayInput({ size="icon" className="size-7.5" onClick={(): void => { - append({} as FieldArray>); + append({} as FieldArrayItem); trigger(attendeesStayPath as Path); }} > diff --git a/packages/client/src/components/common/business-trip-report/parts/flight-path-input.tsx b/packages/client/src/components/common/business-trip-report/parts/flight-path-input.tsx index 50328d92d0..143cfa87c9 100644 --- a/packages/client/src/components/common/business-trip-report/parts/flight-path-input.tsx +++ b/packages/client/src/components/common/business-trip-report/parts/flight-path-input.tsx @@ -1,13 +1,10 @@ import type { ReactElement } from 'react'; import { ListPlus, Trash2 } from 'lucide-react'; +import { type ArrayPath, type FieldValues, type Path, type UseFormReturn } from 'react-hook-form'; import { - useFieldArray, - type ArrayPath, - type FieldArray, - type FieldValues, - type Path, - type UseFormReturn, -} from 'react-hook-form'; + useControlledFieldArray, + type FieldArrayItem, +} from '../../../../hooks/use-controlled-field-array.js'; import { Button } from '../../../ui/button.js'; import { FormControl, FormField, FormItem, FormMessage } from '../../../ui/form'; import { Input } from '../../../ui/input'; @@ -24,19 +21,11 @@ export function FlightPathInput({ flightPathPath, flightPathData, }: Props): ReactElement { - const { control, watch, trigger } = formManager; - const { fields, append, remove } = useFieldArray({ - control, - name: flightPathPath as ArrayPath, - }); - - const watchFieldArray = watch(flightPathPath); - const controlledFields = fields.map((field, index) => { - return { - ...field, - ...watchFieldArray[index], - }; - }); + const { control, trigger } = formManager; + const { controlledFields, append, remove } = useControlledFieldArray( + formManager, + flightPathPath as ArrayPath, + ); if (!flightPathData) { return
Cannot edit flight path stay, lacking some mandatory information!
; @@ -87,7 +76,7 @@ export function FlightPathInput({ size="icon" className="size-7.5" onClick={(): void => { - append('' as FieldArray>); + append('' as FieldArrayItem); trigger(flightPathPath); }} > diff --git a/packages/client/src/components/common/inputs/charge-spread-input.tsx b/packages/client/src/components/common/inputs/charge-spread-input.tsx index fbc4a5bdcc..750fcc17c5 100644 --- a/packages/client/src/components/common/inputs/charge-spread-input.tsx +++ b/packages/client/src/components/common/inputs/charge-spread-input.tsx @@ -3,15 +3,17 @@ import { format } from 'date-fns'; import { ListPlus, Trash2 } from 'lucide-react'; import { Controller, - useFieldArray, type ArrayPath, - type FieldArray, type FieldValues, type Path, type UseFormReturn, } from 'react-hook-form'; import { NumberInput } from '@mantine/core'; import { YearPickerInput } from '@mantine/dates'; +import { + useControlledFieldArray, + type FieldArrayItem, +} from '../../../hooks/use-controlled-field-array.js'; import { Button } from '../../ui/button.js'; type Props = { @@ -23,19 +25,11 @@ export function ChargeSpreadInput({ formManager, chargeSpreadPath, }: Props): ReactElement { - const { control, watch, trigger } = formManager; - const { fields, append, remove } = useFieldArray({ - control, - name: chargeSpreadPath, - }); - - const watchFieldArray = watch(chargeSpreadPath as Path); - const controlledFields = fields.map((field, index) => { - return { - ...field, - ...watchFieldArray[index], - }; - }); + const { control, trigger } = formManager; + const { controlledFields, append, remove, watchFieldArray } = useControlledFieldArray( + formManager, + chargeSpreadPath, + ); return (
@@ -57,7 +51,6 @@ export function ChargeSpreadInput({ return true; }, }} - // defaultValue={`${new Date().getFullYear()}-01-01` as PathValue>} render={({ field: { value, ...field }, fieldState }): ReactElement => { return ( ({ size="icon" className="size-7.5" onClick={(): void => { - append({ year: `${new Date().getFullYear()}-01-01` } as FieldArray>); + append({ year: `${new Date().getFullYear()}-01-01` } as FieldArrayItem); trigger(chargeSpreadPath as Path); }} > diff --git a/packages/client/src/components/common/inputs/string-array-input.tsx b/packages/client/src/components/common/inputs/string-array-input.tsx index 53550dbef1..f8d2f7443d 100644 --- a/packages/client/src/components/common/inputs/string-array-input.tsx +++ b/packages/client/src/components/common/inputs/string-array-input.tsx @@ -1,13 +1,10 @@ import type { ReactElement } from 'react'; import { ListPlus, Trash2 } from 'lucide-react'; +import { type ArrayPath, type FieldValues, type Path, type UseFormReturn } from 'react-hook-form'; import { - useFieldArray, - type ArrayPath, - type FieldArray, - type FieldValues, - type Path, - type UseFormReturn, -} from 'react-hook-form'; + useControlledFieldArray, + type FieldArrayItem, +} from '../../../hooks/use-controlled-field-array.js'; import { Button } from '../../ui/button.js'; import { FormControl, FormField, FormItem, FormMessage } from '../../ui/form.js'; import { Input } from '../../ui/input.js'; @@ -23,19 +20,11 @@ export function StringArrayInput({ formManager, arrayPath, }: Props): ReactElement { - const { control, watch } = formManager; - const { fields, append, remove } = useFieldArray({ - control, - name: arrayPath as ArrayPath, - }); - - const watchStringsArray = watch(arrayPath); - const controlledFields = fields.map((field, index) => { - return { - id: field.id, - ...watchStringsArray[index], - }; - }); + const { control } = formManager; + const { controlledFields, append, remove } = useControlledFieldArray( + formManager, + arrayPath as ArrayPath, + ); return (
@@ -82,7 +71,7 @@ export function StringArrayInput({ variant="ghost" size="icon" className="size-7.5" - onClick={() => append(undefined as FieldArray)} + onClick={() => append(undefined as FieldArrayItem)} > diff --git a/packages/client/src/hooks/use-controlled-field-array.ts b/packages/client/src/hooks/use-controlled-field-array.ts new file mode 100644 index 0000000000..bfe900c037 --- /dev/null +++ b/packages/client/src/hooks/use-controlled-field-array.ts @@ -0,0 +1,51 @@ +import { + useFieldArray, + type ArrayPath, + type FieldArrayPathValue, + type FieldValues, + type Path, + type PathValue, + type UseFieldArrayReturn, + type UseFormReturn, +} from 'react-hook-form'; + +/** + * Item type of the field array at the given path. Reconstruction of react-hook-form's + * `FieldArray` type, which is shadowed since v7.82 by the new `FieldArray` component + * and can no longer be imported from the package root. + */ +export type FieldArrayItem< + TFieldValues extends FieldValues, + TPath extends ArrayPath = ArrayPath, +> = + FieldArrayPathValue extends ReadonlyArray | null | undefined + ? TItem + : never; + +type UseControlledFieldArrayReturn = Pick< + UseFieldArrayReturn>, + 'append' | 'remove' +> & { + controlledFields: UseFieldArrayReturn>['fields']; + watchFieldArray: PathValue>; +}; + +/** + * Wraps `useFieldArray` and merges the registered fields with the watched values, + * so consumers render always-current values while keeping stable field ids as keys. + */ +export function useControlledFieldArray( + formManager: UseFormReturn, + arrayPath: ArrayPath, +): UseControlledFieldArrayReturn { + const { control, watch } = formManager; + const { fields, append, remove } = useFieldArray({ control, name: arrayPath }); + + const watchFieldArray = watch(arrayPath as Path); + const controlledFields = fields.map((field, index) => ({ + ...field, + ...watchFieldArray?.[index], + })); + + return { controlledFields, append, remove, watchFieldArray }; +} From 1687df5683e71bdc0171a47634c8c3adccaa17d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 13:40:32 +0000 Subject: [PATCH 2/2] Harden useControlledFieldArray typing and primitive handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Genericize the hook over the array path, type watchFieldArray as the field-array item type, default it to an empty array, and only spread watched values into controlled fields when they are objects — spreading primitive items (string arrays) polluted the merged object with character keys. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UAZSFDb2GcgSmp5uXhy4w8 --- .../common/inputs/charge-spread-input.tsx | 4 +- .../src/hooks/use-controlled-field-array.ts | 37 +++++++++++-------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/client/src/components/common/inputs/charge-spread-input.tsx b/packages/client/src/components/common/inputs/charge-spread-input.tsx index 750fcc17c5..391756ca4b 100644 --- a/packages/client/src/components/common/inputs/charge-spread-input.tsx +++ b/packages/client/src/components/common/inputs/charge-spread-input.tsx @@ -44,8 +44,8 @@ export function ChargeSpreadInput({ rules={{ required: 'Required', validate: (value: string): boolean | string => { - const years = watchFieldArray.map((record: { year: string }) => record.year); - if (years.filter((year: string) => year === value).length > 1) { + const years = watchFieldArray.map(record => (record as { year?: string }).year); + if (years.filter(year => year === value).length > 1) { return 'Years must be unique'; } return true; diff --git a/packages/client/src/hooks/use-controlled-field-array.ts b/packages/client/src/hooks/use-controlled-field-array.ts index bfe900c037..3462f6198f 100644 --- a/packages/client/src/hooks/use-controlled-field-array.ts +++ b/packages/client/src/hooks/use-controlled-field-array.ts @@ -4,7 +4,6 @@ import { type FieldArrayPathValue, type FieldValues, type Path, - type PathValue, type UseFieldArrayReturn, type UseFormReturn, } from 'react-hook-form'; @@ -22,30 +21,38 @@ export type FieldArrayItem< ? TItem : never; -type UseControlledFieldArrayReturn = Pick< - UseFieldArrayReturn>, - 'append' | 'remove' -> & { - controlledFields: UseFieldArrayReturn>['fields']; - watchFieldArray: PathValue>; +type UseControlledFieldArrayReturn< + TFieldValues extends FieldValues, + TPath extends ArrayPath, +> = Pick, 'append' | 'remove'> & { + controlledFields: UseFieldArrayReturn['fields']; + watchFieldArray: FieldArrayItem[]; }; /** * Wraps `useFieldArray` and merges the registered fields with the watched values, * so consumers render always-current values while keeping stable field ids as keys. */ -export function useControlledFieldArray( +export function useControlledFieldArray< + TFieldValues extends FieldValues, + TPath extends ArrayPath = ArrayPath, +>( formManager: UseFormReturn, - arrayPath: ArrayPath, -): UseControlledFieldArrayReturn { + arrayPath: TPath, +): UseControlledFieldArrayReturn { const { control, watch } = formManager; const { fields, append, remove } = useFieldArray({ control, name: arrayPath }); - const watchFieldArray = watch(arrayPath as Path); - const controlledFields = fields.map((field, index) => ({ - ...field, - ...watchFieldArray?.[index], - })); + const watchFieldArray = (watch(arrayPath as unknown as Path) ?? + []) as FieldArrayItem[]; + + const controlledFields = fields.map((field, index) => { + const watchedValue = watchFieldArray[index]; + return { + ...field, + ...(typeof watchedValue === 'object' && watchedValue !== null ? watchedValue : {}), + }; + }); return { controlledFields, append, remove, watchFieldArray }; }