diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 7317d70a1..62a80ee95 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1,4 +1,4 @@ -import { Derived, batch } from '@tanstack/store' +import { Derived, Store, batch } from '@tanstack/store' import { isStandardSchemaValidator, standardSchemaValidators, @@ -864,6 +864,10 @@ export type AnyFieldMeta = FieldMeta< any > +export type FieldBaseState> = { + name: TName +} + /** * An object type representing the state of a field. */ @@ -1052,7 +1056,7 @@ export class FieldApi< /** * The field name. */ - name!: DeepKeys + name: DeepKeys /** * The field options. */ @@ -1151,8 +1155,9 @@ export class FieldApi< TParentSubmitMeta >, ) { - this.form = opts.form as never - this.name = opts.name as never + this.form = opts.form + this.name = opts.name + this.timeoutIds = { validations: {} as Record, listeners: {} as Record, @@ -1309,28 +1314,15 @@ export class FieldApi< TParentSubmitMeta >, ) => { - this.options = opts as never - - const nameHasChanged = this.name !== opts.name + this.options = opts this.name = opts.name // Default Value if ((this.state.value as unknown) === undefined) { - const formDefault = getBy(opts.form.options.defaultValues, opts.name) - - const defaultValue = (opts.defaultValue as unknown) ?? formDefault - - // The name is dynamic in array fields. It changes when the user performs operations like removing or reordering. - // In this case, we don't want to force a default value if the store managed to find an existing value. - if (nameHasChanged) { - this.setValue((val) => (val as unknown) || defaultValue, { - dontUpdateMeta: true, - }) - } else if (defaultValue !== undefined) { - this.setValue(defaultValue as never, { - dontUpdateMeta: true, - }) - } + const formDefault = getBy( + opts.form.options.defaultValues, + opts.name, + ).value } // Default Meta diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts index 0bc575d67..1fdbfba47 100644 --- a/packages/form-core/src/FieldGroupApi.ts +++ b/packages/form-core/src/FieldGroupApi.ts @@ -283,14 +283,14 @@ export class FieldGroupApi< let values: TFieldGroupData if (typeof this.fieldsMap === 'string') { // all values live at that name, so we can directly fetch it - values = getBy(currFormStore.values, this.fieldsMap) + values = getBy(currFormStore.values, this.fieldsMap).value } else { // we need to fetch the values from all places where they were mapped from values = {} as never const fields: Record = this .fieldsMap as never for (const key in fields) { - values[key] = getBy(currFormStore.values, fields[key]) + values[key] = getBy(currFormStore.values, fields[key]).value } } diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index fca20377e..3db443117 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1051,7 +1051,7 @@ export class FormApi< const prevFieldInfo = prevVal?.[fieldName as never as keyof typeof prevVal] - const curFieldVal = getBy(currBaseStore.values, fieldName) + const curFieldVal = getBy(currBaseStore.values, fieldName).value let fieldErrors = prevFieldInfo?.errors if ( @@ -1079,7 +1079,7 @@ export class FormApi< const isDefaultValue = evaluate( curFieldVal, - getBy(this.options.defaultValues, fieldName), + getBy(this.options.defaultValues, fieldName).value, ) || evaluate( curFieldVal, @@ -2131,7 +2131,7 @@ export class FormApi< */ getFieldValue = >( field: TField, - ): DeepValue => getBy(this.state.values, field) + ): DeepValue => getBy(this.state.values, field).value /** * Gets the metadata of the specified field. @@ -2304,7 +2304,7 @@ export class FormApi< await this.validateField(field, 'change') // Shift down all meta after validating to make sure the new field has been mounted - metaHelper(this).handleArrayFieldMetaShift(field, index, 'insert') + metaHelper(this).handleArrayInsert(field, index) await this.validateArrayFieldsStartingFrom(field, index, 'change') } @@ -2360,7 +2360,7 @@ export class FormApi< ) // Shift up all meta - metaHelper(this).handleArrayFieldMetaShift(field, index, 'remove') + metaHelper(this).handleArrayRemove(field, index) if (lastIndex !== null) { const start = `${field}[${lastIndex}]` @@ -2392,7 +2392,7 @@ export class FormApi< ) // Swap meta - metaHelper(this).handleArrayFieldMetaShift(field, index1, 'swap', index2) + metaHelper(this).handleArraySwap(field, index1, index2) // Validate the whole array this.validateField(field, 'change') @@ -2421,7 +2421,7 @@ export class FormApi< ) // Move meta between index1 and index2 - metaHelper(this).handleArrayFieldMetaShift(field, index1, 'move', index2) + metaHelper(this).handleArrayMove(field, index1, index2) // Validate the whole array this.validateField(field, 'change') @@ -2472,7 +2472,11 @@ export class FormApi< [field]: defaultFieldMeta, }, values: this.options.defaultValues - ? setBy(prev.values, field, getBy(this.options.defaultValues, field)) + ? setBy( + prev.values, + field, + getBy(this.options.defaultValues, field).value, + ) : prev.values, } }) diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts index c267c938d..a990c0c0a 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -6,7 +6,7 @@ import type { import type { AnyFieldMeta } from './FieldApi' import type { DeepKeys } from './util-types' -type ArrayFieldMode = 'insert' | 'remove' | 'swap' | 'move' +type ValueFieldMode = 'insert' | 'remove' | 'swap' | 'move' export const defaultFieldMeta: AnyFieldMeta = { isValidating: false, @@ -33,7 +33,7 @@ export function metaHelper< TOnDynamic extends undefined | FormValidateOrFn, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, TOnServer extends undefined | FormAsyncValidateOrFn, - TSubmitMeta, + TSubmitMeta = never, >( formApi: FormApi< TFormData, @@ -50,57 +50,148 @@ export function metaHelper< TSubmitMeta >, ) { - function handleArrayFieldMetaShift( + /** + * Handle the meta shift caused from moving a field from one index to another. + */ + function handleArrayMove( field: DeepKeys, - index: number, - mode: ArrayFieldMode, - secondIndex?: number, + fromIndex: number, + toIndex: number, ) { - const affectedFields = getAffectedFields(field, index, mode, secondIndex) - - const handlers = { - insert: () => handleInsertMode(affectedFields, field, index), - remove: () => handleRemoveMode(affectedFields), - swap: () => - secondIndex !== undefined && - handleSwapMode(affectedFields, field, index, secondIndex), - move: () => - secondIndex !== undefined && - handleMoveMode(affectedFields, field, index, secondIndex), + const affectedFields = getAffectedFields(field, fromIndex, 'move', toIndex) + + const startIndex = Math.min(fromIndex, toIndex) + const endIndex = Math.max(fromIndex, toIndex) + for (let i = startIndex; i <= endIndex; i++) { + affectedFields.push(getFieldPath(field, i)) } - handlers[mode]() + // Store the original field meta that will be reapplied at the destination index + const fromFields = Object.keys(formApi.fieldInfo).reduce( + (fieldMap, fieldKey) => { + if (fieldKey.startsWith(getFieldPath(field, fromIndex))) { + fieldMap.set( + fieldKey as DeepKeys, + formApi.getFieldMeta(fieldKey as DeepKeys), + ) + } + return fieldMap + }, + new Map, AnyFieldMeta | undefined>(), + ) + + shiftMeta(affectedFields, fromIndex < toIndex ? 'up' : 'down') + + // Reapply the stored field meta at the destination index + Object.keys(formApi.fieldInfo) + .filter((fieldKey) => fieldKey.startsWith(getFieldPath(field, toIndex))) + .forEach((fieldKey) => { + const fromKey = fieldKey.replace( + getFieldPath(field, toIndex), + getFieldPath(field, fromIndex), + ) as DeepKeys + + const fromMeta = fromFields.get(fromKey) + if (fromMeta) { + formApi.setFieldMeta(fieldKey as DeepKeys, fromMeta) + } + }) + } + + /** + * Handle the meta shift from removing a field at the specified index. + */ + function handleArrayRemove(field: DeepKeys, index: number) { + const affectedFields = getAffectedFields(field, index, 'remove') + + shiftMeta(affectedFields, 'up') + } + + /** + * Handle the meta shift from swapping two fields at the specified indeces. + */ + function handleArraySwap( + field: DeepKeys, + index: number, + secondIndex: number, + ) { + const affectedFields = getAffectedFields(field, index, 'swap', secondIndex) + + affectedFields.forEach((fieldKey) => { + if (!fieldKey.toString().startsWith(getFieldPath(field, index))) { + return + } + + const swappedKey = fieldKey + .toString() + .replace( + getFieldPath(field, index), + getFieldPath(field, secondIndex), + ) as DeepKeys + + const [meta1, meta2] = [ + formApi.getFieldMeta(fieldKey), + formApi.getFieldMeta(swappedKey), + ] + + if (meta1) formApi.setFieldMeta(swappedKey, meta1) + if (meta2) formApi.setFieldMeta(fieldKey, meta2) + }) + } + + /** + * Handle the meta shift from inserting a field at the specified index. + */ + function handleArrayInsert(field: DeepKeys, insertIndex: number) { + const affectedFields = getAffectedFields(field, insertIndex, 'insert') + + shiftMeta(affectedFields, 'down') + + affectedFields.forEach((fieldKey) => { + if (fieldKey.toString().startsWith(getFieldPath(field, insertIndex))) { + formApi.setFieldMeta(fieldKey, getEmptyFieldMeta()) + } + }) } - function getFieldPath(field: DeepKeys, index: number): string { - return `${field}[${index}]` + function getFieldPath( + field: DeepKeys, + index: number, + ): DeepKeys { + return `${field}[${index}]` as DeepKeys } function getAffectedFields( field: DeepKeys, index: number, - mode: ArrayFieldMode, + mode: ValueFieldMode, secondIndex?: number, ): DeepKeys[] { const affectedFieldKeys = [getFieldPath(field, index)] - if (mode === 'swap') { - affectedFieldKeys.push(getFieldPath(field, secondIndex!)) - } else if (mode === 'move') { - const [startIndex, endIndex] = [ - Math.min(index, secondIndex!), - Math.max(index, secondIndex!), - ] - for (let i = startIndex; i <= endIndex; i++) { - affectedFieldKeys.push(getFieldPath(field, i)) + switch (mode) { + case 'swap': + affectedFieldKeys.push(getFieldPath(field, secondIndex!)) + break + case 'move': { + const [startIndex, endIndex] = [ + Math.min(index, secondIndex!), + Math.max(index, secondIndex!), + ] + for (let i = startIndex; i <= endIndex; i++) { + affectedFieldKeys.push(getFieldPath(field, i)) + } + break } - } else { - const currentValue = formApi.getFieldValue(field) - const fieldItems = Array.isArray(currentValue) - ? (currentValue as Array).length - : 0 - for (let i = index + 1; i < fieldItems; i++) { - affectedFieldKeys.push(getFieldPath(field, i)) + default: { + const currentValue = formApi.getFieldValue(field) + const fieldItems = Array.isArray(currentValue) + ? (currentValue as Array).length + : 0 + for (let i = index + 1; i < fieldItems; i++) { + affectedFieldKeys.push(getFieldPath(field, i)) + } + break } } @@ -137,85 +228,10 @@ export function metaHelper< const getEmptyFieldMeta = (): AnyFieldMeta => defaultFieldMeta - const handleInsertMode = ( - fields: DeepKeys[], - field: DeepKeys, - insertIndex: number, - ) => { - shiftMeta(fields, 'down') - - fields.forEach((fieldKey) => { - if (fieldKey.toString().startsWith(getFieldPath(field, insertIndex))) { - formApi.setFieldMeta(fieldKey, getEmptyFieldMeta()) - } - }) + return { + handleArrayMove, + handleArrayRemove, + handleArraySwap, + handleArrayInsert, } - - const handleRemoveMode = (fields: DeepKeys[]) => { - shiftMeta(fields, 'up') - } - - const handleMoveMode = ( - fields: DeepKeys[], - field: DeepKeys, - fromIndex: number, - toIndex: number, - ) => { - // Store the original field meta that will be reapplied at the destination index - const fromFields = new Map( - Object.keys(formApi.fieldInfo) - .filter((fieldKey) => - fieldKey.startsWith(getFieldPath(field, fromIndex)), - ) - .map((fieldKey) => [ - fieldKey as DeepKeys, - formApi.getFieldMeta(fieldKey as DeepKeys), - ]), - ) - - shiftMeta(fields, fromIndex < toIndex ? 'up' : 'down') - - // Reapply the stored field meta at the destination index - Object.keys(formApi.fieldInfo) - .filter((fieldKey) => fieldKey.startsWith(getFieldPath(field, toIndex))) - .forEach((fieldKey) => { - const fromKey = fieldKey.replace( - getFieldPath(field, toIndex), - getFieldPath(field, fromIndex), - ) as DeepKeys - - const fromMeta = fromFields.get(fromKey) - if (fromMeta) { - formApi.setFieldMeta(fieldKey as DeepKeys, fromMeta) - } - }) - } - - const handleSwapMode = ( - fields: DeepKeys[], - field: DeepKeys, - index: number, - secondIndex: number, - ) => { - fields.forEach((fieldKey) => { - if (!fieldKey.toString().startsWith(getFieldPath(field, index))) return - - const swappedKey = fieldKey - .toString() - .replace( - getFieldPath(field, index), - getFieldPath(field, secondIndex), - ) as DeepKeys - - const [meta1, meta2] = [ - formApi.getFieldMeta(fieldKey), - formApi.getFieldMeta(swappedKey), - ] - - if (meta1) formApi.setFieldMeta(swappedKey, meta1) - if (meta2) formApi.setFieldMeta(fieldKey, meta2) - }) - } - - return { handleArrayFieldMetaShift } } diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index 6d6878308..391e9596d 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -31,15 +31,24 @@ export function functionalUpdate( * Get a value from an object using a path, including dot notation. * @private */ -export function getBy(obj: any, path: any) { +export function getBy( + obj: unknown, + path: string | (string | number)[], +): { found: boolean; value: any } { const pathObj = makePathArray(path) - return pathObj.reduce((current: any, pathPart: any) => { - if (current === null) return null - if (typeof current !== 'undefined') { - return current[pathPart] + let current: unknown = obj + for (const pathPart of pathObj) { + // path is trying to access props of undefined/null, so it doesn't exist + if (typeof current === 'undefined' || current === null) { + return { found: false, value: undefined } } - return undefined - }, obj) + if (typeof current === 'object' && pathPart in current) { + current = current[pathPart as never] + } else { + return { found: false, value: undefined } + } + } + return { found: true, value: current } } /** diff --git a/packages/form-core/tests/utils.spec.ts b/packages/form-core/tests/utils.spec.ts index ec06f554f..93bf9ea12 100644 --- a/packages/form-core/tests/utils.spec.ts +++ b/packages/form-core/tests/utils.spec.ts @@ -22,25 +22,52 @@ describe('getBy', () => { mother: { name: 'Lisa', }, + siblings: [ + { + name: undefined, + }, + ], } it('should get subfields by path', () => { - expect(getBy(structure, 'name')).toBe(structure.name) - expect(getBy(structure, 'mother.name')).toBe(structure.mother.name) + const name = getBy(structure, 'name') + expect(name.value).toBe(structure.name) + expect(name.found).toBe(true) + + const motherName = getBy(structure, 'mother.name') + expect(motherName.value).toBe(structure.mother.name) + expect(motherName.found).toBe(true) }) it('should get array subfields by path', () => { - expect(getBy(structure, 'kids[0].name')).toBe(structure.kids[0]!.name) - expect(getBy(structure, 'kids[0].age')).toBe(structure.kids[0]!.age) + const kidsName = getBy(structure, 'kids[0].name') + expect(kidsName.value).toBe(structure.kids[0]!.name) + expect(kidsName.found).toBe(true) + + const kidsAge = getBy(structure, 'kids[0].age') + expect(kidsAge.value).toBe(structure.kids[0]!.age) + expect(kidsAge.found).toBe(true) }) it('should get nested array subfields by path', () => { - expect(getBy(structure, 'kids[0].hobbies[0]')).toBe( - structure.kids[0]!.hobbies[0], - ) - expect(getBy(structure, 'kids[0].hobbies[1]')).toBe( - structure.kids[0]!.hobbies[1], - ) + const hobbies0 = getBy(structure, 'kids[0].hobbies[0]') + expect(hobbies0.value).toBe(structure.kids[0]!.hobbies[0]) + expect(hobbies0.found).toBe(true) + + const hobbies1 = getBy(structure, 'kids[0].hobbies[1]') + expect(hobbies1.value).toBe(structure.kids[0]!.hobbies[1]) + expect(hobbies1.found).toBe(true) + }) + + it('should differentiate between explicit undefined vs. no path', () => { + const sibling0 = getBy(structure, 'siblings[0].name') + const sibling1 = getBy(structure, 'siblings[1].name') + + expect(sibling0.value).toBeUndefined() + expect(sibling1.value).toBeUndefined() + + expect(sibling0.found).toBe(true) + expect(sibling1.found).toBe(false) }) }) diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 61d12c730..c7dea2a48 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -191,7 +191,7 @@ export function useField< TPatentSubmitMeta >, ) { - const [fieldApi] = useState(() => { + const fieldApi = useMemo(() => { const api = new FieldApi({ ...opts, form: opts.form, @@ -217,7 +217,13 @@ export function useField< extendedApi.Field = Field as never return extendedApi - }) + // We only want to + // update on name changes since those are at risk of becoming stale. The field + // state must be up to date for the internal JSX render. + // The other options can freely be in `fieldApi.update` + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opts.name]) useIsomorphicLayoutEffect(fieldApi.mount, [fieldApi]) diff --git a/packages/react-form/tests/useForm.test.tsx b/packages/react-form/tests/useForm.test.tsx index 415feb3db..e7553f0c2 100644 --- a/packages/react-form/tests/useForm.test.tsx +++ b/packages/react-form/tests/useForm.test.tsx @@ -896,4 +896,48 @@ describe('useForm', () => { await user.click(target) expect(result).toHaveTextContent('1') }) + + it('should allow custom component keys for arrays', async () => { + function Comp() { + const form = useForm({ + defaultValues: { + foo: [ + { name: 'nameA', id: 'a' }, + { name: 'nameB', id: 'b' }, + { name: 'nameC', id: 'c' }, + ], + }, + }) + + return ( + <> + + {(arrayField) => + arrayField.state.value.map((row, i) => ( + + {(field) => { + expect(field.name).toBe(`foo[${i}].name`) + expect(field.state.value).not.toBeUndefined() + return null + }} + + )) + } + + + + ) + } + + const { getByTestId } = render() + + const target = getByTestId('removeField') + await user.click(target) + }) })