diff --git a/.changeset/tasty-humans-joke.md b/.changeset/tasty-humans-joke.md new file mode 100644 index 0000000000..82ab346146 --- /dev/null +++ b/.changeset/tasty-humans-joke.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Refactor: Store Form Groups on trie nodes instead of the form instance diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index b517c21b9a..927e40dd38 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -50,6 +50,7 @@ import type { import type { ResolvedInternalFieldUpdateOptions } from '../types.lib' import type { FieldUpdateOptions, Updater } from '../types.public' import type { AnyInternalFormApi } from '../FormApi/FormApi.lib' +import type { AnyInternalFormGroupApi } from '../FormGroupApi/FormGroupApi.lib' import type { ReadonlyAtom } from '@tanstack/store' import type { FieldApi, FieldApiOptions } from './FieldApi.public' import type { @@ -310,6 +311,8 @@ export class InternalFieldApi< _listeners: Array | null _errorVisibility: ErrorVisibility | undefined _errorBoundary: boolean + /** The form group occupying this trie node. */ + _formGroup: AnyInternalFormGroupApi | null = null // TODO implement /** @@ -651,15 +654,15 @@ export class InternalFieldApi< const seenValidatorFields = new WeakSet() - visitFieldAndAncestors(this, (current) => { - if (current._isKilled) return false + visitFieldAndAncestors(this, (current, stop) => { + if (current._isKilled) return stop current._runFieldValidation(event) current._notifyValidator(event, seenValidatorFields) return undefined }) - const group = this.form._getNearestFormGroupForField(this.name) + const group = this._getFormGroup() if (group) { group.validate(event, { triggerFieldApi: this }) return @@ -677,6 +680,7 @@ export class InternalFieldApi< options?: { onResult?: boolean onlyRunValidatorIndeces?: Array | null + _startValidation?: () => () => void }, ): Promise { if (this._isKilled) @@ -695,7 +699,14 @@ export class InternalFieldApi< thrownError: null, } - this._setValidationCount((count) => count + 1) + let finishValidation = options?._startValidation?.() + if (!finishValidation) { + this._setValidationCount((count) => count + 1) + finishValidation = () => { + this._setValidationCount((count) => Math.max(0, count - 1)) + } + } + try { return await runFieldValidatorPipeline({ pipeline: validators, @@ -712,7 +723,7 @@ export class InternalFieldApi< validatorIndecesToRun: options?.onlyRunValidatorIndeces ?? null, }) } finally { - this._setValidationCount((count) => Math.max(0, count - 1)) + finishValidation() } } @@ -848,7 +859,7 @@ export class InternalFieldApi< batch(() => { const seenListenerFields = new WeakSet() - visitFieldAndAncestors(this, (currNode) => { + visitFieldAndAncestors(this, (currNode, stop) => { const isOriginalField = currNode === originalField const { isSelfDirty, isSelfTouched, isBlurred } = currNode.meta const shouldUpdateDirty = isOriginalField && markAsDirty && !isSelfDirty @@ -868,7 +879,7 @@ export class InternalFieldApi< currNode._notifyListener(event, seenListenerFields) - if (!doPropagate) return false + if (!doPropagate) return stop return undefined }) }) @@ -1084,6 +1095,30 @@ export class InternalFieldApi< pruneFieldIfUnused(this) } + /** Updates the form group occupying this trie node. */ + _setFormGroup(formGroup: AnyInternalFormGroupApi | null): void { + if (this._formGroup === formGroup) return + + this._formGroup = formGroup + devtools().updateField?.(this) + + if (!formGroup) this._pruneIfUnused() + } + + /** Returns the form group containing this trie node, if one exists. */ + _getFormGroup(): AnyInternalFormGroupApi | null { + let formGroup: AnyInternalFormGroupApi | null = null + + visitFieldAndAncestors(this, (field, stop) => { + if (!field._formGroup) return + + formGroup = field._formGroup + return stop + }) + + return formGroup + } + _getValue(): any { return this.form.getFieldValue(this.name) } diff --git a/packages/form-core/src/FieldApi/fieldState.lib.ts b/packages/form-core/src/FieldApi/fieldState.lib.ts index c213d596f2..2ea19099e4 100644 --- a/packages/form-core/src/FieldApi/fieldState.lib.ts +++ b/packages/form-core/src/FieldApi/fieldState.lib.ts @@ -29,7 +29,7 @@ export const childContributionKeys: Array = [ interface MetaExtension { _formValidatorErrors: Array> _formValidatorErrorSourceEvents: Array - _formGroupValidatorErrors: Map + _formGroupValidatorErrors: FormGroupFieldErrorMeta | null _fieldValidatorErrors: Array> _fieldValidatorErrorSourceEvents: Array childContributionCounts: ChildContributionCounts @@ -87,7 +87,7 @@ export const defaultInternalBaseFieldMeta: InternalBaseFieldMeta = { _fieldValidatorErrorSourceEvents: [], _formValidatorErrors: [], _formValidatorErrorSourceEvents: [], - _formGroupValidatorErrors: new Map(), + _formGroupValidatorErrors: null, _arrayVersion: 0, } @@ -232,7 +232,7 @@ function shouldDisplayErrors( isDefaultValue = true, ): boolean { if (!field || !errorVisibility) return true - const group = field.form._getNearestFormGroupForField(field.name) + const group = field._getFormGroup() const stateOverrides = group?._getScopedFormStateOverrides() return errorVisibility({ @@ -295,8 +295,10 @@ export function getChildContributionStates( } } -function hasValidatorErrors(errors: Array>): boolean { - return errors.some((validatorErrors) => validatorErrors.length > 0) +function hasValidatorErrors( + errors: Array> | undefined, +): boolean { + return errors?.some((validatorErrors) => validatorErrors.length > 0) ?? false } export function isPrunableMeta(meta: InternalBaseFieldMeta): boolean { @@ -307,7 +309,7 @@ export function isPrunableMeta(meta: InternalBaseFieldMeta): boolean { if (meta._validationCount !== 0) return false if (meta._arrayVersion !== 0) return false if (hasValidatorErrors(meta._fieldValidatorErrors)) return false - if (hasFormGroupValidatorErrors(meta._formGroupValidatorErrors)) return false + if (hasValidatorErrors(meta._formGroupValidatorErrors?.errors)) return false if (hasValidatorErrors(meta._formValidatorErrors)) return false return childContributionKeys.every( @@ -329,11 +331,7 @@ function getErrorsFromBaseMeta( result = previousMeta.original.errors } else { result = baseMeta._fieldValidatorErrors - .concat( - Array.from(baseMeta._formGroupValidatorErrors.values()).flatMap( - (groupErrors) => groupErrors.errors, - ), - ) + .concat(baseMeta._formGroupValidatorErrors?.errors ?? []) .concat(baseMeta._formValidatorErrors) // ValidationError is OneOrMany, TypeScript doesn't realize that // flat also takes care of that @@ -342,15 +340,6 @@ function getErrorsFromBaseMeta( return result } -export function hasFormGroupValidatorErrors( - groupErrors: Map, -): boolean { - for (const { errors } of groupErrors.values()) { - if (hasValidatorErrors(errors)) return true - } - return false -} - export function hasFieldMetaErrors(meta: InternalBaseFieldMeta): boolean { return ( getErrorsFromBaseMeta(meta).length > 0 || diff --git a/packages/form-core/src/FieldApi/fieldTraversal.lib.ts b/packages/form-core/src/FieldApi/fieldTraversal.lib.ts index 81928abc33..b87ed96665 100644 --- a/packages/form-core/src/FieldApi/fieldTraversal.lib.ts +++ b/packages/form-core/src/FieldApi/fieldTraversal.lib.ts @@ -1,11 +1,18 @@ import type { AnyInternalFieldApi } from './FieldApi.lib' import type { InternalRootFieldApi } from './RootFieldApi.lib' -type FieldVisitor = (field: AnyInternalFieldApi) => void | false +const stop = Symbol('stop field traversal') +type FieldTraversalStop = typeof stop + +type FieldVisitor = ( + field: AnyInternalFieldApi, + stop: FieldTraversalStop, +) => void | FieldTraversalStop /** * Visits a field node followed by each of its ancestors, stopping before the - * synthetic root node. Return `false` from the visitor to stop the traversal. + * synthetic root node. Return the visitor's `stop` argument to stop the + * traversal. * * The next parent is captured before the visitor runs, so removing or * reparenting the current node does not change the ancestor chain being walked. @@ -18,7 +25,7 @@ export function visitFieldAndAncestors( while (!current._isRoot) { const parent: AnyInternalFieldApi | InternalRootFieldApi = current._parent - if (visitor(current) === false) return + if (visitor(current, stop) === stop) return current = parent } } @@ -39,7 +46,7 @@ function visitFields( while (stack.length > 0) { const field = stack.pop()! - if (visitor(field) === false) return + if (visitor(field, stop) === stop) return const children = field._children for (let index = children.length - 1; index >= 0; index--) { @@ -50,7 +57,7 @@ function visitFields( /** * Visits a field node and its descendants in insertion-order preorder. The - * starting field is included. Return `false` from the visitor to stop the + * starting field is included. Return the visitor's `stop` argument to stop the * entire traversal, not only the current branch. * * A node's children are read after its visitor runs, so structural mutations @@ -66,7 +73,7 @@ export function visitFieldSubtree( /** * Visits every field in a form trie in insertion-order preorder. The synthetic * root node is excluded; traversal starts at each of its field children. Return - * `false` from the visitor to stop the entire traversal. + * the visitor's `stop` argument to stop the entire traversal. * * A node's children are read after its visitor runs, so structural mutations * made by the visitor affect which descendants are visited. diff --git a/packages/form-core/src/FieldApi/fieldTree.lib.ts b/packages/form-core/src/FieldApi/fieldTree.lib.ts index 3cad438c20..d30fdbc53d 100644 --- a/packages/form-core/src/FieldApi/fieldTree.lib.ts +++ b/packages/form-core/src/FieldApi/fieldTree.lib.ts @@ -262,6 +262,41 @@ function notifyFieldSubtreeListeners( } } +function prepareFormGroupsForFieldReplacement( + fields: ReadonlyArray, +): () => void { + const fieldsToReplace = new Set(fields) + const formGroups = fields.flatMap((field) => + field._formGroup ? [{ group: field._formGroup, name: field.name }] : [], + ) + const affectedFormGroups = new Set(formGroups.map(({ group }) => group)) + + const replacementRoot = fields[0] + if (replacementRoot) { + visitFieldAndAncestors(replacementRoot, (field) => { + if (field._formGroup) affectedFormGroups.add(field._formGroup) + }) + } + + for (const group of affectedFormGroups) { + group._removeRoutedErrorFields(fieldsToReplace) + } + + for (const { group } of formGroups) { + group._cancelValidation() + } + + return () => { + if (formGroups.length === 0) return + + batch(() => { + for (const { group, name } of formGroups) { + group._attachToFieldTrie(name) + } + }) + } +} + export function killField( field: AnyInternalFieldApi, options: { @@ -276,6 +311,7 @@ export function killField( field: AnyInternalFieldApi previousPath: string }> = [] + let reattachFormGroups = () => {} batch(() => { const nodesToKill = collectFieldSubtree(field) @@ -286,6 +322,8 @@ export function killField( const nodesToKillSet = new Set(nodesToKill) const fieldsToPruneAfterKill = new Set() + reattachFormGroups = prepareFormGroupsForFieldReplacement(nodesToKill) + if (options.listenerEvent) { notifyFieldSubtreeListeners(field, options.listenerEvent) } @@ -330,6 +368,7 @@ export function killField( node._isKilled = true node._refCount = 0 + node._formGroup = null node._defaultValueCache = null node._atoms.store = undefined if (node._pipelineCache) { @@ -403,12 +442,14 @@ export function killField( if (dependencyChanges && dependencyChanges.length > 0) { bridge.fieldDependenciesChanged?.(dependencyChanges) } + reattachFormGroups() } export function canPruneField(field: AnyInternalFieldApi): boolean { if (field._isKilled) return false if (field._refCount > 0) return false + if (field._formGroup) return false if (field._childrenMap.size > 0) return false if (field._watchingFields) return false if (field._watchingValidatorFields) return false @@ -428,8 +469,8 @@ export function pruneFieldIfUnused(field: AnyInternalFieldApi): void { ? new Array<{ field: AnyInternalFieldApi; previousPath: string }>() : null - visitFieldAndAncestors(field, (node) => { - if (!canPruneField(node)) return false + visitFieldAndAncestors(field, (node, stop) => { + if (!canPruneField(node)) return stop removedFields?.push({ field: node, previousPath: node.name }) node._parent._removeChild(node._segment) diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index affb00452b..e6bd0edab8 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -83,11 +83,8 @@ import type { ValidationTrigger, } from '../validation.public' import type { FormListenerTriggers } from '../listeners.public' -import type { InternalFormGroupApi } from '../FormGroupApi/FormGroupApi.lib' import type { ServerFormState } from '../ssr.public' -type AnyFormGroupApi = InternalFormGroupApi - export interface FormMetaAtoms { isDirty: Atom /** @@ -193,14 +190,14 @@ function notifyDevtoolsFieldValueUpdate( const updateField = devtools().updateField if (!field || !updateField) return - visitFieldAndAncestors(field, updateField) + visitFieldAndAncestors(field, (current) => updateField(current)) } function notifyDevtoolsDefaultValuesUpdate(form: AnyInternalFormApi): void { const updateField = devtools().updateField if (!updateField) return - visitAllFormFields(form._fieldRootNode, updateField) + visitAllFormFields(form._fieldRootNode, (current) => updateField(current)) } export class InternalFormApi< @@ -226,7 +223,6 @@ export class InternalFormApi< _lastUpdateDefaultValues: TFormData _pipelineCache: PipelineCache _schemaOutputs: Array = [] - _formGroups = new Set>() _lastServerState: ServerFormState | null = null get state(): FormState< @@ -326,36 +322,6 @@ export class InternalFormApi< } } - _registerFormGroup(group: AnyFormGroupApi): void { - this._formGroups.add(group) - const groupField = this._tryGetFieldApi(String(group.name)) - if (groupField) devtools().updateField?.(groupField) - } - - _unregisterFormGroup(group: AnyFormGroupApi): void { - this._formGroups.delete(group) - const groupField = this._tryGetFieldApi(String(group.name)) - if (groupField) devtools().updateField?.(groupField) - } - - _getNearestFormGroupForField(fieldName: string): AnyFormGroupApi | null { - let nearest: AnyFormGroupApi | null = null - for (const group of this._formGroups) { - const groupName = String(group.name) - const isContained = - fieldName === groupName || - fieldName.startsWith(`${groupName}.`) || - fieldName.startsWith(`${groupName}[`) - - if (!isContained) continue - - if (!nearest || groupName.length > String(nearest.name).length) { - nearest = group - } - } - return nearest - } - _clearFormValidationSource(sourceEvent: string): void { const formErrors = this._atoms.meta.formErrors.get() const fieldErrors = this._atoms.meta.fieldErrors.get() diff --git a/packages/form-core/src/FormApi/FormApi.public.ts b/packages/form-core/src/FormApi/FormApi.public.ts index bafb9446ad..14da7fcd09 100644 --- a/packages/form-core/src/FormApi/FormApi.public.ts +++ b/packages/form-core/src/FormApi/FormApi.public.ts @@ -266,7 +266,7 @@ export interface FormState< */ isSubmitSuccessful: boolean /** - * Whether the form or any field is currently validating. + * Whether the form, any form group, or any field is currently validating. */ isValidating: boolean /** diff --git a/packages/form-core/src/FormApi/formState.lib.ts b/packages/form-core/src/FormApi/formState.lib.ts index abab01734e..7e5ad6040a 100644 --- a/packages/form-core/src/FormApi/formState.lib.ts +++ b/packages/form-core/src/FormApi/formState.lib.ts @@ -129,27 +129,20 @@ function hasFormValidatorFieldEventError( ) } -function hasFormGroupValidatorErrors( - groupErrors: ReturnType< - AnyInternalFieldApi['_getBaseMeta'] - >['_formGroupValidatorErrors'], -): boolean { - for (const { errors } of groupErrors.values()) { - if (hasIndexedErrors(errors)) return true - } - - return false -} - function hasFieldErrors(field: AnyInternalFieldApi): boolean { const meta = field._getBaseMeta() - return ( - hasIndexedErrors(meta._fieldValidatorErrors) || - hasFormGroupValidatorErrors(meta._formGroupValidatorErrors) || - hasIndexedErrors(meta._formValidatorErrors) || - meta.childContributionCounts.error > 0 - ) + if (hasIndexedErrors(meta._fieldValidatorErrors)) return true + if (hasIndexedErrors(meta._formValidatorErrors)) return true + if (meta.childContributionCounts.error > 0) return true + + if (meta._formGroupValidatorErrors !== null) { + if (hasIndexedErrors(meta._formGroupValidatorErrors.errors)) { + return true + } + } + + return false } export function reconcileFormErrorFields( diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index 4fc7f4dd93..93271cc56b 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -3,7 +3,6 @@ import { cancelPipelineCache, concatenateFieldNames, createPipelineCache, - evaluate, getBy, setBy, } from '../utils.lib' @@ -21,7 +20,10 @@ import { } from '../validation.lib' import { transformFieldOptionsFieldNames } from '../FieldApi/FieldApi.lib' import { visitFieldSubtree } from '../FieldApi/fieldTraversal.lib' -import { hasFieldMetaErrors } from '../FieldApi/fieldState.lib' +import { + deriveFromBaseFieldMeta, + hasFieldMetaErrors, +} from '../FieldApi/fieldState.lib' import { parseStandardSchemaIssues } from '../standardSchema.lib' import { createErrorMap } from '../validation.public' import type { FormApi } from '../FormApi/FormApi.public' @@ -31,8 +33,10 @@ import type { AnyInternalFieldApi, } from '../FieldApi/FieldApi.lib' import type { + DerivedMetaMarkers, FormGroupFieldErrorMeta, InternalBaseFieldMeta, + InternalFieldMeta, } from '../FieldApi/fieldState.lib' import type { FormStateOverrides } from '../FormApi/formState.lib' import type { DeepKeys, DeepValue } from '../deep-keys.public' @@ -59,6 +63,19 @@ interface FormGroupValidationOutcome { hasException: boolean } +const emptyFormGroupFieldErrorMeta: FormGroupFieldErrorMeta = { + errors: [], + errorSourceEvents: [], +} + +export type AnyInternalFormGroupApi = InternalFormGroupApi< + any, + any, + any, + any, + any +> + export class InternalFormGroupApi< TFormData, TGroupName extends DeepKeys, @@ -74,7 +91,8 @@ export class InternalFormGroupApi< > { readonly form: FormApi & InternalFormApi - readonly name: TGroupName + /** The trie node occupied by this form group. */ + _groupField: AnyInternalFieldApi _options: FormGroupOptions< TFormData, TGroupName, @@ -87,18 +105,24 @@ export class InternalFormGroupApi< > _pipelineCache: PipelineCache> _schemaOutputs: Array = [] - _errorOwner = {} - _fieldErrors: Array | undefined> = [] - _errors = createAtom>>([]) + /** Tracks the trie nodes receiving routed errors from each validator. */ + _routedErrorFields: Array | undefined> = [] _isSubmitting = createAtom(false) _isSubmitSuccessful = createAtom(false) - _isValidating = createAtom(false) _submissionAttempts = createAtom(0) + /** Callbacks that remove validation-count increments made by this group. */ + _validationCountCleanups = new Set<() => void>() + /** Invalidates group state when reset or remount replaces its trie node. */ + _groupFieldVersion = createAtom(0) get state() { return this.atom.get() } + get name(): TGroupName { + return this._groupField.name as TGroupName + } + get value(): TGroupValue { return getBy(this.form.state.values, this.name) as TGroupValue } @@ -114,13 +138,107 @@ export class InternalFormGroupApi< ) { this._options = options this.form = options.form as never - this.name = options.name + this._groupField = this.form._getOrCreateFieldApi({ name: options.name }) + this._groupField._setFormGroup(this) this._pipelineCache = createPipelineCache() - this.atom = createAtom(() => this._getStateSnapshot(), { - compare: shallow, - }) - this.form._registerFormGroup(this) + const groupMetaMarkers: DerivedMetaMarkers = { + source: undefined, + canDisplayErrors: undefined, + } + + this.atom = createAtom< + FormGroupState> + >( + (prev) => { + void this._groupFieldVersion.get() + + const groupField = this._groupField + const groupBaseMeta = groupField._getBaseMeta() + const groupValue = this.value + const previousGroupMeta = prev?.meta as InternalFieldMeta | undefined + const groupMeta = deriveFromBaseFieldMeta( + groupBaseMeta, + previousGroupMeta, + groupField, + groupValue, + groupMetaMarkers, + ) + const groupErrorMeta = groupBaseMeta._formGroupValidatorErrors + let groupErrors: FormErrors> + + if ( + prev && + previousGroupMeta?._formGroupValidatorErrors === groupErrorMeta + ) { + groupErrors = prev.errors + } else if (groupErrorMeta) { + groupErrors = groupErrorMeta.errors.flat() as never + } else { + groupErrors = [] + } + + const isInvalid = hasFieldMetaErrors(groupBaseMeta) + const isTouched = groupMeta.isTouched + const isDirty = groupMeta.isDirty + + return { + values: groupValue, + meta: groupMeta, + errors: groupErrors, + isTouched, + isDirty, + isPristine: !isDirty, + isValid: !isInvalid, + isInvalid, + canSubmit: !this._isSubmitting.get() && !isInvalid, + isSubmitting: this._isSubmitting.get(), + isSubmitSuccessful: this._isSubmitSuccessful.get(), + isValidating: groupMeta.isValidating, + submissionAttempts: this._submissionAttempts.get(), + } + }, + { compare: shallow }, + ) + } + + /** Attaches this group to the trie node at the given path. */ + _attachToFieldTrie(name: string): void { + const groupField = this.form._getOrCreateFieldApi({ name }) + + if (groupField !== this._groupField) { + this._groupField = groupField + this._groupFieldVersion.set((version) => version + 1) + } + + groupField._setFormGroup(this) + } + + /** Adds a group-owned validation contribution to a field. */ + _startValidation(field = this._groupField): () => void { + let isComplete = false + + field._setValidationCount((count) => count + 1) + + const finishValidation = () => { + if (isComplete) return + isComplete = true + this._validationCountCleanups.delete(finishValidation) + field._setValidationCount((count) => Math.max(0, count - 1)) + } + + this._validationCountCleanups.add(finishValidation) + + return finishValidation + } + + /** Cancels group validation and clears its field-meta contributions. */ + _cancelValidation(): void { + cancelPipelineCache(this._pipelineCache) + this._pipelineCache = createPipelineCache() + for (const finishValidation of Array.from(this._validationCountCleanups)) { + finishValidation() + } } update = ( @@ -136,12 +254,12 @@ export class InternalFormGroupApi< } mount = (): void => { - this.form._registerFormGroup(this) + this._attachToFieldTrie(this.name) const pipeline = this._options.validators if (!pipeline || pipeline.length === 0) return - this._isValidating.set(true) + const finishValidation = this._startValidation() const { didRun, asyncPromise } = runGroupMountValidatorPipeline({ pipeline: pipeline as ReadonlyArray>, @@ -150,105 +268,53 @@ export class InternalFormGroupApi< }) if (!didRun) { - this._isValidating.set(false) + finishValidation() return } if (asyncPromise) { - void asyncPromise.finally(() => { - this._isValidating.set(false) - }) + void asyncPromise.finally(finishValidation) return } - this._isValidating.set(false) - } - - _getStateSnapshot(): FormGroupState< - TGroupValue, - ToFormGroupErrorTypes - > { - const groupField = this.form._tryGetFieldApi(this.name) - const groupMeta = groupField?.meta - const groupErrors = ( - groupField - ? this._getFieldErrorMeta(groupField._getBaseMeta()).errors.flat() - : [] - ) as FormErrors> - const isInvalid = groupField - ? hasFieldMetaErrors(groupField._getBaseMeta()) - : false - const isTouched = groupMeta?.isTouched ?? false - const isDirty = groupMeta?.isDirty ?? false - const isValidating = - this._isValidating.get() || (groupMeta?.isValidating ?? false) - - return { - values: this.value, - meta: groupMeta, - errors: groupErrors, - isTouched, - isDirty, - isPristine: !isDirty, - isValid: !isInvalid, - isInvalid, - canSubmit: !this._isSubmitting.get() && !isInvalid, - isSubmitting: this._isSubmitting.get(), - isSubmitSuccessful: this._isSubmitSuccessful.get(), - isValidating, - submissionAttempts: this._submissionAttempts.get(), - } + finishValidation() } _getScopedFormStateOverrides(): FormStateOverrides { return { isTouched: () => { - const meta = this.form._tryGetFieldApi(this.name)?._getBaseMeta() - return meta - ? meta.isTouched || meta.childContributionCounts.touched > 0 - : false + const meta = this._groupField._getBaseMeta() + return meta.isTouched || meta.childContributionCounts.touched > 0 }, isDirty: () => { - const meta = this.form._tryGetFieldApi(this.name)?._getBaseMeta() - return meta - ? meta.isDirty || meta.childContributionCounts.dirty > 0 - : false + const meta = this._groupField._getBaseMeta() + return meta.isDirty || meta.childContributionCounts.dirty > 0 }, isPristine: () => { - const meta = this.form._tryGetFieldApi(this.name)?._getBaseMeta() - return meta - ? !meta.isDirty && meta.childContributionCounts.dirty === 0 - : true + const meta = this._groupField._getBaseMeta() + return !meta.isDirty && meta.childContributionCounts.dirty === 0 }, isDefaultValue: () => { - const field = this.form._tryGetFieldApi(this.name) const value = getBy(this.form._atoms.values.get(), this.name) - if (field) return field._getIsDefaultValue(value) - void this.form._atoms.defaultValuesVersion.get() - return evaluate(getBy(this.form.defaultValues, this.name), value) + return this._groupField._getIsDefaultValue(value) }, isValid: () => { - const meta = this.form._tryGetFieldApi(this.name)?._getBaseMeta() - return meta ? !hasFieldMetaErrors(meta) : true + return !hasFieldMetaErrors(this._groupField._getBaseMeta()) }, isInvalid: () => { - const meta = this.form._tryGetFieldApi(this.name)?._getBaseMeta() - return meta ? hasFieldMetaErrors(meta) : false + return hasFieldMetaErrors(this._groupField._getBaseMeta()) }, canSubmit: () => { - const meta = this.form._tryGetFieldApi(this.name)?._getBaseMeta() - return !this._isSubmitting.get() && !(meta && hasFieldMetaErrors(meta)) + return ( + !this._isSubmitting.get() && + !hasFieldMetaErrors(this._groupField._getBaseMeta()) + ) }, isSubmitting: () => this._isSubmitting.get(), isSubmitSuccessful: () => this._isSubmitSuccessful.get(), isValidating: () => { - const meta = this.form._tryGetFieldApi(this.name)?._getBaseMeta() - return ( - this._isValidating.get() || - (meta - ? meta.isValidating || meta.childContributionCounts.validating > 0 - : false) - ) + const meta = this._groupField._getBaseMeta() + return meta.isValidating || meta.childContributionCounts.validating > 0 }, submissionAttempts: () => this._submissionAttempts.get(), } @@ -273,13 +339,11 @@ export class InternalFormGroupApi< meta: InternalBaseFieldMeta, groupErrors: FormGroupFieldErrorMeta, ): InternalBaseFieldMeta { - const formGroupValidatorErrors = new Map(meta._formGroupValidatorErrors) + const formGroupValidatorErrors = hasIndexedErrors(groupErrors.errors) + ? groupErrors + : null - if (hasIndexedErrors(groupErrors.errors)) { - formGroupValidatorErrors.set(this._errorOwner, groupErrors) - } else { - formGroupValidatorErrors.delete(this._errorOwner) - } + if (meta._formGroupValidatorErrors === formGroupValidatorErrors) return meta return { ...meta, @@ -310,12 +374,7 @@ export class InternalFormGroupApi< } _getFieldErrorMeta(meta: InternalBaseFieldMeta): FormGroupFieldErrorMeta { - return ( - meta._formGroupValidatorErrors.get(this._errorOwner) ?? { - errors: [], - errorSourceEvents: [], - } - ) + return meta._formGroupValidatorErrors ?? emptyFormGroupFieldErrorMeta } _clearFieldValidatorError( @@ -341,8 +400,8 @@ export class InternalFormGroupApi< const parsedResult = parseValidationResult(result.result) const validatorIndex = result.validatorIndex - const groupField = this.form._getOrCreateFieldApi({ name: this.name }) - const oldFieldRefs = this._fieldErrors[validatorIndex] + const groupField = this._groupField + const oldFieldRefs = this._routedErrorFields[validatorIndex] const resolvedFieldErrors = this.form._resolveRoutedFieldErrors( Object.entries(parsedResult.subfields ?? {}), groupField, @@ -367,15 +426,12 @@ export class InternalFormGroupApi< (field, index) => this._clearFieldValidatorError(field, index), ) - this._fieldErrors[validatorIndex] = fieldRefs + this._routedErrorFields[validatorIndex] = fieldRefs }) } _visitGroupFields(visitor: (field: AnyInternalFieldApi) => void) { - const root = this.form._tryGetFieldApi(this.name) - if (!root) return - - visitFieldSubtree(root, visitor) + visitFieldSubtree(this._groupField, visitor) } async _runFieldValidations( @@ -386,7 +442,11 @@ export class InternalFormGroupApi< > = [] this._visitGroupFields((field) => { - fieldValidationPromises.push(field._runFieldValidation(signal)) + fieldValidationPromises.push( + field._runFieldValidation(signal, { + _startValidation: () => this._startValidation(field), + }), + ) }) const results = await Promise.all(fieldValidationPromises) @@ -443,13 +503,11 @@ export class InternalFormGroupApi< event: ConfigurableValidationTrigger, ) { const validatorCount = this._options.validators?.length ?? 0 - const groupField = this.form._tryGetFieldApi(this.name) + const groupField = this._groupField const eventErrorCount = Math.max( validatorCount, - this._fieldErrors.length, - groupField - ? this._getFieldErrorMeta(groupField._getBaseMeta()).errors.length - : 0, + this._routedErrorFields.length, + this._getFieldErrorMeta(groupField._getBaseMeta()).errors.length, this._getFieldErrorMeta(field._getBaseMeta()).errors.length, ) const eventErrorIndexes: Array = [] @@ -478,20 +536,18 @@ export class InternalFormGroupApi< if (eventErrorIndexes.length === 0) return batch(() => { - if (groupField) { - this._clearFieldEventErrors(groupField, eventErrorIndexes, sourceEvent) - } + this._clearFieldEventErrors(groupField, eventErrorIndexes, sourceEvent) const indexesToClearFromField: Array = [] for (const validatorIndex of eventErrorIndexes) { - const fieldRefs = this._fieldErrors[validatorIndex] + const fieldRefs = this._routedErrorFields[validatorIndex] if ( fieldRefs?.has(field) && this._hasFieldEventError(field, validatorIndex, sourceEvent) ) { const nextFieldRefs = new Set(fieldRefs) nextFieldRefs.delete(field) - this._fieldErrors[validatorIndex] = nextFieldRefs + this._routedErrorFields[validatorIndex] = nextFieldRefs indexesToClearFromField.push(validatorIndex) } } @@ -504,25 +560,38 @@ export class InternalFormGroupApi< _clearRoutedErrors() { const fields = new Set() - const groupField = this.form._tryGetFieldApi(this.name) - if (groupField) fields.add(groupField) - for (const fieldRefs of this._fieldErrors) { + fields.add(this._groupField) + for (const fieldRefs of this._routedErrorFields) { for (const field of fieldRefs ?? []) fields.add(field) } for (const field of fields) { field._setMeta((prev) => { - if (!prev._formGroupValidatorErrors.has(this._errorOwner)) return prev - const groupErrors = new Map(prev._formGroupValidatorErrors) - groupErrors.delete(this._errorOwner) + if (!prev._formGroupValidatorErrors) return prev return { ...prev, - _formGroupValidatorErrors: groupErrors, + _formGroupValidatorErrors: null, } }) field._pruneIfUnused() } - this._fieldErrors = [] + this._routedErrorFields = [] + } + + // TODO: Remove this targeted cleanup when routed errors migrate to the new + // error state structure. Until then, replacement must drop stale field refs. + _removeRoutedErrorFields(fieldsToRemove: ReadonlySet) { + for (let index = 0; index < this._routedErrorFields.length; index++) { + const fieldRefs = this._routedErrorFields[index] + if (!fieldRefs) continue + + const liveFieldRefs = new Set( + Array.from(fieldRefs).filter((field) => !fieldsToRemove.has(field)), + ) + if (liveFieldRefs.size !== fieldRefs.size) { + this._routedErrorFields[index] = liveFieldRefs + } + } } _validate = async ( @@ -539,12 +608,11 @@ export class InternalFormGroupApi< const pipeline = this._options.validators if (!pipeline || pipeline.length === 0) { const fieldOutcome = await this._runFieldValidations(signal) - this._errors.set([]) this._clearRoutedErrors() return fieldOutcome } - this._isValidating.set(true) + const finishValidation = this._startValidation() try { const fieldOutcomePromise = this._runFieldValidations(signal) const results = await runValidatorPipeline< @@ -590,16 +658,12 @@ export class InternalFormGroupApi< ...fieldOutcome.errors, ] - batch(() => { - this._errors.set(groupErrors) - }) - return { errors, hasException: results.thrownError !== null || fieldOutcome.hasException, } } finally { - this._isValidating.set(false) + finishValidation() } } @@ -657,8 +721,7 @@ export class InternalFormGroupApi< } reset = () => { - cancelPipelineCache(this._pipelineCache) - this._pipelineCache = createPipelineCache() + this._cancelValidation() this._schemaOutputs = [] this.form._atoms.values.set((prev: TFormData) => setBy(prev, this.name, getBy(this.form.defaultValues, this.name)), @@ -675,26 +738,21 @@ export class InternalFormGroupApi< } }) }) - this._errors.set([]) this._isSubmitting.set(false) this._isSubmitSuccessful.set(false) - this._isValidating.set(false) this._submissionAttempts.set(0) this._clearRoutedErrors() }) } _cleanup() { - this.form._unregisterFormGroup(this) - cancelPipelineCache(this._pipelineCache) - this._pipelineCache = createPipelineCache() + this._cancelValidation() this._schemaOutputs = [] batch(() => { - this._errors.set([]) this._isSubmitting.set(false) this._isSubmitSuccessful.set(false) - this._isValidating.set(false) this._clearRoutedErrors() }) + this._groupField._setFormGroup(null) } } diff --git a/packages/form-core/src/validation.lib.ts b/packages/form-core/src/validation.lib.ts index 3851767f41..b19dc213b9 100644 --- a/packages/form-core/src/validation.lib.ts +++ b/packages/form-core/src/validation.lib.ts @@ -33,7 +33,7 @@ import type { } from './validation.public' import type { InternalFormApi } from './FormApi/FormApi.lib' import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' -import type { InternalFormGroupApi } from './FormGroupApi/FormGroupApi.lib' +import type { AnyInternalFormGroupApi } from './FormGroupApi/FormGroupApi.lib' type FormValidateContext = { scope: 'form' @@ -1263,8 +1263,6 @@ export function runFieldMountValidatorPipeline({ // ===== GROUP MOUNT VALIDATION ===== -type AnyInternalFormGroupApi = InternalFormGroupApi - interface GroupMountValidatorPipelineArgs { pipeline: ReadonlyArray> groupApi: AnyInternalFormGroupApi diff --git a/packages/form-core/src/validation.public.ts b/packages/form-core/src/validation.public.ts index 66fda0a42c..655a4621d5 100644 --- a/packages/form-core/src/validation.public.ts +++ b/packages/form-core/src/validation.public.ts @@ -205,8 +205,8 @@ export interface ErrorVisibilityContext< /** * Decides whether a field exposes its validation errors publicly. * - * For fields inside a registered form group, scalar meta properties read from - * `state` are scoped to the nearest group. `values` and `errors` remain + * For fields inside a form group, scalar meta properties read from `state` are + * scoped to the containing group. `values` and `errors` remain * form-wide. */ export type ErrorVisibility< diff --git a/packages/form-core/tests/FieldApi/fieldTraversal.spec.ts b/packages/form-core/tests/FieldApi/fieldTraversal.spec.ts index 03276843cb..1a1d9ae270 100644 --- a/packages/form-core/tests/FieldApi/fieldTraversal.spec.ts +++ b/packages/form-core/tests/FieldApi/fieldTraversal.spec.ts @@ -65,19 +65,19 @@ describe('field traversal', () => { ]) }) - it('stops ancestor and subtree traversal when the visitor returns false', () => { + it('stops ancestor and subtree traversal when the visitor returns stop', () => { const { section, deep } = createFieldTree() const ancestors: Array = [] const subtree: Array = [] - visitFieldAndAncestors(deep, (field) => { + visitFieldAndAncestors(deep, (field, stop) => { ancestors.push(field.name) - if (field.name === 'section.second') return false + if (field.name === 'section.second') return stop return undefined }) - visitFieldSubtree(section, (field) => { + visitFieldSubtree(section, (field, stop) => { subtree.push(field.name) - if (field.name === 'section.second') return false + if (field.name === 'section.second') return stop return undefined }) diff --git a/packages/form-core/tests/FieldApi/meta.spec.ts b/packages/form-core/tests/FieldApi/meta.spec.ts index 8dab8a5953..8e3aa68351 100644 --- a/packages/form-core/tests/FieldApi/meta.spec.ts +++ b/packages/form-core/tests/FieldApi/meta.spec.ts @@ -1,9 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' -import { - defaultFieldMeta, - hasFormGroupValidatorErrors, -} from '../../src/FieldApi/fieldState.lib' +import { defaultFieldMeta } from '../../src/FieldApi/fieldState.lib' describe('field - meta', () => { afterEach(() => { @@ -11,27 +8,6 @@ describe('field - meta', () => { }) describe('field meta derived properties', () => { - it('detects whether form-group validator error maps contain errors', () => { - expect( - hasFormGroupValidatorErrors( - new Map([[{}, { errors: [[]], errorSourceEvents: [null] }]]), - ), - ).toBe(false) - expect( - hasFormGroupValidatorErrors( - new Map([ - [ - {}, - { - errors: [[{ message: 'Group error' }]], - errorSourceEvents: ['submit'], - }, - ], - ]), - ), - ).toBe(true) - }) - it('starts with defaultFieldMeta values', () => { const form = new InternalFormApi({ defaultValues: { x: '' } }) const field = form._getOrCreateFieldApi({ name: 'x' }) diff --git a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts index e4ff54fb2f..4a4dc4466f 100644 --- a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts @@ -35,6 +35,37 @@ describe('FormGroupApi', () => { expect(group.state.isValidating).toBe(false) }) + it('keeps flattened group errors stable while their bucket is unchanged', async () => { + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: '' } }, + }) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + validators: [ + { + triggers: [], + run: () => 'Group error', + }, + ], + }) + + await group.validate('submit') + + const previousState = group.state + const previousErrors = previousState.errors + + group._groupFieldVersion.set((version) => version + 1) + + expect(group.state).toBe(previousState) + expect(group.state.errors).toBe(previousErrors) + + group._isSubmitting.set(true) + + expect(group.state).not.toBe(previousState) + expect(group.state.errors).toBe(previousErrors) + }) + it('tracks async group mount validation', async () => { let resolve!: (value: string) => void const result = new Promise((res) => { @@ -57,13 +88,191 @@ describe('FormGroupApi', () => { group.mount() expect(group.state.isValidating).toBe(true) + expect(group._groupField.meta.isSelfValidating).toBe(true) + expect(group._groupField.meta.isValidating).toBe(true) + expect(form.state.isValidating).toBe(true) resolve('Async group error') await vi.waitFor(() => expect(group.state.isValidating).toBe(false)) + expect(group._groupField.meta.isSelfValidating).toBe(false) + expect(group._groupField.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) expect(group.state.errors).toEqual([{ message: 'Async group error' }]) }) + it('tracks overlapping group validation on the backing node and form', async () => { + const resolvers: Array<(value: null) => void> = [] + const validator = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve) + }), + ) + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: '' } }, + }) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + validators: [{ triggers: [], run: validator }], + }) + + const firstValidation = group.validate('submit') + await vi.waitFor(() => expect(validator).toHaveBeenCalledOnce()) + + const secondValidation = group.validate('submit') + await vi.waitFor(() => expect(validator).toHaveBeenCalledTimes(2)) + await firstValidation + + expect(group._groupField._getBaseMeta()._validationCount).toBe(1) + expect(group._groupField.meta.isSelfValidating).toBe(true) + expect(group.state.isValidating).toBe(true) + expect(form.state.isValidating).toBe(true) + + resolvers[1]!(null) + await secondValidation + + expect(group._groupField._getBaseMeta()._validationCount).toBe(0) + expect(group._groupField.meta.isSelfValidating).toBe(false) + expect(group.state.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + + it('does not let validation canceled by group reset clear a newer run', async () => { + const resolvers: Array<(value: null) => void> = [] + const validator = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve) + }), + ) + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: '' } }, + }) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + validators: [{ triggers: [], run: validator }], + }) + + const canceledValidation = group.validate('submit') + await vi.waitFor(() => expect(validator).toHaveBeenCalledOnce()) + + group.reset() + + expect(group.state.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + + const currentValidation = group.validate('submit') + await vi.waitFor(() => expect(validator).toHaveBeenCalledTimes(2)) + await canceledValidation + + expect(group._groupField._getBaseMeta()._validationCount).toBe(1) + expect(group.state.isValidating).toBe(true) + expect(form.state.isValidating).toBe(true) + + resolvers[1]!(null) + await currentValidation + + expect(group._groupField._getBaseMeta()._validationCount).toBe(0) + expect(group.state.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + + it('only removes group-owned validation counts when canceled', async () => { + const resolvers: Array<(value: null) => void> = [] + const fieldValidator = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve) + }), + ) + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: '' } }, + }) + const groupField = form._getOrCreateFieldApi({ + name: 'guestDetails', + validators: [{ triggers: [], run: fieldValidator }], + }) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + validators: [{ triggers: [], run: () => null }], + }) + + const groupValidation = group.validate('submit') + await vi.waitFor(() => expect(fieldValidator).toHaveBeenCalledOnce()) + const independentValidation = groupField._runFieldValidation('submit') + await vi.waitFor(() => expect(fieldValidator).toHaveBeenCalledTimes(2)) + + expect(groupField._getBaseMeta()._validationCount).toBe(3) + + group.reset() + + expect(groupField._getBaseMeta()._validationCount).toBe(1) + expect(groupField.meta.isSelfValidating).toBe(true) + expect(groupField.meta.isValidating).toBe(true) + expect(form.state.isValidating).toBe(true) + + resolvers[0]!(null) + await groupValidation + + expect(groupField._getBaseMeta()._validationCount).toBe(1) + expect(groupField.meta.isSelfValidating).toBe(true) + expect(groupField.meta.isValidating).toBe(true) + + resolvers[1]!(null) + await independentValidation + + expect(groupField._getBaseMeta()._validationCount).toBe(0) + expect(groupField.meta.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + + it('cancels group validation before replacing trie nodes on form reset', async () => { + const resolvers: Array<(value: null) => void> = [] + const validator = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve) + }), + ) + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: '' } }, + }) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + validators: [{ triggers: [], run: validator }], + }) + + const canceledValidation = group.validate('submit') + await vi.waitFor(() => expect(validator).toHaveBeenCalledOnce()) + const previousGroupField = group._groupField + + form.reset() + + expect(group._groupField).not.toBe(previousGroupField) + expect(group.state.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + + const currentValidation = group.validate('submit') + await vi.waitFor(() => expect(validator).toHaveBeenCalledTimes(2)) + await canceledValidation + + expect(group._groupField._getBaseMeta()._validationCount).toBe(1) + expect(group.state.isValidating).toBe(true) + expect(form.state.isValidating).toBe(true) + + resolvers[1]!(null) + await currentValidation + + expect(group._groupField._getBaseMeta()._validationCount).toBe(0) + expect(group.state.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + }) + it('skips group mount validation when no validator opts in', () => { const validator = vi.fn() const form = new InternalFormApi({ @@ -105,6 +314,140 @@ describe('FormGroupApi', () => { expect(group._options.onSubmit).toBe(onSubmit) }) + it('stores the group on its trie node and follows that node when it moves', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'first' }, { name: 'second' }], + }, + }) + const group = new InternalFormGroupApi({ + form, + name: 'items[1]', + }) + const groupField = form._tryGetFieldApi('items[1]') + + expect(groupField).toBe(group._groupField) + expect(groupField?._formGroup).toBe(group) + + form.moveFieldValue('items', 1, 0) + + expect(group._groupField).toBe(groupField) + expect(group.name).toBe('items[0]') + expect(group.value).toEqual({ name: 'second' }) + expect(form._tryGetFieldApi('items[0]')?._formGroup).toBe(group) + }) + + it('detaches and reattaches its trie node across cleanup and remount', () => { + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: '' } }, + }) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + }) + const initialGroupField = group._groupField + + group._cleanup() + + expect(initialGroupField._formGroup).toBeNull() + expect(form._tryGetFieldApi('guestDetails')).toBeNull() + + group.mount() + + expect(group._groupField).not.toBe(initialGroupField) + expect(group._groupField._formGroup).toBe(group) + expect(form._tryGetFieldApi('guestDetails')).toBe(group._groupField) + }) + + it('reattaches its trie node when the backing field is deleted', async () => { + const validator = vi.fn(() => null) + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: '' } }, + }) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + validators: [{ triggers: ['change'], run: validator }], + }) + const initialGroupField = group._groupField + + form.deleteField('guestDetails') + + expect(initialGroupField._isKilled).toBe(true) + expect(initialGroupField._formGroup).toBeNull() + expect(group._groupField).not.toBe(initialGroupField) + expect(group._groupField._isKilled).toBe(false) + expect(group._groupField._formGroup).toBe(group) + expect(form._tryGetFieldApi('guestDetails')).toBe(group._groupField) + + const nameField = form._getOrCreateFieldApi({ + name: 'guestDetails.name', + }) + nameField.handleChange('Tony') + + await vi.waitFor(() => expect(validator).toHaveBeenCalledOnce()) + }) + + it('drops killed routed fields while preserving live routed fields', async () => { + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: '', email: '' } }, + }) + const nameField = form._getOrCreateFieldApi({ + name: 'guestDetails.name', + }) + const emailField = form._getOrCreateFieldApi({ + name: 'guestDetails.email', + }) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + validators: [ + { + triggers: [], + run: () => ({ + fields: { + name: 'Name is required', + email: 'Email is required', + }, + }), + }, + ], + }) + + await group.validate('submit') + expect(group._routedErrorFields[0]).toEqual( + new Set([nameField, emailField]), + ) + + form.deleteField('guestDetails.name') + + expect(nameField._isKilled).toBe(true) + expect(group._routedErrorFields[0]).toEqual(new Set([emailField])) + }) + + it('clears backing-node validation when cleanup cancels a group run', async () => { + const validator = vi.fn(() => new Promise(() => {})) + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: '' } }, + }) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + validators: [{ triggers: [], run: validator }], + }) + const groupField = group._groupField + const validation = group.validate('submit') + await vi.waitFor(() => expect(validator).toHaveBeenCalledOnce()) + + group._cleanup() + await validation + + expect(groupField._getBaseMeta()._validationCount).toBe(0) + expect(group.state.isValidating).toBe(false) + expect(form.state.isValidating).toBe(false) + expect(form._tryGetFieldApi('guestDetails')).toBeNull() + }) + it('prefixes field options declared through a group', () => { const form = new InternalFormApi({ defaultValues: { guestDetails: { name: '', age: 0 } }, @@ -538,7 +881,7 @@ describe('FormGroupApi', () => { expect(overrides.isDefaultValue?.()).toBe(false) }) - it('scopes submit-attempt error visibility to the nearest group', async () => { + it('scopes submit-attempt error visibility to the containing group', async () => { const form = new InternalFormApi({ defaultValues: { guestDetails: { name: '' } }, errorVisibility: ({ state }) => state.submissionAttempts > 0, @@ -579,7 +922,7 @@ describe('FormGroupApi', () => { expect(nameField.errors).toEqual([{ message: 'Name is required' }]) }) - it('scopes scalar error visibility state to the nearest group', async () => { + it('scopes scalar error visibility state to the containing group', async () => { const states: Array> = [] const form = new InternalFormApi({ defaultValues: { @@ -675,9 +1018,7 @@ describe('FormGroupApi', () => { await group.validate('submit') expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect( - nameField._getBaseMeta()._formGroupValidatorErrors.get(group._errorOwner), - ).toEqual({ + expect(nameField._getBaseMeta()._formGroupValidatorErrors).toEqual({ errors: [[{ message: 'Name is required' }]], errorSourceEvents: ['submit'], }) @@ -736,9 +1077,7 @@ describe('FormGroupApi', () => { { message: 'Group name error' }, { message: 'Root name error' }, ]) - expect( - nameField._getBaseMeta()._formGroupValidatorErrors.get(group._errorOwner), - ).toEqual({ + expect(nameField._getBaseMeta()._formGroupValidatorErrors).toEqual({ errors: [[{ message: 'Group name error' }]], errorSourceEvents: ['submit'], }) @@ -747,46 +1086,53 @@ describe('FormGroupApi', () => { ]) }) - it('keeps overlapping group validator errors independently owned', async () => { + it('keeps sibling group validator errors independently owned', async () => { const form = new InternalFormApi({ - defaultValues: { guestDetails: { name: '' } }, + defaultValues: { + guestDetails: { name: '' }, + billingDetails: { name: '' }, + }, }) - const nameField = form._getOrCreateFieldApi({ name: 'guestDetails.name' }) - const parentGroup = new InternalFormGroupApi({ + const guestName = form._getOrCreateFieldApi({ + name: 'guestDetails.name', + }) + const billingName = form._getOrCreateFieldApi({ + name: 'billingDetails.name', + }) + const guestGroup = new InternalFormGroupApi({ form, name: 'guestDetails', validators: [ { triggers: [], - run: () => ({ fields: { name: 'Parent error' } }), + run: () => ({ fields: { name: 'Guest error' } }), }, ], }) - const childGroup = new InternalFormGroupApi({ + const billingGroup = new InternalFormGroupApi({ form, - name: 'guestDetails.name', + name: 'billingDetails', validators: [ { triggers: [], - run: () => 'Child error', + run: () => ({ fields: { name: 'Billing error' } }), }, ], }) - await parentGroup.validate('submit') - await childGroup.validate('submit') + await guestGroup.validate('submit') + await billingGroup.validate('submit') - expect(nameField.errors).toEqual([ - { message: 'Parent error' }, - { message: 'Child error' }, - ]) - expect(parentGroup.state.isInvalid).toBe(true) - expect(childGroup.state.isInvalid).toBe(true) + expect(guestName.errors).toEqual([{ message: 'Guest error' }]) + expect(billingName.errors).toEqual([{ message: 'Billing error' }]) + expect(guestGroup.state.isInvalid).toBe(true) + expect(billingGroup.state.isInvalid).toBe(true) - childGroup.reset() + guestGroup.reset() - expect(nameField.errors).toEqual([{ message: 'Parent error' }]) - expect(parentGroup.state.isInvalid).toBe(true) + expect(guestName.errors).toEqual([]) + expect(billingName.errors).toEqual([{ message: 'Billing error' }]) + expect(billingGroup.state.isInvalid).toBe(true) }) it('routes group-level errors to the field at the group name', async () => { @@ -1068,6 +1414,8 @@ describe('FormGroupApi', () => { const validating = nameField._runFieldValidation('submit') await vi.waitFor(() => expect(group.state.isValidating).toBe(true)) + expect(group._groupField.meta.isSelfValidating).toBe(false) + expect(group._groupField.meta.isValidating).toBe(true) resolveValidation() await validating expect(group.state.isValidating).toBe(false) @@ -1224,6 +1572,39 @@ describe('FormGroupApi', () => { await vi.waitFor(() => expect(rootValidator).toHaveBeenCalledTimes(2)) }) + it('reattaches active groups after a form reset', async () => { + const rootValidator = vi.fn(() => null) + const groupValidator = vi.fn(() => 'Group error') + const form = new InternalFormApi({ + defaultValues: { guestDetails: { name: '' } }, + validators: [{ triggers: ['change'], run: rootValidator }], + }) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + validators: [{ triggers: ['change'], run: groupValidator }], + }) + const initialGroupField = group._groupField + + await group.validate('submit') + expect(group.state.isInvalid).toBe(true) + + form.reset() + + expect(initialGroupField._isKilled).toBe(true) + expect(group._groupField).not.toBe(initialGroupField) + expect(group._groupField._formGroup).toBe(group) + expect(group.state.isValid).toBe(true) + + const nameField = form._getOrCreateFieldApi({ + name: 'guestDetails.name', + }) + nameField.handleChange('Tony') + + await vi.waitFor(() => expect(groupValidator).toHaveBeenCalledTimes(2)) + expect(rootValidator).not.toHaveBeenCalled() + }) + it('only clears the changed field when a group validator conditionally skips change', async () => { const form = new InternalFormApi({ defaultValues: { diff --git a/packages/form-devtools/src/bridge/fields/debug/schemaErrorOnUnmountedField.ts b/packages/form-devtools/src/bridge/fields/debug/schemaErrorOnUnmountedField.ts index 66d42d530a..b74e45210e 100644 --- a/packages/form-devtools/src/bridge/fields/debug/schemaErrorOnUnmountedField.ts +++ b/packages/form-devtools/src/bridge/fields/debug/schemaErrorOnUnmountedField.ts @@ -8,11 +8,11 @@ export const schemaErrorOnUnmountedField = { } let mountedAncestorPath: string | undefined - visitFieldAndAncestors(field, (candidate) => { + visitFieldAndAncestors(field, (candidate, stop) => { if (candidate === field || !candidate._isMounted) return mountedAncestorPath = candidate.name - return false + return stop }) if (!mountedAncestorPath) return undefined diff --git a/packages/form-devtools/src/bridge/fields/detailSnapshot.ts b/packages/form-devtools/src/bridge/fields/detailSnapshot.ts index aedb16af5e..96434f900c 100644 --- a/packages/form-devtools/src/bridge/fields/detailSnapshot.ts +++ b/packages/form-devtools/src/bridge/fields/detailSnapshot.ts @@ -95,10 +95,9 @@ export function getDevtoolsFieldErrors( }), }) - for (const [owner, groupErrors] of meta._formGroupValidatorErrors) { - const group = Array.from(field.form._formGroups).find( - (candidate) => candidate._errorOwner === owner, - ) + const groupErrors = meta._formGroupValidatorErrors + if (groupErrors) { + const containingGroup = field._getFormGroup() appendErrors({ destination: errors, @@ -107,10 +106,12 @@ export function getDevtoolsFieldErrors( mode, getSource: (validatorIndex) => ({ scope: 'formGroup', - formGroupPath: group ? String(group.name) : '(unknown form group)', + formGroupPath: containingGroup + ? String(containingGroup.name) + : '(unknown form group)', validatorIndex, validatorType: getValidatorType( - group?._options.validators?.[validatorIndex], + containingGroup?._options.validators?.[validatorIndex], ), }), }) diff --git a/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts b/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts index 9d4d06a9ea..392801940c 100644 --- a/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts +++ b/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts @@ -30,7 +30,7 @@ export const validatorsWithoutTriggers = { (validatorIndex) => ({ scope: 'field', validatorIndex }), ) - const group = field.form._getNearestFormGroupForField(field.name) + const group = field._getFormGroup() if (group) { appendValidatorsWithoutTriggers( validators, diff --git a/packages/form-devtools/src/bridge/fields/list.ts b/packages/form-devtools/src/bridge/fields/list.ts index 4e884795e8..020f2fa3bd 100644 --- a/packages/form-devtools/src/bridge/fields/list.ts +++ b/packages/form-devtools/src/bridge/fields/list.ts @@ -63,8 +63,7 @@ interface FieldRowsSnapshotOptions { } function isFormGroupField(field: AnyInternalFieldApi): boolean { - const group = field.form._getNearestFormGroupForField(field.name) - return group !== null && String(group.name) === field.name + return field._formGroup !== null } export function getFieldRowsSnapshot( diff --git a/packages/form-devtools/tests/fieldDebugCases.test.ts b/packages/form-devtools/tests/fieldDebugCases.test.ts index cb6a2f84dd..5409a1d323 100644 --- a/packages/form-devtools/tests/fieldDebugCases.test.ts +++ b/packages/form-devtools/tests/fieldDebugCases.test.ts @@ -94,7 +94,7 @@ describe('field debug cases', () => { } }) - it('uses the nearest form group instead of form validators', () => { + it('uses the containing form group instead of form validators', () => { const form = new InternalFormApi({ defaultValues: { profile: { name: '' } }, validators: [emptyTriggerValidator] as never, @@ -133,17 +133,12 @@ describe('field debug cases', () => { } }) - it('does not fall back past the nearest form group', () => { + it('does not fall back to form validators when the group has none', () => { const form = new InternalFormApi({ defaultValues: { profile: { contact: { email: '' } } }, validators: [emptyTriggerValidator] as never, }) - const outerGroup = new InternalFormGroupApi({ - form, - name: 'profile', - validators: [emptyTriggerValidator] as never, - }) - const innerGroup = new InternalFormGroupApi({ + const group = new InternalFormGroupApi({ form, name: 'profile.contact', }) @@ -157,8 +152,7 @@ describe('field debug cases', () => { expect(getFieldDebugSuspicions({ field })).toEqual([]) } finally { unregister() - innerGroup._cleanup() - outerGroup._cleanup() + group._cleanup() } }) diff --git a/packages/form-devtools/tests/fieldDetailsBridge.test.ts b/packages/form-devtools/tests/fieldDetailsBridge.test.ts index 43c4c6a7cb..33a4edeec7 100644 --- a/packages/form-devtools/tests/fieldDetailsBridge.test.ts +++ b/packages/form-devtools/tests/fieldDetailsBridge.test.ts @@ -80,15 +80,10 @@ describe('field detail snapshots', () => { [{ message: 'Field schema', path: ['name'] } as never], ], _fieldValidatorErrorSourceEvents: ['change', 'blur'], - _formGroupValidatorErrors: new Map([ - [ - group._errorOwner, - { - errors: [[{ message: 'Group schema', path: ['name'] } as never]], - errorSourceEvents: ['server'], - }, - ], - ]), + _formGroupValidatorErrors: { + errors: [[{ message: 'Group schema', path: ['name'] } as never]], + errorSourceEvents: ['server'], + }, _formValidatorErrors: [ [{ message: 'Form callback', code: 'form-code' } as never], [{ message: 'Form schema', path: ['profile', 'name'] } as never],