From 9de050f32095e17f7450eea1f60f3c14c9130df7 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:08:10 +0200 Subject: [PATCH 01/16] chore: add createErrorMap to submit context --- packages/form-core/src/FormApi/FormApi.public.ts | 7 +++++++ packages/form-core/src/FormApi/handleSubmit.lib.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/form-core/src/FormApi/FormApi.public.ts b/packages/form-core/src/FormApi/FormApi.public.ts index f7824c0907..8091f516bb 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 634755d402..43beb1e991 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), }) From db439775729a1b7fc6b3b317fd3ee93bac411618 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:08:25 +0200 Subject: [PATCH 02/16] chore: migrate unit tests for lifecycle --- .../tests/FieldApi/FieldApi.lib.spec.ts | 770 ++++++++++++++ .../tests/FieldApi/Lifecycle.spec.ts | 875 ---------------- .../tests/FormApi/FormApi.lib.spec.ts | 860 ++++++++++++++++ .../tests/FormApi/FormApi.public.test-d.ts | 68 ++ .../tests/FormApi/handleSubmit.lib.spec.ts | 72 ++ .../form-core/tests/FormApi/lifecycle.spec.ts | 953 ------------------ .../tests/devtoolsBridge.lib.spec.ts | 269 +++++ tsconfig.json | 2 +- 8 files changed, 2040 insertions(+), 1829 deletions(-) create mode 100644 packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts delete mode 100644 packages/form-core/tests/FieldApi/Lifecycle.spec.ts create mode 100644 packages/form-core/tests/FormApi/FormApi.lib.spec.ts create mode 100644 packages/form-core/tests/FormApi/FormApi.public.test-d.ts create mode 100644 packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts delete mode 100644 packages/form-core/tests/FormApi/lifecycle.spec.ts create mode 100644 packages/form-core/tests/devtoolsBridge.lib.spec.ts 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 0000000000..0cf633718e --- /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 b2e02dcdba..0000000000 --- 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 0000000000..834da29cc9 --- /dev/null +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -0,0 +1,860 @@ +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 - 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 0000000000..096d273118 --- /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/handleSubmit.lib.spec.ts b/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts new file mode 100644 index 0000000000..d534f7bd85 --- /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 28bd868b35..0000000000 --- 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 0000000000..8ef8f80381 --- /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 9ad8c64133..c495877a1f 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", "ES2022", "ES2024.Promise"], "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, From ee7b366f99c2c02ac270b10caa777b67df059134 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:58:26 +0200 Subject: [PATCH 03/16] chore: migrate field-state.spec.ts --- .../tests/FormApi/FormApi.lib.spec.ts | 342 ++++++++++++++++++ .../tests/FormApi/field-state.spec.ts | 151 -------- 2 files changed, 342 insertions(+), 151 deletions(-) delete mode 100644 packages/form-core/tests/FormApi/field-state.spec.ts diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts index 834da29cc9..af5c60051b 100644 --- a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -269,6 +269,348 @@ describe('InternalFormApi - _getOrCreateFieldApi', () => { }) }) +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() 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 970c6e65c7..0000000000 --- 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) - }) - }) -}) From c889e7b7911fd279dfe9cd1ba2d0436cf9fe37b7 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:55:31 +0200 Subject: [PATCH 04/16] chore: migrate array-methods unit tests --- .../tests/FormApi/array-methods.lib.spec.ts | 1195 +++++++++++++++++ .../tests/FormApi/field-array-methods.spec.ts | 640 --------- .../tests/FormApi/field-meta.spec.ts | 16 - 3 files changed, 1195 insertions(+), 656 deletions(-) create mode 100644 packages/form-core/tests/FormApi/array-methods.lib.spec.ts delete mode 100644 packages/form-core/tests/FormApi/field-array-methods.spec.ts delete mode 100644 packages/form-core/tests/FormApi/field-meta.spec.ts 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 0000000000..582ee86dbc --- /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 ea36165186..0000000000 --- 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 29d846562e..0000000000 --- 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() - }) - }) -}) From 1c67c679d7a788650d2af14100bd2e268cc594d2 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:05:37 +0200 Subject: [PATCH 05/16] fix: satisfy knip and fix tsconfig --- packages/form-core/src/FieldApi/fieldTree.lib.ts | 2 +- tsconfig.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/form-core/src/FieldApi/fieldTree.lib.ts b/packages/form-core/src/FieldApi/fieldTree.lib.ts index 98faeb593c..7cc558f2f5 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/tsconfig.json b/tsconfig.json index c495877a1f..82cbd80270 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, - "lib": ["DOM", "DOM.Iterable", "ES2022", "ES2024.Promise"], + "lib": ["DOM", "DOM.Iterable", "ES2022", "ESNext.Promise"], "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, From 0860a9d9c5c6537d0965dde5825ab3b1ffb37deb Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:23:03 +0200 Subject: [PATCH 06/16] fix: fix tsconfig for real this time --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 82cbd80270..338bc4f6ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, - "lib": ["DOM", "DOM.Iterable", "ES2022", "ESNext.Promise"], + "lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, From 2c237814084104f344988d2b761e61e2c1e8d975 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:09:01 +0200 Subject: [PATCH 07/16] chore: migrate unit tests for field array methods --- .../tests/FieldApi/FieldApi.lib.spec.ts | 138 +++++ .../tests/FieldApi/array-methods.spec.ts | 555 ------------------ 2 files changed, 138 insertions(+), 555 deletions(-) delete mode 100644 packages/form-core/tests/FieldApi/array-methods.spec.ts diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts index 0cf633718e..344521c251 100644 --- a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -395,6 +395,8 @@ describe('InternalFieldApi - _kill', () => { expect(form.getFieldValue('items')).toEqual(beforeDelete) field.removeValue(0) expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.replaceValue(0, 'ignored') + expect(form.getFieldValue('items')).toEqual(beforeDelete) field.filterValues(() => false) expect(form.getFieldValue('items')).toEqual(beforeDelete) }) @@ -643,6 +645,142 @@ describe('InternalFieldApi - _unregister', () => { }) }) +describe('InternalFieldApi - pushValue', () => { + it('appends an item through pushFieldValue', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const pushFieldValue = vi.spyOn(form, 'pushFieldValue') + onTestFinished(() => pushFieldValue.mockRestore()) + + field.pushValue('b') + + expect(pushFieldValue).toHaveBeenCalledOnce() + expect(field.value).toEqual(['a', 'b']) + }) +}) + +describe('InternalFieldApi - insertValue', () => { + it('inserts an item through insertFieldValue', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const insertFieldValue = vi.spyOn(form, 'insertFieldValue') + onTestFinished(() => insertFieldValue.mockRestore()) + + field.insertValue(1, 'x') + + expect(insertFieldValue).toHaveBeenCalledOnce() + expect(field.value).toEqual(['a', 'x', 'b']) + }) +}) + +describe('InternalFieldApi - clearValues', () => { + it('empties the array through clearFieldValues', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const clearFieldValues = vi.spyOn(form, 'clearFieldValues') + onTestFinished(() => clearFieldValues.mockRestore()) + + field.clearValues() + + expect(clearFieldValues).toHaveBeenCalledOnce() + expect(field.value).toEqual([]) + }) +}) + +describe('InternalFieldApi - swapValues', () => { + it('swaps two items through swapFieldValues', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const swapFieldValues = vi.spyOn(form, 'swapFieldValues') + onTestFinished(() => swapFieldValues.mockRestore()) + + field.swapValues(0, 2) + + expect(swapFieldValues).toHaveBeenCalledOnce() + expect(field.value).toEqual(['c', 'b', 'a']) + }) +}) + +describe('InternalFieldApi - moveValue', () => { + it('moves an item through moveFieldValue', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c', 'd'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const moveFieldValue = vi.spyOn(form, 'moveFieldValue') + onTestFinished(() => moveFieldValue.mockRestore()) + + field.moveValue(0, 2) + + expect(moveFieldValue).toHaveBeenCalledOnce() + expect(field.value).toEqual(['b', 'c', 'a', 'd']) + }) +}) + +describe('InternalFieldApi - removeValue', () => { + it('removes an item through removeFieldValue', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const removeFieldValue = vi.spyOn(form, 'removeFieldValue') + onTestFinished(() => removeFieldValue.mockRestore()) + + field.removeValue(1) + + expect(removeFieldValue).toHaveBeenCalledOnce() + expect(field.value).toEqual(['a', 'c']) + }) +}) + +describe('InternalFieldApi - replaceValue', () => { + it('replaces an item through replaceFieldValue', ({ onTestFinished }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const replaceFieldValue = vi.spyOn(form, 'replaceFieldValue') + onTestFinished(() => replaceFieldValue.mockRestore()) + + field.replaceValue(1, 'x') + + expect(replaceFieldValue).toHaveBeenCalledOnce() + expect(field.value).toEqual(['a', 'x', 'c']) + }) +}) + +describe('InternalFieldApi - filterValues', () => { + it('filters items with the supplied predicate context through filterFieldValues', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + const filterFieldValues = vi.spyOn(form, 'filterFieldValues') + onTestFinished(() => filterFieldValues.mockRestore()) + const thisArg = { excluded: 'b' } + + field.filterValues( + function (this: { excluded: string }, value) { + return value !== this.excluded + }, + { thisArg }, + ) + + expect(filterFieldValues).toHaveBeenCalledOnce() + expect(field.value).toEqual(['a', 'c']) + }) +}) + describe('InternalFieldApi - _clearEventErrors', () => { it('clears submit-only validator errors only for the field that changes', async () => { const form = new InternalFormApi({ diff --git a/packages/form-core/tests/FieldApi/array-methods.spec.ts b/packages/form-core/tests/FieldApi/array-methods.spec.ts deleted file mode 100644 index 01b29f8b6c..0000000000 --- a/packages/form-core/tests/FieldApi/array-methods.spec.ts +++ /dev/null @@ -1,555 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('field - array methods', () => { - describe('pushValue', () => { - it('appends a value to an array field', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.pushValue('b') - expect(field.value).toEqual(['a', 'b']) - }) - - it('marks the field as dirty and touched', () => { - const form = new InternalFormApi({ - defaultValues: { items: [] as Array }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.pushValue('a') - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - - 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' }) - field.pushValue('x') - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - }) - - describe('insertFieldValue', () => { - it('inserts a value at the specified index', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.insertValue(1, 'x') - expect(field.value).toEqual(['a', 'x', 'b']) - field.insertValue(0, 'y') - expect(field.value).toEqual(['y', 'a', 'x', 'b']) - field.insertValue(3, 'z') - expect(field.value).toEqual(['y', 'a', 'x', 'z', 'b']) - }) - - it('inserts a value in an empty array', () => { - const form = new InternalFormApi({ - defaultValues: { items: [] as Array }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.insertValue(0, 'x') - expect(field.value).toEqual(['x']) - }) - - 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' }) - field.insertValue(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'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.insertValue(-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'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.insertValue(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 arrayField = form._getOrCreateFieldApi({ name: 'items' }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - arrayField.insertValue(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' }) - field.insertValue(1, 'x') - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - }) - - describe('clearValues', () => { - it('empties an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.clearValues() - expect(field.value).toEqual([]) - }) - - 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' }) - field.clearValues() - 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]' }) - - arrayField.clearValues() - - expect(form._tryGetFieldApi('items[0]')).toBeNull() - expect(form._tryGetFieldApi('items[1]')).toBeNull() - expect(arrayField._children).toEqual([]) - }) - }) - - describe('swapValues', () => { - it('swaps two elements in an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.swapValues(0, 2) - expect(field.value).toEqual(['c', 'b', 'a']) - }) - - it('swaps child field segments', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - arrayField.swapValues(0, 1) - expect(field0._segment).toBe(1) - expect(field1._segment).toBe(0) - }) - - it('does nothing when indexA === indexB', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.swapValues(0, 0) - expect(field.value).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: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.swapValues(0, 1) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - }) - - describe('moveValue', () => { - it('moves an element forward in an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.moveValue(0, 2) - expect(field.value).toEqual(['b', 'c', 'a', 'd']) - }) - - it('moves an element backward in an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.moveValue(2, 0) - expect(field.value).toEqual(['c', 'a', 'b', 'd']) - }) - - it('moves child field segments forward', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - 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]' }) - - arrayField.moveValue(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 child field segments backward', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - 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]' }) - - arrayField.moveValue(2, 0) - - expect(field0._segment).toBe(1) - expect(field1._segment).toBe(2) - expect(field2._segment).toBe(0) - expect(field3._segment).toBe(3) - expect(form._tryGetFieldApi('items[0]')).toBe(field2) - expect(form._tryGetFieldApi('items[1]')).toBe(field0) - expect(form._tryGetFieldApi('items[2]')).toBe(field1) - expect(form._tryGetFieldApi('items[3]')).toBe(field3) - }) - - it('does nothing when fromIndex === toIndex', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.moveValue(1, 1) - expect(field.value).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: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.moveValue(0, 1) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - }) - - describe('removeValue', () => { - it('removes an element at the specified index', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd', 'e', 'f'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field.removeValue(1) - expect(field.value).toEqual(['a', 'c', 'd', 'e', 'f']) - field.removeValue(0) - expect(field.value).toEqual(['c', 'd', 'e', 'f']) - field.removeValue(3) - expect(field.value).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: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.removeValue(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'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field.removeValue(-1) - expect(warn).toHaveBeenCalled() - expect(field.value).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'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field.removeValue(2) - expect(warn).toHaveBeenCalled() - expect(field.value).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 arrayField = form._getOrCreateFieldApi({ name: 'items' }) - - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - - arrayField.removeValue(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) - }) - }) - - describe('replaceValue', () => { - it('replaces the element at the specified index', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field.replaceValue(1, 'x') - expect(field.value).toEqual(['a', 'x', 'c']) - field.replaceValue(0, 'y') - expect(field.value).toEqual(['y', 'x', 'c']) - field.replaceValue(2, 'z') - expect(field.value).toEqual(['y', 'x', 'z']) - }) - - it('marks the field as dirty and touched', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field.replaceValue(0, 'b') - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - - 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' }) - - field.replaceValue(0, 'x') - expect(warn).toHaveBeenCalled() - expect(field.value).toBe('') - 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'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field.replaceValue(-1, 'x') - field.replaceValue(2, 'x') - expect(warn).toHaveBeenCalledTimes(2) - expect(field.value).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('keeps child fields in place', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - - arrayField.replaceValue(1, 'x') - - expect(form._tryGetFieldApi('items[0]')).toBe(field0) - expect(form._tryGetFieldApi('items[1]')).toBe(field1) - expect(form._tryGetFieldApi('items[2]')).toBe(field2) - expect(field1.value).toBe('x') - }) - }) - - describe('filterFieldValues', () => { - it('filters array elements based on predicate', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues((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'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues((_, 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'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues((_, 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 field = form._getOrCreateFieldApi({ name: 'items' }) - const thisObj = { threshold: 1 } - field.filterValues( - 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'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues(() => false) - expect(form.getFieldValue('items')).toEqual([]) - }) - - it('keeps all elements when predicate always returns true', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues(() => true) - expect(form.getFieldValue('items')).toEqual(['a', 'b', 'c']) - }) - - it('filters an empty array', () => { - const form = new InternalFormApi({ - defaultValues: { items: [] as Array }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues(() => 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: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.filterValues(() => true) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('reorganizes child field segments after filtering', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - 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 - arrayField.filterValues((_, 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 arrayField = form._getOrCreateFieldApi({ name: 'items' }) - - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - - arrayField.filterValues((_, 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' }) - field.filterValues(() => 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 field = form._getOrCreateFieldApi({ name: 'items' }) - - const originalArray = field.value - field.filterValues(() => true) - expect(field.value).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 }, - ], - }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - field.filterValues((person: any) => person.age > 28) - expect(form.getFieldValue('items')).toEqual([ - { name: 'Alice', age: 30 }, - { name: 'Charlie', age: 35 }, - ]) - }) - }) - - // End of Field array methods -}) From 6da8f9174a53d1a1f9d8c6398eea8d72ee91a863 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:18:33 +0200 Subject: [PATCH 08/16] chore: migrate unit tests for field handleChange --- .../tests/FieldApi/FieldApi.lib.spec.ts | 87 +++++++++++++++++++ .../form-core/tests/FieldApi/state.spec.ts | 42 --------- 2 files changed, 87 insertions(+), 42 deletions(-) delete mode 100644 packages/form-core/tests/FieldApi/state.spec.ts diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts index 344521c251..a84fa5f515 100644 --- a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -781,6 +781,93 @@ describe('InternalFieldApi - filterValues', () => { }) }) +describe('InternalFieldApi - handleChange', () => { + it('sets the value through setFieldValue and marks the field and form touched and dirty', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const setFieldValue = vi.spyOn(form, 'setFieldValue') + onTestFinished(() => setFieldValue.mockRestore()) + + field.handleChange('Alice') + + expect(setFieldValue).toHaveBeenCalledOnce() + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: true, + isDirty: true, + }) + }) + + it('updates the value through setFieldValue with an updater function', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { count: 1 } }) + const field = form._getOrCreateFieldApi({ name: 'count' }) + const setFieldValue = vi.spyOn(form, 'setFieldValue') + onTestFinished(() => setFieldValue.mockRestore()) + + field.handleChange((count: number) => count + 1) + + expect(setFieldValue).toHaveBeenCalledOnce() + expect(field.value).toBe(2) + }) + + it('updates the value and touched state while keeping the field and form pristine when markAsDirty is false', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const setFieldValue = vi.spyOn(form, 'setFieldValue') + onTestFinished(() => setFieldValue.mockRestore()) + + field.handleChange('Alice', { markAsDirty: false }) + + expect(setFieldValue).toHaveBeenCalledOnce() + 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('updates the value and dirty state while keeping the field and form untouched when markAsTouched is false', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const setFieldValue = vi.spyOn(form, 'setFieldValue') + onTestFinished(() => setFieldValue.mockRestore()) + + field.handleChange('Alice', { markAsTouched: false }) + + expect(setFieldValue).toHaveBeenCalledOnce() + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: true, + }) + }) +}) + describe('InternalFieldApi - _clearEventErrors', () => { it('clears submit-only validator errors only for the field that changes', async () => { const form = new InternalFormApi({ diff --git a/packages/form-core/tests/FieldApi/state.spec.ts b/packages/form-core/tests/FieldApi/state.spec.ts deleted file mode 100644 index cf3d421e92..0000000000 --- a/packages/form-core/tests/FieldApi/state.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('field - state', () => { - describe('handleChange', () => { - it('updates the field value', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.handleChange('Alice') - expect(field.value).toBe('Alice') - }) - - it('accepts an updater function', () => { - const form = new InternalFormApi({ defaultValues: { count: 1 } }) - const field = form._getOrCreateFieldApi({ name: 'count' }) - field.handleChange((prev: number) => prev + 1) - expect(field.value).toBe(2) - }) - - it('marks the field as dirty and touched by default', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.handleChange('Alice') - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - - it('respects markAsDirty: false', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.handleChange('Alice', { markAsDirty: false }) - expect(field.meta.isDirty).toBe(false) - }) - - it('respects markAsTouched: false', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field.handleChange('Alice', { markAsTouched: false }) - expect(field.meta.isTouched).toBe(false) - }) - }) -}) From e664e710f9054922d053eac184199fbcb4392eb4 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:06:54 +0200 Subject: [PATCH 09/16] chore: migrate unit tests for default value --- .../tests/FieldApi/FieldApi.lib.spec.ts | 391 ++++++++++++++++++ .../tests/FieldApi/default-value.spec.ts | 123 ------ 2 files changed, 391 insertions(+), 123 deletions(-) delete mode 100644 packages/form-core/tests/FieldApi/default-value.spec.ts diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts index a84fa5f515..eeff098a8d 100644 --- a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -221,6 +221,397 @@ describe('InternalFieldApi - atom', () => { }) }) +describe('InternalFieldApi - _getIsDefaultValue', () => { + it('returns to the default value while remaining dirty after an edit is undone', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + expect(field.meta.isDefaultValue).toBe(true) + + field.handleChange('Alice') + + expect(field.value).toBe('Alice') + expect(field.meta.isDefaultValue).toBe(false) + + field.handleChange('') + + expect(field.value).toBe('') + expect(field.meta).toMatchObject({ + isDefaultValue: true, + isDirty: true, + isPristine: false, + }) + }) + + it('reports default-value state across nested fields', () => { + const form = new InternalFormApi({ + defaultValues: { user: { firstName: 'Tony', lastName: 'Hawk' } }, + }) + const user = form._getOrCreateFieldApi({ name: 'user' }) + + expect(user.meta.isDefaultValue).toBe(true) + + const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) + const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) + + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + + firstName.handleChange('Anthony') + + expect(user.value).toEqual({ firstName: 'Anthony', lastName: 'Hawk' }) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(true) + + firstName.handleChange('Tony') + + expect(user.value).toEqual({ firstName: 'Tony', lastName: 'Hawk' }) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + }) + + it('keeps partially restored objects non-default', () => { + const form = new InternalFormApi({ + defaultValues: { user: { firstName: 'Tony', lastName: 'Hawk' } }, + }) + const user = form._getOrCreateFieldApi({ name: 'user' }) + const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) + const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) + + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + + firstName.handleChange('Max') + lastName.handleChange('Mustermann') + + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(false) + + firstName.handleChange('Tony') + + expect(user.value).toEqual({ firstName: 'Tony', lastName: 'Mustermann' }) + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(false) + + lastName.handleChange('Hawk') + + expect(user.value).toEqual({ firstName: 'Tony', lastName: 'Hawk' }) + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + }) + + it('reports nested defaults after object replacement', () => { + const form = new InternalFormApi({ + defaultValues: { user: { firstName: 'Tony', lastName: 'Hawk' } }, + }) + const user = form._getOrCreateFieldApi({ name: 'user' }) + const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) + const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) + + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + + user.handleChange({ firstName: 'Max', lastName: 'Hawk' }) + + expect(firstName.value).toBe('Max') + expect(lastName.value).toBe('Hawk') + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(true) + + user.handleChange({ firstName: 'Tony', lastName: 'Hawk' }) + + expect(user.value).toEqual({ firstName: 'Tony', lastName: 'Hawk' }) + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + }) + + it('reports nested state against updated defaults', () => { + const form = new InternalFormApi({ + defaultValues: { user: { firstName: 'Tony', lastName: 'Hawk' } }, + }) + const user = form._getOrCreateFieldApi({ name: 'user' }) + const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) + const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) + + firstName.handleChange('Max') + + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(true) + + form._update({ + defaultValues: { user: { firstName: 'Max', lastName: 'Hawk' } }, + }) + + expect(form.state.values).toEqual({ + user: { firstName: 'Max', lastName: 'Hawk' }, + }) + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + + form._update({ + defaultValues: { user: { firstName: 'Antonio', lastName: 'Hawk' } }, + }) + + expect(form.state.values).toEqual({ + user: { firstName: 'Max', lastName: 'Hawk' }, + }) + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(true) + }) + + it('reports new defaults after a form reset', () => { + const form = new InternalFormApi({ + defaultValues: { user: { firstName: 'Tony', lastName: 'Hawk' } }, + }) + const previousUser = form._getOrCreateFieldApi({ name: 'user' }) + const previousFirstName = form._getOrCreateFieldApi({ + name: 'user.firstName', + }) + const previousLastName = form._getOrCreateFieldApi({ + name: 'user.lastName', + }) + + previousFirstName.handleChange('Max') + + expect(form.state.isDefaultValue).toBe(false) + expect(previousUser.meta.isDefaultValue).toBe(false) + expect(previousFirstName.meta.isDefaultValue).toBe(false) + expect(previousLastName.meta.isDefaultValue).toBe(true) + + form.reset({ user: { firstName: 'Rodney', lastName: 'Mullen' } }) + + const user = form._getOrCreateFieldApi({ name: 'user' }) + const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) + const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) + + previousFirstName.handleChange('stale edit') + + expect(form.state.values).toEqual({ + user: { firstName: 'Rodney', lastName: 'Mullen' }, + }) + expect(form.defaultValues).toEqual({ + user: { firstName: 'Rodney', lastName: 'Mullen' }, + }) + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + + firstName.handleChange('Tony') + + expect(user.value).toEqual({ firstName: 'Tony', lastName: 'Mullen' }) + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(true) + + firstName.handleChange('Rodney') + + expect(user.value).toEqual({ firstName: 'Rodney', lastName: 'Mullen' }) + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + }) + + it('reports preserved defaults after a form reset', () => { + const form = new InternalFormApi({ + defaultValues: { user: { firstName: 'Tony', lastName: 'Hawk' } }, + }) + const previousUser = form._getOrCreateFieldApi({ name: 'user' }) + const previousFirstName = form._getOrCreateFieldApi({ + name: 'user.firstName', + }) + const previousLastName = form._getOrCreateFieldApi({ + name: 'user.lastName', + }) + + previousFirstName.handleChange('edited') + + expect(form.state.isDefaultValue).toBe(false) + expect(previousUser.meta.isDefaultValue).toBe(false) + expect(previousFirstName.meta.isDefaultValue).toBe(false) + expect(previousLastName.meta.isDefaultValue).toBe(true) + + form.reset( + { user: { firstName: 'Max', lastName: 'Hawk' } }, + { updateDefaultValues: false }, + ) + + const user = form._getOrCreateFieldApi({ name: 'user' }) + const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) + const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) + + previousFirstName.handleChange('stale edit') + + expect(form.state.values).toEqual({ + user: { firstName: 'Max', lastName: 'Hawk' }, + }) + expect(form.defaultValues).toEqual({ + user: { firstName: 'Tony', lastName: 'Hawk' }, + }) + expect(form.state.isDefaultValue).toBe(false) + expect(user.meta.isDefaultValue).toBe(false) + expect(firstName.meta.isDefaultValue).toBe(false) + expect(lastName.meta.isDefaultValue).toBe(true) + + firstName.handleChange('Tony') + + expect(user.value).toEqual({ firstName: 'Tony', lastName: 'Hawk' }) + expect(form.state.isDefaultValue).toBe(true) + expect(user.meta.isDefaultValue).toBe(true) + expect(firstName.meta.isDefaultValue).toBe(true) + expect(lastName.meta.isDefaultValue).toBe(true) + }) + + it('reports moved fields as non-default and returns them to default when moved back', () => { + const form = new InternalFormApi({ + defaultValues: { items: [{ name: 'a' }, { name: 'b' }] }, + }) + const items = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + + expect(items.meta.isDefaultValue).toBe(true) + expect(item0.meta.isDefaultValue).toBe(true) + expect(name0.meta.isDefaultValue).toBe(true) + + // Before: 0 1 + // After: 1 0 + // Fields: - item0 + items.swapValues(0, 1) + + expect(items.value).toEqual([{ name: 'b' }, { name: 'a' }]) + expect(item0.name).toBe('items[1]') + expect(item0.value).toEqual({ name: 'a' }) + expect(name0.name).toBe('items[1].name') + expect(name0.value).toBe('a') + expect(items.meta.isDefaultValue).toBe(false) + expect(item0.meta.isDefaultValue).toBe(false) + expect(name0.meta.isDefaultValue).toBe(false) + + items.swapValues(0, 1) + + expect(items.value).toEqual([{ name: 'a' }, { name: 'b' }]) + expect(item0.name).toBe('items[0]') + expect(name0.name).toBe('items[0].name') + expect(items.meta.isDefaultValue).toBe(true) + expect(item0.meta.isDefaultValue).toBe(true) + expect(name0.meta.isDefaultValue).toBe(true) + + name0.handleChange('updated') + + expect(items.value).toEqual([{ name: 'updated' }, { name: 'b' }]) + expect(item0.value).toEqual({ name: 'updated' }) + expect(items.meta.isDefaultValue).toBe(false) + expect(item0.meta.isDefaultValue).toBe(false) + expect(name0.meta.isDefaultValue).toBe(false) + }) + + it('returns an array to default after reversing an item move', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const items = form._getOrCreateFieldApi({ name: 'items' }) + + expect(items.meta.isDefaultValue).toBe(true) + + items.moveValue(0, 2) + + expect(items.value).toEqual(['b', 'c', 'a']) + expect(items.meta.isDefaultValue).toBe(false) + + items.moveValue(2, 0) + + expect(items.value).toEqual(['a', 'b', 'c']) + expect(items.meta.isDefaultValue).toBe(true) + }) + + it('returns an array to default after removing an inserted item', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const items = form._getOrCreateFieldApi({ name: 'items' }) + + expect(items.meta.isDefaultValue).toBe(true) + + items.insertValue(1, 'x') + + expect(items.value).toEqual(['a', 'x', 'b', 'c']) + expect(items.meta.isDefaultValue).toBe(false) + + items.removeValue(1) + + expect(items.value).toEqual(['a', 'b', 'c']) + expect(items.meta.isDefaultValue).toBe(true) + }) + + it('returns an array to default after removing an appended item', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const items = form._getOrCreateFieldApi({ name: 'items' }) + + expect(items.meta.isDefaultValue).toBe(true) + + items.pushValue('d') + + expect(items.value).toEqual(['a', 'b', 'c', 'd']) + expect(items.meta.isDefaultValue).toBe(false) + + items.removeValue(3) + + expect(items.value).toEqual(['a', 'b', 'c']) + expect(items.meta.isDefaultValue).toBe(true) + }) + + it('returns a cleared array to default after reset and reports later changes as non-default', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const items = form._getOrCreateFieldApi({ name: 'items' }) + + expect(items.meta.isDefaultValue).toBe(true) + + items.clearValues() + + expect(items.value).toEqual([]) + expect(items.meta.isDefaultValue).toBe(false) + + items.reset() + + expect(items.value).toEqual(['a', 'b', 'c']) + expect(items.meta.isDefaultValue).toBe(true) + + items.pushValue('d') + + expect(items.value).toEqual(['a', 'b', 'c', 'd']) + expect(items.meta.isDefaultValue).toBe(false) + }) +}) + describe('InternalFieldApi - reset', () => { it('aborts active validation, ignores its late result, and allows validation to run again', async ({ onTestFinished, diff --git a/packages/form-core/tests/FieldApi/default-value.spec.ts b/packages/form-core/tests/FieldApi/default-value.spec.ts deleted file mode 100644 index 9ac21fd3ce..0000000000 --- a/packages/form-core/tests/FieldApi/default-value.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('field - isDefaultValue', () => { - it('tracks leaf fields independently from dirty state', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - expect(field.meta.isDefaultValue).toBe(true) - - field.handleChange('Alice') - expect(field.meta.isDefaultValue).toBe(false) - - field.handleChange('') - expect(field.meta.isDefaultValue).toBe(true) - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isPristine).toBe(false) - }) - - it('does not cache primitive field comparisons', () => { - const form = new InternalFormApi({ defaultValues: { user: { name: '' } } }) - const user = form._getOrCreateFieldApi({ name: 'user' }) - const name = form._getOrCreateFieldApi({ name: 'user.name' }) - - expect(name.meta.isDefaultValue).toBe(true) - expect(name._defaultValueCache).toBeNull() - - name.handleChange('Tony') - expect(name.meta.isDefaultValue).toBe(false) - expect(name._defaultValueCache).toBeNull() - expect(user._defaultValueCache?.isDefaultValue).toBe(false) - }) - - it('derives parent fields from descendant values', () => { - const form = new InternalFormApi({ - defaultValues: { - user: { firstName: 'Tony', lastName: 'Hawk' }, - }, - }) - const user = form._getOrCreateFieldApi({ name: 'user' }) - const firstName = form._getOrCreateFieldApi({ name: 'user.firstName' }) - const lastName = form._getOrCreateFieldApi({ name: 'user.lastName' }) - - expect(user.meta.isDefaultValue).toBe(true) - expect(firstName.meta.isDefaultValue).toBe(true) - expect(lastName.meta.isDefaultValue).toBe(true) - - firstName.handleChange('Anthony') - expect(firstName.meta.isDefaultValue).toBe(false) - expect(user.meta.isDefaultValue).toBe(false) - expect(lastName.meta.isDefaultValue).toBe(true) - - firstName.handleChange('Tony') - expect(firstName.meta.isDefaultValue).toBe(true) - expect(user.meta.isDefaultValue).toBe(true) - }) - - it('keeps descendants default when an ancestor cache reports default', () => { - const form = new InternalFormApi({ - defaultValues: { user: { name: 'Tony' } }, - }) - const user = form._getOrCreateFieldApi({ name: 'user' }) - - expect(user.meta.isDefaultValue).toBe(true) - - const name = form._getOrCreateFieldApi({ name: 'user.name' }) - expect(name.meta.isDefaultValue).toBe(true) - }) - - it('handles array mutations and path cache invalidation', () => { - const form = new InternalFormApi({ - defaultValues: { - items: [{ name: 'a' }, { name: 'b' }], - }, - }) - const items = form._getOrCreateFieldApi({ name: 'items' }) - const firstName = form._getOrCreateFieldApi({ name: 'items[0].name' }) - - expect(items.meta.isDefaultValue).toBe(true) - expect(firstName.meta.isDefaultValue).toBe(true) - - items.swapValues(0, 1) - expect(firstName.name).toBe('items[1].name') - expect(firstName.meta.isDefaultValue).toBe(false) - expect(items.meta.isDefaultValue).toBe(false) - - items.swapValues(0, 1) - expect(firstName.name).toBe('items[0].name') - expect(firstName.meta.isDefaultValue).toBe(true) - expect(items.meta.isDefaultValue).toBe(true) - }) - - it('returns arrays to default after inverse operations', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const items = form._getOrCreateFieldApi({ name: 'items' }) - - items.moveValue(0, 2) - expect(items.meta.isDefaultValue).toBe(false) - - items.moveValue(2, 0) - expect(items.meta.isDefaultValue).toBe(true) - - items.insertValue(1, 'x') - expect(items.meta.isDefaultValue).toBe(false) - - items.removeValue(1) - expect(items.meta.isDefaultValue).toBe(true) - - items.pushValue('d') - expect(items.meta.isDefaultValue).toBe(false) - - items.removeValue(3) - expect(items.meta.isDefaultValue).toBe(true) - - items.clearValues() - expect(items.meta.isDefaultValue).toBe(false) - - items.reset() - expect(items.meta.isDefaultValue).toBe(true) - }) -}) From 032c5e7a0dcfb4afddefe44a436c155ccfceb97b Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:23:43 +0200 Subject: [PATCH 10/16] chore: migrate unit tests for form default value --- .../tests/FieldApi/FieldApi.lib.spec.ts | 1 + .../tests/FormApi/FormApi.lib.spec.ts | 48 +++++++++ .../tests/FormApi/default-value.spec.ts | 101 ------------------ 3 files changed, 49 insertions(+), 101 deletions(-) delete mode 100644 packages/form-core/tests/FormApi/default-value.spec.ts diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts index eeff098a8d..a3d65c41bd 100644 --- a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -472,6 +472,7 @@ describe('InternalFieldApi - _getIsDefaultValue', () => { expect(form.defaultValues).toEqual({ user: { firstName: 'Tony', lastName: 'Hawk' }, }) + expect(form.state.isDirty).toBe(false) expect(form.state.isDefaultValue).toBe(false) expect(user.meta.isDefaultValue).toBe(false) expect(firstName.meta.isDefaultValue).toBe(false) diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts index af5c60051b..d293dd9aaf 100644 --- a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -269,6 +269,50 @@ describe('InternalFormApi - _getOrCreateFieldApi', () => { }) }) +describe('InternalFormApi - _getIsDefaultValue', () => { + it('reports restored values as default while the form remains dirty', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + + expect(form.state.isDefaultValue).toBe(true) + + form.setFieldValue('name', 'Alice') + + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isDefaultValue: false, + }) + + form.setFieldValue('name', '') + + expect(form.state).toMatchObject({ + values: { name: '' }, + isDefaultValue: true, + isDirty: true, + isPristine: false, + }) + }) + + it('reports deeply equal objects and arrays as default values', () => { + const form = new InternalFormApi({ + defaultValues: { profile: { name: 'Tony', tags: ['skater'] } }, + }) + + expect(form.state.isDefaultValue).toBe(true) + + form.setFieldValue('profile', { name: 'Tony', tags: ['skater'] }) + + expect(form.state.isDefaultValue).toBe(true) + + form.setFieldValue('profile.tags', ['skater', 'legend']) + + expect(form.state.isDefaultValue).toBe(false) + + form.setFieldValue('profile.tags', ['skater']) + + expect(form.state.isDefaultValue).toBe(true) + }) +}) + describe('InternalFormApi - getFieldValue', () => { it('reads values through top-level, nested object, and array paths', () => { const form = new InternalFormApi({ @@ -736,6 +780,7 @@ describe('InternalFormApi - reset', () => { expect(form.state).toMatchObject({ values: { name: 'dirty' }, + isDefaultValue: false, isDirty: true, isTouched: true, errors: [{ message: 'Validation error' }], @@ -746,6 +791,7 @@ describe('InternalFormApi - reset', () => { expect(form.state).toMatchObject({ values: { name: '' }, + isDefaultValue: true, isDirty: false, isTouched: false, errors: [], @@ -828,6 +874,7 @@ describe('InternalFormApi - resetField', () => { expect(field.value).toBe('bye') expect(field.meta.isTouched).toBe(true) expect(field.errors).toEqual([{ message: 'Reset me' }]) + expect(form.state.isDefaultValue).toBe(false) expect(form.state.isValid).toBe(false) form.resetField('name') @@ -836,6 +883,7 @@ describe('InternalFormApi - resetField', () => { expect(field.meta.isTouched).toBe(false) expect(field.errors).toEqual([]) expect(form.state.isTouched).toBe(false) + expect(form.state.isDefaultValue).toBe(true) expect(form.state.isValid).toBe(true) }) diff --git a/packages/form-core/tests/FormApi/default-value.spec.ts b/packages/form-core/tests/FormApi/default-value.spec.ts deleted file mode 100644 index 6a93102fae..0000000000 --- a/packages/form-core/tests/FormApi/default-value.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('form - isDefaultValue', () => { - it('tracks whether current values deeply equal default values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - expect(form.state.isDefaultValue).toBe(true) - - form.setFieldValue('name', 'Alice') - expect(form.state.isDefaultValue).toBe(false) - - form.setFieldValue('name', '') - expect(form.state.isDefaultValue).toBe(true) - expect(form.state.isDirty).toBe(true) - expect(form.state.isPristine).toBe(false) - }) - - it('uses deep equality rather than reference equality', () => { - const form = new InternalFormApi({ - defaultValues: { profile: { name: 'Tony', tags: ['skater'] } }, - }) - - form.setFieldValue('profile', { name: 'Tony', tags: ['skater'] }) - expect(form.state.isDefaultValue).toBe(true) - - form.setFieldValue('profile.tags', ['skater', 'legend']) - expect(form.state.isDefaultValue).toBe(false) - }) - - it('returns to true after field and form resets', () => { - const form = new InternalFormApi({ - defaultValues: { name: '', items: ['a', 'b'] }, - }) - - form.setFieldValue('name', 'Alice') - expect(form.state.isDefaultValue).toBe(false) - - form.resetField('name') - expect(form.state.isDefaultValue).toBe(true) - - form.pushFieldValue('items', 'c') - expect(form.state.isDefaultValue).toBe(false) - - form.reset() - expect(form.state.isDefaultValue).toBe(true) - }) - - it('keeps isDefaultValue lazy for eager form state snapshots', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const getIsDefaultValue = vi.spyOn(form, '_getIsDefaultValue') - - const state = form.atom.get() - - expect(getIsDefaultValue).not.toHaveBeenCalled() - expect(state.isDirty).toBe(false) - expect(getIsDefaultValue).not.toHaveBeenCalled() - expect(Object.keys(state)).toContain('isDefaultValue') - expect(getIsDefaultValue).not.toHaveBeenCalled() - - expect(state.isDefaultValue).toBe(true) - expect(getIsDefaultValue).toHaveBeenCalledOnce() - }) - - it('recomputes when defaultValues update', () => { - const form = new InternalFormApi({ defaultValues: { name: 'initial' } }) - - form.setFieldValue('name', 'current') - expect(form.state.isDefaultValue).toBe(false) - - form._update({ defaultValues: { name: 'current' } }) - expect(form.state.isDefaultValue).toBe(true) - }) - - it('updates default values by default when resetting to new values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form.setFieldValue('name', 'dirty') - 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) - }) - - it('can reset values without updating default values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form.setFieldValue('name', 'dirty') - form.reset({ name: 'reset value' }, { updateDefaultValues: false }) - - expect(form.state.values).toEqual({ name: 'reset value' }) - expect(form.defaultValues).toEqual({ name: '' }) - expect(form.state.isDirty).toBe(false) - expect(form.state.isDefaultValue).toBe(false) - - form.reset() - expect(form.state.values).toEqual({ name: '' }) - expect(form.state.isDefaultValue).toBe(true) - }) -}) From cd116942527b93ecca7cf3138f9970046a9b84f2 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:38:40 +0200 Subject: [PATCH 11/16] chore: migrate unit tests for utils.spec.ts --- .../tests/FieldApi/FieldApi.lib.spec.ts | 55 ++++++ .../form-core/tests/FieldApi/utils.spec.ts | 179 ------------------ .../tests/FormApi/array-methods.lib.spec.ts | 10 + packages/form-core/tests/utils.lib.spec.ts | 47 +++++ 4 files changed, 112 insertions(+), 179 deletions(-) delete mode 100644 packages/form-core/tests/FieldApi/utils.spec.ts create mode 100644 packages/form-core/tests/utils.lib.spec.ts diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts index a3d65c41bd..6b2f065463 100644 --- a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -32,6 +32,61 @@ describe('getOrCreateFieldApi', () => { }) }) +describe('InternalFieldApi - name', () => { + it('reports names for nested object fields', () => { + const form = new InternalFormApi({ + defaultValues: { user: { profile: { name: 'Alice' } } }, + }) + const user = form._getOrCreateFieldApi({ name: 'user' }) + const profile = form._getOrCreateFieldApi({ name: 'user.profile' }) + const name = form._getOrCreateFieldApi({ name: 'user.profile.name' }) + + expect(user.name).toBe('user') + expect(profile.name).toBe('user.profile') + expect(name.name).toBe('user.profile.name') + }) + + it('reports names for array items and nested properties', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'Alice' }], + user: { items: [{ name: 'Bob' }] }, + }, + }) + const item = form._getOrCreateFieldApi({ name: 'items[0]' }) + const name = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const nestedName = form._getOrCreateFieldApi({ name: 'user.items[0].name' }) + + expect(item.name).toBe('items[0]') + expect(name.name).toBe('items[0].name') + expect(nestedName.name).toBe('user.items[0].name') + }) + + it('reports names with consecutive array indices', () => { + const form = new InternalFormApi({ + defaultValues: { items: [[['Alice'], ['Bob', 'Carol', 'Dan']]] }, + }) + const nestedArray = form._getOrCreateFieldApi({ name: 'items[0][1]' }) + const item = form._getOrCreateFieldApi({ name: 'items[0][1][2]' }) + + expect(nestedArray.name).toBe('items[0][1]') + expect(item.name).toBe('items[0][1][2]') + expect(item.value).toBe('Dan') + }) + + it('reports names inside a top-level array', () => { + const form = new InternalFormApi({ + defaultValues: [{ name: 'Alice' }], + }) + const item = form._getOrCreateFieldApi({ name: '[0]' }) + const name = form._getOrCreateFieldApi({ name: '[0].name' }) + + expect(item.name).toBe('[0]') + expect(name.name).toBe('[0].name') + expect(name.value).toBe('Alice') + }) +}) + describe('InternalFieldApi - _update', () => { it('retains default listeners before incoming listeners during construction and updates', () => { const defaultListener = vi.fn() diff --git a/packages/form-core/tests/FieldApi/utils.spec.ts b/packages/form-core/tests/FieldApi/utils.spec.ts deleted file mode 100644 index 5576d4f925..0000000000 --- a/packages/form-core/tests/FieldApi/utils.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { InternalFormApi, nameToFieldNodeSegments } from '../../src/internals' - -describe('nameToFieldNodeSegments', () => { - it('splits dot-separated field names', () => { - expect(nameToFieldNodeSegments('foo.bar')).toEqual(['foo', 'bar']) - }) - - it('splits multiple dot-separated field names', () => { - expect(nameToFieldNodeSegments('foo.bar.foobar')).toEqual([ - 'foo', - 'bar', - 'foobar', - ]) - }) - - it('splits bracket array access into segments', () => { - expect(nameToFieldNodeSegments('foo[0].bar')).toEqual(['foo', 0, 'bar']) - }) - - it('splits chained bracket access into segments', () => { - expect(nameToFieldNodeSegments('foo[0][1].bar')).toEqual([ - 'foo', - 0, - 1, - 'bar', - ]) - }) - - it('handles empty string', () => { - expect(nameToFieldNodeSegments('')).toEqual([]) - }) - - it('handles single segment', () => { - expect(nameToFieldNodeSegments('foo')).toEqual(['foo']) - }) - - it('handles array syntax without dot', () => { - expect(nameToFieldNodeSegments('foo[0]')).toEqual(['foo', 0]) - }) - - it('handles mixed brackets and dots', () => { - expect(nameToFieldNodeSegments('foo.bar[0].baz')).toEqual([ - 'foo', - 'bar', - 0, - 'baz', - ]) - }) - - it('returns a shallow copy of array input', () => { - // A dev may want to reuse segments for multiple calls, so it's essential - // that we don't mutate the input array. - const segments = ['foo', 'bar'] - const result = nameToFieldNodeSegments(segments) - expect(result).toEqual(segments) - expect(result).not.toBe(segments) - }) -}) - -describe('Form Field names', () => { - it('returns the correct name for a top-level field', () => { - const form = new InternalFormApi({ - defaultValues: { foo: '' }, - }) - const field = form._getOrCreateFieldApi({ name: 'foo' }) - expect(field.name).toBe('foo') - }) - - it('returns the correct name for nested fields with dot notation', () => { - const form = new InternalFormApi({ - defaultValues: { foo: { bar: { baz: '' } } }, - }) - const field = form._getOrCreateFieldApi({ name: 'foo.bar.baz' }) - expect(field.name).toBe('foo.bar.baz') - }) - - it('returns the correct name for array fields with bracket notation', () => { - const form = new InternalFormApi({ - defaultValues: { arr: [''] }, - }) - const field = form._getOrCreateFieldApi({ name: 'arr[0]' }) - expect(field.name).toBe('arr[0]') - }) - - it('returns the correct name for nested array fields', () => { - const form = new InternalFormApi({ - defaultValues: { arr: [{ nested: '' }] }, - }) - const field = form._getOrCreateFieldApi({ name: 'arr[0].nested' }) - expect(field.name).toBe('arr[0].nested') - }) - - it('returns the correct name for deeply nested array fields', () => { - const form = new InternalFormApi({ - defaultValues: { foo: { arr: [{ bar: '' }] } }, - }) - const field = form._getOrCreateFieldApi({ name: 'foo.arr[0].bar' }) - expect(field.name).toBe('foo.arr[0].bar') - }) - - it('returns the correct name for chained array access', () => { - const form = new InternalFormApi({ - defaultValues: { arr: [[['']]] }, - }) - const field = form._getOrCreateFieldApi({ name: 'arr[0][1][2]' }) - expect(field.name).toBe('arr[0][1][2]') - }) - - it('updates child fields when the parent name changes', () => { - const form = new InternalFormApi({ - defaultValues: { arr: [{ foo: '' }, { bar: '' }] }, - }) - const field = form._getOrCreateFieldApi({ name: 'arr[0]' }) - const subField = form._getOrCreateFieldApi({ name: 'arr[0].bar' }) - - expect(field.name).toBe('arr[0]') - expect(subField.name).toBe('arr[0].bar') - - field._moveTo(1) - - expect(field.name).toBe('arr[1]') - expect(subField.name).toBe('arr[1].bar') - }) - - it('builds name correctly from parent chain', () => { - const form = new InternalFormApi({ - defaultValues: { a: { b: { c: '' } } }, - }) - const fieldC = form._getOrCreateFieldApi({ name: 'a.b.c' }) - - expect(fieldC.name).toBe('a.b.c') - expect(fieldC._parent.name).toBe('a.b') - // Root node doesn't have parent, so it complains about the types - expect((fieldC._parent as any)._parent.name).toBe('a') - expect((fieldC._parent as any)._parent._parent.name).toBe('') - }) - - it('uses bracket notation for array parent segments', () => { - const form = new InternalFormApi({ - defaultValues: { arr: [{ nested: '' }] }, - }) - const field = form._getOrCreateFieldApi({ name: 'arr[0].nested' }) - - expect(field.name).toBe('arr[0].nested') - expect(field._parent.name).toBe('arr[0]') - }) - - it('allows top-level arrays', () => { - const form = new InternalFormApi({ - defaultValues: [{ name: '' }], - }) - - const field = form._getOrCreateFieldApi({ name: '[0].name' }) - expect(field.name).toBe('[0].name') - }) - - it('updates field lookup after form.swapFieldValues', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - - const fieldAt0Before = form._getOrCreateFieldApi({ name: 'items[0]' }) - const fieldAt1Before = form._getOrCreateFieldApi({ name: 'items[1]' }) - - expect(fieldAt0Before.name).toBe('items[0]') - expect(fieldAt1Before.name).toBe('items[1]') - - form.swapFieldValues('items', 0, 1) - - const fieldAt0After = form._getOrCreateFieldApi({ name: 'items[0]' }) - const fieldAt1After = form._getOrCreateFieldApi({ name: 'items[1]' }) - - expect(fieldAt0After).toBe(fieldAt1Before) - expect(fieldAt0After.name).toBe('items[0]') - - expect(fieldAt1After).toBe(fieldAt0Before) - expect(fieldAt1After.name).toBe('items[1]') - }) -}) diff --git a/packages/form-core/tests/FormApi/array-methods.lib.spec.ts b/packages/form-core/tests/FormApi/array-methods.lib.spec.ts index 582ee86dbc..a69b17c9b6 100644 --- a/packages/form-core/tests/FormApi/array-methods.lib.spec.ts +++ b/packages/form-core/tests/FormApi/array-methods.lib.spec.ts @@ -19,11 +19,19 @@ describe('InternalFormApi - swapFieldValues', () => { expect(form.state.values).toEqual({ items: [{ name: 'Carol' }, { name: 'Bob' }, { name: 'Alice' }], }) + expect(item0.name).toBe('items[2]') + expect(item2.name).toBe('items[0]') expect(name0.name).toBe('items[2].name') expect(name0.value).toBe('Alice') expect(name2.name).toBe('items[0].name') expect(name2.value).toBe('Carol') + const firstItem = form._getOrCreateFieldApi({ name: 'items[0]' }) + const lastItem = form._getOrCreateFieldApi({ name: 'items[2]' }) + + expect(firstItem.name).toBe('items[0]') + expect(lastItem.name).toBe('items[2]') + expect(arrayField.meta).toMatchObject({ isTouched: true, isDirty: true, @@ -41,6 +49,8 @@ describe('InternalFormApi - swapFieldValues', () => { expect(form.state.values).toEqual({ items: [{ name: 'Caroline' }, { name: 'Bob' }, { name: 'Alicia' }], }) + expect(firstItem.value).toEqual({ name: 'Caroline' }) + expect(lastItem.value).toEqual({ name: 'Alicia' }) }) it('leaves values and interaction state unchanged when both indices are equal', () => { diff --git a/packages/form-core/tests/utils.lib.spec.ts b/packages/form-core/tests/utils.lib.spec.ts new file mode 100644 index 0000000000..16eabd8a1e --- /dev/null +++ b/packages/form-core/tests/utils.lib.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { nameToFieldNodeSegments } from '../src/utils.lib' + +describe('nameToFieldNodeSegments', () => { + it('returns no segments for the root name', () => { + const segments = nameToFieldNodeSegments('') + + expect(segments).toEqual([]) + }) + + it('splits object field names into segments', () => { + const topLevel = nameToFieldNodeSegments('foo') + const nested = nameToFieldNodeSegments('foo.bar') + const deeplyNested = nameToFieldNodeSegments('foo.bar.foobar') + + expect(topLevel).toEqual(['foo']) + expect(nested).toEqual(['foo', 'bar']) + expect(deeplyNested).toEqual(['foo', 'bar', 'foobar']) + }) + + it('keeps array indices numeric in mixed object and array paths', () => { + const item = nameToFieldNodeSegments('foo[0]') + const property = nameToFieldNodeSegments('foo[0].bar') + const nestedArray = nameToFieldNodeSegments('foo.bar[0].baz') + + expect(item).toEqual(['foo', 0]) + expect(property).toEqual(['foo', 0, 'bar']) + expect(nestedArray).toEqual(['foo', 'bar', 0, 'baz']) + }) + + it('splits consecutive array indices', () => { + const segments = nameToFieldNodeSegments('foo[0][1].bar') + + expect(segments).toEqual(['foo', 0, 1, 'bar']) + }) + + it('keeps supplied segments reusable after changing a parsed path', () => { + const supplied = ['items', 0, 'name'] + const firstPath = nameToFieldNodeSegments(supplied) + + firstPath[1] = 1 + const nextPath = nameToFieldNodeSegments(supplied) + + expect(supplied).toEqual(['items', 0, 'name']) + expect(nextPath).toEqual(['items', 0, 'name']) + }) +}) From 78e42ea1beb083d41636105fadfc3b2e1049d51b Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:16:07 +0200 Subject: [PATCH 12/16] chore: migrate field traversal --- .../tests/FieldApi/fieldTraversal.lib.spec.ts | 136 ++++++++++++++++++ .../tests/FieldApi/fieldTraversal.spec.ts | 98 ------------- 2 files changed, 136 insertions(+), 98 deletions(-) create mode 100644 packages/form-core/tests/FieldApi/fieldTraversal.lib.spec.ts delete mode 100644 packages/form-core/tests/FieldApi/fieldTraversal.spec.ts diff --git a/packages/form-core/tests/FieldApi/fieldTraversal.lib.spec.ts b/packages/form-core/tests/FieldApi/fieldTraversal.lib.spec.ts new file mode 100644 index 0000000000..98fde663ab --- /dev/null +++ b/packages/form-core/tests/FieldApi/fieldTraversal.lib.spec.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +import { + collectFieldSubtree, + visitAllFormFields, + visitFieldAndAncestors, + visitFieldSubtree, +} from '../../src/FieldApi/fieldTraversal.lib' + +function createFieldTree() { + const form = new InternalFormApi({ + defaultValues: { + section: { first: { deep: '' }, second: '' }, + other: '', + }, + }) + const section = form._getOrCreateFieldApi({ name: 'section' }) + form._getOrCreateFieldApi({ name: 'section.first' }) + const deep = form._getOrCreateFieldApi({ name: 'section.first.deep' }) + form._getOrCreateFieldApi({ name: 'section.second' }) + form._getOrCreateFieldApi({ name: 'other' }) + + return { form, section, deep } +} + +describe('visitFieldAndAncestors', () => { + it('visits a field and its ancestors without visiting the synthetic root', () => { + const { deep } = createFieldTree() + const names: Array = [] + + visitFieldAndAncestors(deep, (field) => { + names.push(field.name) + }) + + expect(names).toEqual(['section.first.deep', 'section.first', 'section']) + }) + + it('stops before visiting further ancestors when the visitor returns stop', () => { + const { deep } = createFieldTree() + const names: Array = [] + + visitFieldAndAncestors(deep, (field, stop) => { + names.push(field.name) + if (field.name === 'section.first') return stop + return undefined + }) + + expect(names).toEqual(['section.first.deep', 'section.first']) + }) +}) + +describe('visitFieldSubtree', () => { + it('visits the field and its descendants, parents before children', () => { + const { section } = createFieldTree() + const names: Array = [] + + visitFieldSubtree(section, (field) => { + names.push(field.name) + }) + + const firstIndex = names.indexOf('section.first') + const deepIndex = names.indexOf('section.first.deep') + + expect(names.toSorted()).toEqual([ + 'section', + 'section.first', + 'section.first.deep', + 'section.second', + ]) + expect(names[0]).toBe('section') + expect(firstIndex).toBeLessThan(deepIndex) + }) + + it('stops before visiting any more fields when the visitor returns stop', () => { + const { section } = createFieldTree() + const names: Array = [] + + visitFieldSubtree(section, (field, stop) => { + names.push(field.name) + if (field.name !== 'section') return stop + return undefined + }) + + expect(names).toEqual([ + 'section', + expect.stringMatching(/^section\.(first|second)$/), + ]) + }) +}) + +describe('visitAllFormFields', () => { + it('visits every field, parents before children, without visiting the synthetic root', () => { + const { form } = createFieldTree() + const names: Array = [] + + visitAllFormFields(form._fieldRootNode, (field) => { + names.push(field.name) + }) + + const sectionIndex = names.indexOf('section') + const firstIndex = names.indexOf('section.first') + const deepIndex = names.indexOf('section.first.deep') + const secondIndex = names.indexOf('section.second') + + expect(names.toSorted()).toEqual([ + 'other', + 'section', + 'section.first', + 'section.first.deep', + 'section.second', + ]) + expect(sectionIndex).toBeLessThan(firstIndex) + expect(sectionIndex).toBeLessThan(secondIndex) + expect(firstIndex).toBeLessThan(deepIndex) + }) +}) + +describe('collectFieldSubtree', () => { + it('collects the field and its descendants, parents before children', () => { + const { section } = createFieldTree() + + const fields = collectFieldSubtree(section) + const names = fields.map((field) => field.name) + const firstIndex = names.indexOf('section.first') + const deepIndex = names.indexOf('section.first.deep') + + expect(names.toSorted()).toEqual([ + 'section', + 'section.first', + 'section.first.deep', + 'section.second', + ]) + expect(names[0]).toBe('section') + expect(firstIndex).toBeLessThan(deepIndex) + }) +}) diff --git a/packages/form-core/tests/FieldApi/fieldTraversal.spec.ts b/packages/form-core/tests/FieldApi/fieldTraversal.spec.ts deleted file mode 100644 index 1a1d9ae270..0000000000 --- a/packages/form-core/tests/FieldApi/fieldTraversal.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' -import { - collectFieldSubtree, - visitAllFormFields, - visitFieldAndAncestors, - visitFieldSubtree, -} from '../../src/FieldApi/fieldTraversal.lib' -import type { AnyInternalFieldApi } from '../../src/FieldApi/FieldApi.lib' - -function createFieldTree() { - const form = new InternalFormApi({ - defaultValues: { - section: { first: '', second: { deep: '' } }, - other: '', - }, - }) - const section = form._getOrCreateFieldApi({ name: 'section' }) - const first = form._getOrCreateFieldApi({ name: 'section.first' }) - const second = form._getOrCreateFieldApi({ name: 'section.second' }) - const deep = form._getOrCreateFieldApi({ name: 'section.second.deep' }) - const other = form._getOrCreateFieldApi({ name: 'other' }) - - return { form, section, first, second, deep, other } -} - -function collectNames( - visit: (visitor: (field: AnyInternalFieldApi) => void) => void, -): Array { - const names: Array = [] - visit((field) => names.push(field.name)) - return names -} - -describe('field traversal', () => { - it('visits a field and its ancestors without visiting the synthetic root', () => { - const { deep } = createFieldTree() - - expect( - collectNames((visitor) => visitFieldAndAncestors(deep, visitor)), - ).toEqual(['section.second.deep', 'section.second', 'section']) - }) - - it('visits subtrees and complete forms in insertion-order preorder', () => { - const { form, section } = createFieldTree() - - expect( - collectNames((visitor) => visitFieldSubtree(section, visitor)), - ).toEqual([ - 'section', - 'section.first', - 'section.second', - 'section.second.deep', - ]) - expect( - collectNames((visitor) => - visitAllFormFields(form._fieldRootNode, visitor), - ), - ).toEqual([ - 'section', - 'section.first', - 'section.second', - 'section.second.deep', - 'other', - ]) - }) - - it('stops ancestor and subtree traversal when the visitor returns stop', () => { - const { section, deep } = createFieldTree() - const ancestors: Array = [] - const subtree: Array = [] - - visitFieldAndAncestors(deep, (field, stop) => { - ancestors.push(field.name) - if (field.name === 'section.second') return stop - return undefined - }) - visitFieldSubtree(section, (field, stop) => { - subtree.push(field.name) - if (field.name === 'section.second') return stop - return undefined - }) - - expect(ancestors).toEqual(['section.second.deep', 'section.second']) - expect(subtree).toEqual(['section', 'section.first', 'section.second']) - }) - - it('collects the same ordered fields produced by subtree visitation', () => { - const { section } = createFieldTree() - const visited: Array = [] - - visitFieldSubtree(section, (field) => { - visited.push(field) - }) - - expect(collectFieldSubtree(section)).toEqual(visited) - }) -}) From 1149bdcf03085b7a502867e7d625fbe664812ee9 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:51:56 +0200 Subject: [PATCH 13/16] chore: migrate unit test for listeners.spec.ts --- .../tests/FormApi/FormApi.lib.spec.ts | 171 +++++++++++ .../form-core/tests/FormApi/listeners.spec.ts | 284 ------------------ .../form-core/tests/listeners.lib.spec.ts | 124 ++++++++ 3 files changed, 295 insertions(+), 284 deletions(-) delete mode 100644 packages/form-core/tests/FormApi/listeners.spec.ts create mode 100644 packages/form-core/tests/listeners.lib.spec.ts diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts index d293dd9aaf..038a5dead6 100644 --- a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -25,6 +25,28 @@ describe('InternalFormApi - constructor', () => { }) }) +describe('InternalFormApi - mount', () => { + it('notifies mount listeners with the form values', ({ onTestFinished }) => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['mount'], run: listener }], + }) + + const unmount = form.mount() + onTestFinished(unmount) + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: undefined, + value: { name: '' }, + }), + ) + }) +}) + describe('InternalFormApi - _update', () => { it('changes a supplied formId', () => { const form = new InternalFormApi({ @@ -159,6 +181,53 @@ describe('InternalFormApi - _update', () => { expect(initialListener).not.toHaveBeenCalled() }) + it('uses the updated listener when a pending debounce completes', async () => { + vi.useFakeTimers() + const initialListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: 300, + run: initialListener, + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Alice') + await vi.advanceTimersByTimeAsync(100) + + form._update({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: 300, + run: updatedListener, + }, + ], + }) + + await vi.advanceTimersByTimeAsync(199) + expect(initialListener).not.toHaveBeenCalled() + expect(updatedListener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + expect(initialListener).not.toHaveBeenCalled() + expect(updatedListener).toHaveBeenCalledOnce() + expect(updatedListener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + }), + ) + }) + it('uses the updated onSubmit callback and replaces its previous errors', async () => { const form = new InternalFormApi({ defaultValues: { name: '' }, @@ -344,6 +413,25 @@ describe('InternalFormApi - getFieldValue', () => { }) describe('InternalFormApi - setFieldValue', () => { + it('notifies change listeners with the updated values and triggering field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['change'], run: listener }], + }) + + form.setFieldValue('name', 'Alice') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: expect.objectContaining({ name: 'name' }), + value: { name: 'Alice' }, + }), + ) + }) + it('updates a nested value without changing unrelated values or defaults', () => { const form = new InternalFormApi({ defaultValues: { @@ -656,6 +744,27 @@ describe('InternalFormApi - setFieldValue', () => { }) describe('InternalFormApi - reset', () => { + it('notifies form reset listeners after restoring the default values', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['reset'], run: listener }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field.handleChange('Alice') + + form.reset() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: undefined, + value: { name: '' }, + }), + ) + }) + it('notifies parent and child reset listeners with their values before removing the fields', () => { const parentListener = vi.fn() const childListener = vi.fn() @@ -1054,6 +1163,68 @@ describe('InternalFormApi - deleteField', () => { }) }) +describe('InternalFormApi - _notifyFormListener', () => { + it('notifies the matching form listeners as a field changes and blurs', () => { + const changeListener = vi.fn() + const blurListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { triggers: ['change'], run: changeListener }, + { triggers: ['blur'], run: blurListener }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Alice') + + expect(changeListener).toHaveBeenCalledOnce() + expect(changeListener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + }), + ) + expect(blurListener).not.toHaveBeenCalled() + changeListener.mockClear() + + field.handleBlur() + + expect(blurListener).toHaveBeenCalledOnce() + expect(blurListener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + }), + ) + expect(changeListener).not.toHaveBeenCalled() + }) +}) + +describe('InternalFormApi - handleSubmit', () => { + it('notifies submit listeners when submission starts without a triggering field', async () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['submit'], run: listener }], + }) + + const submission = form.handleSubmit() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: undefined, + value: { name: '' }, + }), + ) + await expect(submission).resolves.toEqual([]) + }) +}) + describe('InternalFormApi - _clearEventErrors', () => { it('clears submit errors when change validation is disabled by its predicate', async () => { const validator = vi.fn(() => 'Submit validator failed') diff --git a/packages/form-core/tests/FormApi/listeners.spec.ts b/packages/form-core/tests/FormApi/listeners.spec.ts deleted file mode 100644 index 863c23de17..0000000000 --- a/packages/form-core/tests/FormApi/listeners.spec.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('form - listeners', () => { - it('should run change listeners when a field changes', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['change'], run: listener }], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Alice') - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: field, - value: { name: 'Alice' }, - }) - }) - - it('should run change listeners when form.setFieldValue is called', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['change'], run: listener }], - }) - - form.setFieldValue('name', 'Alice') - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: form._tryGetFieldApi('name') ?? undefined, - value: { name: 'Alice' }, - }) - }) - - it('should run field blur listeners when a field blurs', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['blur'], run: listener }], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleBlur() - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: field, - value: { name: '' }, - }) - }) - - it('should run form submit listeners when a submission starts', async () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['submit'], run: listener }], - }) - - await form.handleSubmit() - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: undefined, - value: { name: '' }, - }) - }) - - it('should run form mount listeners when form.mount() is called', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['mount'], run: listener }], - }) - - form.mount() - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: undefined, - value: { name: '' }, - }) - }) - - it('should run form reset listeners when the form resets', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['reset'], run: listener }], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Alice') - form.reset() - - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: undefined, - value: { name: '' }, - }) - }) - - it('should only run listeners matching the form event', () => { - const changeListener = vi.fn() - const blurListener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [ - { triggers: ['change'], run: changeListener }, - { triggers: ['blur'], run: blurListener }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Alice') - - expect(changeListener).toHaveBeenCalledOnce() - expect(blurListener).not.toHaveBeenCalled() - }) - - it('should not run object triggers when the form event does not match', () => { - const listener = vi.fn() - const when = vi.fn(() => true) - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: [{ trigger: 'blur', when }], - run: listener, - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Alice') - - expect(listener).not.toHaveBeenCalled() - expect(when).not.toHaveBeenCalled() - }) - - it('supports listener predicates for form events', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: [ - { - trigger: 'change', - when: ({ value }) => value.name === 'Alice', - }, - ], - run: listener, - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Bob') - field.handleChange('Alice') - - expect(listener).toHaveBeenCalledOnce() - }) - - it('supports dynamic debounce for form listeners', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const triggerDebounceMs = vi.fn(({ formApi, triggerFieldApi, value }) => { - expect(formApi.state.values).toEqual({ name: 'Alice' }) - expect(triggerFieldApi?.name).toBe('name') - expect(value).toEqual({ name: 'Alice' }) - return 100 - }) - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: ['change'], - triggerDebounceMs, - run: listener, - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Alice') - - expect(triggerDebounceMs).toHaveBeenCalledOnce() - expect(listener).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(99) - expect(listener).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(1) - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: field, - value: { name: 'Alice' }, - }) - - vi.useRealTimers() - }) - - it('uses the updated listener definition when a pending debounce executes', async () => { - vi.useFakeTimers() - const listenerA = vi.fn() - const listenerB = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: ['change'], - triggerDebounceMs: 300, - run: listenerA, - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const listenerInstance = form._listenerInstances?.[0] - - field.handleChange('Alice') - await vi.advanceTimersByTimeAsync(100) - - form._update({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: ['change'], - triggerDebounceMs: 300, - run: listenerB, - }, - ], - }) - - expect(form._listenerInstances?.[0]).toBe(listenerInstance) - - await vi.advanceTimersByTimeAsync(199) - expect(listenerA).not.toHaveBeenCalled() - expect(listenerB).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(1) - expect(listenerA).not.toHaveBeenCalled() - expect(listenerB).toHaveBeenCalledOnce() - expect(listenerB).toHaveBeenCalledWith({ - formApi: form, - triggerFieldApi: field, - value: { name: 'Alice' }, - }) - - vi.useRealTimers() - }) - - it('logs rejected async form listener errors', async () => { - const error = new Error('listener failed') - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: ['change'], - run: () => Promise.reject(error), - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - field.handleChange('Alice') - - await vi.waitFor(() => { - expect(consoleSpy).toHaveBeenCalled() - }) - - consoleSpy.mockRestore() - }) -}) diff --git a/packages/form-core/tests/listeners.lib.spec.ts b/packages/form-core/tests/listeners.lib.spec.ts new file mode 100644 index 0000000000..4e981a4ace --- /dev/null +++ b/packages/form-core/tests/listeners.lib.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../src/FormApi/FormApi.lib' + +describe('runFormListenerPipeline', () => { + it('runs a blur listener only when the field blurs', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: [{ trigger: 'blur', when: () => true }], + run: listener, + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Alice') + + expect(listener).not.toHaveBeenCalled() + + field.handleBlur() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + }), + ) + }) + + it('runs a form listener only when its predicate allows the current values', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: [ + { + trigger: 'change', + when: ({ value }) => value.name === 'Alice', + }, + ], + run: listener, + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Bob') + expect(listener).not.toHaveBeenCalled() + + field.handleChange('Alice') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + }), + ) + }) + + it('delays a listener according to the current form values', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: ({ value }) => value.name.length * 20, + run: listener, + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Alice') + + expect(listener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(99) + expect(listener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + }), + ) + }) + + it('reports the error rejected by an async form listener', async ({ + onTestFinished, + }) => { + const error = new Error('listener failed') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => consoleError.mockRestore()) + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + run: () => Promise.reject(error), + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Alice') + + await expect + .poll(() => consoleError.mock.calls) + .toContainEqual(expect.arrayContaining([error])) + }) +}) From f070121eee23219c5cb7da0bdc7817e2fcb333a5 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:02:34 +0200 Subject: [PATCH 14/16] chore: migrate field listener spec --- .../tests/FieldApi/FieldApi.lib.spec.ts | 678 ++++++++++++++- .../tests/FieldApi/listeners.spec.ts | 786 ------------------ .../tests/FormApi/FormApi.lib.spec.ts | 95 ++- .../form-core/tests/listeners.lib.spec.ts | 96 +++ .../debug/serverErrorOnUnmountedField.ts | 2 +- .../tests/fieldActionsBridge.test.ts | 3 - 6 files changed, 828 insertions(+), 832 deletions(-) delete mode 100644 packages/form-core/tests/FieldApi/listeners.spec.ts diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts index 6b2f065463..a46947675f 100644 --- a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -88,6 +88,346 @@ describe('InternalFieldApi - name', () => { }) describe('InternalFieldApi - _update', () => { + it('switches watched fields when the configured name changes', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', other: 'other', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: listener }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const other = form._getOrCreateFieldApi({ name: 'other' }) + source._register() + other._register() + target._register() + + source.handleChange('before update') + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target' }), + ) + listener.mockClear() + + target._update({ + listeners: [ + { triggers: ['change'], watchFields: ['other'], run: listener }, + ], + }) + source.handleChange('after update') + expect(listener).not.toHaveBeenCalled() + + other.handleChange('new source') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + }) + + it('keeps a listener watching its source when another listener drops that watch', () => { + const retainedListener = vi.fn() + const detachedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { + triggers: ['change'], + watchFields: ['source'], + run: retainedListener, + }, + { + triggers: ['change'], + watchFields: ['source'], + run: detachedListener, + }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + source._register() + target._register() + + source.handleChange('before detaching') + expect(retainedListener).toHaveBeenCalledOnce() + expect(detachedListener).toHaveBeenCalledOnce() + retainedListener.mockClear() + detachedListener.mockClear() + + target._update({ + listeners: [ + { + triggers: ['change'], + watchFields: ['source'], + run: retainedListener, + }, + { triggers: ['change'], watchFields: [], run: detachedListener }, + ], + }) + source.handleChange('after detaching') + + expect(retainedListener).toHaveBeenCalledOnce() + expect(retainedListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(detachedListener).not.toHaveBeenCalled() + + target.handleChange('direct edit') + + expect(detachedListener).toHaveBeenCalledOnce() + expect(detachedListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'direct edit', + fieldApi: target, + formApi: form, + }), + ) + }) + + it('keeps other fields watching a source when one field stops watching', () => { + const detachedListener = vi.fn() + const retainedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', first: 'first', second: 'second' }, + }) + const first = form._getOrCreateFieldApi({ + name: 'first', + listeners: [ + { + triggers: ['change'], + watchFields: ['source'], + run: detachedListener, + }, + ], + }) + const second = form._getOrCreateFieldApi({ + name: 'second', + listeners: [ + { + triggers: ['change'], + watchFields: ['source'], + run: retainedListener, + }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + source._register() + first._register() + second._register() + + source.handleChange('before detaching') + expect(detachedListener).toHaveBeenCalledOnce() + expect(retainedListener).toHaveBeenCalledOnce() + detachedListener.mockClear() + retainedListener.mockClear() + + first._update({ + listeners: [ + { triggers: ['change'], watchFields: [], run: detachedListener }, + ], + }) + source.handleChange('after detaching') + + expect(detachedListener).not.toHaveBeenCalled() + expect(retainedListener).toHaveBeenCalledOnce() + expect(retainedListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'second', + fieldApi: second, + formApi: form, + }), + ) + + first.handleChange('direct edit') + + expect(detachedListener).toHaveBeenCalledOnce() + expect(detachedListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'direct edit', + fieldApi: first, + formApi: form, + }), + ) + }) + + it('preserves a watch when both the source and the watching field move', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { users: [{ name: 'First' }, { name: 'Second' }] }, + }) + const name0 = form._getOrCreateFieldApi({ name: 'users[0].name' }) + const name1 = form._getOrCreateFieldApi({ + name: 'users[1].name', + listeners: [ + { triggers: ['change'], watchFields: ['users[0].name'], run: listener }, + ], + }) + + name0.handleChange('before move') + expect(listener).toHaveBeenCalledOnce() + listener.mockClear() + + // Before: 0 1 + // After: 1 0 + // Fields: name1 name0 + form.swapFieldValues('users', 0, 1) + name0.handleChange('before confirmation') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'Second', + fieldApi: name1, + formApi: form, + }), + ) + listener.mockClear() + + name1._update({ + listeners: [ + { triggers: ['change'], watchFields: ['users[1].name'], run: listener }, + ], + }) + name0.handleChange('after confirmation') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'Second', + fieldApi: name1, + formApi: form, + }), + ) + expect(form.state.values.users).toEqual([ + { name: 'Second' }, + { name: 'after confirmation' }, + ]) + }) + + it('retargets a watch when an update rejects following the moved field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { users: ['first', 'second'], target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['users[0]'], run: listener }, + ], + }) + const user0 = form._getOrCreateFieldApi({ name: 'users[0]' }) + const user1 = form._getOrCreateFieldApi({ name: 'users[1]' }) + user0._register() + user1._register() + target._register() + + // Before: 0 1 + // After: 1 0 + // Fields: user1 user0 + form.swapFieldValues('users', 0, 1) + + user1.handleChange('first index before update') + expect(listener).not.toHaveBeenCalled() + + user0.handleChange('second index before update') + expect(listener).toHaveBeenCalledOnce() + listener.mockClear() + + // Keeping users[0] rejects following user0 to users[1]. + target._update({ + listeners: [ + { triggers: ['change'], watchFields: ['users[0]'], run: listener }, + ], + }) + user0.handleChange('second index after update') + expect(listener).not.toHaveBeenCalled() + + user1.handleChange('first index after update') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(form.state.values.users).toEqual([ + 'first index after update', + 'second index after update', + ]) + }) + + it('keeps following a moved field when an update confirms its new name', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { users: ['first', 'second'], target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['users[0]'], run: listener }, + ], + }) + const user0 = form._getOrCreateFieldApi({ name: 'users[0]' }) + const user1 = form._getOrCreateFieldApi({ name: 'users[1]' }) + + user0.handleChange('first changed') + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target' }), + ) + listener.mockClear() + + // Before: 0 1 + // After: 1 0 + // Fields: user1 user0 + form.swapFieldValues('users', 0, 1) + + // Follow the moved field before the next update confirms the watched name. + user1.handleChange('first index before update') + expect(listener).not.toHaveBeenCalled() + + user0.handleChange('second index before update') + expect(listener).toHaveBeenCalledOnce() + listener.mockClear() + + target._update({ + listeners: [ + { triggers: ['change'], watchFields: ['users[1]'], run: listener }, + ], + }) + + user1.handleChange('first index changed') + expect(listener).not.toHaveBeenCalled() + + user0.handleChange('second index changed') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(form.state.values).toEqual({ + users: ['first index changed', 'second index changed'], + target: 'target', + }) + }) + it('retains default listeners before incoming listeners during construction and updates', () => { const defaultListener = vi.fn() const incomingListener = vi.fn() @@ -208,11 +548,7 @@ describe('InternalFieldApi - _update', () => { 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()) + it('uses updated listener callbacks and triggers, retains omitted listeners, and disables listeners with empty triggers', () => { const initialListener = vi.fn() const updatedListener = vi.fn() const form = new InternalFormApi({ defaultValues: { name: '' } }) @@ -246,12 +582,12 @@ describe('InternalFieldApi - _update', () => { expect(updatedListener).toHaveBeenCalledOnce() updatedListener.mockClear() - field._update({ listeners: [] }) + field._update({ + listeners: [{ triggers: [], run: updatedListener }], + }) + field.handleChange('disabled') field.handleBlur() - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('length of the listener array should not change'), - ) expect(updatedListener).not.toHaveBeenCalled() }) }) @@ -968,6 +1304,27 @@ describe('InternalFieldApi - _register', () => { }) describe('InternalFieldApi - _unregister', () => { + it('notifies unmount listeners with the last field value', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ triggers: ['unmount'], run: listener }], + }) + const unregister = field._register() + field.handleChange('Bob') + expect(listener).not.toHaveBeenCalled() + + unregister() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'Bob', fieldApi: field, formApi: form }), + ) + await vi.runOnlyPendingTimersAsync() + }) + it('continues validating a field while another consumer remains registered', async () => { vi.useFakeTimers() const form = new InternalFormApi({ defaultValues: { name: '' } }) @@ -1315,6 +1672,309 @@ describe('InternalFieldApi - handleChange', () => { }) }) +describe('InternalFieldApi - _notifyListener', () => { + it('notifies a listener when either of its watched fields changes', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', other: 'other', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { + triggers: ['change'], + watchFields: ['source', 'other'], + run: listener, + }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const other = form._getOrCreateFieldApi({ name: 'other' }) + + source.handleChange('source changed') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + listener.mockClear() + + other.handleChange('other changed') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + }) + + it('warns about a field watching itself and runs its listener only once', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { triggers: ['change'], watchFields: ['name'], run: listener }, + ], + }) + + field.handleChange('Alice') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'Alice', + fieldApi: field, + formApi: form, + }), + ) + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([ + expect.stringContaining('cyclical listener cycle detected'), + ]), + ) + expect(field.value).toBe('Alice') + }) + + it('runs every listener watching a source without running direct-only listeners', () => { + const firstListener = vi.fn() + const directListener = vi.fn() + const secondListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: firstListener }, + { triggers: ['change'], run: directListener }, + { triggers: ['change'], watchFields: ['source'], run: secondListener }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + + source.handleChange('changed source') + + expect(firstListener).toHaveBeenCalledOnce() + expect(firstListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(secondListener).toHaveBeenCalledOnce() + expect(secondListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(directListener).not.toHaveBeenCalled() + + target.handleChange('direct edit') + + expect(directListener).toHaveBeenCalledOnce() + expect(directListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'direct edit', + fieldApi: target, + formApi: form, + }), + ) + }) + + it('debounces watched listeners independently', async () => { + vi.useFakeTimers() + const firstListener = vi.fn() + const secondListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { firstSource: '', secondSource: '', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { + triggers: ['change'], + watchFields: ['firstSource'], + triggerDebounceMs: 100, + run: firstListener, + }, + { + triggers: ['change'], + watchFields: ['secondSource'], + triggerDebounceMs: 100, + run: secondListener, + }, + ], + }) + const firstSource = form._getOrCreateFieldApi({ name: 'firstSource' }) + const secondSource = form._getOrCreateFieldApi({ name: 'secondSource' }) + + secondSource.handleChange('second') + await vi.advanceTimersByTimeAsync(50) + firstSource.handleChange('first') + await vi.advanceTimersByTimeAsync(49) + + expect(firstListener).not.toHaveBeenCalled() + expect(secondListener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + expect(firstListener).not.toHaveBeenCalled() + expect(secondListener).toHaveBeenCalledOnce() + expect(secondListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + + await vi.advanceTimersByTimeAsync(50) + + expect(firstListener).toHaveBeenCalledOnce() + expect(firstListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(secondListener).toHaveBeenCalledOnce() + }) + + it('notifies only matching change and blur listeners watching the affected field', () => { + const changeListener = vi.fn() + const blurListener = vi.fn() + const unrelatedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', other: 'other', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: changeListener }, + { triggers: ['blur'], watchFields: ['source'], run: blurListener }, + { + triggers: ['change', 'blur'], + watchFields: ['other'], + run: unrelatedListener, + }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + target._register() + source._register() + + source.handleChange('updated source') + + expect(changeListener).toHaveBeenCalledOnce() + expect(changeListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(blurListener).not.toHaveBeenCalled() + expect(unrelatedListener).not.toHaveBeenCalled() + + source.handleBlur() + + expect(changeListener).toHaveBeenCalledOnce() + expect(blurListener).toHaveBeenCalledOnce() + expect(blurListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(unrelatedListener).not.toHaveBeenCalled() + }) + + it('notifies only listeners watching a field being registered', () => { + const listener = vi.fn() + const unrelatedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', other: 'other', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['mount'], watchFields: ['source'], run: listener }, + { triggers: ['mount'], watchFields: ['other'], run: unrelatedListener }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + target._register() + listener.mockClear() + unrelatedListener.mockClear() + + source._register() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(unrelatedListener).not.toHaveBeenCalled() + }) + + it('notifies only listeners watching a field being unregistered', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const unrelatedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', other: 'other', target: 'target' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['unmount'], watchFields: ['source'], run: listener }, + { + triggers: ['unmount'], + watchFields: ['other'], + run: unrelatedListener, + }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + target._register() + const unregisterSource = source._register() + expect(listener).not.toHaveBeenCalled() + + unregisterSource() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(unrelatedListener).not.toHaveBeenCalled() + await vi.runOnlyPendingTimersAsync() + }) +}) + describe('InternalFieldApi - _clearEventErrors', () => { it('clears submit-only validator errors only for the field that changes', async () => { const form = new InternalFormApi({ diff --git a/packages/form-core/tests/FieldApi/listeners.spec.ts b/packages/form-core/tests/FieldApi/listeners.spec.ts deleted file mode 100644 index 21abb2e0c1..0000000000 --- a/packages/form-core/tests/FieldApi/listeners.spec.ts +++ /dev/null @@ -1,786 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('field - listeners', () => { - it('change listeners', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [{ triggers: ['change'], run: listener }], - }) - - field.handleChange('x') - expect(listener).toHaveBeenCalledOnce() - }) - - it('blur listeners', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [{ triggers: ['blur'], run: listener }], - }) - - field.handleBlur() - expect(listener).toHaveBeenCalledOnce() - }) - - it('skips a field listener when a boolean predicate disables the trigger', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [ - { - triggers: [{ trigger: 'change', when: false }], - run: listener, - }, - ], - }) - - field.handleChange('x') - - expect(listener).not.toHaveBeenCalled() - }) - - it('passes field context to listener predicates', () => { - const listener = vi.fn() - const when = vi.fn(({ triggerFieldApi, value }) => { - expect(triggerFieldApi.name).toBe('x') - return value === 'run' - }) - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [ - { - triggers: [{ trigger: 'change', when }], - run: listener, - }, - ], - }) - - field.handleChange('skip') - field.handleChange('run') - - expect(when).toHaveBeenCalledTimes(2) - expect(listener).toHaveBeenCalledOnce() - }) - - it('does not run field listeners with empty triggers', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [{ triggers: [], run: listener }], - }) - - field.handleChange('x') - - expect(listener).not.toHaveBeenCalled() - }) - - it('reset listeners', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [{ triggers: ['reset'], run: listener }], - }) - - field.reset() - expect(listener).toHaveBeenCalledOnce() - }) - - it('notifies reset listeners for a reset field and its descendants', () => { - 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 }], - }) - const child = form._getOrCreateFieldApi({ - name: 'a.b', - listeners: [{ triggers: ['reset'], run: childListener }], - }) - - parent.reset() - - expect(parentListener).toHaveBeenCalledOnce() - expect(childListener).toHaveBeenCalledOnce() - expect(form._tryGetFieldApi('a')).toBe(parent) - expect(form._tryGetFieldApi('a.b')).toBe(child) - }) - - it('notifies reset listeners when a form reset happens', () => { - const parentListener = vi.fn() - const childListener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - form._getOrCreateFieldApi({ - name: 'a', - listeners: [{ triggers: ['reset'], run: parentListener }], - }) - form._getOrCreateFieldApi({ - name: 'a.b', - listeners: [{ triggers: ['reset'], run: childListener }], - }) - - form.reset() - expect(parentListener).toHaveBeenCalledOnce() - expect(childListener).toHaveBeenCalledOnce() - }) - - it('mount listeners', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [{ triggers: ['mount'], run: listener }], - }) - - field._register() - expect(listener).toHaveBeenCalledOnce() - }) - - it('unmount listeners', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [{ triggers: ['unmount'], run: listener }], - }) - - field._register() - field._unregister() - expect(listener).toHaveBeenCalledOnce() - }) - - it('supports dynamic debounce for field listeners', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const triggerDebounceMs = vi.fn(({ formApi, triggerFieldApi, value }) => { - expect(formApi.state.values).toEqual({ name: 'Alice' }) - expect(triggerFieldApi?.name).toBe('name') - expect(value).toBe('Alice') - return 100 - }) - - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'name', - listeners: [ - { - triggers: ['change'], - triggerDebounceMs, - run: listener, - }, - ], - }) - - field.handleChange('Alice') - - expect(triggerDebounceMs).toHaveBeenCalledOnce() - expect(listener).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(99) - expect(listener).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(1) - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - value: 'Alice', - fieldApi: field, - formApi: form, - }) - - vi.useRealTimers() - }) - - describe('field - linked listeners ', () => { - it('change listeners', () => { - const watchedFieldListener = vi.fn() - const unrelatedListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: watchedFieldListener, - }, - { - triggers: ['change'], - watchFields: ['other'], - run: unrelatedListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField.handleChange('source') - expect(watchedFieldListener).toHaveBeenCalledOnce() - expect(watchedFieldListener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - expect(unrelatedListener).not.toHaveBeenCalled() - }) - - it('blur listeners', () => { - const watchedFieldListener = vi.fn() - const unrelatedListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['blur'], - watchFields: ['source'], - run: watchedFieldListener, - }, - { - triggers: ['blur'], - watchFields: ['other'], - run: unrelatedListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField.handleBlur() - expect(watchedFieldListener).toHaveBeenCalledOnce() - expect(watchedFieldListener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - expect(unrelatedListener).not.toHaveBeenCalled() - }) - - it('reset listeners', () => { - const watchedFieldListener = vi.fn() - const unrelatedListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['reset'], - watchFields: ['source'], - run: watchedFieldListener, - }, - { - triggers: ['reset'], - watchFields: ['other'], - run: unrelatedListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField.reset() - expect(watchedFieldListener).toHaveBeenCalledOnce() - expect(watchedFieldListener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - expect(unrelatedListener).not.toHaveBeenCalled() - }) - - it('mount listeners', () => { - const watchedFieldListener = vi.fn() - const unrelatedListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['mount'], - watchFields: ['source'], - run: watchedFieldListener, - }, - { - triggers: ['mount'], - watchFields: ['other'], - run: unrelatedListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField._register() - expect(watchedFieldListener).toHaveBeenCalledOnce() - expect(watchedFieldListener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - expect(unrelatedListener).not.toHaveBeenCalled() - }) - - it('unmount listeners', () => { - const watchedFieldListener = vi.fn() - const unrelatedListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['unmount'], - watchFields: ['source'], - run: watchedFieldListener, - }, - { - triggers: ['unmount'], - watchFields: ['other'], - run: unrelatedListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField._register() - sourceField._unregister() - expect(watchedFieldListener).toHaveBeenCalledOnce() - expect(watchedFieldListener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - expect(unrelatedListener).not.toHaveBeenCalled() - }) - - it('updates a dynamic watched field name after its field moves', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { users: ['first', 'second'], target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['change'], - watchFields: ['users[0]'], - run: listener, - }, - ], - }) - const fieldAt0Before = form._getOrCreateFieldApi({ name: 'users[0]' }) - const fieldAt1Before = form._getOrCreateFieldApi({ name: 'users[1]' }) - - form.swapFieldValues('users', 0, 1) - expect(form._getOrCreateFieldApi({ name: 'users[1]' })).toBe( - fieldAt0Before, - ) - - targetField._update({ - listeners: [ - { - triggers: ['change'], - watchFields: ['users[1]'], - run: listener, - }, - ], - }) - - fieldAt1Before.handleChange('first index changed') - expect(listener).not.toHaveBeenCalled() - - fieldAt0Before.handleChange('second index changed') - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - }) - - it('updates watched fields when the watched name changes without moving a field', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: listener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - const otherField = form._getOrCreateFieldApi({ name: 'other' }) - - targetField._update({ - listeners: [ - { - triggers: ['change'], - watchFields: ['other'], - run: listener, - }, - ], - }) - - sourceField.handleChange('source changed') - expect(listener).not.toHaveBeenCalled() - - otherField.handleChange('other changed') - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ - value: '', - fieldApi: targetField, - formApi: form, - }) - }) - - it('keeps remaining listener watches when one watched listener is removed', () => { - const firstListener = vi.fn() - const secondListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: firstListener, - }, - { - triggers: ['change'], - watchFields: ['source'], - run: secondListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - targetField._update({ - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: firstListener, - }, - ], - }) - sourceField.handleChange('source') - - expect(firstListener).toHaveBeenCalledOnce() - expect(secondListener).not.toHaveBeenCalled() - }) - - it('keeps other watching fields attached when one field stops listening', () => { - const firstListener = vi.fn() - const secondListener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', first: '', second: '' }, - }) - const firstField = form._getOrCreateFieldApi({ - name: 'first', - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: firstListener, - }, - ], - }) - form._getOrCreateFieldApi({ - name: 'second', - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: secondListener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - firstField._update({ listeners: [] }) - sourceField.handleChange('source') - - expect(firstListener).not.toHaveBeenCalled() - expect(secondListener).toHaveBeenCalledOnce() - }) - - it("does not run a watched field's listener when the trigger does not match", () => { - const listener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['blur'], - watchFields: ['source'], - run: listener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField.handleChange('source') - - expect(listener).not.toHaveBeenCalled() - }) - - it('runs a linked listener when any of multiple watched fields changes', () => { - const listener = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', other: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['change'], - watchFields: ['source', 'other'], - run: listener, - }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - const otherField = form._getOrCreateFieldApi({ name: 'other' }) - - sourceField.handleChange('source') - otherField.handleChange('other') - - expect(listener).toHaveBeenCalledTimes(2) - expect(listener).toHaveBeenNthCalledWith(1, { - value: '', - fieldApi: targetField, - formApi: form, - }) - expect(listener).toHaveBeenNthCalledWith(2, { - value: '', - fieldApi: targetField, - formApi: form, - }) - }) - - it('follows fields across dynamic names', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { users: [{ name: 'First' }, { name: 'Second' }] }, - }) - - const firstField = form._getOrCreateFieldApi({ name: 'users[0]' }) - const secondField = form._getOrCreateFieldApi({ - name: 'users[1]', - listeners: [ - { run: listener, watchFields: ['users[0]'], triggers: ['change'] }, - ], - }) - - // Confirm that SecondField is listening to firstField -> firstField change - firstField.handleChange('New name') - expect(listener).toHaveBeenCalledOnce() - - // swapFieldValues - form.swapFieldValues('users', 0, 1) - - secondField._update({ - listeners: [ - { run: listener, watchFields: ['users[1]'], triggers: ['change'] }, - ], - }) - firstField.handleChange('New new name') - expect(listener).toHaveBeenCalledTimes(2) - }) - - it('handles mismatched field references when field name resolves to different instance', () => { - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { - users: [{ name: 'First' }, { name: 'Second' }, { name: 'Third' }], - }, - }) - - const oldField = form._getOrCreateFieldApi({ name: 'users[0]' }) - const watchingField = form._getOrCreateFieldApi({ - name: 'users[1]', - listeners: [ - { run: listener, watchFields: ['users[0]'], triggers: ['change'] }, - ], - }) - - oldField.handleChange('First change') - expect(listener).toHaveBeenCalledOnce() - listener.mockClear() - - // Swap the field values - now users[0] resolves to what was users[2] - form.swapFieldValues('users', 0, 2) - - // Update listener to still watch 'users[0]', but now that name resolves to thirdField - watchingField._update({ - listeners: [ - { run: listener, watchFields: ['users[0]'], triggers: ['change'] }, - ], - }) - - oldField.handleChange('Second change') - // old firstField change should NOT trigger listener - // because we're no longer watching it - expect(listener).not.toHaveBeenCalled() - - const newField = form._getOrCreateFieldApi({ name: 'users[0]' }) - newField.handleChange('Third change') - expect(listener).toHaveBeenCalledOnce() - }) - - it('should warn if a field watches itself', async () => { - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - - const firstField = form._getOrCreateFieldApi({ - name: 'name', - listeners: [ - { run: listener, watchFields: ['name'], triggers: ['change'] }, - ], - }) - - firstField.handleChange('Change') - await vi.waitFor(() => { - expect(consoleSpy).toHaveBeenCalled() - expect(listener).toHaveBeenCalledOnce() - }) - - consoleSpy.mockRestore() - }) - - it('should skip listeners that do not watch the linked field', () => { - const listener1 = vi.fn() - const listener2 = vi.fn() - const listener3 = vi.fn() - - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - - form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { run: listener1, triggers: ['change'], watchFields: ['source'] }, - { run: listener2, triggers: ['change'] }, - { run: listener3, triggers: ['change'], watchFields: ['source'] }, - ], - }) - - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - sourceField.handleChange('new value') - - expect(listener1).toHaveBeenCalledOnce() - expect(listener2).not.toHaveBeenCalled() - expect(listener3).toHaveBeenCalledOnce() - }) - - it('keeps debounced watched listeners isolated by stable instance', async () => { - vi.useFakeTimers() - const firstListener = vi.fn() - const secondListener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { firstSource: '', secondSource: '', target: '' }, - }) - form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - run: firstListener, - triggers: ['change'], - watchFields: ['firstSource'], - triggerDebounceMs: 100, - }, - { - run: secondListener, - triggers: ['change'], - watchFields: ['secondSource'], - triggerDebounceMs: 100, - }, - ], - }) - const firstSource = form._getOrCreateFieldApi({ name: 'firstSource' }) - const secondSource = form._getOrCreateFieldApi({ name: 'secondSource' }) - - secondSource.handleChange('second') - firstSource.handleChange('first') - await vi.advanceTimersByTimeAsync(100) - - expect(firstListener).toHaveBeenCalledOnce() - expect(secondListener).toHaveBeenCalledOnce() - vi.useRealTimers() - }) - - it('clears watched listener links when reset kills fields', () => { - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { run: vi.fn(), watchFields: ['source'], triggers: ['change'] }, - ], - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - - expect(sourceField._watchingListenerFields?.has(targetField)).toBe(true) - expect( - targetField._listenerInstances?.[0]?.resolvedWatchFields?.get('source'), - ).toBe(sourceField) - - form.reset() - - expect(sourceField._watchingListenerFields).toBeNull() - expect(targetField._listenerInstances).toBeNull() - expect(sourceField._isKilled).toBe(true) - expect(targetField._isKilled).toBe(true) - expect(form._tryGetFieldApi('source')).toBeNull() - expect(form._tryGetFieldApi('target')).toBeNull() - }) - }) -}) diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts index 038a5dead6..f720ab6b8e 100644 --- a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -744,6 +744,68 @@ describe('InternalFormApi - setFieldValue', () => { }) describe('InternalFormApi - reset', () => { + it('discards old watched fields and allows rebuilt fields to establish new listeners', () => { + const discardedListener = vi.fn() + const currentListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: 'source', target: 'target' }, + }) + const oldTarget = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { + triggers: ['change'], + watchFields: ['source'], + run: discardedListener, + }, + ], + }) + const oldSource = form._getOrCreateFieldApi({ name: 'source' }) + oldSource._register() + oldTarget._register() + + oldSource.handleChange('before reset') + expect(discardedListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target' }), + ) + discardedListener.mockClear() + + form.reset() + + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: currentListener }, + ], + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + source._register() + target._register() + + oldSource.handleChange('discarded source') + oldTarget.handleChange('discarded target') + + expect(discardedListener).not.toHaveBeenCalled() + expect(currentListener).not.toHaveBeenCalled() + expect(form.state.values).toEqual({ source: 'source', target: 'target' }) + + source.handleChange('after reset') + + expect(currentListener).toHaveBeenCalledOnce() + expect(currentListener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'target', + fieldApi: target, + formApi: form, + }), + ) + expect(discardedListener).not.toHaveBeenCalled() + expect(form.state.values).toEqual({ + source: 'after reset', + target: 'target', + }) + }) + it('notifies form reset listeners after restoring the default values', () => { const listener = vi.fn() const form = new InternalFormApi({ @@ -765,39 +827,6 @@ 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, }) => { diff --git a/packages/form-core/tests/listeners.lib.spec.ts b/packages/form-core/tests/listeners.lib.spec.ts index 4e981a4ace..b7237d8af3 100644 --- a/packages/form-core/tests/listeners.lib.spec.ts +++ b/packages/form-core/tests/listeners.lib.spec.ts @@ -122,3 +122,99 @@ describe('runFormListenerPipeline', () => { .toContainEqual(expect.arrayContaining([error])) }) }) + +describe('runFieldListenerPipeline', () => { + it('skips disabled listeners and listeners with no triggers', () => { + const disabledListener = vi.fn() + const inactiveListener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { + triggers: [{ trigger: 'change', when: false }], + run: disabledListener, + }, + { triggers: [], run: inactiveListener }, + ], + }) + + field.handleChange('Alice') + + expect(field.value).toBe('Alice') + expect(disabledListener).not.toHaveBeenCalled() + expect(inactiveListener).not.toHaveBeenCalled() + }) + + it('runs a listener only when its predicate allows the current field context', () => { + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { + triggers: [ + { + trigger: 'change', + when: ({ triggerFieldApi, value }) => + triggerFieldApi?.name === 'name' && value === 'Alice', + }, + ], + run: listener, + }, + ], + }) + + field.handleChange('Bob') + expect(listener).not.toHaveBeenCalled() + + field.handleChange('Alice') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'Alice', + fieldApi: field, + formApi: form, + }), + ) + }) + + it('delays a listener according to the current field context', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: ({ formApi, triggerFieldApi, value }) => { + if (triggerFieldApi?.name !== 'name') return 0 + if (formApi.state.values.name === value) return 0 + return value.length * 20 + }, + run: listener, + }, + ], + }) + + field.handleChange('Alice') + + expect(listener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(99) + expect(listener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'Alice', + fieldApi: field, + formApi: form, + }), + ) + }) +}) diff --git a/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts b/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts index 5a6c1a5791..dd9a1505e2 100644 --- a/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts +++ b/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts @@ -12,7 +12,7 @@ export const serverErrorOnUnmountedField = { const validator = field.form._validatorInstances?.[error.source.validatorIndex]?.definition - const hasServerTrigger = validator?.triggers.includes('server') + const hasServerTrigger = validator?.triggers?.includes('server') if (!hasServerTrigger) return undefined diff --git a/packages/form-devtools/tests/fieldActionsBridge.test.ts b/packages/form-devtools/tests/fieldActionsBridge.test.ts index a6a74aabc0..61ded176c2 100644 --- a/packages/form-devtools/tests/fieldActionsBridge.test.ts +++ b/packages/form-devtools/tests/fieldActionsBridge.test.ts @@ -14,7 +14,6 @@ describe('field actions bridge', () => { const fields = createFieldsController(mountedForms) const changeListener = vi.fn() const blurListener = vi.fn() - const resetListener = vi.fn() const details: Array = [] const cleanupDetails = formDevtoolsEventClient.on( 'field-detail-changed', @@ -26,7 +25,6 @@ describe('field actions bridge', () => { listeners: [ { triggers: ['change'], run: changeListener }, { triggers: ['blur'], run: blurListener }, - { triggers: ['reset'], run: resetListener }, ], }) @@ -61,7 +59,6 @@ describe('field actions bridge', () => { field.handleChange('Grace') changeListener.mockClear() formDevtoolsEventClient.emit('field-reset-request', request) - expect(resetListener).toHaveBeenCalledOnce() expect(changeListener).not.toHaveBeenCalled() expect(field.value).toBe('Ada') expect(field.meta).toMatchObject({ From 30953db27da08b6d4808007b4478e746d49b04dd Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:42:50 +0200 Subject: [PATCH 15/16] fix: correct faulty unit test --- packages/form-core/tests/listeners.lib.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/form-core/tests/listeners.lib.spec.ts b/packages/form-core/tests/listeners.lib.spec.ts index b7237d8af3..60f7d77df9 100644 --- a/packages/form-core/tests/listeners.lib.spec.ts +++ b/packages/form-core/tests/listeners.lib.spec.ts @@ -190,8 +190,10 @@ describe('runFieldListenerPipeline', () => { { triggers: ['change'], triggerDebounceMs: ({ formApi, triggerFieldApi, value }) => { + // These make the unit test fail if true. Checks that + // the params agree with each other if (triggerFieldApi?.name !== 'name') return 0 - if (formApi.state.values.name === value) return 0 + if (formApi.state.values.name !== value) return 0 return value.length * 20 }, run: listener, From 96ade799111c5b91ca851b663e73aa2a52e200a9 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:57:17 +0200 Subject: [PATCH 16/16] chore: refactor tests for node 18 compat --- .../tests/FieldApi/FieldApi.lib.spec.ts | 5 ++-- .../tests/FieldApi/fieldTraversal.lib.spec.ts | 6 ++--- .../tests/FormApi/FormApi.lib.spec.ts | 6 ++--- packages/form-core/tests/test-utils.ts | 24 +++++++++++++++++++ 4 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 packages/form-core/tests/test-utils.ts diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts index a46947675f..a50f0e77d4 100644 --- a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +import { promiseWithResolvers } from '../test-utils' describe('getOrCreateFieldApi', () => { it('applies validators to a field whose value was set before registration', async () => { @@ -1008,7 +1009,7 @@ describe('InternalFieldApi - reset', () => { it('aborts active validation, ignores its late result, and allows validation to run again', async ({ onTestFinished, }) => { - const request = Promise.withResolvers() + const request = promiseWithResolvers() onTestFinished(() => request.resolve('Late error')) const onAbort = vi.fn() const validator = vi.fn(() => request.promise) @@ -1208,7 +1209,7 @@ describe('InternalFieldApi - _kill', () => { it('aborts active validation when deleted and ignores late results and subsequent interactions', async ({ onTestFinished, }) => { - const request = Promise.withResolvers() + const request = promiseWithResolvers() onTestFinished(() => request.resolve('Late error')) const onAbort = vi.fn() const validator = vi.fn(() => request.promise) diff --git a/packages/form-core/tests/FieldApi/fieldTraversal.lib.spec.ts b/packages/form-core/tests/FieldApi/fieldTraversal.lib.spec.ts index 98fde663ab..cd01e9d28b 100644 --- a/packages/form-core/tests/FieldApi/fieldTraversal.lib.spec.ts +++ b/packages/form-core/tests/FieldApi/fieldTraversal.lib.spec.ts @@ -61,7 +61,7 @@ describe('visitFieldSubtree', () => { const firstIndex = names.indexOf('section.first') const deepIndex = names.indexOf('section.first.deep') - expect(names.toSorted()).toEqual([ + expect([...names].sort()).toEqual([ 'section', 'section.first', 'section.first.deep', @@ -102,7 +102,7 @@ describe('visitAllFormFields', () => { const deepIndex = names.indexOf('section.first.deep') const secondIndex = names.indexOf('section.second') - expect(names.toSorted()).toEqual([ + expect([...names].sort()).toEqual([ 'other', 'section', 'section.first', @@ -124,7 +124,7 @@ describe('collectFieldSubtree', () => { const firstIndex = names.indexOf('section.first') const deepIndex = names.indexOf('section.first.deep') - expect(names.toSorted()).toEqual([ + expect([...names].sort()).toEqual([ 'section', 'section.first', 'section.first.deep', diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts index f720ab6b8e..3dc9393ff7 100644 --- a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' import { formOptions } from '../../src' +import { promiseWithResolvers } from '../test-utils' describe('InternalFormApi - constructor', () => { it('initializes default values with untouched, submittable state', () => { @@ -830,7 +831,7 @@ describe('InternalFormApi - reset', () => { it('aborts active validation on reset and allows validation to run again', async ({ onTestFinished, }) => { - const request = Promise.withResolvers() + const request = promiseWithResolvers() onTestFinished(() => request.resolve('Late error')) const onAbort = vi.fn() const validator = vi.fn(() => request.promise) @@ -871,8 +872,7 @@ describe('InternalFormApi - reset', () => { it('ignores the result of a submission that finishes after reset', async ({ onTestFinished, }) => { - const { promise: request, resolve: finishRequest } = - Promise.withResolvers() + const { promise: request, resolve: finishRequest } = promiseWithResolvers() onTestFinished(() => finishRequest()) const options = formOptions({ defaultValues: { name: '' }, diff --git a/packages/form-core/tests/test-utils.ts b/packages/form-core/tests/test-utils.ts new file mode 100644 index 0000000000..b7767a448f --- /dev/null +++ b/packages/form-core/tests/test-utils.ts @@ -0,0 +1,24 @@ +/** + * The same as `Promise.withResolvers`, but compatible with node 18. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers + */ +export function promiseWithResolvers(): { + promise: Promise + resolve: (value: T) => void + reject: (reason?: any) => void +} { + let resolve: ((value: T) => void) | undefined = undefined + let reject: ((reason?: any) => void) | undefined = undefined + + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + + return { + promise, + resolve: resolve!, + reject: reject!, + } +}