diff --git a/.changeset/fast-yaks-kneel.md b/.changeset/fast-yaks-kneel.md new file mode 100644 index 000000000..d46f66929 --- /dev/null +++ b/.changeset/fast-yaks-kneel.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +`onSubmit` now has access to `createErrorMap` diff --git a/packages/form-core/src/FieldApi/fieldTree.lib.ts b/packages/form-core/src/FieldApi/fieldTree.lib.ts index 98faeb593..7cc558f2f 100644 --- a/packages/form-core/src/FieldApi/fieldTree.lib.ts +++ b/packages/form-core/src/FieldApi/fieldTree.lib.ts @@ -450,7 +450,7 @@ export function killField( reattachFormGroups() } -export function canPruneField(field: AnyInternalFieldApi): boolean { +function canPruneField(field: AnyInternalFieldApi): boolean { if (field._isKilled) return false if (field._refCount > 0) return false diff --git a/packages/form-core/src/FormApi/FormApi.public.ts b/packages/form-core/src/FormApi/FormApi.public.ts index f7824c090..8091f516b 100644 --- a/packages/form-core/src/FormApi/FormApi.public.ts +++ b/packages/form-core/src/FormApi/FormApi.public.ts @@ -9,6 +9,7 @@ import type { ParsedStandardSchemaIssues, ToFormErrorTypes, ToFormOutputs, + ValidationErrorMap, } from '../validation.public' import type { StandardSchemaV1Issue } from '../standardSchema.public' import type { FormListeners } from '../listeners.public' @@ -62,6 +63,11 @@ export type CreateValidationErrorFn = < error: TError, ) => OnSubmitError +export type CreateSubmitErrorMapFn = ( + initial?: Partial>, +) => ValidationErrorMap & + OnSubmitError> + /** * Converts Standard Schema issues into an `onSubmit` validation error. * @@ -146,6 +152,7 @@ export interface FormSubmitContext< * ``` */ createValidationError: CreateValidationErrorFn + createErrorMap: CreateSubmitErrorMapFn /** * Parses Standard Schema issues into an error returnable from `onSubmit`. * diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index 634755d40..43beb1e99 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -5,13 +5,16 @@ import { parseValidationResult, } from '../validation' import { parseStandardSchemaIssues } from '../standardSchema.lib' +import { createErrorMap } from '../validation.public' import type { InternalFormApi } from './FormApi.lib' import type { FormValidateResult, FormValidationError, ValidationErrorInput, + ValidationErrorMap, } from '../validation.public' import type { + CreateSubmitErrorMapFn, CreateValidationErrorFn, OnSubmitError, ParseSubmitIssuesFn, @@ -39,6 +42,14 @@ function createSubmitError>( return { [SUBMIT_ERROR]: error } } +function createSubmitErrorMap( + initial?: Partial>, +): ReturnType> { + const map = createErrorMap(initial) + Object.defineProperty(map, SUBMIT_ERROR, { value: map }) + return map as ReturnType> +} + function createParseIssues( value: TFormData, ): ParseSubmitIssuesFn { @@ -168,6 +179,7 @@ export async function runSubmissionProcess( validatorOutputs, value, createValidationError, + createErrorMap: createSubmitErrorMap, parseIssues: createParseIssues(value), }) diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts new file mode 100644 index 000000000..0cf633718 --- /dev/null +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -0,0 +1,770 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../../src/FormApi/FormApi.lib' + +describe('getOrCreateFieldApi', () => { + it('applies validators to a field whose value was set before registration', async () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + + form.setFieldValue('name', 'before render') + expect(form.getFieldMeta('name')).toMatchObject({ + isTouched: true, + isDirty: true, + }) + expect(form.state.isValid).toBe(true) + + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Name is invalid', triggers: [] }], + }) + field._register() + + expect(form.state.isValid).toBe(true) + expect(field.value).toBe('before render') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + }) + + await form.handleSubmit() + + expect(field.errors).toEqual([{ message: 'Name is invalid' }]) + expect(form.state.isValid).toBe(false) + }) +}) + +describe('InternalFieldApi - _update', () => { + it('retains default listeners before incoming listeners during construction and updates', () => { + const defaultListener = vi.fn() + const incomingListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi( + { defaultValues: { name: '' } }, + { + field: { + listenersMerge: 'append', + listeners: [{ triggers: ['change'], run: defaultListener }], + }, + }, + ) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ triggers: ['change'], run: incomingListener }], + }) + + field.handleChange('initial') + + expect(defaultListener).toHaveBeenCalledOnce() + expect(incomingListener).toHaveBeenCalledOnce() + expect(defaultListener).toHaveBeenCalledBefore(incomingListener) + expect(incomingListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'initial' }), + ) + + defaultListener.mockClear() + incomingListener.mockClear() + field._update({ + listeners: [{ triggers: ['change'], run: updatedListener }], + }) + field.handleChange('updated') + + expect(defaultListener).toHaveBeenCalledOnce() + expect(updatedListener).toHaveBeenCalledOnce() + expect(defaultListener).toHaveBeenCalledBefore(updatedListener) + expect(updatedListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'updated' }), + ) + expect(incomingListener).not.toHaveBeenCalled() + }) + + it('applies the default error boundary only after configuration and respects an explicit override', async () => { + // This kinda masks a problem where an immediate mount validation on the form can 100% NOT respect + // a field component with `errorBoundary`. Too bad, I assume this won't happen much. + const form = new InternalFormApi( + { + defaultValues: { user: { name: '' } }, + validators: [ + { + triggers: ['change'], + run: ({ createErrorMap }) => + createErrorMap({ fields: { 'user.name': 'Name is required' } }), + }, + ], + }, + { field: { errorBoundary: true } }, + ) + const parent = form._getOrCreateFieldApi({ name: 'user' }, 'internal') + const child = form._getOrCreateFieldApi({ name: 'user.name' }, 'internal') + parent._register() + child._register() + + await form.validate('change') + expect(child.errors).toEqual([{ message: 'Name is required' }]) + expect(parent.errors).toEqual([]) + + form._getOrCreateFieldApi({ name: 'user' }) + + await form.validate('change') + expect(parent.errors).toEqual([{ message: 'Name is required' }]) + expect(child.errors).toEqual([]) + + parent._update({ errorBoundary: false }) + + await form.validate('change') + expect(child.errors).toEqual([{ message: 'Name is required' }]) + expect(parent.errors).toEqual([]) + }) + + it('uses updated validators, retains omitted validators, and removes validators with an empty array', async ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Initial error', triggers: [] }], + }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Initial error' }]) + + field._update({ + validators: [{ run: () => 'Updated error', triggers: [] }], + }) + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Updated error' }]) + + field._update({}) + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Updated error' }]) + + field._update({ validators: [] }) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'length of the validator array should not change', + ), + ) + await expect(form.handleSubmit()).resolves.toEqual([]) + expect(field.errors).toEqual([]) + expect(form.state.canSubmit).toBe(true) + }) + + it('uses updated listener callbacks and triggers, retains omitted listeners, and removes listeners with an empty array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const initialListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ triggers: ['change'], run: initialListener }], + }) + + field.handleChange('initial') + expect(initialListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'initial' }), + ) + initialListener.mockClear() + + field._update({ + listeners: [{ triggers: ['blur'], run: updatedListener }], + }) + field.handleChange('updated') + + expect(initialListener).not.toHaveBeenCalled() + expect(updatedListener).not.toHaveBeenCalled() + + field.handleBlur() + expect(updatedListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'updated' }), + ) + updatedListener.mockClear() + + field._update({}) + field.handleBlur() + expect(updatedListener).toHaveBeenCalledOnce() + updatedListener.mockClear() + + field._update({ listeners: [] }) + field.handleBlur() + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('length of the listener array should not change'), + ) + expect(updatedListener).not.toHaveBeenCalled() + }) +}) + +describe('InternalFieldApi - atom', () => { + it('exposes the default value with untouched, valid state', () => { + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + const state = field.atom.get() + + expect(state).toMatchObject({ + value: 'Alice', + meta: { + errors: [], + isTouched: false, + isDirty: false, + isValid: true, + isValidating: false, + }, + }) + }) +}) + +describe('InternalFieldApi - reset', () => { + it('aborts active validation, ignores its late result, and allows validation to run again', async ({ + onTestFinished, + }) => { + const request = Promise.withResolvers() + onTestFinished(() => request.resolve('Late error')) + const onAbort = vi.fn() + const validator = vi.fn(() => request.promise) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: [], + run: ({ signal }: { signal: AbortSignal }) => { + signal.addEventListener('abort', onAbort, { once: true }) + return validator() + }, + }, + ], + }) + field._register() + + const submission = form.handleSubmit() + await expect.poll(() => validator).toHaveBeenCalledOnce() + expect(field.meta.isValidating).toBe(true) + + field.reset() + + expect(onAbort).toHaveBeenCalledOnce() + await expect(submission).resolves.toEqual([]) + request.resolve('Late error') + await request.promise + + expect(field.errors).toEqual([]) + expect(field.meta.isValidating).toBe(false) + + validator.mockResolvedValue('Current error') + await form.handleSubmit() + + expect(field.errors).toEqual([{ message: 'Current error' }]) + }) + + it('cancels a pending listener callback and allows subsequent changes to notify it', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { run: listener, triggers: ['change'], triggerDebounceMs: 100 }, + ], + }) + + field.handleChange('initial') + await vi.advanceTimersByTimeAsync(100) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'initial' }), + ) + listener.mockClear() + + field.handleChange('pending') + field.reset() + await vi.advanceTimersByTimeAsync(100) + + expect(listener).not.toHaveBeenCalled() + expect(field.value).toBe('') + + field.handleChange('after reset') + await vi.advanceTimersByTimeAsync(100) + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'after reset' }), + ) + }) +}) + +describe('InternalFieldApi - _kill', () => { + it('does not run an unmounted field validator when a watched field is deleted and recreated', async () => { + vi.useFakeTimers() + const validator = vi.fn(() => null) + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const target = form._getOrCreateFieldApi({ + name: 'target', + validators: [ + { triggers: ['change'], watchFields: ['source'], run: validator }, + ], + }) + source._register() + const unregisterTarget = target._register() + + source.handleChange('first') + await vi.runOnlyPendingTimersAsync() + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target', event: 'change' }), + ) + validator.mockClear() + + unregisterTarget() + await vi.runOnlyPendingTimersAsync() + form.deleteField('source') + + form.setFieldValue('source', 'replacement') + await vi.runOnlyPendingTimersAsync() + + expect(validator).not.toHaveBeenCalled() + expect(form.getFieldValue('target')).toBe('target') + expect(form.state.isValid).toBe(true) + }) + + it('keeps a registered watcher usable after deleting its source and detaches the old dependency', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: listener }, + ], + }) + source._register() + target._register() + + source.handleChange('first') + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target' }), + ) + listener.mockClear() + + form.deleteField('source') + form.setFieldValue('source', 'replacement') + + expect(listener).not.toHaveBeenCalled() + + target.handleChange('direct edit') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'direct edit' }), + ) + expect(form.getFieldValue('target')).toBe('direct edit') + }) + + it('ignores array edits made through a deleted field', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + field._register() + field.pushValue('active') + + const beforeDelete = form.getFieldValue('items') + expect(beforeDelete).toEqual(['a', 'b', 'active']) + + form.deleteField('items') + + field.swapValues(0, 1) + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.moveValue(0, 1) + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.clearValues() + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.pushValue('ignored') + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.insertValue(1, 'ignored') + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.removeValue(0) + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.filterValues(() => false) + expect(form.getFieldValue('items')).toEqual(beforeDelete) + }) + + it('ignores blur events made through a deleted field', () => { + const onBlur = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['blur'], run: onBlur }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + field.handleBlur() + expect(onBlur).toHaveBeenCalledWith( + expect.objectContaining({ triggerFieldApi: field }), + ) + onBlur.mockClear() + + form.deleteField('name') + field.handleBlur() + + expect(onBlur).not.toHaveBeenCalled() + expect(form.getFieldMeta('name')).toBeUndefined() + }) + + it('aborts active validation when deleted and ignores late results and subsequent interactions', async ({ + onTestFinished, + }) => { + const request = Promise.withResolvers() + onTestFinished(() => request.resolve('Late error')) + const onAbort = vi.fn() + const validator = vi.fn(() => request.promise) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: ({ signal }: { signal: AbortSignal }) => { + signal.addEventListener('abort', onAbort, { once: true }) + return validator() + }, + }, + ], + }) + field._register() + + const submission = form.handleSubmit() + await expect.poll(() => validator).toHaveBeenCalledOnce() + expect(field.meta.isValidating).toBe(true) + + form.deleteField('name') + + expect(onAbort).toHaveBeenCalledOnce() + await expect(submission).resolves.toEqual([]) + request.resolve('Late error') + await request.promise + validator.mockClear() + + field.handleChange('ignored') + await expect(form.handleSubmit()).resolves.toEqual([]) + + expect(validator).not.toHaveBeenCalled() + expect(form.getFieldValue('name')).toBe('') + expect(form.state.isValid).toBe(true) + expect(form.state.isValidating).toBe(false) + expect(field.errors).toEqual([]) + }) + + it('cancels pending listener callbacks when deleted and ignores subsequent changes', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { run: listener, triggers: ['change'], triggerDebounceMs: 100 }, + ], + }) + + field.handleChange('initial') + await vi.advanceTimersByTimeAsync(100) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'initial' }), + ) + listener.mockClear() + + field.handleChange('pending') + form.deleteField('name') + await vi.advanceTimersByTimeAsync(100) + + expect(listener).not.toHaveBeenCalled() + + field.handleChange('ignored') + await vi.advanceTimersByTimeAsync(100) + + expect(listener).not.toHaveBeenCalled() + expect(form.getFieldValue('name')).toBe('pending') + }) +}) + +describe('InternalFieldApi - _register', () => { + it('ignores registration and cleanup callbacks for a deleted field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ triggers: ['mount', 'unmount'], run: listener }], + }) + const unregister = field._register() + expect(listener).toHaveBeenCalledOnce() + listener.mockClear() + + form.deleteField('name') + const unregisterAfterDeletion = field._register() + unregisterAfterDeletion() + unregister() + + expect(listener).not.toHaveBeenCalled() + expect(form.getFieldMeta('name')).toBeUndefined() + }) +}) + +describe('InternalFieldApi - _unregister', () => { + it('continues validating a field while another consumer remains registered', async () => { + vi.useFakeTimers() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ triggers: [], run: () => 'Name is required' }], + }) + const unregisterFirst = field._register() + const unregisterSecond = field._register() + + unregisterFirst() + await vi.runOnlyPendingTimersAsync() + + await form.handleSubmit() + + expect(field.errors).toEqual([{ message: 'Name is required' }]) + expect(form.state.canSubmit).toBe(false) + + unregisterSecond() + await vi.runOnlyPendingTimersAsync() + }) + + it('preserves nested default values and field interactions after unmounting and remounting', async () => { + vi.useFakeTimers() + const form = new InternalFormApi({ + defaultValues: { user: { name: 'Alice' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const child = form._getOrCreateFieldApi({ name: 'user.name' }) + const unregisterParent = parent._register() + const unregisterChild = child._register() + expect(parent.value).toEqual({ name: 'Alice' }) + expect(child.value).toBe('Alice') + + unregisterChild() + unregisterParent() + await vi.runOnlyPendingTimersAsync() + + const remountedParent = form._getOrCreateFieldApi({ name: 'user' }) + const remountedChild = form._getOrCreateFieldApi({ name: 'user.name' }) + remountedParent._register() + remountedChild._register() + + expect(remountedChild.value).toBe('Alice') + expect(remountedChild.meta).toMatchObject({ + isTouched: false, + isDirty: false, + errors: [], + }) + + remountedChild.handleChange('Bob') + + expect(form.state.values).toEqual({ user: { name: 'Bob' } }) + expect(remountedParent.value).toEqual({ name: 'Bob' }) + expect(remountedParent.meta.isTouched).toBe(true) + }) + + it('preserves edited values and interaction state after unmounting and remounting', async () => { + vi.useFakeTimers() + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const unregister = field._register() + field.handleChange('Bob') + field.handleBlur() + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: true, + }) + + unregister() + await vi.runOnlyPendingTimersAsync() + + const remounted = form._getOrCreateFieldApi({ name: 'name' }) + remounted._register() + + expect(remounted.value).toBe('Bob') + expect(remounted.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: true, + }) + expect(form.state.isTouched).toBe(true) + }) + + it('keeps linked validation working after both field components unregister', async ({ + annotate, + }) => { + await annotate( + 'https://github.com/TanStack/form/issues/2296', + 'GitHub RFC link. Its decision affects the outcome of this test.', + ) + vi.useFakeTimers() + const validator = vi.fn(() => 'Target is invalid') + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const target = form._getOrCreateFieldApi({ + name: 'target', + validators: [ + { triggers: ['change'], watchFields: ['source'], run: validator }, + ], + }) + const unregisterSource = source._register() + const unregisterTarget = target._register() + + unregisterSource() + unregisterTarget() + await vi.runOnlyPendingTimersAsync() + + form.setFieldValue('source', 'changed') + await vi.runOnlyPendingTimersAsync() + + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target', event: 'change' }), + ) + expect(form.getFieldMeta('target')?.errors).toEqual([ + { message: 'Target is invalid' }, + ]) + expect(form.state.isValid).toBe(false) + }) +}) + +describe('InternalFieldApi - _clearEventErrors', () => { + it('clears submit-only validator errors only for the field that changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + }) + const nameField = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Name is required', triggers: [] }], + }) + const emailField = form._getOrCreateFieldApi({ + name: 'email', + validators: [{ run: () => 'Email is required', triggers: [] }], + }) + nameField._register() + emailField._register() + + await form.handleSubmit() + expect(nameField.errors).toEqual([{ message: 'Name is required' }]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + + nameField.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(nameField.errors).toEqual([]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + }) + + it('clears submit errors when the field validator only listens for blur', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + }) + const nameField = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Name is required', triggers: ['blur'] }], + }) + const emailField = form._getOrCreateFieldApi({ + name: 'email', + validators: [{ run: () => 'Email is required', triggers: ['blur'] }], + }) + nameField._register() + emailField._register() + + await form.handleSubmit() + expect(nameField.errors).toEqual([{ message: 'Name is required' }]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + + nameField.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(nameField.errors).toEqual([]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + }) + + it('preserves errors produced by blur validation when the field changes', async () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Name is required', triggers: ['blur'] }], + }) + field._register() + + field.handleBlur() + await expect.poll(() => form.state.isValidating).toBe(false) + expect(field.errors).toEqual([{ message: 'Name is required' }]) + + field.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(field.errors).toEqual([{ message: 'Name is required' }]) + }) + + it('replaces submit errors with the result of field change validation', async () => { + const validator = vi + .fn() + .mockReturnValueOnce('Submit field validator failed') + .mockReturnValue('Change field validator failed') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: validator, triggers: ['change'] }], + }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Submit field validator failed' }]) + expect(validator).toHaveBeenLastCalledWith( + expect.objectContaining({ event: 'submit' }), + ) + + field.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(field.errors).toEqual([{ message: 'Change field validator failed' }]) + expect(validator).toHaveBeenLastCalledWith( + expect.objectContaining({ event: 'change' }), + ) + }) + + it('clears submit errors when field change validation is disabled by its predicate', async () => { + const validator = vi.fn(() => 'Submit field validator failed') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + run: validator, + triggers: [{ trigger: 'change', when: false }], + }, + ], + }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Submit field validator failed' }]) + validator.mockClear() + + field.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(field.errors).toEqual([]) + expect(validator).not.toHaveBeenCalled() + }) +}) diff --git a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts deleted file mode 100644 index b2e02dcdb..000000000 --- a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts +++ /dev/null @@ -1,875 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - defaultFieldMeta, - defaultInternalBaseFieldMeta, -} from '../../src/FieldApi/fieldState.lib' -import { canPruneField } from '../../src/FieldApi/fieldTree.lib' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' -import { validationSourceScopes } from '../../src/ValidationSourceInstance.lib' -import { installDevtoolsBridge } from '../../src/devtoolsBridge.lib' - -describe('field - lifecycle', () => { - describe('default options', () => { - it('resolves defaults during construction and updates', () => { - const calls: Array = [] - const form = new InternalFormApi( - { defaultValues: { name: '', internal: '' } }, - { - field: { - errorBoundary: true, - listenersMerge: 'append', - listeners: [ - { triggers: ['change'], run: () => calls.push('default') }, - ], - }, - }, - ) - const internalField = form._getOrCreateFieldApi( - { name: 'internal' }, - 'internal', - ) - const field = form._getOrCreateFieldApi({ - name: 'name', - listeners: [ - { triggers: ['change'], run: () => calls.push('incoming') }, - ], - }) - - expect(internalField._errorBoundary).toBe(false) - field.handleChange('initial') - expect(field._errorBoundary).toBe(true) - expect(calls).toEqual(['default', 'incoming']) - - calls.length = 0 - field._update({ - errorBoundary: false, - listeners: [{ triggers: ['change'], run: () => calls.push('updated') }], - }) - field.handleChange('updated') - expect(field._errorBoundary).toBe(false) - expect(calls).toEqual(['default', 'updated']) - }) - - it('configures a newly created field once', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const validator = { run: () => null, triggers: [] } - - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [validator], - }) - - expect(field._fieldOptionsInitialized).toBe(true) - expect(field._validatorInstances?.[0]?.definition).toBe(validator) - expect(field._validatorInstances?.[0]?.revision).toBe(0) - }) - - it('updates an internal field once when it is first configured', () => { - const form = new InternalFormApi( - { defaultValues: { x: '' } }, - { field: { errorBoundary: true } }, - ) - const field = form._getOrCreateFieldApi({ name: 'x' }, 'internal') - const update = vi.spyOn(field, '_update') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const initialValidator = { run: () => null, triggers: [] } - - expect(field._errorBoundary).toBe(false) - - const configuredField = form._getOrCreateFieldApi({ - name: 'x', - validators: [initialValidator], - }) - - expect(configuredField).toBe(field) - expect(field._fieldOptionsInitialized).toBe(true) - expect(field._errorBoundary).toBe(true) - expect(update).toHaveBeenCalledOnce() - expect(field._validatorInstances?.[0]?.definition).toBe(initialValidator) - expect(field._validatorInstances?.[0]?.revision).toBe(0) - expect(warn).not.toHaveBeenCalled() - - update.mockClear() - const nextValidator = { run: () => null, triggers: [] } - form._getOrCreateFieldApi({ - name: 'x', - validators: [nextValidator], - }) - - expect(update).not.toHaveBeenCalled() - expect(field._validatorInstances?.[0]?.definition).toBe(initialValidator) - - field._update({ validators: [nextValidator] }) - - expect(field._validatorInstances?.[0]?.definition).toBe(nextValidator) - expect(field._validatorInstances?.[0]?.revision).toBe(1) - expect(warn).not.toHaveBeenCalled() - - update.mockRestore() - warn.mockRestore() - }) - }) - - describe('_isMounted and atom', () => { - it('is false before the atom is accessed', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - expect(field._isMounted).toBe(false) - }) - - it('is true after registering', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - field._register() - expect(field._isMounted).toBe(true) - }) - - it('atom returns consistent state', () => { - const form = new InternalFormApi({ defaultValues: { x: 'hello' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - const state = field.atom.get() - expect(state.value).toBe('hello') - expect(state.meta).toMatchObject(defaultFieldMeta) - }) - }) - - describe('validator instances', () => { - it('keeps instances stable by slot and distinguishes omitted validators from an empty array', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const firstDefinition = { run: () => null, triggers: [] } - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [firstDefinition], - }) - const instance = field._validatorInstances?.[0] - const initialRevision = instance?.revision - const nextDefinition = { run: () => null, triggers: [] } - - field._update({ validators: [nextDefinition] }) - - expect(field._validatorInstances?.[0]).toBe(instance) - expect(instance?.definition).toBe(nextDefinition) - expect(instance?.owner).toBe(field) - expect(instance?.scope).toBe(validationSourceScopes.field) - expect(instance?.revision).toBe((initialRevision ?? 0) + 1) - - field._update({}) - expect(field._validatorInstances?.[0]).toBe(instance) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - field._update({ validators: [] }) - expect(field._validatorInstances).toBeNull() - expect(instance?.disposed).toBe(true) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('resets runtime on field reset and disposes instances on kill', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [{ run: () => null, triggers: [] }], - }) - const instance = field._validatorInstances?.[0] - const abortController = new AbortController() - instance?.setAbortController(abortController) - instance?.setOutput({ - output: 'output', - hasOutput: true, - }) - - field.reset() - - expect(field._validatorInstances?.[0]).toBe(instance) - expect(abortController.signal.aborted).toBe(true) - expect(instance?.hasOutput).toBe(false) - expect(instance?.disposed).toBe(false) - - field._kill() - - expect(field._validatorInstances).toBeNull() - expect(instance?.disposed).toBe(true) - }) - }) - - describe('listener instances', () => { - it('keeps instances stable by slot and distinguishes omitted listeners from an empty array', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const firstDefinition = { - run: () => {}, - triggers: ['change'] as Array<'change'>, - } - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [firstDefinition], - }) - const instance = field._listenerInstances?.[0] - const initialRevision = instance?.revision - const nextDefinition = { - run: () => {}, - triggers: ['blur'] as Array<'blur'>, - } - - field._update({ listeners: [nextDefinition] }) - - expect(field._listenerInstances?.[0]).toBe(instance) - expect(instance?.definition).toBe(nextDefinition) - expect(instance?.owner).toBe(field) - expect(instance?.index).toBe(0) - expect(instance?.revision).toBe((initialRevision ?? 0) + 1) - - field._update({}) - expect(field._listenerInstances?.[0]).toBe(instance) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - field._update({ listeners: [] }) - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining( - 'length of the listener array should not change', - ), - ) - expect(field._listenerInstances).toBeNull() - expect(instance?.disposed).toBe(true) - warn.mockRestore() - }) - - it('resets runtime on field reset and disposes instances on kill', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [ - { - run: listener, - triggers: ['change'], - triggerDebounceMs: 100, - }, - ], - }) - const instance = field._listenerInstances?.[0] - - field.handleChange('pending') - field.reset() - await vi.advanceTimersByTimeAsync(100) - - expect(listener).not.toHaveBeenCalled() - expect(field._listenerInstances?.[0]).toBe(instance) - expect(instance?.debouncer).toBeNull() - expect(instance?.disposed).toBe(false) - - field._kill() - - expect(field._listenerInstances).toBeNull() - expect(instance?.disposed).toBe(true) - vi.useRealTimers() - }) - }) - - describe('devtools bridge notifications', () => { - it('notifies field mount and final unmount transitions only', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const mountField = vi.fn() - const unmountField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ - mountField, - unmountField, - }) - - try { - const unregister1 = field._register() - const unregister2 = field._register() - - expect(mountField).toHaveBeenCalledOnce() - expect(mountField).toHaveBeenCalledWith(field) - - unregister1() - expect(unmountField).not.toHaveBeenCalled() - - unregister2() - expect(unmountField).toHaveBeenCalledOnce() - expect(unmountField).toHaveBeenCalledWith(field, 'name') - } finally { - uninstallBridge() - } - }) - - it('notifies field moves with the previous path', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b'] }, - }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const moveField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ moveField }) - - try { - form.swapFieldValues('items', 0, 1) - - expect(moveField).toHaveBeenCalledWith(field0, 'items[0]') - expect(moveField).toHaveBeenCalledWith(field1, 'items[1]') - expect(field0.name).toBe('items[1]') - expect(field1.name).toBe('items[0]') - } finally { - uninstallBridge() - } - }) - - it('notifies field meta updates for changed fields and their parents', () => { - const form = new InternalFormApi({ - defaultValues: { user: { name: '' } }, - }) - const parent = form._getOrCreateFieldApi({ name: 'user' }) - const child = form._getOrCreateFieldApi({ name: 'user.name' }) - const updateField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ updateField }) - - try { - child.handleChange('changed') - - expect(child.meta.isDirty).toBe(true) - expect(parent.meta.isDirty).toBe(true) - expect(updateField).toHaveBeenCalledWith(child) - expect(updateField).toHaveBeenCalledWith(parent) - } finally { - uninstallBridge() - } - }) - - it('notifies field value updates for the field and its parents even when summary meta is unchanged', () => { - const form = new InternalFormApi({ - defaultValues: { user: { name: '' } }, - }) - const parent = form._getOrCreateFieldApi({ name: 'user' }) - const field = form._getOrCreateFieldApi({ name: 'user.name' }) - const updateField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ updateField }) - - try { - field.handleChange('first') - updateField.mockClear() - - field.handleChange('second') - - expect(field.meta.isDirty).toBe(true) - expect(updateField).toHaveBeenCalledWith(field) - expect(updateField).toHaveBeenCalledWith(parent) - } finally { - uninstallBridge() - } - }) - - it('notifies field and default value updates that bypass field change events', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const updateField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ updateField }) - - try { - field.handleChange('changed') - updateField.mockClear() - - form.resetField('name') - expect(updateField).toHaveBeenCalledWith(field) - - updateField.mockClear() - form._update({ defaultValues: { name: 'new default' } }) - expect(updateField).toHaveBeenCalledWith(field) - } finally { - uninstallBridge() - } - }) - - it('notifies removed field subtrees with previous paths', () => { - const form = new InternalFormApi({ - defaultValues: { user: { name: '' } }, - }) - const parent = form._getOrCreateFieldApi({ name: 'user' }) - const child = form._getOrCreateFieldApi({ name: 'user.name' }) - const removeFieldSubtree = vi.fn() - const uninstallBridge = installDevtoolsBridge({ removeFieldSubtree }) - - try { - parent._kill() - - expect(removeFieldSubtree).toHaveBeenCalledOnce() - expect(removeFieldSubtree).toHaveBeenCalledWith(form, [ - { field: parent, previousPath: 'user' }, - { field: child, previousPath: 'user.name' }, - ]) - } finally { - uninstallBridge() - } - }) - - it('notifies semantic field topology changes', () => { - const form = new InternalFormApi({ - defaultValues: { parent: {}, source: '', target: '' }, - }) - form._getOrCreateFieldApi({ name: 'parent' }) - const source = form._getOrCreateFieldApi({ name: 'source' }) - const target = form._getOrCreateFieldApi({ name: 'target' }) - const fieldAdded = vi.fn() - const fieldDependenciesChanged = vi.fn() - const uninstallBridge = installDevtoolsBridge({ - fieldAdded, - fieldDependenciesChanged, - }) - - try { - const child = form._getOrCreateFieldApi({ name: 'parent.child' }) - - expect(fieldAdded).toHaveBeenCalledWith(child) - expect(form._tryGetFieldApi('parent.child')).toBe(child) - - target._update({ - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: () => {}, - }, - ], - }) - const listenerInstance = target._listenerInstances![0]! - - expect(fieldDependenciesChanged).toHaveBeenCalledWith([ - { - kind: 'listener', - sourceField: source, - watchingField: target, - listenerInstance, - }, - ]) - - fieldDependenciesChanged.mockClear() - target._kill() - - expect(fieldDependenciesChanged).toHaveBeenCalledWith([ - { - kind: 'listener', - sourceField: source, - watchingField: target, - listenerInstance, - }, - ]) - expect(source._watchingListenerFields).toBeNull() - } finally { - uninstallBridge() - } - }) - }) - - describe('_kill', () => { - it('unmounts the field atom', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - field._register() - expect(field._isMounted).toBe(true) - field._kill() - expect(field._isMounted).toBe(false) - expect(field._isKilled).toBe(true) - }) - - it('also kills child fields', () => { - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ name: 'a' }) - const child = form._getOrCreateFieldApi({ name: 'a.b' }) - void parent.atom - void child.atom - parent._kill() - expect(parent._isMounted).toBe(false) - expect(child._isMounted).toBe(false) - expect(parent._isKilled).toBe(true) - expect(child._isKilled).toBe(true) - }) - - it('can notify a listener event before killing the field subtree', () => { - const parentListener = vi.fn() - const childListener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ - name: 'a', - listeners: [{ triggers: ['reset'], run: parentListener }], - }) - form._getOrCreateFieldApi({ - name: 'a.b', - listeners: [{ triggers: ['reset'], run: childListener }], - }) - - parent._kill({ listenerEvent: 'reset' }) - - expect(parentListener).toHaveBeenCalledOnce() - expect(childListener).toHaveBeenCalledOnce() - }) - - it('ignores validation started after a field is killed', async () => { - const validator = vi.fn(() => ({ message: 'Too late' })) - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [{ run: validator, triggers: [] }], - }) - - field._kill() - - const result = await field._runFieldValidation('submit') - - expect(validator).not.toHaveBeenCalled() - expect(result.results).toEqual([]) - expect(field.errors).toEqual([]) - }) - - it('ignores validation results that arrive after a field is killed', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [{ run: () => null, triggers: [] }], - }) - const validatorInstance = field._validatorInstances![0]! - - field._kill() - field._processValidationResult( - { - validatorInstance, - result: { message: 'Too late' }, - output: null, - }, - 'submit', - ) - - expect(field.errors).toEqual([]) - }) - - it('ignores stale field writes after a field is killed', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - field._kill() - field.handleChange('ignored') - - expect(form.getFieldValue('x')).toBe('') - }) - - it('ignores stale array and blur methods after a field is killed', () => { - const formListener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b'] }, - listeners: [{ triggers: ['blur'], run: formListener }], - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field._kill() - field.swapValues(0, 1) - field.moveValue(0, 1) - field.clearValues() - field.pushValue('c') - field.insertValue(1, 'x') - field.removeValue(0) - field.filterValues(() => false) - field.handleBlur() - - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - expect(formListener).not.toHaveBeenCalled() - }) - - it('returns a no-op unregister callback for a killed field', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - field._kill() - const unregister = field._register() - unregister() - field._unregister() - - expect(field._isMounted).toBe(false) - }) - - it('does not prune killed fields or fields with retained meta state', () => { - const cases = [ - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, isDirty: true })), - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, isBlurred: true })), - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, isValidating: true })), - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, _validationCount: 1 })), - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, _arrayVersion: 1 })), - ] - - cases.forEach((markRetainedMeta, index) => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: `x${index}` }) - markRetainedMeta(field) - - expect(canPruneField(field)).toBe(false) - }) - - const form = new InternalFormApi({ defaultValues: { killed: '' } }) - const killedField = form._getOrCreateFieldApi({ name: 'killed' }) - killedField._kill() - - expect(canPruneField(killedField)).toBe(false) - }) - - it('removes killed fields from form-level routed error bookkeeping', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - triggers: [], - run: () => ({ - fields: { - name: 'Name is required', - }, - }), - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.validate('submit') - expect(field.errors).toEqual([{ message: 'Name is required' }]) - expect(form.state.canSubmit).toBe(false) - - field._kill() - - expect(form.state.canSubmit).toBe(true) - expect(form._validatorInstances?.[0]?.errorTargets).toBeNull() - }) - - it('removes killed fields from onSubmit routed error bookkeeping', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - onSubmit: ({ createValidationError }) => - createValidationError({ fields: { name: 'Name is required' } }), - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form._onSubmitSource.errorTargets).toEqual(new Set([field])) - - field._kill() - - expect(form._onSubmitSource.errorTargets).toBeNull() - }) - - it('preserves routed errors for fields outside the killed subtree', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', other: '' }, - validators: [ - { - triggers: [], - run: () => ({ - fields: { - name: 'Name is required', - }, - }), - }, - ], - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - const otherField = form._getOrCreateFieldApi({ name: 'other' }) - - await form.validate('submit') - otherField._kill() - - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(form.state.canSubmit).toBe(false) - }) - - it('does not prune either endpoint while watched validators are attached', () => { - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { - triggers: ['change'], - watchFields: ['source'], - run: () => null, - }, - ], - }) - - expect(canPruneField(sourceField)).toBe(false) - expect(canPruneField(targetField)).toBe(false) - }) - - it('prunes an unmounted watcher after its final source is killed', () => { - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { - triggers: ['change'], - watchFields: ['source'], - run: () => null, - }, - ], - }) - - sourceField._kill() - - expect(form._tryGetFieldApi('source')).toBeNull() - expect(form._tryGetFieldApi('target')).toBeNull() - }) - - it('keeps a mounted watcher after its source is killed', () => { - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: () => {}, - }, - ], - }) - const unregisterTarget = targetField._register() - - try { - sourceField._kill() - - expect(form._tryGetFieldApi('target')).toBe(targetField) - expect( - targetField._listenerInstances?.[0]?.resolvedWatchFields, - ).toBeNull() - } finally { - unregisterTarget() - } - }) - }) - - describe('_unregister', () => { - it('unmounts when no registers remain', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister = field._register() - expect(field._isMounted).toBe(true) - - unregister() - expect(field._isMounted).toBe(false) - }) - - it('cleans up the atom cache on unmount', async () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister = field._register() - expect(field._atoms.store).toBeDefined() - - unregister() - - await vi.waitFor(() => { - expect(field._atoms.store).toBeUndefined() - }) - }) - - it('cleans up atoms on unmount when meta was not modified', async () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister = field._register() - expect(field._atoms.meta).toBeDefined() - expect(field._atoms.meta?.get()).toBe(defaultInternalBaseFieldMeta) - - unregister() - - await vi.waitFor(() => { - expect(field._atoms.meta).toBeUndefined() - expect(field._atoms.store).toBeUndefined() - }) - }) - - it('keeps meta atom on unmount when meta was modified', async () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister = field._register() - expect(field._atoms.meta).toBeDefined() - expect(field._atoms.meta?.get()).toBe(defaultInternalBaseFieldMeta) - - field.handleChange('New value') - - unregister() - - await vi.waitFor(() => { - expect(field._atoms.meta).toBeDefined() - expect(field._atoms.store).toBeUndefined() - }) - }) - - it('does not clean up when registers still exist', async () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister1 = field._register() - const unregister2 = field._register() - - expect(field._atoms.store).toBeDefined() - - unregister1() - // Still mounted because of second registration - expect(field._isMounted).toBe(true) - - unregister2() - expect(field._isMounted).toBe(false) - - await vi.waitFor(() => { - expect(field._atoms.store).toBeUndefined() - }) - }) - - it('cleans up atom caches for child fields when parent unregisters', async () => { - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ name: 'a' }) - const child = form._getOrCreateFieldApi({ name: 'a.b' }) - - const unregisterParent = parent._register() - const unregisterChild = child._register() - - expect(parent._atoms.store).toBeDefined() - expect(child._atoms.store).toBeDefined() - - unregisterChild() - unregisterParent() - - await vi.waitFor(() => { - expect(parent._atoms.store).toBeUndefined() - // Child's atom cache should also be cleaned up (separate refCount) - expect(child._atoms.store).toBeUndefined() - }) - }) - }) -}) diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts new file mode 100644 index 000000000..af5c60051 --- /dev/null +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -0,0 +1,1202 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +import { formOptions } from '../../src' + +describe('InternalFormApi - constructor', () => { + it('initializes default values with untouched, submittable state', () => { + const form = new InternalFormApi({ + defaultValues: { name: 'Alice', age: 30 }, + }) + + expect(form.state).toMatchObject({ + values: { name: 'Alice', age: 30 }, + isTouched: false, + canSubmit: true, + }) + }) + + it('uses a supplied formId', () => { + const form = new InternalFormApi({ + formId: 'profile-form', + defaultValues: { name: '' }, + }) + + expect(form.formId).toBe('profile-form') + }) +}) + +describe('InternalFormApi - _update', () => { + it('changes a supplied formId', () => { + const form = new InternalFormApi({ + formId: 'profile-form', + defaultValues: { name: '' }, + }) + + form._update({ + formId: 'renamed-profile-form', + defaultValues: { name: '' }, + }) + + expect(form.formId).toBe('renamed-profile-form') + }) + + it('generates a nonempty formId and preserves it when options update', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const formId = form.formId + + expect(formId).toBeTypeOf('string') + expect(formId.length).toBeGreaterThan(0) + + form._update({ defaultValues: { name: 'async' } }) + + expect(form.formId).toBe(formId) + }) + + it('applies updated defaults to an untouched field without marking it touched', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + expect(field.value).toBe('') + expect(field.meta.isTouched).toBe(false) + + form._update({ defaultValues: { name: 'async' } }) + + expect(form.state.values).toEqual({ name: 'async' }) + expect(form.defaultValues).toEqual({ name: 'async' }) + expect(field.value).toBe('async') + expect(field.meta.isTouched).toBe(false) + expect(form.state.isTouched).toBe(false) + }) + + it('preserves user edits while applying updated defaults to untouched fields', () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + }) + const nameField = form._getOrCreateFieldApi({ name: 'name' }) + form._getOrCreateFieldApi({ name: 'email' }) + + nameField.handleChange('user edit') + form._update({ + defaultValues: { name: 'server name', email: 'server@example.com' }, + }) + + expect(form.state.values).toEqual({ + name: 'user edit', + email: 'server@example.com', + }) + expect(form.defaultValues).toEqual({ + name: 'server name', + email: 'server@example.com', + }) + }) + + it('warns when the validator array length changes', ({ onTestFinished }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [{ run: () => null, triggers: [] }], + }) + + form._update({ + defaultValues: { name: '' }, + validators: [ + { run: () => null, triggers: [] }, + // @ts-expect-error We're testing this on purpose + { run: () => null, triggers: [] }, + ], + }) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'length of the validator array should not change', + ), + ) + }) + + it('uses the updated validator callback on subsequent validation', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [{ run: () => 'Initial error', triggers: ['change'] }], + }) + + await form.validate('change') + expect(form.state.errors).toEqual([{ message: 'Initial error' }]) + + form._update({ + defaultValues: { name: '' }, + validators: [{ run: () => 'Updated error', triggers: ['change'] }], + }) + await form.validate('change') + + expect(form.state.errors).toEqual([{ message: 'Updated error' }]) + }) + + it('uses the updated listener callback and triggers', () => { + const initialListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['change'], run: initialListener }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('before update') + expect(initialListener).toHaveBeenCalledOnce() + initialListener.mockClear() + + form._update({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['blur'], run: updatedListener }], + }) + field.handleChange('after update') + expect(initialListener).not.toHaveBeenCalled() + expect(updatedListener).not.toHaveBeenCalled() + + field.handleBlur() + + expect(updatedListener).toHaveBeenCalledOnce() + expect(initialListener).not.toHaveBeenCalled() + }) + + it('uses the updated onSubmit callback and replaces its previous errors', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + onSubmit: ({ createValidationError }) => + createValidationError('Initial submit error'), + }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Initial submit error' }]) + + form._update({ + defaultValues: { name: '' }, + onSubmit: ({ createValidationError }) => + createValidationError('Updated submit error'), + }) + await form.handleSubmit() + + expect(form.state.errors).toEqual([{ message: 'Updated submit error' }]) + }) + + it('retains default listeners when incoming listeners update', () => { + const defaultListener = vi.fn() + const incomingListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi( + { + defaultValues: { name: '' }, + listeners: [{ triggers: ['change'], run: incomingListener }], + }, + { + form: { + listenersMerge: 'append', + listeners: [{ triggers: ['change'], run: defaultListener }], + }, + }, + ) + + form.setFieldValue('name', 'initial') + expect(defaultListener).toHaveBeenCalledOnce() + expect(incomingListener).toHaveBeenCalledOnce() + expect(defaultListener).toHaveBeenCalledBefore(incomingListener) + + defaultListener.mockClear() + incomingListener.mockClear() + form._update({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['change'], run: updatedListener }], + }) + form.setFieldValue('name', 'updated') + + expect(defaultListener).toHaveBeenCalledOnce() + expect(updatedListener).toHaveBeenCalledOnce() + expect(defaultListener).toHaveBeenCalledBefore(updatedListener) + expect(incomingListener).not.toHaveBeenCalled() + }) + + it('preserves reset defaults until the incoming defaults change', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + + form._update({ defaultValues: { name: 'async' } }) + + expect(form.state.values).toEqual({ name: 'async' }) + expect(form.defaultValues).toEqual({ name: 'async' }) + expect(form.state.isDefaultValue).toBe(true) + + form.reset({ name: 'reset default' }) + + expect(form.state.values).toEqual({ name: 'reset default' }) + expect(form.defaultValues).toEqual({ name: 'reset default' }) + expect(form.state.isDefaultValue).toBe(true) + + form._update({ defaultValues: { name: 'async' } }) + + expect(form.state.values).toEqual({ name: 'reset default' }) + expect(form.defaultValues).toEqual({ name: 'reset default' }) + expect(form.state.isDefaultValue).toBe(true) + + form._update({ defaultValues: { name: 'new async' } }) + + expect(form.state.values).toEqual({ name: 'new async' }) + expect(form.defaultValues).toEqual({ name: 'new async' }) + expect(form.state.isDefaultValue).toBe(true) + }) +}) + +describe('InternalFormApi - _getOrCreateFieldApi', () => { + it('attaches nested field listeners only to the requested field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { foo: { bar: '' } }, + }) + const field = form._getOrCreateFieldApi({ + name: 'foo.bar', + listeners: [{ run: listener, triggers: ['change'] }], + }) + const parent = form._getOrCreateFieldApi({ name: 'foo' }) + + parent.handleChange({ bar: 'parent edit' }) + + expect(listener).not.toHaveBeenCalled() + + field.handleChange('field edit') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ fieldApi: field, value: 'field edit' }), + ) + }) +}) + +describe('InternalFormApi - getFieldValue', () => { + it('reads values through top-level, nested object, and array paths', () => { + const form = new InternalFormApi({ + defaultValues: { + name: 'Alice', + address: { city: 'London' }, + items: ['a', 'b', 'c'], + }, + }) + + const name = form.getFieldValue('name') + const city = form.getFieldValue('address.city') + const item = form.getFieldValue('items[1]') + + expect(name).toBe('Alice') + expect(city).toBe('London') + expect(item).toBe('b') + }) + + it('returns undefined for missing paths and paths through null', () => { + const form = new InternalFormApi({ defaultValues: { user: null } }) + + const missing = form.getFieldValue('missing') + const name = form.getFieldValue('user.name') + + expect(missing).toBeUndefined() + expect(name).toBeUndefined() + }) +}) + +describe('InternalFormApi - setFieldValue', () => { + it('updates a nested value without changing unrelated values or defaults', () => { + const form = new InternalFormApi({ + defaultValues: { + name: 'Alice', + address: { city: 'London', country: 'UK' }, + }, + }) + + form.setFieldValue('address.city', 'Manchester') + + const city = form.getFieldValue('address.city') + expect(city).toBe('Manchester') + expect(form.state.values).toEqual({ + name: 'Alice', + address: { city: 'Manchester', country: 'UK' }, + }) + expect(form.defaultValues).toEqual({ + name: 'Alice', + address: { city: 'London', country: 'UK' }, + }) + }) + + it('updates a nested array value without changing other items or defaults', () => { + const form = new InternalFormApi({ + defaultValues: { items: [{ label: 'a' }, { label: 'b' }] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.setFieldValue('items[0].label', 'updated') + + const label = form.getFieldValue('items[0].label') + expect(label).toBe('updated') + expect(field.value).toEqual([{ label: 'updated' }, { label: 'b' }]) + expect(form.defaultValues).toEqual({ + items: [{ label: 'a' }, { label: 'b' }], + }) + }) + + it('marks the field and form touched and dirty when a value changes', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + form.setFieldValue('name', 'Alice') + + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: true, + isDirty: true, + isPristine: false, + }) + }) + + it('keeps the field and form untouched when markAsTouched is false', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + form.setFieldValue('name', 'Alice', { markAsTouched: false }) + + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: true, + isPristine: false, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: true, + isPristine: false, + }) + }) + + it('keeps the field and form pristine when markAsDirty is false', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + form.setFieldValue('name', 'Alice', { markAsDirty: false }) + + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: false, + isPristine: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: true, + isDirty: false, + isPristine: true, + }) + }) + + it('reveals field errors when markAsBlurred is true on a value update', async () => { + const form = new InternalFormApi({ + defaultValues: { name: 'Alice' }, + errorVisibility: ({ fieldState }) => fieldState.meta.isBlurred, + }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: ({ value }: { value: string }) => + value.length < 3 + ? 'Name must have at least 3 characters' + : undefined, + }, + ], + }) + field._register() + + form.setFieldValue('name', 'A') + + await expect + .poll(() => field.meta.original.errors) + .toEqual([{ message: 'Name must have at least 3 characters' }]) + expect(field.meta.isBlurred).toBe(false) + expect(field.errors).toEqual([]) + + form.setFieldValue('name', 'Al', { markAsBlurred: true }) + + expect(field.value).toBe('Al') + expect(field.meta.isBlurred).toBe(true) + await expect + .poll(() => field.errors) + .toEqual([{ message: 'Name must have at least 3 characters' }]) + }) + + it('preserves existing touched, dirty, and blurred state when metadata flags are false', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field.handleChange('Alice') + field.handleBlur() + + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + isBlurred: true, + }) + expect(form.state).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + }) + + form.setFieldValue('name', 'Bob', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(field.value).toBe('Bob') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + isBlurred: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Bob' }, + isTouched: true, + isDirty: true, + isPristine: false, + }) + }) + + it('continues field and form validation when all metadata flags are false', async () => { + const form = new InternalFormApi({ + defaultValues: { name: 'Alice' }, + validators: [ + { + triggers: ['change'], + run: ({ value }) => + value.name.length < 3 ? 'Form name is too short' : undefined, + }, + ], + }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: ({ value }: { value: string }) => + value.length < 3 ? 'Field name is too short' : undefined, + }, + ], + }) + field._register() + + form.setFieldValue('name', 'Al', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + await expect + .poll(() => field.errors) + .toEqual([{ message: 'Field name is too short' }]) + await expect + .poll(() => form.state.errors) + .toEqual([{ message: 'Form name is too short' }]) + expect(field.value).toBe('Al') + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + isPristine: true, + isBlurred: false, + isValid: false, + }) + expect(form.state).toMatchObject({ + values: { name: 'Al' }, + isTouched: false, + isDirty: false, + isPristine: true, + isValid: false, + }) + + form.setFieldValue('name', 'Alice', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + await expect.poll(() => field.errors).toEqual([]) + await expect.poll(() => form.state.errors).toEqual([]) + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + isPristine: true, + isBlurred: false, + isValid: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + isPristine: true, + isValid: true, + }) + }) + + // TODO: Form validation is skipped when disabled metadata updates prevent field creation. + it.fails( + 'validates form changes before field creation when all metadata flags are false', + async () => { + const form = new InternalFormApi({ + defaultValues: { name: 'Alice' }, + validators: [ + { + triggers: ['change'], + run: ({ value }) => + value.name.length < 3 ? 'Name is too short' : undefined, + }, + ], + }) + + form.setFieldValue('name', 'Al', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + await expect + .poll(() => form.state.errors) + .toEqual([{ message: 'Name is too short' }]) + }, + ) + + it('applies updater functions to the current field value', () => { + const form = new InternalFormApi({ defaultValues: { count: 1 } }) + + form.setFieldValue('count', (count: number) => count + 1) + + const firstUpdate = form.getFieldValue('count') + expect(firstUpdate).toBe(2) + + form.setFieldValue('count', (count: number) => count + 1) + + const secondUpdate = form.getFieldValue('count') + expect(secondUpdate).toBe(3) + }) + + it('replaces array values directly and through an updater', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.setFieldValue('items', ['b']) + + const replacement = form.getFieldValue('items') + expect(replacement).toEqual(['b']) + expect(field.value).toEqual(['b']) + + form.setFieldValue('items', (items: Array) => + items.map((item) => item.toUpperCase()), + ) + + const updated = form.getFieldValue('items') + expect(updated).toEqual(['B']) + expect(field.value).toEqual(['B']) + expect(form.defaultValues).toEqual({ items: ['a'] }) + }) +}) + +describe('InternalFormApi - reset', () => { + it('notifies parent and child reset listeners with their values before removing the fields', () => { + const parentListener = vi.fn() + const childListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { user: { name: 'Alice' } }, + }) + const parent = form._getOrCreateFieldApi({ + name: 'user', + listeners: [{ triggers: ['reset'], run: parentListener }], + }) + const child = form._getOrCreateFieldApi({ + name: 'user.name', + listeners: [{ triggers: ['reset'], run: childListener }], + }) + parent._register() + child._register() + child.handleChange('Bob') + + form.reset() + + expect(parentListener).toHaveBeenCalledOnce() + expect(parentListener).toHaveBeenCalledWith( + expect.objectContaining({ value: { name: 'Bob' } }), + ) + expect(childListener).toHaveBeenCalledOnce() + expect(childListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'Bob' }), + ) + expect(form.state.values).toEqual({ user: { name: 'Alice' } }) + expect(form.getFieldMeta('user')).toBeUndefined() + expect(form.getFieldMeta('user.name')).toBeUndefined() + }) + + it('aborts active validation on reset and allows validation to run again', async ({ + onTestFinished, + }) => { + const request = Promise.withResolvers() + onTestFinished(() => request.resolve('Late error')) + const onAbort = vi.fn() + const validator = vi.fn(() => request.promise) + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['change'], + run: ({ signal }) => { + signal.addEventListener('abort', onAbort, { once: true }) + return validator() + }, + }, + ], + }) + const form = new InternalFormApi(options) + + const validation = form.validate('change') + expect(validator).toHaveBeenCalledOnce() + expect(form.state.isValidating).toBe(true) + + form.reset() + + expect(onAbort).toHaveBeenCalledOnce() + await expect(validation).resolves.toEqual([]) + request.resolve('Late error') + await request.promise + + expect(form.state.errors).toEqual([]) + expect(form.state.isValidating).toBe(false) + + validator.mockResolvedValue('Current error') + await form.validate('change') + + expect(form.state.errors).toEqual([{ message: 'Current error' }]) + }) + + it('ignores the result of a submission that finishes after reset', async ({ + onTestFinished, + }) => { + const { promise: request, resolve: finishRequest } = + Promise.withResolvers() + onTestFinished(() => finishRequest()) + const options = formOptions({ + defaultValues: { name: '' }, + onSubmit: async ({ createValidationError }) => { + await request + return createValidationError('Late submit error') + }, + }) + const onSubmit = vi.spyOn(options, 'onSubmit') + const form = new InternalFormApi(options) + + const submission = form.handleSubmit() + await vi.waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + }) + expect(form.state.isSubmitting).toBe(true) + + form.reset({ name: 'reset value' }, { updateDefaultValues: false }) + expect(form.state.isSubmitting).toBe(false) + + finishRequest() + + await expect(submission).resolves.toEqual([]) + expect(form.state).toMatchObject({ + values: { name: 'reset value' }, + errors: [], + isSubmitting: false, + isSubmitSuccessful: false, + submissionAttempts: 0, + }) + }) + + it('restores values and clears field, validation, and submission state', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [{ run: () => 'Validation error', triggers: ['change'] }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + field.handleChange('dirty') + await form.validate('change') + await form.handleSubmit() + + expect(form.state).toMatchObject({ + values: { name: 'dirty' }, + isDirty: true, + isTouched: true, + errors: [{ message: 'Validation error' }], + submissionAttempts: 1, + }) + + form.reset() + + expect(form.state).toMatchObject({ + values: { name: '' }, + isDirty: false, + isTouched: false, + errors: [], + submissionAttempts: 0, + canSubmit: true, + }) + expect(form.getFieldMeta('name')).toBeUndefined() + }) + + it('cancels pending form validation without applying late errors', async () => { + vi.useFakeTimers() + const validator = vi.fn(() => 'Late error') + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['change'], + triggerDebounceMs: 100, + run: validator, + }, + ], + }) + + const validation = form.validate('change') + expect(form.state.isValidating).toBe(true) + + form.reset() + await vi.advanceTimersByTimeAsync(100) + + await expect(validation).resolves.toEqual([]) + expect(validator).not.toHaveBeenCalled() + expect(form.state.errors).toEqual([]) + expect(form.state.isValidating).toBe(false) + }) + + it('cancels pending field listener callbacks', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: 100, + run: listener, + }, + ], + }) + field._register() + + field.handleChange('first edit') + await vi.advanceTimersByTimeAsync(100) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'first edit' }), + ) + listener.mockClear() + + field.handleChange('pending edit') + form.reset() + await vi.advanceTimersByTimeAsync(100) + + expect(listener).not.toHaveBeenCalled() + expect(form.state.values).toEqual({ name: '' }) + }) +}) + +describe('InternalFormApi - resetField', () => { + it('restores the field default and clears touched state and validation errors', async () => { + const form = new InternalFormApi({ defaultValues: { name: 'hi' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Reset me', triggers: [] }], + }) + field._register() + + field.handleChange('bye') + await form.handleSubmit() + + expect(field.value).toBe('bye') + expect(field.meta.isTouched).toBe(true) + expect(field.errors).toEqual([{ message: 'Reset me' }]) + expect(form.state.isValid).toBe(false) + + form.resetField('name') + + expect(field.value).toBe('hi') + expect(field.meta.isTouched).toBe(false) + expect(field.errors).toEqual([]) + expect(form.state.isTouched).toBe(false) + expect(form.state.isValid).toBe(true) + }) + + it('resets descendant values and touched state while keeping fields usable', () => { + const form = new InternalFormApi({ + defaultValues: { person: { name: 'Alice' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'person' }) + const child = form._getOrCreateFieldApi({ name: 'person.name' }) + parent._register() + child._register() + + child.handleChange('Bob') + child.handleBlur() + + expect(parent.meta.isTouched).toBe(true) + expect(child.meta.isTouched).toBe(true) + + form.resetField('person') + + expect(parent.value).toEqual({ name: 'Alice' }) + expect(child.value).toBe('Alice') + expect(parent.meta.isTouched).toBe(false) + expect(child.meta.isTouched).toBe(false) + expect(form.state.isTouched).toBe(false) + + child.handleChange('Carol') + + expect(form.state.values).toEqual({ person: { name: 'Carol' } }) + expect(form.state.isTouched).toBe(true) + }) +}) + +describe('InternalFormApi - deleteField', () => { + it('clears routed errors from a deleted subtree while preserving errors on other fields', async () => { + const form = new InternalFormApi({ + defaultValues: { user: { name: '' }, email: '' }, + validators: [ + { + triggers: [], + run: ({ createErrorMap }) => + createErrorMap({ + fields: { + 'user.name': 'Name is required', + email: 'Email is required', + }, + }), + }, + ], + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const name = form._getOrCreateFieldApi({ name: 'user.name' }) + const email = form._getOrCreateFieldApi({ name: 'email' }) + parent._register() + name._register() + email._register() + + await form.handleSubmit() + expect(name.errors).toEqual([{ message: 'Name is required' }]) + expect(email.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.canSubmit).toBe(false) + + form.deleteField('user') + + expect(form.getFieldMeta('user.name')).toBeUndefined() + expect(email.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + expect(form.state.canSubmit).toBe(false) + + form.deleteField('email') + + expect(form.state.isValid).toBe(true) + expect(form.state.canSubmit).toBe(true) + }) + + it('clears errors returned by onSubmit when their field is deleted', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + onSubmit: ({ createErrorMap }) => + createErrorMap({ fields: { name: 'Name is required' } }), + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Name is required' }]) + expect(form.state.isValid).toBe(false) + expect(form.state.canSubmit).toBe(false) + + form.deleteField('name') + + expect(form.getFieldMeta('name')).toBeUndefined() + expect(form.state.isValid).toBe(true) + expect(form.state.canSubmit).toBe(true) + }) + + it('removes field metadata and prevents further updates through the deleted field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ run: listener, triggers: ['change'] }], + }) + field._register() + field.handleChange('Alice') + + expect(form.getFieldMeta('name')).toMatchObject({ isTouched: true }) + expect(form.state.isTouched).toBe(true) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'Alice' }), + ) + listener.mockClear() + + form.deleteField('name') + field.handleChange('Bob') + + expect(listener).not.toHaveBeenCalled() + expect(form.getFieldMeta('name')).toBeUndefined() + expect(form.state.values).toEqual({ name: 'Alice' }) + expect(form.state.isTouched).toBe(false) + }) + + it('leaves other values and touched state intact when the field does not exist', () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + }) + const emailField = form._getOrCreateFieldApi({ name: 'email' }) + emailField.handleChange('alice@example.com') + expect(form.getFieldMeta('name')).toBeUndefined() + + form.deleteField('name') + + expect(form.state.values).toEqual({ name: '', email: 'alice@example.com' }) + expect(emailField.meta.isTouched).toBe(true) + expect(form.state.isTouched).toBe(true) + }) + + it('removes descendant metadata and prevents updates through deleted fields', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { person: { name: 'Alice' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'person' }) + const child = form._getOrCreateFieldApi({ + name: 'person.name', + listeners: [{ run: listener, triggers: ['change'] }], + }) + parent._register() + child._register() + child.handleChange('Bob') + + expect(parent.meta.isTouched).toBe(true) + expect(child.meta.isTouched).toBe(true) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'Bob' }), + ) + listener.mockClear() + + form.deleteField('person') + child.handleChange('Carol') + parent.handleChange({ name: 'Dana' }) + + expect(listener).not.toHaveBeenCalled() + expect(form.getFieldMeta('person')).toBeUndefined() + expect(form.getFieldMeta('person.name')).toBeUndefined() + expect(form.state.values).toEqual({ person: { name: 'Bob' } }) + expect(form.state.isTouched).toBe(false) + }) +}) + +describe('InternalFormApi - _clearEventErrors', () => { + it('clears submit errors when change validation is disabled by its predicate', async () => { + const validator = vi.fn(() => 'Submit validator failed') + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + run: validator, + triggers: [{ trigger: 'change', when: false }], + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Submit validator failed' }]) + validator.mockClear() + + field.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(form.state.errors).toEqual([]) + expect(validator).not.toHaveBeenCalled() + }) + + it('clears onSubmit field errors only for the field that changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + onSubmit: ({ createErrorMap }) => + createErrorMap({ + fields: { + name: 'Name is required', + email: 'Email is required', + }, + }), + }) + const nameField = form._getOrCreateFieldApi({ name: 'name' }) + const emailField = form._getOrCreateFieldApi({ name: 'email' }) + nameField._register() + emailField._register() + + await form.handleSubmit() + expect(nameField.errors).toEqual([{ message: 'Name is required' }]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + + nameField.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(nameField.errors).toEqual([]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + }) + + // TODO: Clearing onSubmit errors on blur is debated and may change. + it('clears onSubmit field errors only for the field that blurs', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + onSubmit: ({ createErrorMap }) => + createErrorMap({ + fields: { + name: 'Name is required', + email: 'Email is required', + }, + }), + }) + const nameField = form._getOrCreateFieldApi({ name: 'name' }) + const emailField = form._getOrCreateFieldApi({ name: 'email' }) + nameField._register() + emailField._register() + + await form.handleSubmit() + expect(nameField.errors).toEqual([{ message: 'Name is required' }]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + + nameField.handleBlur() + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(nameField.errors).toEqual([]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + }) + + it('clears form-level onSubmit errors when a field changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + onSubmit: ({ createValidationError }) => + createValidationError('Submission failed'), + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Submission failed' }]) + expect(form.state.canSubmit).toBe(false) + + field.handleChange('Alice') + + expect(form.state.errors).toEqual([]) + expect(form.state.canSubmit).toBe(true) + }) + + // TODO clearing onSubmit errors on blur is debated and may change. + it('clears form-level onSubmit errors when a field blurs', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + onSubmit: ({ createValidationError }) => + createValidationError('Submission failed'), + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Submission failed' }]) + + field.handleBlur() + + expect(form.state.errors).toEqual([]) + }) + + it('clears submit-only validator errors when a field changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + validators: [{ run: () => 'Submit-only validator failed', triggers: [] }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([ + { message: 'Submit-only validator failed' }, + ]) + + field.handleChange('Alice') + + expect(form.state.errors).toEqual([]) + }) + + it('clears submit validator errors when the validator only listens for blur', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + validators: [ + { run: () => 'Submit or blur validator failed', triggers: ['blur'] }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([ + { message: 'Submit or blur validator failed' }, + ]) + + field.handleChange('Alice') + + expect(form.state.errors).toEqual([]) + }) + + it('preserves errors produced by blur validation when a field changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + validators: [{ run: () => 'Blur validator failed', triggers: ['blur'] }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.validate('blur') + expect(form.state.errors).toEqual([{ message: 'Blur validator failed' }]) + + field.handleChange('Alice') + + expect(form.state.errors).toEqual([{ message: 'Blur validator failed' }]) + }) + + it('replaces submit errors with the result of change validation', async () => { + const options = formOptions({ + defaultValues: { name: '', email: '' }, + validators: [ + { + run: ({ event }) => + event === 'submit' + ? 'Submit validator failed' + : 'Change validator failed', + triggers: ['change'], + }, + ], + }) + const form = new InternalFormApi(options) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Submit validator failed' }]) + + field.handleChange('Alice') + + await expect + .poll(() => form.state.errors) + .toEqual([{ message: 'Change validator failed' }]) + }) +}) diff --git a/packages/form-core/tests/FormApi/FormApi.public.test-d.ts b/packages/form-core/tests/FormApi/FormApi.public.test-d.ts new file mode 100644 index 000000000..096d27311 --- /dev/null +++ b/packages/form-core/tests/FormApi/FormApi.public.test-d.ts @@ -0,0 +1,68 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { formOptions } from '../../src' +import type { + CreateSubmitErrorMapFn, + OnSubmitError, + ParseSubmitIssuesFn, + StandardSchemaV1Issue, + ToFormErrorTypes, + ValidationErrorMap, + ValidationIssue, +} from '../../src' + +type FormData = { name: string; user: { age: number } } + +describe('FormSubmitContext', () => { + it('infers mutable submission error maps and restricts their field paths', () => { + const options = formOptions({ + defaultValues: { name: '', user: { age: 0 } }, + onSubmit: ({ createErrorMap, parseIssues }) => { + expectTypeOf(createErrorMap).toEqualTypeOf< + CreateSubmitErrorMapFn + >() + expectTypeOf(parseIssues).toEqualTypeOf>() + + const errors = createErrorMap({ form: 'Submission failed' }) + expectTypeOf(errors).toEqualTypeOf< + ValidationErrorMap & + OnSubmitError> + >() + + errors.fields.name = 'Name is required' + errors.fields['user.age'] = { message: 'Age is required' } + // @ts-expect-error Submission error maps require valid form field paths. + errors.fields.missing = 'Missing' + // @ts-expect-error Initial error maps require valid form field paths. + createErrorMap({ fields: { missing: 'Missing' } }) + // @ts-expect-error Standard Schema issues are handled by parseIssues. + createErrorMap([{ message: 'Name is required', path: ['name'] }]) + + return errors + }, + }) + + type SubmitReturn = ReturnType> + expectTypeOf< + ToFormErrorTypes<[], SubmitReturn>['formError'] + >().toEqualTypeOf() + expectTypeOf< + ToFormErrorTypes<[], SubmitReturn>['fieldError'] + >().toEqualTypeOf() + }) + + it('keeps parsed Standard Schema submission errors separately typed', () => { + const options = formOptions({ + defaultValues: { name: '', user: { age: 0 } }, + onSubmit: ({ parseIssues }) => + parseIssues([{ message: 'Name is required', path: ['name'] }]), + }) + + type SubmitReturn = ReturnType> + expectTypeOf< + ToFormErrorTypes<[], SubmitReturn>['formError'] + >().toEqualTypeOf() + expectTypeOf< + ToFormErrorTypes<[], SubmitReturn>['fieldError'] + >().toEqualTypeOf() + }) +}) diff --git a/packages/form-core/tests/FormApi/array-methods.lib.spec.ts b/packages/form-core/tests/FormApi/array-methods.lib.spec.ts new file mode 100644 index 000000000..582ee86db --- /dev/null +++ b/packages/form-core/tests/FormApi/array-methods.lib.spec.ts @@ -0,0 +1,1195 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../../src/FormApi/FormApi.lib' + +describe('InternalFormApi - swapFieldValues', () => { + it('swaps items and keeps existing nested fields attached to their items', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Carol' }], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + + form.swapFieldValues('items', 0, 2) + + expect(form.state.values).toEqual({ + items: [{ name: 'Carol' }, { name: 'Bob' }, { name: 'Alice' }], + }) + expect(name0.name).toBe('items[2].name') + expect(name0.value).toBe('Alice') + expect(name2.name).toBe('items[0].name') + expect(name2.value).toBe('Carol') + + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: false, + }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + + name0.handleChange('Alicia') + name2.handleChange('Caroline') + + expect(form.state.values).toEqual({ + items: [{ name: 'Caroline' }, { name: 'Bob' }, { name: 'Alicia' }], + }) + }) + + it('leaves values and interaction state unchanged when both indices are equal', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.swapFieldValues('items', 1, 1) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.swapFieldValues('name', 0, 1) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('swapFieldValues')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('swaps values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + + form.swapFieldValues('items', 0, 1, { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: ['b', 'a'] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) + +describe('InternalFormApi - moveFieldValue', () => { + it('moves an item forward and keeps existing nested fields attached to their items', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [ + { name: 'Alice' }, + { name: 'Bob' }, + { name: 'Carol' }, + { name: 'Dan' }, + ], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item1 = form._getOrCreateFieldApi({ name: 'items[1]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const item3 = form._getOrCreateFieldApi({ name: 'items[3]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + const name3 = form._getOrCreateFieldApi({ name: 'items[3].name' }) + + // Before: 0 1 2 3 + // After: 1 2 0 3 + // Fields: name1 name2 name0 name3 + form.moveFieldValue('items', 0, 2) + + expect(form.state.values).toEqual({ + items: [ + { name: 'Bob' }, + { name: 'Carol' }, + { name: 'Alice' }, + { name: 'Dan' }, + ], + }) + expect(name0.name).toBe('items[2].name') + expect(name0.value).toBe('Alice') + expect(name1.name).toBe('items[0].name') + expect(name1.value).toBe('Bob') + expect(name2.name).toBe('items[1].name') + expect(name2.value).toBe('Carol') + expect(name3.name).toBe('items[3].name') + expect(name3.value).toBe('Dan') + + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: false, + }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item1.meta).toMatchObject(untouchedMeta) + expect(name1.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + expect(item3.meta).toMatchObject(untouchedMeta) + expect(name3.meta).toMatchObject(untouchedMeta) + + name0.handleChange('Alicia') + name1.handleChange('Bobby') + name2.handleChange('Caroline') + name3.handleChange('Daniel') + + expect(form.state.values).toEqual({ + items: [ + { name: 'Bobby' }, + { name: 'Caroline' }, + { name: 'Alicia' }, + { name: 'Daniel' }, + ], + }) + }) + + it('moves an item backward while preserving the other items in order', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c', 'd'] }, + }) + + form.moveFieldValue('items', 2, 0) + + expect(form.state.values).toEqual({ items: ['c', 'a', 'b', 'd'] }) + }) + + it('leaves values and interaction state unchanged when both indices are equal', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.moveFieldValue('items', 1, 1) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.moveFieldValue('name', 0, 1) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('moveFieldValue')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for an out-of-bounds move', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.moveFieldValue('items', 0, 2) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('moves values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + + form.moveFieldValue('items', 0, 2, { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: ['b', 'c', 'a'] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) + +describe('InternalFormApi - pushFieldValue', () => { + it('marks only the array field touched and dirty when appending an item', () => { + const form = new InternalFormApi({ + defaultValues: { items: [{ name: 'Alice' }, { name: 'Bob' }] }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item1 = form._getOrCreateFieldApi({ name: 'items[1]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + + form.pushFieldValue('items', { name: 'Carol' }) + + expect(form.state.values).toEqual({ + items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Carol' }], + }) + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: false, + }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item1.meta).toMatchObject(untouchedMeta) + expect(name1.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + }) + + it('warns and preserves values and interaction state when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + form.pushFieldValue('name', 'Bob') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('pushFieldValue')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) +}) + +describe('InternalFormApi - insertFieldValue', () => { + it('inserts an item at the beginning without changing the order of existing items', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + + form.insertFieldValue('items', 0, 'x') + + expect(form.state.values).toEqual({ items: ['x', 'a', 'b'] }) + }) + + it('inserts an item between existing items and keeps nested fields attached to their items', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Carol' }], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item1 = form._getOrCreateFieldApi({ name: 'items[1]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + + // Before: 0 1 2 + // After: 0 x 1 2 + // Fields: name0 - name1 name2 + form.insertFieldValue('items', 1, { name: 'Dan' }) + + expect(form.state).toMatchObject({ + values: { + items: [ + { name: 'Alice' }, + { name: 'Dan' }, + { name: 'Bob' }, + { name: 'Carol' }, + ], + }, + isTouched: true, + isDirty: true, + isPristine: false, + }) + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + }) + expect(name0.name).toBe('items[0].name') + expect(name0.value).toBe('Alice') + expect(name1.name).toBe('items[2].name') + expect(name1.value).toBe('Bob') + expect(name2.name).toBe('items[3].name') + expect(name2.value).toBe('Carol') + + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item1.meta).toMatchObject(untouchedMeta) + expect(name1.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + + name0.handleChange('Alicia') + name1.handleChange('Bobby') + name2.handleChange('Caroline') + + expect(form.state.values).toEqual({ + items: [ + { name: 'Alicia' }, + { name: 'Dan' }, + { name: 'Bobby' }, + { name: 'Caroline' }, + ], + }) + }) + + it('inserts the first item into an empty array', () => { + const form = new InternalFormApi({ + defaultValues: { items: [] as Array }, + }) + + form.insertFieldValue('items', 0, 'x') + + expect(form.state.values).toEqual({ items: ['x'] }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.insertFieldValue('name', 0, 'Bob') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('insertFieldValue')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for a negative insertion index', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.insertFieldValue('items', -1, 'x') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for an insertion index beyond the array length', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.insertFieldValue('items', 3, 'x') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('inserts values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + + form.insertFieldValue('items', 1, 'x', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'x', 'b'] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) + +describe('InternalFormApi - clearFieldValues', () => { + it('clears all items, deactivates their fields, and keeps the array field usable', () => { + const form = new InternalFormApi({ + defaultValues: { items: [{ name: 'Alice' }, { name: 'Bob' }] }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + + form.clearFieldValues('items') + + expect(form.state.values).toEqual({ items: [] }) + expect(arrayField.value).toEqual([]) + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: false, + }) + + name0.handleChange('Ignored old row 0') + name1.handleChange('Ignored old row 1') + + expect(form.state.values).toEqual({ items: [] }) + + // Reuse index 0; its old field must not overwrite the new item. + arrayField.pushValue({ name: 'Carol' }) + const newItem0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const newName0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(newItem0.meta).toMatchObject(untouchedMeta) + expect(newName0.meta).toMatchObject(untouchedMeta) + newName0.handleChange('Caroline') + name0.handleChange('Ignored old row 0') + + expect(arrayField.value).toEqual([{ name: 'Caroline' }]) + expect(form.state.values).toEqual({ items: [{ name: 'Caroline' }] }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.clearFieldValues('name') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('clearFieldValues')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('clears values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + + form.clearFieldValues('items', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: [] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) + +describe('InternalFormApi - removeFieldValue', () => { + it('removes middle, first, and last items while preserving the remaining order', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c', 'd', 'e', 'f'] }, + }) + + // Before: 0 1 2 3 4 5 + // After: 0 2 3 4 5 + form.removeFieldValue('items', 1) + + expect(form.state.values).toEqual({ items: ['a', 'c', 'd', 'e', 'f'] }) + + // Before: 0 2 3 4 5 + // After: 2 3 4 5 + form.removeFieldValue('items', 0) + + expect(form.state.values).toEqual({ items: ['c', 'd', 'e', 'f'] }) + + // Before: 2 3 4 5 + // After: 2 3 4 + form.removeFieldValue('items', 3) + + expect(form.state.values).toEqual({ items: ['c', 'd', 'e'] }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.removeFieldValue('name', 0) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('removeFieldValue')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for a negative removal index', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.removeFieldValue('items', -1) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for a removal index outside the array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.removeFieldValue('items', 2) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('deactivates the removed field and keeps surviving nested fields attached to their items', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Carol' }], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + + // Before: 0 1 2 + // After: 0 2 + // Fields: name0 name2 + form.removeFieldValue('items', 1) + + expect(form.state.values).toEqual({ + items: [{ name: 'Alice' }, { name: 'Carol' }], + }) + expect(name0.name).toBe('items[0].name') + expect(name0.value).toBe('Alice') + expect(name2.name).toBe('items[1].name') + expect(name2.value).toBe('Carol') + + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: false, + }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + + name0.handleChange('Alicia') + name2.handleChange('Caroline') + name1.handleChange('Ignored removed row') + + expect(form.state.values).toEqual({ + items: [{ name: 'Alicia' }, { name: 'Caroline' }], + }) + }) + + it('applies markAsBlurred only to the array when removing an item', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Carol' }], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + + form.removeFieldValue('items', 1, { markAsBlurred: true }) + + expect(form.state.values).toEqual({ + items: [{ name: 'Alice' }, { name: 'Carol' }], + }) + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: true, + }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + }) + + it('removes values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + + form.removeFieldValue('items', 1, { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'c'] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) + +describe('InternalFormApi - replaceFieldValue', () => { + it('replaces items at their indices and keeps existing nested fields usable', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Carol' }], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item1 = form._getOrCreateFieldApi({ name: 'items[1]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + + // Before: 0 1 2 + // After: 0 x 2 + // Fields: name0 name1 name2 + form.replaceFieldValue('items', 1, { name: 'Dan' }) + + expect(form.state).toMatchObject({ + values: { + items: [{ name: 'Alice' }, { name: 'Dan' }, { name: 'Carol' }], + }, + isTouched: true, + isDirty: true, + isPristine: false, + }) + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + }) + + form.replaceFieldValue('items', 0, { name: 'Alicia' }) + + expect(form.state.values).toEqual({ + items: [{ name: 'Alicia' }, { name: 'Dan' }, { name: 'Carol' }], + }) + + form.replaceFieldValue('items', 2, { name: 'Caroline' }) + + expect(form.state.values).toEqual({ + items: [{ name: 'Alicia' }, { name: 'Dan' }, { name: 'Caroline' }], + }) + expect(name0.name).toBe('items[0].name') + expect(name0.value).toBe('Alicia') + expect(name1.name).toBe('items[1].name') + expect(name1.value).toBe('Dan') + expect(name2.name).toBe('items[2].name') + expect(name2.value).toBe('Caroline') + + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item1.meta).toMatchObject(untouchedMeta) + expect(name1.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + + name0.handleChange('Alice edited') + name1.handleChange('Dan edited') + name2.handleChange('Carol edited') + + expect(form.state.values).toEqual({ + items: [ + { name: 'Alice edited' }, + { name: 'Dan edited' }, + { name: 'Carol edited' }, + ], + }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.replaceFieldValue('name', 0, 'Bob') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('replaceFieldValue')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for a negative replacement index', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.replaceFieldValue('items', -1, 'x') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for a replacement index outside the array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.replaceFieldValue('items', 2, 'x') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for a fractional replacement index', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.replaceFieldValue('items', 0.5, 'x') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('revalidates replaced nested values and preserves errors on other items', async () => { + const requiredName = ({ value }: { value: string }) => + value ? undefined : 'Name is required' + const form = new InternalFormApi({ + defaultValues: { items: [{ name: '' }, { name: '' }] }, + }) + form._getOrCreateFieldApi({ name: 'items' })._register() + const name0 = form._getOrCreateFieldApi({ + name: 'items[0].name', + validators: [{ triggers: ['change'], run: requiredName }], + }) + const name1 = form._getOrCreateFieldApi({ + name: 'items[1].name', + validators: [{ triggers: ['change'], run: requiredName }], + }) + name0._register() + name1._register() + + name0.handleChange('') + name1.handleChange('') + + await expect + .poll(() => name0.errors) + .toEqual([{ message: 'Name is required' }]) + await expect + .poll(() => name1.errors) + .toEqual([{ message: 'Name is required' }]) + + form.replaceFieldValue('items', 1, { name: 'Bob' }) + + expect(name1.value).toBe('Bob') + await expect.poll(() => name1.errors).toEqual([]) + expect(name0.errors).toEqual([{ message: 'Name is required' }]) + + form.replaceFieldValue('items', 1, { name: '' }) + + expect(name1.value).toBe('') + await expect + .poll(() => name1.errors) + .toEqual([{ message: 'Name is required' }]) + expect(name0.errors).toEqual([{ message: 'Name is required' }]) + }) + + it('reports and clears array validation errors when replacing an item', async () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b'] }, + }) + const field = form._getOrCreateFieldApi({ + name: 'items', + validators: [ + { + triggers: ['change'], + run: ({ value }: { value: Array }) => + value.includes('blocked') + ? 'Blocked items are not allowed' + : undefined, + }, + ], + }) + field._register() + + form.replaceFieldValue('items', 0, 'blocked') + + expect(field.value).toEqual(['blocked', 'b']) + await expect + .poll(() => field.errors) + .toEqual([{ message: 'Blocked items are not allowed' }]) + + form.replaceFieldValue('items', 0, 'a') + + expect(field.value).toEqual(['a', 'b']) + await expect.poll(() => field.errors).toEqual([]) + }) + + it('replaces values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + + form.replaceFieldValue('items', 1, 'x', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'x'] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) + +describe('InternalFormApi - filterFieldValues', () => { + it('filters objects and keeps only surviving nested fields usable', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + { name: 'Carol', age: 35 }, + { name: 'Dan', age: 20 }, + ], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + const name3 = form._getOrCreateFieldApi({ name: 'items[3].name' }) + + // Before: 0 1 2 3 + // After: 0 2 + // Fields: name0 name2 + form.filterFieldValues( + 'items', + (person: { age: number }) => person.age > 28, + ) + + expect(form.state.values).toEqual({ + items: [ + { name: 'Alice', age: 30 }, + { name: 'Carol', age: 35 }, + ], + }) + expect(name0.name).toBe('items[0].name') + expect(name0.value).toBe('Alice') + expect(name2.name).toBe('items[1].name') + expect(name2.value).toBe('Carol') + + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: false, + }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + + name0.handleChange('Alicia') + name2.handleChange('Caroline') + name1.handleChange('Ignored removed row 1') + name3.handleChange('Ignored removed row 3') + + expect(form.state.values).toEqual({ + items: [ + { name: 'Alicia', age: 30 }, + { name: 'Caroline', age: 35 }, + ], + }) + }) + + it('passes original item indices to the filter predicate', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c', 'd'] }, + }) + + // Before: 0 1 2 3 + // After: 0 2 + form.filterFieldValues('items', (_, index) => index % 2 === 0) + + expect(form.state.values).toEqual({ items: ['a', 'c'] }) + }) + + it('passes the original array to the filter predicate', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + + form.filterFieldValues( + 'items', + // is last + (_, index, array) => index === array.length - 1, + ) + + expect(form.state.values).toEqual({ items: ['c'] }) + }) + + it('uses the supplied thisArg as the filter predicate context', () => { + // Yeah, this one is mostly just to be the same implementation as + // a normal array.filter . Who would actually use this? no idea. + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const thisArg = { firstKeptIndex: 1 } + + form.filterFieldValues( + 'items', + function (this: { firstKeptIndex: number }, _, index) { + return index >= this.firstKeptIndex + }, + { thisArg }, + ) + + expect(form.state.values).toEqual({ items: ['b', 'c'] }) + }) + + it('removes every item when the predicate rejects all items', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + + form.filterFieldValues('items', () => false) + + expect(form.state.values).toEqual({ items: [] }) + }) + + it('keeps every item and marks the field and form touched and dirty', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.filterFieldValues('items', () => true) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'b', 'c'] }, + isTouched: true, + isDirty: true, + isPristine: false, + }) + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + }) + }) + + it('leaves an empty array empty', () => { + const form = new InternalFormApi({ + defaultValues: { items: [] as Array }, + }) + + form.filterFieldValues('items', () => true) + + expect(form.state.values).toEqual({ items: [] }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.filterFieldValues('name', () => true) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('filterFieldValues')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('filters values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + + form.filterFieldValues('items', (value) => value !== 'b', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'c'] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) diff --git a/packages/form-core/tests/FormApi/field-array-methods.spec.ts b/packages/form-core/tests/FormApi/field-array-methods.spec.ts deleted file mode 100644 index ea3616518..000000000 --- a/packages/form-core/tests/FormApi/field-array-methods.spec.ts +++ /dev/null @@ -1,640 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('form - field array methods', () => { - const withoutFieldMeta = { - markAsDirty: false, - markAsTouched: false, - markAsBlurred: false, - } - - describe('swapFieldValues', () => { - it('swaps two elements in an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - form.swapFieldValues('items', 0, 2) - expect(form.getFieldValue('items')).toEqual(['c', 'b', 'a']) - }) - - it('does nothing when indexA === indexB', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.swapFieldValues('items', 1, 1) - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form.swapFieldValues('name', 0, 1) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('updates field segments after swap', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - form.swapFieldValues('items', 0, 1) - expect(field0._segment).toBe(1) - expect(field1._segment).toBe(0) - }) - - it('updates values without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - - form.swapFieldValues('items', 0, 1, withoutFieldMeta) - - expect(form.getFieldValue('items')).toEqual(['b', 'a']) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) - - describe('moveFieldValue', () => { - it('moves an element forward in an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - form.moveFieldValue('items', 0, 2) - expect(form.getFieldValue('items')).toEqual(['b', 'c', 'a', 'd']) - }) - - it('moves an element backward in an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - form.moveFieldValue('items', 2, 0) - expect(form.getFieldValue('items')).toEqual(['c', 'a', 'b', 'd']) - }) - - it('does nothing when fromIndex === toIndex', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.moveFieldValue('items', 1, 1) - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form.moveFieldValue('name', 0, 1) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('warns when an index is out of bounds', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - - form.moveFieldValue('items', 0, 2) - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('updates field segments after move', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - const field3 = form._getOrCreateFieldApi({ name: 'items[3]' }) - - form.moveFieldValue('items', 0, 2) - - expect(field0._segment).toBe(2) - expect(field1._segment).toBe(0) - expect(field2._segment).toBe(1) - expect(field3._segment).toBe(3) - expect(form._tryGetFieldApi('items[0]')).toBe(field1) - expect(form._tryGetFieldApi('items[1]')).toBe(field2) - expect(form._tryGetFieldApi('items[2]')).toBe(field0) - expect(form._tryGetFieldApi('items[3]')).toBe(field3) - }) - - it('moves values without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - - form.moveFieldValue('items', 0, 2, withoutFieldMeta) - - expect(form.getFieldValue('items')).toEqual(['b', 'c', 'a']) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) - - describe('pushFieldValue', () => { - it('appends a value to an array field', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.pushFieldValue('items', 'c') - expect(form.getFieldValue('items')).toEqual(['a', 'b', 'c']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - form.pushFieldValue('name', 'x', { fieldApiOverride: field }) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - }) - - describe('insertFieldValue', () => { - it('inserts a value at the specified index', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.insertFieldValue('items', 1, 'x') - expect(form.getFieldValue('items')).toEqual(['a', 'x', 'b']) - form.insertFieldValue('items', 0, 'y') - expect(form.getFieldValue('items')).toEqual(['y', 'a', 'x', 'b']) - form.insertFieldValue('items', 3, 'z') - expect(form.getFieldValue('items')).toEqual(['y', 'a', 'x', 'z', 'b']) - }) - - it('inserts a value in an empty array', () => { - const form = new InternalFormApi({ - defaultValues: { items: [] as Array }, - }) - form.insertFieldValue('items', 0, 'x') - expect(form.getFieldValue('items')).toEqual(['x']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - form.insertFieldValue('name', 0, 'x') - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('warns when index is negative', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.insertFieldValue('items', -1, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('warns when index is out of bounds', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.insertFieldValue('items', 3, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('shifts child field segments after insert', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - form.insertFieldValue('items', 1, 'x') - expect(field0._segment).toBe(0) - expect(field1._segment).toBe(2) - expect(field2._segment).toBe(3) - - expect(form._getOrCreateFieldApi({ name: 'items[0]' })).toBe(field0) - expect(form._getOrCreateFieldApi({ name: 'items[1]' })).not.toBe(field1) - expect(form._getOrCreateFieldApi({ name: 'items[2]' })).toBe(field1) - expect(form._getOrCreateFieldApi({ name: 'items[3]' })).toBe(field2) - }) - - it("updates the array field's meta", () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - form.insertFieldValue('items', 1, 'x') - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - - it('inserts without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - - form.insertFieldValue('items', 1, 'x', withoutFieldMeta) - - expect(form.getFieldValue('items')).toEqual(['a', 'x', 'b']) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) - - describe('clearFieldValues', () => { - it('empties an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - form.clearFieldValues('items') - expect(form.getFieldValue('items')).toEqual([]) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - form.clearFieldValues('name') - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('kills child fields', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - - form.clearFieldValues('items') - - expect(form._tryGetFieldApi('items[0]')).toBeNull() - expect(form._tryGetFieldApi('items[1]')).toBeNull() - expect(arrayField._children).toEqual([]) - }) - - it('clears values without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - - form.clearFieldValues('items', withoutFieldMeta) - - expect(form.getFieldValue('items')).toEqual([]) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) - - describe('removeFieldValue', () => { - it('removes an element at the specified index', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd', 'e', 'f'] }, - }) - form.removeFieldValue('items', 1) - expect(form.getFieldValue('items')).toEqual(['a', 'c', 'd', 'e', 'f']) - form.removeFieldValue('items', 0) - expect(form.getFieldValue('items')).toEqual(['c', 'd', 'e', 'f']) - form.removeFieldValue('items', 3) - expect(form.getFieldValue('items')).toEqual(['c', 'd', 'e']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - form.removeFieldValue('name', 0) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('warns when index is negative', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.removeFieldValue('items', -1) - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('warns when index is out of bounds', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.removeFieldValue('items', 2) - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('kills the removed child field and shifts remaining children', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - - form.removeFieldValue('items', 1) - - expect(form._tryGetFieldApi('items[0]')).toBe(field0) - expect(form._tryGetFieldApi('items[1]')).toBe(field2) - expect(form._tryGetFieldApi('items[2]')).toBeNull() - expect(field0._segment).toBe(0) - expect(field2._segment).toBe(1) - }) - - it('removes values without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - - form.removeFieldValue('items', 1, withoutFieldMeta) - - expect(form.getFieldValue('items')).toEqual(['a', 'c']) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) - - describe('replaceFieldValue', () => { - it('replaces the element at the specified index', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - form.replaceFieldValue('items', 1, 'x') - expect(form.getFieldValue('items')).toEqual(['a', 'x', 'c']) - form.replaceFieldValue('items', 0, 'y') - expect(form.getFieldValue('items')).toEqual(['y', 'x', 'c']) - form.replaceFieldValue('items', 2, 'z') - expect(form.getFieldValue('items')).toEqual(['y', 'x', 'z']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - form.replaceFieldValue('name', 0, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('name')).toBe('') - warn.mockRestore() - }) - - it('warns when index is negative', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.replaceFieldValue('items', -1, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('warns when index is out of bounds', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.replaceFieldValue('items', 2, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('warns when index is not an integer', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - form.replaceFieldValue('items', 0.5, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - expect(Object.keys(form.getFieldValue('items'))).toEqual(['0', '1']) - expect(field.meta.isDirty).toBe(false) - warn.mockRestore() - }) - - it("revalidates the replaced element's child fields", async () => { - const nameValidator = vi.fn(({ value }: { value: string }) => - value ? undefined : 'required', - ) - const form = new InternalFormApi({ - defaultValues: { items: [{ name: 'a' }, { name: '' }] }, - }) - form._getOrCreateFieldApi({ name: 'items' })._register() - const otherField = form._getOrCreateFieldApi({ - name: 'items[0].name', - validators: [{ run: nameValidator, triggers: ['change'] }], - }) - const nameField = form._getOrCreateFieldApi({ - name: 'items[1].name', - validators: [{ run: nameValidator, triggers: ['change'] }], - }) - otherField._register() - nameField._register() - - nameField.handleChange('') - await vi.waitFor(() => - expect(nameField.errors).toEqual([{ message: 'required' }]), - ) - nameValidator.mockClear() - - form.replaceFieldValue('items', 1, { name: 'x' }) - - expect(form._tryGetFieldApi('items[1].name')).toBe(nameField) - await vi.waitFor(() => expect(nameField.errors).toEqual([])) - expect(nameValidator).toHaveBeenCalledTimes(1) - expect(nameValidator).toHaveBeenCalledWith( - expect.objectContaining({ event: 'change', value: 'x' }), - ) - }) - - it("updates the array field's meta", () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - form.replaceFieldValue('items', 1, 'x') - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - - it('keeps child fields at their index', () => { - const form = new InternalFormApi({ - defaultValues: { items: [{ name: 'a' }, { name: 'b' }] }, - }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) - - form.replaceFieldValue('items', 1, { name: 'x' }) - - expect(form._tryGetFieldApi('items[0].name')).toBe(field0) - expect(form._tryGetFieldApi('items[1].name')).toBe(field1) - expect(field0.value).toBe('a') - expect(field1.value).toBe('x') - }) - - it('runs change validation on the array field', () => { - const validator = vi.fn(() => undefined) - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b'] }, - }) - const field = form._getOrCreateFieldApi({ - name: 'items', - validators: [{ run: validator, triggers: ['change'] }], - }) - const unregister = field._register() - - form.replaceFieldValue('items', 0, 'x') - expect(validator).toHaveBeenCalledWith( - expect.objectContaining({ event: 'change', value: ['x', 'b'] }), - ) - - unregister() - }) - - it('replaces values without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - - form.replaceFieldValue('items', 1, 'x', withoutFieldMeta) - - expect(form.getFieldValue('items')).toEqual(['a', 'x']) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) - - describe('filterFieldValues', () => { - it('filters array elements based on predicate', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - form.filterFieldValues('items', (value) => value !== 'b' && value !== 'd') - expect(form.getFieldValue('items')).toEqual(['a', 'c']) - }) - - it('filters with index-aware predicate', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - form.filterFieldValues('items', (_, index) => index % 2 === 0) - expect(form.getFieldValue('items')).toEqual(['a', 'c']) - }) - - it('filters with array-aware predicate', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - form.filterFieldValues('items', (_, index, array) => { - return index === array.length - 1 - }) - expect(form.getFieldValue('items')).toEqual(['c']) - }) - - it('uses thisArg when provided', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const thisObj = { threshold: 1 } - form.filterFieldValues( - 'items', - function (_, index) { - // @ts-expect-error - Who tf uses thisArg anyways - return index >= this.threshold - }, - { thisArg: thisObj }, - ) - expect(form.getFieldValue('items')).toEqual(['b', 'c']) - }) - - it('filters out all elements', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - form.filterFieldValues('items', () => false) - expect(form.getFieldValue('items')).toEqual([]) - }) - - it('keeps all elements when predicate always returns true', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - form.filterFieldValues('items', () => true) - expect(form.getFieldValue('items')).toEqual(['a', 'b', 'c']) - }) - - it('filters an empty array', () => { - const form = new InternalFormApi({ - defaultValues: { items: [] as Array }, - }) - form.filterFieldValues('items', () => true) - expect(form.getFieldValue('items')).toEqual([]) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - form.filterFieldValues('name', () => true) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('reorganizes child field segments after filtering', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - form._getOrCreateFieldApi({ name: 'items[3]' }) - - // Keep only indices 0 and 2 - form.filterFieldValues('items', (_, index) => index === 0 || index === 2) - - expect(field0._segment).toBe(0) - expect(field2._segment).toBe(1) - expect(form._tryGetFieldApi('items[0]')).toBe(field0) - expect(form._tryGetFieldApi('items[1]')).toBe(field2) - expect(form._tryGetFieldApi('items[2]')).toBeNull() - expect(form._tryGetFieldApi('items[3]')).toBeNull() - }) - - it('kills removed child fields', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - - form.filterFieldValues('items', (_, index) => index !== 1) - - expect(form._tryGetFieldApi('items[0]')).toBe(field0) - expect(form._tryGetFieldApi('items[1]')).toBe(field2) - }) - - it('updates array field meta when filtered', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - form.filterFieldValues('items', () => true) - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - - it('does not set value when no elements are filtered out', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - - const originalArray = form.getFieldValue('items') - form.filterFieldValues('items', () => true) - expect(form.getFieldValue('items')).toBe(originalArray) - }) - - it('handles complex objects in array', () => { - const form = new InternalFormApi({ - defaultValues: { - items: [ - { name: 'Alice', age: 30 }, - { name: 'Bob', age: 25 }, - { name: 'Charlie', age: 35 }, - ], - }, - }) - form.filterFieldValues('items', (person: any) => person.age > 28) - expect(form.getFieldValue('items')).toEqual([ - { name: 'Alice', age: 30 }, - { name: 'Charlie', age: 35 }, - ]) - }) - - it('filters values without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - - form.filterFieldValues( - 'items', - (value) => value !== 'b', - withoutFieldMeta, - ) - - expect(form.getFieldValue('items')).toEqual(['a', 'c']) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) -}) diff --git a/packages/form-core/tests/FormApi/field-meta.spec.ts b/packages/form-core/tests/FormApi/field-meta.spec.ts deleted file mode 100644 index 29d846562..000000000 --- a/packages/form-core/tests/FormApi/field-meta.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('form - field meta', () => { - describe('getFieldMeta', () => { - it('should retrieve field meta', () => { - const form = new InternalFormApi({ defaultValues: { name: 'hi' } }) - - expect(form.getFieldMeta('name')).toEqual(undefined) - - form._getOrCreateFieldApi({ name: 'name' }) - form.setFieldValue('name', 'bye') - expect(form.getFieldMeta('name')).toBeDefined() - }) - }) -}) diff --git a/packages/form-core/tests/FormApi/field-state.spec.ts b/packages/form-core/tests/FormApi/field-state.spec.ts deleted file mode 100644 index 970c6e65c..000000000 --- a/packages/form-core/tests/FormApi/field-state.spec.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('form - field state', () => { - describe('getFieldValue', () => { - it('returns a top-level value', () => { - const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) - expect(form.getFieldValue('name')).toBe('Alice') - }) - - it('returns a nested value using dot notation', () => { - const form = new InternalFormApi({ - defaultValues: { address: { city: 'London' } }, - }) - expect(form.getFieldValue('address.city')).toBe('London') - }) - - it('returns an array element using bracket notation', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - expect(form.getFieldValue('items[1]')).toBe('b') - }) - - it('returns undefined for a path that does not exist', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - expect(form.getFieldValue('nonexistent')).toBeUndefined() - }) - - it('returns undefined when a nested path traverses through null', () => { - const form = new InternalFormApi({ defaultValues: { user: null } }) - expect(form.getFieldValue('user.name')).toBeUndefined() - }) - }) - - describe('setFieldValue', () => { - it('updates a top-level value', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - form.setFieldValue('name', 'Alice') - expect(form.getFieldValue('name')).toBe('Alice') - }) - - it('updates a nested value without mutating unrelated keys', () => { - const form = new InternalFormApi({ - defaultValues: { address: { city: 'London', country: 'UK' } }, - }) - form.setFieldValue('address.city', 'Manchester') - expect(form.getFieldValue('address.city')).toBe('Manchester') - expect(form.getFieldValue('address.country')).toBe('UK') - }) - - it('accepts an updater function', () => { - const form = new InternalFormApi({ defaultValues: { count: 1 } }) - form.setFieldValue('count', (prev: number) => prev + 1) - expect(form.getFieldValue('count')).toBe(2) - }) - - it('increments the array version for a same-length array replacement', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - form.setFieldValue('items', ['b']) - - expect(form.getFieldValue('items')).toEqual(['b']) - expect(field.meta._arrayVersion).toBe(1) - }) - - it('increments the array version for an updater replacement', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - form.setFieldValue('items', (items: Array) => - items.map((item) => item.toUpperCase()), - ) - - expect(form.getFieldValue('items')).toEqual(['A']) - expect(field.meta._arrayVersion).toBe(1) - }) - - it('does not increment a parent array version for a nested field update', () => { - const form = new InternalFormApi({ - defaultValues: { items: [{ label: 'a' }] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - form.setFieldValue('items[0].label', 'b') - - expect(form.getFieldValue('items')).toEqual([{ label: 'b' }]) - expect(field.meta._arrayVersion).toBe(0) - }) - - it('increments the array version only once for array helpers', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - form.swapFieldValues('items', 0, 1) - expect(field.meta._arrayVersion).toBe(1) - - form.moveFieldValue('items', 0, 2) - expect(field.meta._arrayVersion).toBe(2) - - form.clearFieldValues('items') - expect(field.meta._arrayVersion).toBe(2) - - form.clearFieldValues('items') - expect(field.meta._arrayVersion).toBe(3) - }) - - it('marks form isTouched and isDirty after a change', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - form.setFieldValue('name', 'Alice', { fieldApiOverride: field }) - expect(form.state.isTouched).toBe(true) - expect(form.state.isDirty).toBe(true) - expect(form.state.isPristine).toBe(false) - }) - - it('does not mark form isTouched when markAsTouched is false', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - form.setFieldValue('name', 'Alice', { - fieldApiOverride: field, - markAsTouched: false, - }) - expect(form.state.isTouched).toBe(false) - }) - - it('does not mark form isDirty when markAsDirty is false', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - form.setFieldValue('name', 'Alice', { - fieldApiOverride: field, - markAsDirty: false, - }) - expect(form.state.isDirty).toBe(false) - expect(form.state.isPristine).toBe(true) - }) - - it('does not mark the field dirty when markAsDirty is false', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - form.setFieldValue('name', 'Alice', { - fieldApiOverride: field, - markAsDirty: false, - }) - expect(field.meta.isDirty).toBe(false) - }) - }) -}) diff --git a/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts b/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts new file mode 100644 index 000000000..d534f7bd8 --- /dev/null +++ b/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../../src/FormApi/FormApi.lib' + +describe('runSubmissionProcess', () => { + it('applies form and field errors added to a submission error map', async () => { + const onSubmitInvalid = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + onSubmit: ({ createErrorMap }) => { + const errors = createErrorMap({ form: 'Initial submit error' }) + errors.form = 'Submission failed' + errors.fields.name = 'Name is required' + return errors + }, + onSubmitInvalid, + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + + const result = await form.handleSubmit() + + expect(result).toEqual([ + { + form: [{ message: 'Submission failed' }], + fields: { name: [{ message: 'Name is required' }] }, + }, + ]) + expect(form.state.errors).toEqual([{ message: 'Submission failed' }]) + expect(field.errors).toEqual([{ message: 'Name is required' }]) + expect(form.state.isSubmitSuccessful).toBe(false) + expect(onSubmitInvalid).toHaveBeenCalledWith({ + value: { name: '' }, + formApi: form, + }) + }) + + it('clears previous submission errors and succeeds when the returned map is empty', async () => { + const onSubmitInvalid = vi.fn() + let shouldReject = true + const form = new InternalFormApi({ + defaultValues: { name: '' }, + onSubmit: ({ createErrorMap }) => { + const errors = createErrorMap() + if (shouldReject) errors.fields.name = 'Name is required' + return errors + }, + onSubmitInvalid, + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Name is required' }]) + expect(form.state.isSubmitSuccessful).toBe(false) + expect(onSubmitInvalid).toHaveBeenCalledOnce() + onSubmitInvalid.mockClear() + + shouldReject = false + expect(field.errors).toEqual([{ message: 'Name is required' }]) + + const result = await form.handleSubmit() + + expect(result).toEqual([]) + expect(field.errors).toEqual([]) + expect(form.state).toMatchObject({ + errors: [], + isValid: true, + isSubmitSuccessful: true, + }) + expect(onSubmitInvalid).not.toHaveBeenCalled() + }) +}) diff --git a/packages/form-core/tests/FormApi/lifecycle.spec.ts b/packages/form-core/tests/FormApi/lifecycle.spec.ts deleted file mode 100644 index 28bd868b3..000000000 --- a/packages/form-core/tests/FormApi/lifecycle.spec.ts +++ /dev/null @@ -1,953 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' -import { validationSourceScopes } from '../../src/ValidationSourceInstance.lib' -import { installDevtoolsBridge } from '../../src/devtoolsBridge.lib' -import { defaultInternalBaseFieldMeta } from '../../src/FieldApi/fieldState.lib' -import { InternalValidatorInstance } from '../../src/ValidatorInstance.lib' - -describe('form - lifecycle', () => { - describe('initial state', () => { - it('state.values matches defaultValues', () => { - const form = new InternalFormApi({ - defaultValues: { name: 'Alice', age: 30 }, - }) - expect(form.state.values).toEqual({ name: 'Alice', age: 30 }) - }) - - it('state.isTouched starts as false', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - expect(form.state.isTouched).toBe(false) - }) - - it('state.canSubmit starts as true', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - expect(form.state.canSubmit).toBe(true) - }) - - it('uses a supplied formId', () => { - const form = new InternalFormApi({ - formId: 'profile-form', - defaultValues: { name: '' }, - }) - - expect(form.formId).toBe('profile-form') - }) - - it('generates and preserves a formId when one is not supplied', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const formId = form.formId - - expect(formId).toBeTypeOf('string') - expect(formId.length).toBeGreaterThan(0) - - form._update({ defaultValues: { name: 'async' } }) - - expect(form.formId).toBe(formId) - }) - - it('supports changing a supplied formId during update', () => { - const form = new InternalFormApi({ - formId: 'profile-form', - defaultValues: { name: '' }, - }) - - form._update({ - formId: 'renamed-profile-form', - defaultValues: { name: '' }, - }) - - expect(form.formId).toBe('renamed-profile-form') - }) - - it('notifies an installed devtools bridge during form lifecycle hooks', () => { - const form = new InternalFormApi({ - formId: 'profile-form', - defaultValues: { name: '' }, - }) - const mountForm = vi.fn() - const unmountForm = vi.fn() - const updateForm = vi.fn() - const uninstallBridge = installDevtoolsBridge({ - mountForm, - unmountForm, - updateForm, - }) - - try { - const unmount = form.mount() - expect(mountForm).toHaveBeenCalledWith(form) - - form._update({ - formId: 'renamed-profile-form', - defaultValues: { name: '' }, - }) - expect(updateForm).toHaveBeenCalledWith(form) - - unmount() - expect(unmountForm).toHaveBeenCalledWith(form) - } finally { - uninstallBridge() - } - }) - - it('stops notifying a devtools bridge after it uninstalls', () => { - const form = new InternalFormApi({ - formId: 'profile-form', - defaultValues: { name: '' }, - }) - const mountForm = vi.fn() - const unmountForm = vi.fn() - const updateForm = vi.fn() - const uninstallBridge = installDevtoolsBridge({ - mountForm, - unmountForm, - updateForm, - }) - uninstallBridge() - - const unmount = form.mount() - form._update({ - formId: 'renamed-profile-form', - defaultValues: { name: '' }, - }) - unmount() - - expect(mountForm).not.toHaveBeenCalled() - expect(updateForm).not.toHaveBeenCalled() - expect(unmountForm).not.toHaveBeenCalled() - }) - - it('supports updating defaultValues after initialization', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - expect(form.state.values).toEqual({ name: '' }) - expect(form.state.isTouched).toBe(false) - form._update({ defaultValues: { name: 'async' } }) - - expect(form.state.isTouched).toBe(false) - expect(form.state.values).toEqual({ name: 'async' }) - }) - - it('preserves touched field values when defaultValues update', () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - form._getOrCreateFieldApi({ name: 'email' }) - - nameField.handleChange('user edit') - form._update({ - defaultValues: { name: 'server name', email: 'server@example.com' }, - }) - - expect(form.state.values).toEqual({ - name: 'user edit', - email: 'server@example.com', - }) - }) - - it('warns when the validator array length changes after initialization', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [{ run: () => null, triggers: [] }], - }) - - form._update({ - defaultValues: { name: '' }, - validators: [ - { run: () => null, triggers: [] }, - { run: () => null, triggers: [] }, - ], - }) - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining( - 'length of the validator array should not change', - ), - ) - warn.mockRestore() - }) - - it('keeps form validator instances stable by slot across updates', () => { - const firstDefinition = { run: () => null, triggers: [] } - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [firstDefinition], - }) - const instance = form._validatorInstances?.[0] - const nextDefinition = { run: () => null, triggers: [] } - - form._update({ - defaultValues: { name: '' }, - validators: [nextDefinition], - }) - - expect(form._validatorInstances?.[0]).toBe(instance) - expect(instance?.definition).toBe(nextDefinition) - expect(instance?.owner).toBe(form) - expect(instance?.scope).toBe(validationSourceScopes.form) - expect(instance?.revision).toBe(1) - }) - - it('keeps form listener instances stable and warns on length changes', () => { - const firstDefinition = { - run: () => {}, - triggers: ['change'] as Array<'change'>, - } - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [firstDefinition], - }) - const instance = form._listenerInstances?.[0] - const nextDefinition = { - run: () => {}, - triggers: ['blur'] as Array<'blur'>, - } - - form._update({ - defaultValues: { name: '' }, - listeners: [nextDefinition], - }) - - expect(form._listenerInstances?.[0]).toBe(instance) - expect(instance?.definition).toBe(nextDefinition) - expect(instance?.owner).toBe(form) - expect(instance?.index).toBe(0) - expect(instance?.revision).toBe(1) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - form._update({ defaultValues: { name: '' }, listeners: [] }) - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining( - 'length of the listener array should not change', - ), - ) - expect(form._listenerInstances).toBeNull() - expect(instance?.disposed).toBe(true) - warn.mockRestore() - }) - - it('keeps the onSubmit source stable across callback updates', () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const onSubmitSource = form._onSubmitSource - - form._update({ - defaultValues: { name: '' }, - onSubmit: () => null, - }) - expect(form._onSubmitSource).toBe(onSubmitSource) - - form._update({ - defaultValues: { name: '' }, - onSubmit: () => ({ message: 'updated' }), - }) - - expect(form._onSubmitSource).toBe(onSubmitSource) - expect(onSubmitSource.owner).toBe(form) - expect(onSubmitSource.scope).toBe(validationSourceScopes.onSubmit) - expect(onSubmitSource.index).toBe(0) - }) - - it('resets form validator runtime without replacing its instance', () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [{ run: () => null, triggers: [] }], - }) - const instance = form._validatorInstances?.[0] - const abortController = new AbortController() - instance?.setAbortController(abortController) - instance?.setOutput({ - output: 'output', - hasOutput: true, - }) - - form.reset() - - expect(form._validatorInstances?.[0]).toBe(instance) - expect(abortController.signal.aborted).toBe(true) - expect(instance?.hasOutput).toBe(false) - expect(instance?.disposed).toBe(false) - }) - - it('should only apply options to the leaf node', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { foo: { bar: '' } }, - }) - const field = form._getOrCreateFieldApi({ - name: 'foo.bar', - listeners: [{ run: listener, triggers: ['change'] }], - }) - - field.handleChange('New value') - - await vi.runAllTimersAsync() - - expect(listener).toHaveBeenCalledOnce() - }) - - // TODO extend with default state - }) - - describe('default options', () => { - it('resolves defaults during construction and updates', () => { - const calls: Array = [] - const form = new InternalFormApi( - { - defaultValues: { name: '' }, - listeners: [ - { triggers: ['change'], run: () => calls.push('incoming') }, - ], - }, - { - form: { - listenersMerge: 'append', - listeners: [ - { triggers: ['change'], run: () => calls.push('default') }, - ], - }, - }, - ) - - form.setFieldValue('name', 'initial') - expect(calls).toEqual(['default', 'incoming']) - - calls.length = 0 - form._update({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['change'], run: () => calls.push('updated') }], - }) - form.setFieldValue('name', 'updated') - expect(calls).toEqual(['default', 'updated']) - }) - }) - - describe('reset', () => { - it('resets form state', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - - field.handleChange('bye') - form.reset() - expect(form.state.values).toEqual({ name: '' }) - }) - - it('resets form state and updated default values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - - field.handleChange('bye') - form.reset({ name: 'reset default' }) - expect(form.state.values).toEqual({ name: 'reset default' }) - expect(form.defaultValues).toEqual({ name: 'reset default' }) - }) - - it('resets form state and does not update default values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - - field.handleChange('bye') - form.reset({ name: 'reset state' }, { updateDefaultValues: false }) - expect(form.state.values).toEqual({ name: 'reset state' }) - expect(form.defaultValues).toEqual({ name: '' }) - }) - - it('does not let a repeated _update default overwrite reset values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form._update({ defaultValues: { name: 'async' } }) - form.reset({ name: 'reset default' }) - form._update({ defaultValues: { name: 'async' } }) - - expect(form.state.values).toEqual({ name: 'reset default' }) - expect(form.defaultValues).toEqual({ name: 'reset default' }) - expect(form.state.isDefaultValue).toBe(true) - - form._update({ defaultValues: { name: 'new async' } }) - - expect(form.state.values).toEqual({ name: 'new async' }) - expect(form.defaultValues).toEqual({ name: 'new async' }) - expect(form.state.isDefaultValue).toBe(true) - }) - - it('resets meta and kills fields', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - run: () => 'Submit error', - triggers: [], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - field.handleChange('dirty') - - await form.handleSubmit() - form.reset() - - expect(form.state.values).toEqual({ name: '' }) - expect(form.state.isDirty).toBe(false) - expect(form.state.isTouched).toBe(false) - expect(form.state.errors).toEqual([]) - expect(form.state.submissionAttempts).toBe(0) - expect(form.getFieldMeta('name')).toBeUndefined() - }) - - it('cancels pending form validators on reset', async () => { - vi.useFakeTimers() - const validator = vi.fn(() => 'Late error') - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - triggers: ['change'], - triggerDebounceMs: 100, - run: validator, - }, - ], - }) - - const validatePromise = form.validate('change') - form.reset() - await vi.advanceTimersByTimeAsync(100) - const result = await validatePromise - - expect(result).toEqual([]) - expect(validator).not.toHaveBeenCalled() - expect(form.state.errors).toEqual([]) - }) - - it('cancels pending field listeners on reset', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'name', - listeners: [ - { - triggers: ['change'], - triggerDebounceMs: 100, - run: listener, - }, - ], - }) - - field.handleChange('dirty') - form.reset() - await vi.advanceTimersByTimeAsync(100) - - expect(listener).not.toHaveBeenCalled() - }) - - it('ignores submit cleanup and submit errors after reset', async () => { - let finishSubmit!: () => void - const form = new InternalFormApi({ - defaultValues: { name: '' }, - onSubmit: ({ createValidationError }) => - new Promise((resolve) => { - finishSubmit = () => - resolve(createValidationError('Late submit error')) - }), - }) - - const submitPromise = form.handleSubmit() - await vi.waitFor(() => { - expect(finishSubmit).toBeTypeOf('function') - }) - - form.reset({ name: 'reset value' }, { updateDefaultValues: false }) - finishSubmit() - const result = await submitPromise - - expect(result).toEqual([]) - expect(form.state.values).toEqual({ name: 'reset value' }) - expect(form.state.errors).toEqual([]) - expect(form.state.isSubmitting).toBe(false) - expect(form.state.submissionAttempts).toBe(0) - }) - }) - - // field methods - - describe('resetField', () => { - it('should reset field', () => { - const form = new InternalFormApi({ defaultValues: { name: 'hi' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const validatorInstance = new InternalValidatorInstance({ - definition: { run: () => null, triggers: [] }, - owner: field, - scope: 'field', - }) - field.handleChange('bye') - field.handleBlur() - field._setMeta((prev) => ({ - ...prev, - isValidating: true, - _validationCount: 1, - _arrayVersion: 1, - _validationSourceErrors: new Map([ - [ - validatorInstance, - { errors: [{ message: 'Reset me' }], sourceEvent: 'change' }, - ], - ]), - })) - - expect(field._getBaseMeta()).not.toBe(defaultInternalBaseFieldMeta) - - form.resetField('name') - - expect(form.getFieldValue('name')).toEqual('hi') - expect(field._getBaseMeta()).toBe(defaultInternalBaseFieldMeta) - }) - - it('recursively resets descendant fields without replacing them', () => { - const form = new InternalFormApi({ - defaultValues: { person: { name: 'Alice' } }, - }) - const parent = form._getOrCreateFieldApi({ name: 'person' }) - const child = form._getOrCreateFieldApi({ name: 'person.name' }) - parent._register() - child._register() - - child.handleChange('Bob') - child.handleBlur() - - expect(parent._getBaseMeta()).not.toBe(defaultInternalBaseFieldMeta) - expect(child._getBaseMeta()).not.toBe(defaultInternalBaseFieldMeta) - - form.resetField('person') - - expect(form.getFieldValue('person')).toEqual({ name: 'Alice' }) - expect(form._tryGetFieldApi('person')).toBe(parent) - expect(form._tryGetFieldApi('person.name')).toBe(child) - expect(parent._isKilled).toBe(false) - expect(child._isKilled).toBe(false) - expect(parent._isMounted).toBe(true) - expect(child._isMounted).toBe(true) - expect(parent._getBaseMeta()).toBe(defaultInternalBaseFieldMeta) - expect(child._getBaseMeta()).toBe(defaultInternalBaseFieldMeta) - expect(form.state.isTouched).toBe(false) - }) - - it('deletes a field through an explicit field override', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - form.deleteField('name', { fieldApiOverride: field }) - - expect(form._tryGetFieldApi('name')).toBeNull() - }) - - it('ignores deleting a field that has not been created', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form.deleteField('name') - - expect(form.state.values).toEqual({ name: '' }) - }) - }) - - describe('event error cleanup', () => { - it('clears form-level submit errors when any field changes', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - onSubmit: ({ createValidationError }) => - createValidationError('Submission failed'), - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - expect.objectContaining({ message: 'Submission failed' }), - ]) - expect( - form._atoms.meta.formErrors - .get() - .validationSourceErrors?.get(form._onSubmitSource)?.errors, - ).toEqual([expect.objectContaining({ message: 'Submission failed' })]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([]) - expect( - form._atoms.meta.formErrors - .get() - .validationSourceErrors?.get(form._onSubmitSource), - ).toBeUndefined() - }) - - it('clears form-level submit errors when any field blurs', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - onSubmit: ({ createValidationError }) => - createValidationError('Submission failed'), - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - expect.objectContaining({ message: 'Submission failed' }), - ]) - - field.handleBlur() - - expect(form.state.errors).toEqual([]) - }) - - it('clears form-level submit-only validator errors when any field changes', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: () => 'Submit-only validator failed', - triggers: [], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - { message: 'Submit-only validator failed' }, - ]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([]) - }) - - it('clears form-level submit validator errors when the validator does not run on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: () => 'Submit or blur validator failed', - triggers: ['blur'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - { message: 'Submit or blur validator failed' }, - ]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([]) - }) - - it('does not clear non-submit-sourced validator errors on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: () => 'Blur validator failed', - triggers: ['blur'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.validate('blur') - expect(form.state.errors).toEqual([{ message: 'Blur validator failed' }]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([{ message: 'Blur validator failed' }]) - }) - - it('does not event-clear submit errors when the validator runs on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: ({ event }) => - event === 'submit' - ? 'Submit validator failed' - : 'Change validator failed', - triggers: ['change'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - { message: 'Submit validator failed' }, - ]) - - field.handleChange('Alice') - - await vi.waitFor(() => { - expect(form.state.errors).toEqual([ - { message: 'Change validator failed' }, - ]) - }) - }) - - it('clears submit errors when the validator conditionally does not run on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: () => 'Submit validator failed', - triggers: [{ trigger: 'change', when: false }], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - { message: 'Submit validator failed' }, - ]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([]) - }) - - it('only clears field-level submit errors for the field that changes', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - onSubmit: ({ createValidationError }) => - createValidationError({ - fields: { - name: 'Name is required', - email: 'Email is required', - }, - }), - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - const emailField = form._getOrCreateFieldApi({ name: 'email' }) - void nameField.atom - void emailField.atom - - await form.handleSubmit() - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - expect( - nameField - ._getBaseMeta() - ._validationSourceErrors?.get(form._onSubmitSource)?.errors, - ).toEqual([{ message: 'Name is required' }]) - expect(form._onSubmitSource.errorTargets).toEqual( - new Set([nameField, emailField]), - ) - - nameField.handleChange('Alice') - - expect(nameField.errors).toEqual([]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - expect(form._onSubmitSource.errorTargets).toEqual(new Set([emailField])) - }) - - it('only clears field-level submit errors for the field that blurs', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - onSubmit: ({ createValidationError }) => - createValidationError({ - fields: { - name: 'Name is required', - email: 'Email is required', - }, - }), - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - const emailField = form._getOrCreateFieldApi({ name: 'email' }) - - await form.handleSubmit() - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - - nameField.handleBlur() - - expect(nameField.errors).toEqual([]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - }) - - it('clears field-level submit-only validator errors for the field that changes', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - }) - const nameField = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => 'Name is required', - triggers: [], - }, - ], - }) - const emailField = form._getOrCreateFieldApi({ - name: 'email', - validators: [ - { - run: () => 'Email is required', - triggers: [], - }, - ], - }) - void nameField.atom - void emailField.atom - - await form.handleSubmit() - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - - nameField.handleChange('Alice') - - expect(nameField.errors).toEqual([]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - }) - - it('clears field-level submit validator errors when the validator does not run on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - }) - const nameField = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => 'Name is required', - triggers: ['blur'], - }, - ], - }) - const emailField = form._getOrCreateFieldApi({ - name: 'email', - validators: [ - { - run: () => 'Email is required', - triggers: ['blur'], - }, - ], - }) - void nameField.atom - void emailField.atom - - await form.handleSubmit() - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - - nameField.handleChange('Alice') - - expect(nameField.errors).toEqual([]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - }) - - it('does not clear non-submit-sourced field validator errors on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => 'Name is required', - triggers: ['blur'], - }, - ], - }) - void field.atom - - await field._runFieldValidation('blur') - expect(field.errors).toEqual([{ message: 'Name is required' }]) - - field.handleChange('Alice') - - expect(field.errors).toEqual([{ message: 'Name is required' }]) - }) - - it('does not event-clear field validator errors when the validator runs on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: ({ event }: { event: string }) => - event === 'submit' - ? 'Submit field validator failed' - : 'Change field validator failed', - triggers: ['change'], - }, - ], - }) - void field.atom - - await form.handleSubmit() - expect(field.errors).toEqual([ - { message: 'Submit field validator failed' }, - ]) - - field.handleChange('Alice') - - await vi.waitFor(() => { - expect(field.errors).toEqual([ - { message: 'Change field validator failed' }, - ]) - }) - }) - - it('clears field submit errors when the validator conditionally does not run on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => 'Submit field validator failed', - triggers: [{ trigger: 'change', when: false }], - }, - ], - }) - void field.atom - - await form.handleSubmit() - expect(field.errors).toEqual([ - { message: 'Submit field validator failed' }, - ]) - - field.handleChange('Alice') - - expect(field.errors).toEqual([]) - }) - }) - - describe('deleteField ', () => { - it('unmounts the field atom', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - expect(field._isMounted).toBe(true) - form.deleteField('name', { fieldApiOverride: field }) - expect(field._isMounted).toBe(false) - }) - - it('also unmounts child field atoms', () => { - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ name: 'a' }) - const child = form._getOrCreateFieldApi({ name: 'a.b' }) - void parent.atom - void child.atom - form.deleteField('a', { fieldApiOverride: parent }) - expect(parent._isMounted).toBe(false) - expect(child._isMounted).toBe(false) - }) - }) -}) diff --git a/packages/form-core/tests/devtoolsBridge.lib.spec.ts b/packages/form-core/tests/devtoolsBridge.lib.spec.ts new file mode 100644 index 000000000..8ef8f8038 --- /dev/null +++ b/packages/form-core/tests/devtoolsBridge.lib.spec.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../src/FormApi/FormApi.lib' +import { installDevtoolsBridge } from '../src/devtoolsBridge.lib' + +describe('installDevtoolsBridge', () => { + it('reports a field mount once and unmounts it after its last consumer unregisters', async ({ + onTestFinished, + }) => { + vi.useFakeTimers() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const mountField = vi.fn() + const unmountField = vi.fn() + const uninstall = installDevtoolsBridge({ mountField, unmountField }) + onTestFinished(uninstall) + + const unregisterFirst = field._register() + const unregisterSecond = field._register() + + expect(mountField).toHaveBeenCalledOnce() + expect(mountField).toHaveBeenCalledWith(field) + + unregisterFirst() + expect(unmountField).not.toHaveBeenCalled() + + unregisterSecond() + await vi.runOnlyPendingTimersAsync() + + expect(unmountField).toHaveBeenCalledOnce() + expect(unmountField).toHaveBeenCalledWith(field, 'name') + }) + + it('reports moved fields with their previous paths after an array swap', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b'] }, + }) + const first = form._getOrCreateFieldApi({ name: 'items[0]' }) + const second = form._getOrCreateFieldApi({ name: 'items[1]' }) + const moveField = vi.fn() + const uninstall = installDevtoolsBridge({ moveField }) + onTestFinished(uninstall) + + form.swapFieldValues('items', 0, 1) + + expect(moveField).toHaveBeenCalledWith(first, 'items[0]') + expect(moveField).toHaveBeenCalledWith(second, 'items[1]') + expect(first.name).toBe('items[1]') + expect(second.name).toBe('items[0]') + expect(form.state.values.items).toEqual(['b', 'a']) + }) + + it('reports updates for a changed field and its parent even when both are already dirty', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { user: { name: '' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const field = form._getOrCreateFieldApi({ name: 'user.name' }) + const updateField = vi.fn() + const uninstall = installDevtoolsBridge({ updateField }) + onTestFinished(uninstall) + + field.handleChange('first') + + expect(field.meta.isDirty).toBe(true) + expect(parent.meta.isDirty).toBe(true) + expect(updateField).toHaveBeenCalledWith(field) + expect(updateField).toHaveBeenCalledWith(parent) + updateField.mockClear() + + field.handleChange('second') + + expect(field.meta.isDirty).toBe(true) + expect(parent.meta.isDirty).toBe(true) + expect(field.value).toBe('second') + expect(parent.value).toEqual({ name: 'second' }) + expect(updateField).toHaveBeenCalledWith(field) + expect(updateField).toHaveBeenCalledWith(parent) + }) + + it('reports field resets and updated defaults that bypass change events', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const updateField = vi.fn() + const uninstall = installDevtoolsBridge({ updateField }) + onTestFinished(uninstall) + + field.handleChange('changed') + updateField.mockClear() + + form.resetField('name') + + expect(field.value).toBe('') + expect(updateField).toHaveBeenCalledWith(field) + updateField.mockClear() + + form._update({ defaultValues: { name: 'new default' } }) + + expect(field.value).toBe('new default') + expect(updateField).toHaveBeenCalledWith(field) + }) + + it('reports every removed field in a subtree with its previous path', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { user: { name: '' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const child = form._getOrCreateFieldApi({ name: 'user.name' }) + const removeFieldSubtree = vi.fn() + const uninstall = installDevtoolsBridge({ removeFieldSubtree }) + onTestFinished(uninstall) + + form.deleteField('user') + + expect(removeFieldSubtree).toHaveBeenCalledOnce() + expect(removeFieldSubtree).toHaveBeenCalledWith(form, [ + { field: parent, previousPath: 'user' }, + { field: child, previousPath: 'user.name' }, + ]) + }) + + it('reports fields added beneath an existing parent', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { user: { name: 'Alice' } }, + }) + form._getOrCreateFieldApi({ name: 'user' }) + const fieldAdded = vi.fn() + const uninstall = installDevtoolsBridge({ fieldAdded }) + onTestFinished(uninstall) + + const child = form._getOrCreateFieldApi({ name: 'user.name' }) + + expect(fieldAdded).toHaveBeenCalledWith(child) + expect(child.name).toBe('user.name') + expect(child.value).toBe('Alice') + }) + + it('reports listener dependency endpoints when attached and when the watching field is deleted', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const target = form._getOrCreateFieldApi({ name: 'target' }) + source._register() + target._register() + const listener = vi.fn() + const fieldDependenciesChanged = vi.fn() + const uninstall = installDevtoolsBridge({ fieldDependenciesChanged }) + onTestFinished(uninstall) + + target._update({ + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: listener }, + ], + }) + + expect(fieldDependenciesChanged).toHaveBeenCalledWith([ + expect.objectContaining({ + kind: 'listener', + sourceField: source, + watchingField: target, + listenerInstance: expect.anything(), + }), + ]) + + source.handleChange('first') + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target', fieldApi: target }), + ) + listener.mockClear() + fieldDependenciesChanged.mockClear() + + form.deleteField('target') + + expect(fieldDependenciesChanged).toHaveBeenCalledWith([ + expect.objectContaining({ + kind: 'listener', + sourceField: source, + watchingField: target, + listenerInstance: expect.anything(), + }), + ]) + + source.handleChange('after deletion') + expect(listener).not.toHaveBeenCalled() + }) + + it('receives form mount, update, and unmount notifications', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + formId: 'profile-form', + defaultValues: { name: '' }, + }) + const mountForm = vi.fn() + const unmountForm = vi.fn() + const updateForm = vi.fn() + const uninstall = installDevtoolsBridge({ + mountForm, + unmountForm, + updateForm, + }) + onTestFinished(uninstall) + + const unmount = form.mount() + onTestFinished(unmount) + expect(mountForm).toHaveBeenCalledWith(form) + + form._update({ + formId: 'renamed-profile-form', + defaultValues: { name: '' }, + }) + + expect(updateForm).toHaveBeenCalledWith(form) + + unmount() + + expect(unmountForm).toHaveBeenCalledWith(form) + }) + + it('stops lifecycle notifications after the bridge is uninstalled', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + formId: 'profile-form', + defaultValues: { name: '' }, + }) + const mountForm = vi.fn() + const unmountForm = vi.fn() + const updateForm = vi.fn() + const uninstall = installDevtoolsBridge({ + mountForm, + unmountForm, + updateForm, + }) + onTestFinished(uninstall) + + const unmount = form.mount() + onTestFinished(unmount) + expect(mountForm).toHaveBeenCalledWith(form) + mountForm.mockClear() + + uninstall() + + const unmountAfterUninstall = form.mount() + onTestFinished(unmountAfterUninstall) + form._update({ + formId: 'renamed-profile-form', + defaultValues: { name: '' }, + }) + unmount() + unmountAfterUninstall() + + expect(mountForm).not.toHaveBeenCalled() + expect(updateForm).not.toHaveBeenCalled() + expect(unmountForm).not.toHaveBeenCalled() + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 9ad8c6413..338bc4f6a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, - "lib": ["DOM", "DOM.Iterable", "ES2022"], + "lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true,