From 37d659c951d2116516006a6093dc93657daa9ff4 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sat, 13 Sep 2025 16:28:19 +0200 Subject: [PATCH 01/10] add 'found' property to getBy --- packages/form-core/src/FieldGroupApi.ts | 4 +-- packages/form-core/src/FormApi.ts | 8 ++--- packages/form-core/src/utils.ts | 20 +++++++----- packages/form-core/tests/utils.spec.ts | 41 +++++++++++++++++++++---- 4 files changed, 54 insertions(+), 19 deletions(-) diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts index 32dcfe7a0..cdd9e4373 100644 --- a/packages/form-core/src/FieldGroupApi.ts +++ b/packages/form-core/src/FieldGroupApi.ts @@ -232,14 +232,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 78e7fa8a4..ac3eccf0a 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1000,7 +1000,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 ( @@ -1028,7 +1028,7 @@ export class FormApi< const isDefaultValue = evaluate( curFieldVal, - getBy(this.options.defaultValues, fieldName), + getBy(this.options.defaultValues, fieldName).value, ) || evaluate( curFieldVal, @@ -1995,7 +1995,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. @@ -2323,7 +2323,7 @@ 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/utils.ts b/packages/form-core/src/utils.ts index 3b0186e35..5967704f6 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -31,15 +31,21 @@ 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 08966f330..e9bb1226c 100644 --- a/packages/form-core/tests/utils.spec.ts +++ b/packages/form-core/tests/utils.spec.ts @@ -21,25 +21,54 @@ 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( + const hobbies0 = getBy(structure, 'kids[0].hobbies[0]'); + expect(hobbies0.value).toBe( structure.kids[0]!.hobbies[0], ) - expect(getBy(structure, 'kids[0].hobbies[1]')).toBe( + 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) }) }) From fabf0ecbd207d701f862399ce694a60a3fa5dc55 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:28:23 +0200 Subject: [PATCH 02/10] WIP: for testing --- packages/form-core/src/FieldApi.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index a343bda81..19664997d 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1315,21 +1315,26 @@ export class FieldApi< // Default Value if ((this.state.value as unknown) === undefined) { - const formDefault = getBy(opts.form.options.defaultValues, opts.name) + const formDefault = getBy( + opts.form.options.defaultValues, + opts.name, + ).value 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, - }) - } + + // TODO test what is actually needed here + // if (nameHasChanged) { + // this.setValue((val) => (val as unknown) || defaultValue, { + // dontUpdateMeta: true, + // }) + // } else if (defaultValue !== undefined) { + // this.setValue(defaultValue as never, { + // dontUpdateMeta: true, + // }) + // } } // Default Meta From fb1a06f243c3ae5ff69314ff76edd3f09032b394 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 16:30:40 +0000 Subject: [PATCH 03/10] ci: apply automated fixes and generate docs --- packages/form-core/src/FormApi.ts | 6 +++++- packages/form-core/src/utils.ts | 9 ++++++--- packages/form-core/tests/utils.spec.ts | 28 ++++++++++++-------------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index ac3eccf0a..e93e26c3a 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -2323,7 +2323,11 @@ export class FormApi< [field]: defaultFieldMeta, }, values: this.options.defaultValues - ? setBy(prev.values, field, getBy(this.options.defaultValues, field).value) + ? setBy( + prev.values, + field, + getBy(this.options.defaultValues, field).value, + ) : prev.values, } }) diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index 5967704f6..790133c6a 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -31,7 +31,10 @@ export function functionalUpdate( * Get a value from an object using a path, including dot notation. * @private */ -export function getBy(obj: unknown, path: string | (string | number)[]): { found: boolean; value: any } { +export function getBy( + obj: unknown, + path: string | (string | number)[], +): { found: boolean; value: any } { const pathObj = makePathArray(path) let current: unknown = obj for (const pathPart of pathObj) { @@ -39,13 +42,13 @@ export function getBy(obj: unknown, path: string | (string | number)[]): { found if (typeof current === 'undefined' || current === null) { return { found: false, value: undefined } } - if (typeof current === 'object' && (pathPart in current)) { + if (typeof current === 'object' && pathPart in current) { current = current[pathPart as never] } else { return { found: false, value: undefined } } } - return { found: true, value: current }; + return { found: true, value: current } } /** diff --git a/packages/form-core/tests/utils.spec.ts b/packages/form-core/tests/utils.spec.ts index e9bb1226c..e05dfae33 100644 --- a/packages/form-core/tests/utils.spec.ts +++ b/packages/form-core/tests/utils.spec.ts @@ -21,17 +21,19 @@ describe('getBy', () => { mother: { name: 'Lisa', }, - siblings: [{ - name: undefined - }] + siblings: [ + { + name: undefined, + }, + ], } it('should get subfields by path', () => { - const name = getBy(structure, 'name'); + const name = getBy(structure, 'name') expect(name.value).toBe(structure.name) expect(name.found).toBe(true) - const motherName = getBy(structure, 'mother.name'); + const motherName = getBy(structure, 'mother.name') expect(motherName.value).toBe(structure.mother.name) expect(motherName.found).toBe(true) }) @@ -47,22 +49,18 @@ describe('getBy', () => { }) it('should get nested array subfields by path', () => { - const hobbies0 = getBy(structure, 'kids[0].hobbies[0]'); - expect(hobbies0.value).toBe( - structure.kids[0]!.hobbies[0], - ) + 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], - ) + 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'); + const sibling0 = getBy(structure, 'siblings[0].name') + const sibling1 = getBy(structure, 'siblings[1].name') expect(sibling0.value).toBeUndefined() expect(sibling1.value).toBeUndefined() From 3b07e51b34f951c4f2d753eb24b0d74fec2c2e98 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:18:27 +0200 Subject: [PATCH 04/10] refactor metaHelper for clarity --- packages/form-core/src/FormApi.ts | 14 +- packages/form-core/src/metaHelper.ts | 279 ++++++++++++++++----------- 2 files changed, 171 insertions(+), 122 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index ac3eccf0a..62cd34e2a 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -2159,7 +2159,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') } @@ -2215,7 +2215,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}]` @@ -2247,7 +2247,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') @@ -2276,7 +2276,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') @@ -2323,7 +2323,11 @@ export class FormApi< [field]: defaultFieldMeta, }, values: this.options.defaultValues - ? setBy(prev.values, field, getBy(this.options.defaultValues, field).value) + ? 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..992821a32 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,176 @@ 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()) + } + }) + } + + /** + * Handle the meta shift from filtering out indeces. + * @param remainingIndices An array of indeces that were NOT filtered out of the original array. + */ + function handleArrayFilter( + field: DeepKeys, + remainingIndices: number[], + ) { + if (remainingIndices.length === 0) return + + // create a map between the index and its new location + remainingIndices.forEach((fromIndex, toIndex) => { + if (fromIndex === toIndex) return + // assign it the original meta + const fieldKey = getFieldPath(field, toIndex) + const originalFieldKey = getFieldPath(field, fromIndex) + const originalFieldMeta = formApi.getFieldMeta(originalFieldKey) + if (originalFieldMeta) { + formApi.setFieldMeta(fieldKey, originalFieldMeta) + } else { + formApi.setFieldMeta(fieldKey, { + ...getEmptyFieldMeta(), + isTouched: originalFieldKey as unknown as boolean, + }) + } + }) } - 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 +256,11 @@ 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()) - } - }) - } - - const handleRemoveMode = (fields: DeepKeys[]) => { - shiftMeta(fields, 'up') + return { + handleArrayMove, + handleArrayRemove, + handleArraySwap, + handleArrayInsert, + handleArrayFilter, } - - 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 } } From 60e4c6843b2514c1265f05abc692aed6b4e0c3fb Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Fri, 26 Sep 2025 09:09:48 +0200 Subject: [PATCH 05/10] add baseStore to fieldApi --- packages/form-core/src/FieldApi.ts | 22 ++++++++--- packages/react-form/src/useField.tsx | 5 +++ packages/react-form/tests/useForm.test.tsx | 44 ++++++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 261da4d7e..0aabd49d4 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,9 @@ export class FieldApi< /** * The field name. */ - name!: DeepKeys + get name(): DeepKeys { + return this.baseStore.state.name + } /** * The field options. */ @@ -1081,6 +1087,8 @@ export class FieldApi< TFormOnServer, TParentSubmitMeta > = {} as any + + baseStore: Store> /** * The field state store. */ @@ -1152,7 +1160,9 @@ export class FieldApi< >, ) { this.form = opts.form as never - this.name = opts.name as never + this.baseStore = new Store({ + name: opts.name, + }) this.timeoutIds = { validations: {} as Record, listeners: {} as Record, @@ -1160,7 +1170,7 @@ export class FieldApi< } this.store = new Derived({ - deps: [this.form.store], + deps: [this.baseStore, this.form.store], fn: () => { const value = this.form.getFieldValue(this.name) const meta = this.form.getFieldMeta(this.name) ?? { @@ -1312,7 +1322,9 @@ export class FieldApi< this.options = opts as never const nameHasChanged = this.name !== opts.name - this.name = opts.name + if (nameHasChanged) { + this.baseStore.setState((prev) => ({ ...prev, name: opts.name })) + } // Default Value if ((this.state.value as unknown) === undefined) { diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 61d12c730..385429d16 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -219,6 +219,11 @@ export function useField< return extendedApi }) + const nameHasChanged = fieldApi.name !== opts.name + if (nameHasChanged) { + fieldApi.baseStore.setState((prev) => ({ ...prev, name: 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) + }) }) From 04519ef6c53683fe3b4966e9d1163a310ef3427b Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Fri, 26 Sep 2025 09:15:03 +0200 Subject: [PATCH 06/10] chore: remove dead code from metaHelper --- packages/form-core/src/metaHelper.ts | 29 ---------------------------- 1 file changed, 29 deletions(-) diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts index 992821a32..a990c0c0a 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -154,34 +154,6 @@ export function metaHelper< }) } - /** - * Handle the meta shift from filtering out indeces. - * @param remainingIndices An array of indeces that were NOT filtered out of the original array. - */ - function handleArrayFilter( - field: DeepKeys, - remainingIndices: number[], - ) { - if (remainingIndices.length === 0) return - - // create a map between the index and its new location - remainingIndices.forEach((fromIndex, toIndex) => { - if (fromIndex === toIndex) return - // assign it the original meta - const fieldKey = getFieldPath(field, toIndex) - const originalFieldKey = getFieldPath(field, fromIndex) - const originalFieldMeta = formApi.getFieldMeta(originalFieldKey) - if (originalFieldMeta) { - formApi.setFieldMeta(fieldKey, originalFieldMeta) - } else { - formApi.setFieldMeta(fieldKey, { - ...getEmptyFieldMeta(), - isTouched: originalFieldKey as unknown as boolean, - }) - } - }) - } - function getFieldPath( field: DeepKeys, index: number, @@ -261,6 +233,5 @@ export function metaHelper< handleArrayRemove, handleArraySwap, handleArrayInsert, - handleArrayFilter, } } From ba0d121a4244ae91c1f86dedecc65f7b31fc7867 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Fri, 26 Sep 2025 09:33:03 +0200 Subject: [PATCH 07/10] chore: let's see what sticks --- packages/react-form/src/useField.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 385429d16..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,12 +217,13 @@ export function useField< extendedApi.Field = Field as never return extendedApi - }) - - const nameHasChanged = fieldApi.name !== opts.name - if (nameHasChanged) { - fieldApi.baseStore.setState((prev) => ({ ...prev, name: opts.name })) - } + // 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]) From bc16c7a8bfac73b802ff6d975c37c1ed3664fdee Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:00:03 +0200 Subject: [PATCH 08/10] chore: remove unneeded additional store --- packages/form-core/src/FieldApi.ts | 39 ++++++------------------------ 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 0aabd49d4..62a80ee95 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1056,9 +1056,7 @@ export class FieldApi< /** * The field name. */ - get name(): DeepKeys { - return this.baseStore.state.name - } + name: DeepKeys /** * The field options. */ @@ -1087,8 +1085,6 @@ export class FieldApi< TFormOnServer, TParentSubmitMeta > = {} as any - - baseStore: Store> /** * The field state store. */ @@ -1159,10 +1155,9 @@ export class FieldApi< TParentSubmitMeta >, ) { - this.form = opts.form as never - this.baseStore = new Store({ - name: opts.name, - }) + this.form = opts.form + this.name = opts.name + this.timeoutIds = { validations: {} as Record, listeners: {} as Record, @@ -1170,7 +1165,7 @@ export class FieldApi< } this.store = new Derived({ - deps: [this.baseStore, this.form.store], + deps: [this.form.store], fn: () => { const value = this.form.getFieldValue(this.name) const meta = this.form.getFieldMeta(this.name) ?? { @@ -1319,12 +1314,8 @@ export class FieldApi< TParentSubmitMeta >, ) => { - this.options = opts as never - - const nameHasChanged = this.name !== opts.name - if (nameHasChanged) { - this.baseStore.setState((prev) => ({ ...prev, name: opts.name })) - } + this.options = opts + this.name = opts.name // Default Value if ((this.state.value as unknown) === undefined) { @@ -1332,22 +1323,6 @@ export class FieldApi< opts.form.options.defaultValues, opts.name, ).value - - 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. - - // TODO test what is actually needed here - // if (nameHasChanged) { - // this.setValue((val) => (val as unknown) || defaultValue, { - // dontUpdateMeta: true, - // }) - // } else if (defaultValue !== undefined) { - // this.setValue(defaultValue as never, { - // dontUpdateMeta: true, - // }) - // } } // Default Meta From fc0358d10071f965c3d3fbd5edd07615feed1298 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:30:30 +0200 Subject: [PATCH 09/10] fix undefined errors being passed to fieldMeta --- packages/form-core/src/FormApi.ts | 12 +- packages/solid-form/tests/createForm.test.tsx | 110 +++++++++++++++++- 2 files changed, 114 insertions(+), 8 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 3db443117..13d4aab2c 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1061,20 +1061,18 @@ export class FormApi< // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition fieldErrors = Object.values(currBaseMeta.errorMap ?? {}).filter( (val) => val !== undefined, - ) as never + ) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const fieldInstance = this.getFieldInfo(fieldName)?.instance if (fieldInstance && !fieldInstance.options.disableErrorFlat) { - fieldErrors = (fieldErrors as undefined | string[])?.flat( - 1, - ) as never + fieldErrors = fieldErrors.flat(1) } } // As primitives, we don't need to aggressively persist the same referential value for performance reasons - const isFieldValid = !isNonEmptyArray(fieldErrors ?? []) + const isFieldValid = !isNonEmptyArray(fieldErrors) const isFieldPristine = !currBaseMeta.isDirty const isDefaultValue = evaluate( @@ -1102,11 +1100,11 @@ export class FormApi< fieldMeta[fieldName] = { ...currBaseMeta, - errors: fieldErrors, + errors: fieldErrors ?? [], isPristine: isFieldPristine, isValid: isFieldValid, isDefaultValue: isDefaultValue, - } as AnyFieldMeta + } satisfies AnyFieldMeta as AnyFieldMeta } if (!Object.keys(currBaseStore.fieldMetaBase).length) return fieldMeta diff --git a/packages/solid-form/tests/createForm.test.tsx b/packages/solid-form/tests/createForm.test.tsx index 6de3d2302..f7497177e 100644 --- a/packages/solid-form/tests/createForm.test.tsx +++ b/packages/solid-form/tests/createForm.test.tsx @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { render, screen, waitFor } from '@solidjs/testing-library' import { userEvent } from '@testing-library/user-event' -import { Show, createSignal, onCleanup } from 'solid-js' +import { Index, Show, createSignal, onCleanup } from 'solid-js' +import { z } from 'zod' import { createForm } from '../src/index' import { sleep } from './utils' import type { FormValidationErrorMap } from '../src/index' @@ -477,4 +478,111 @@ describe('createForm', () => { await waitFor(() => getByText(error)) expect(getByText(error)).toBeInTheDocument() }) + + it('should stay up to date with field name inside arrays', async () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { + foo: [ + { name: 'nameA', id: 'a' }, + { name: 'nameB', id: 'b' }, + { name: 'nameC', id: 'c' }, + ], + }, + })) + + return ( + <> + + {(arrayField) => ( + + {(_, 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) + }) + + it('should not have errors undefined on submit with arrays', async () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { + items: ['item1'], + }, + })) + + return ( +
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }} + > +
+ + {(fieldArray) => ( +
+ +
+ + {(_, index) => ( + + {(field) => ( +
+ {field().state.meta.isTouched && + field().state.meta.errors.length + ? 'Some errors' + : null} +
+ )} +
+ )} +
+
+
+ )} +
+
+ +
+ ) + } + + const { getByTestId } = render(() => ) + const addItemBtn = getByTestId('addItemBtn') + await user.click(addItemBtn) + const submitBtn = getByTestId('submitBtn') + await user.click(submitBtn) + }) }) From 97a98779aadbd81f4b49e185f7779266d5af480d Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:35:59 +0200 Subject: [PATCH 10/10] remove unused import --- packages/solid-form/tests/createForm.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/solid-form/tests/createForm.test.tsx b/packages/solid-form/tests/createForm.test.tsx index f7497177e..a85dd24f9 100644 --- a/packages/solid-form/tests/createForm.test.tsx +++ b/packages/solid-form/tests/createForm.test.tsx @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest' import { render, screen, waitFor } from '@solidjs/testing-library' import { userEvent } from '@testing-library/user-event' import { Index, Show, createSignal, onCleanup } from 'solid-js' -import { z } from 'zod' import { createForm } from '../src/index' import { sleep } from './utils' import type { FormValidationErrorMap } from '../src/index'