From d70ede708abd92100581e866422ca9c5310b3da8 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:05:36 +0200 Subject: [PATCH 1/8] feat(core): allow merge config for default options --- .../form-core/src/FieldApi/FieldApi.lib.ts | 104 +++++++++++------- .../src/FieldApi/linked-fields.lib.ts | 4 +- packages/form-core/src/FormApi/FormApi.lib.ts | 54 ++++++--- .../src/FormGroupApi/FormGroupApi.lib.ts | 7 +- packages/form-core/src/defaultOptions.lib.ts | 51 +++++++++ .../form-core/src/defaultOptions.public.ts | 66 +++++++++++ packages/form-core/src/index.ts | 1 + packages/form-core/src/internals.ts | 1 + packages/form-core/src/utils.lib.ts | 2 +- .../tests/FieldApi/Lifecycle.spec.ts | 103 +++++++++++++++++ .../form-core/tests/FormApi/lifecycle.spec.ts | 33 ++++++ .../form-core/tests/defaultOptions.spec.ts | 88 +++++++++++++++ 12 files changed, 457 insertions(+), 57 deletions(-) create mode 100644 packages/form-core/src/defaultOptions.lib.ts create mode 100644 packages/form-core/src/defaultOptions.public.ts create mode 100644 packages/form-core/tests/defaultOptions.spec.ts diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index f5ccb95d35..e9fb53645a 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -11,6 +11,7 @@ import { import { runFieldListenerPipeline } from '../listeners.lib' import { devtools } from '../devtoolsBridge.lib' import { reconcileValidatorInstances } from '../ValidatorInstance.lib' +import { resolveDefaultOptions } from '../defaultOptions.lib' import { attachWatchingListenerField, attachWatchingValidatorField, @@ -193,6 +194,7 @@ export function getOrCreateFieldApi( segments: NameSegments, form: AnyInternalFormApi, options?: Omit, + scope: FieldOptionsScope = 'field', ): AnyInternalFieldApi { const segment = segments.shift() if (segment === undefined) { @@ -200,39 +202,38 @@ export function getOrCreateFieldApi( if (node._isRoot) { throw new Error('Root node cannot be a field API') } - // Say we internally make a field for data storage: - // form._getOrCreateFieldApi({ name: 'foo' }) - - // later in the render cycle, a user renders a component that actually does - // form._getOrCreateFieldApi({ name: 'foo', validators: [...] }) - - // This would be too late! Even worse, we're going to send an error that validators - // changed length when the user did nothing wrong - // TODO - if (options) { - node._update(options) + // Internal trie nodes defer their options until they are first requested as + // a configured field. Adapter updates handle subsequent option changes. + if (scope !== 'internal' && !node._fieldOptionsInitialized) { + node._update(options ?? {}, scope) } return node } let childNode = node._getChild(segment) if (childNode) { - return getOrCreateFieldApi(childNode, segments, form, options) + return getOrCreateFieldApi(childNode, segments, form, options, scope) } - childNode = new InternalFieldApi({ - segment, - parent: node, - form: form, - // We're creating fields on our way to the leaf, so don't - // pass options like listeners etc. - ...(segments.length === 0 ? options : {}), - }) + childNode = new InternalFieldApi( + { + segment, + parent: node, + form: form, + }, + 'internal', + ) node._setChild(childNode) + if (segments.length === 0) { + const field = getOrCreateFieldApi(childNode, segments, form, options, scope) + devtools().fieldAdded?.(childNode) + return field + } + devtools().fieldAdded?.(childNode) - return getOrCreateFieldApi(childNode, segments, form, options) + return getOrCreateFieldApi(childNode, segments, form, options, scope) } /** @@ -271,6 +272,8 @@ export interface InternalFieldApiParams extends Omit< validators?: FieldValidators } +export type FieldOptionsScope = 'internal' | 'field' | 'group' + interface ListenToFieldsMeta { field: AnyInternalFieldApi name: string @@ -324,6 +327,8 @@ export class InternalFieldApi< _watchingValidatorFields: FieldWatchingValidatorFields | null /** Lazily allocated runtime state for debounced field listeners. */ _pipelineCache: PipelineCache | null = null + /** Whether this trie node has received usage-site field options. */ + _fieldOptionsInitialized: boolean _isKilled = false _segmentValue: NameSegment @@ -468,15 +473,23 @@ export class InternalFieldApi< return this._setDefaultValueCache(value, defaultValue, isDefaultValue) } - constructor({ - segment, - parent, - validators, - form, - listeners, - errorVisibility, - errorBoundary, - }: InternalFieldApiParams) { + constructor( + options: InternalFieldApiParams, + scope: FieldOptionsScope = 'field', + ) { + this._fieldOptionsInitialized = scope !== 'internal' + const defaultOptions = + scope === 'field' ? options.form._defaultOptions?.field : undefined + const { + segment, + parent, + validators, + form, + listeners, + errorVisibility, + errorBoundary, + } = resolveDefaultOptions(options, defaultOptions) + this._segmentValue = segment this._parent = parent this.form = form @@ -523,16 +536,25 @@ export class InternalFieldApi< reconciledValidators.attach.forEach(attachWatchingValidatorField) } - _update(options: Omit) { + _update( + options: Omit, + scope: FieldOptionsScope = 'field', + ) { if (this._isKilled) return - this._errorVisibility = options.errorVisibility - this._errorBoundary = options.errorBoundary ?? false + const isInitializing = + scope !== 'internal' && !this._fieldOptionsInitialized + const defaultOptions = + scope === 'field' ? this.form._defaultOptions?.field : undefined + const resolvedOptions = resolveDefaultOptions(options, defaultOptions) + + this._errorVisibility = resolvedOptions.errorVisibility + this._errorBoundary = resolvedOptions.errorBoundary ?? false const reconciledListeners = reconcileWatchedListenerFields({ field: this, prevListenToFields: this._listenToFields, - nextListeners: options.listeners, + nextListeners: resolvedOptions.listeners, form: this.form, }) @@ -549,13 +571,13 @@ export class InternalFieldApi< ? [...reconciledListeners.attach, ...reconciledListeners.detach] : null - if (options.validators) { + if (resolvedOptions.validators) { const previousValidators = this._validatorInstances?.map( (instance) => instance.definition, ) const nextValidators = - options.validators.length > 0 - ? (options.validators as Array) + resolvedOptions.validators.length > 0 + ? (resolvedOptions.validators as Array) : null this._validatorInstances = reconcileValidatorInstances< AnyFieldValidator, @@ -564,7 +586,9 @@ export class InternalFieldApi< AnyInternalFieldApi >({ definitions: nextValidators, - previousDefinitions: previousValidators ?? null, + previousDefinitions: isInitializing + ? undefined + : (previousValidators ?? null), instances: this._validatorInstances, owner: this, scope: 'field', @@ -603,6 +627,10 @@ export class InternalFieldApi< if (dependencyChanges && dependencyChanges.length > 0) { notifyDependencyChanges?.(dependencyChanges) } + + if (scope !== 'internal') { + this._fieldOptionsInitialized = true + } } /** diff --git a/packages/form-core/src/FieldApi/linked-fields.lib.ts b/packages/form-core/src/FieldApi/linked-fields.lib.ts index d548ac1f29..292097a1c6 100644 --- a/packages/form-core/src/FieldApi/linked-fields.lib.ts +++ b/packages/form-core/src/FieldApi/linked-fields.lib.ts @@ -78,7 +78,7 @@ function reconcileWatchedFields }>({ if (names.length === 0) return nextListenToFields[watcherIndex] = names.map((name) => { - const sourceField = form._getOrCreateFieldApi({ name }) + const sourceField = form._getOrCreateFieldApi({ name }, 'internal') const key = toWatcherKey(watcherIndex, name) const prevMeta = prevByKey.get(key) @@ -167,7 +167,7 @@ export function reconcileWatchedValidatorFields({ const names = [...new Set(validatorInstance.definition.watchFields ?? [])] for (const name of names) { - const sourceField = form._getOrCreateFieldApi({ name }) + const sourceField = form._getOrCreateFieldApi({ name }, 'internal') next.set(name, sourceField) const previousField = previous?.get(name) diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index 54d2cb04d8..23a50f81f9 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -39,6 +39,7 @@ import { applyServerState } from '../ssr.lib' import { devtools } from '../devtoolsBridge.lib' import { reconcileValidatorInstances } from '../ValidatorInstance.lib' import { InternalValidationSourceInstance } from '../ValidationSourceInstance.lib' +import { resolveDefaultOptions } from '../defaultOptions.lib' import { runSubmissionProcess } from './handleSubmit.lib' import { ArrayMethods } from './array-methods.lib' import { @@ -61,6 +62,7 @@ import type { AnyFieldApiOptions, AnyInternalFieldApi, DefaultValueCacheEntry, + FieldOptionsScope, } from '../FieldApi/FieldApi.lib' import type { InternalBaseFieldMeta, @@ -89,6 +91,7 @@ import type { InternalValidatorInstances, } from '../ValidatorInstance.lib' import type { AnyInternalValidationSourceInstance } from '../ValidationSourceInstance.lib' +import type { DefaultOptions } from '../defaultOptions.public' export interface FormMetaAtoms { isDirty: Atom @@ -216,6 +219,7 @@ export class InternalFormApi< _atoms: FormAtoms _fieldRootNode: InternalRootFieldApi _defaultValueCache: DefaultValueCacheEntry | null = null + readonly _defaultOptions: DefaultOptions | undefined _options: InternalFormOptions /** Stable runtime instances correlated with `_options.validators` by slot. */ _validatorInstances: InternalValidatorInstances< @@ -291,12 +295,21 @@ export class InternalFormApi< ) } - constructor(options: FormOptions) { - this._options = { ...options, formId: options.formId ?? uuid() } - this._lastUpdateDefaultValues = options.defaultValues + constructor( + options: FormOptions, + defaultOptions?: DefaultOptions, + ) { + this._defaultOptions = defaultOptions + const resolvedOptions = resolveDefaultOptions(options, defaultOptions?.form) + + this._options = { + ...resolvedOptions, + formId: resolvedOptions.formId ?? uuid(), + } + this._lastUpdateDefaultValues = resolvedOptions.defaultValues this._pipelineCache = createPipelineCache() this._atoms = { - values: createAtom(options.defaultValues), + values: createAtom(resolvedOptions.defaultValues), meta: createInitialFormMetaAtoms(), resetVersion: createAtom(0), defaultValuesVersion: createAtom(0), @@ -326,7 +339,7 @@ export class InternalFormApi< applyServerState( this, this._options.serverState ?? null, - options.defaultValues, + resolvedOptions.defaultValues, ) this._runMountValidation() } @@ -397,20 +410,24 @@ export class InternalFormApi< } _update(options: FormOptions) { + const resolvedOptions = resolveDefaultOptions( + options, + this._defaultOptions?.form, + ) const oldOptions = this._options const didDefaultValuesChange = !evaluate( - options.defaultValues, + resolvedOptions.defaultValues, this._lastUpdateDefaultValues, ) - this._lastUpdateDefaultValues = options.defaultValues + this._lastUpdateDefaultValues = resolvedOptions.defaultValues this._defaultValueCache = null this._options = { - ...options, + ...resolvedOptions, defaultValues: didDefaultValuesChange - ? options.defaultValues + ? resolvedOptions.defaultValues : oldOptions.defaultValues, - formId: options.formId ?? oldOptions.formId, + formId: resolvedOptions.formId ?? oldOptions.formId, } this._validatorInstances = reconcileValidatorInstances< @@ -431,12 +448,12 @@ export class InternalFormApi< batch(() => { this._atoms.defaultValuesVersion.set((version) => version + 1) if (this._atoms.meta.touchedFieldCount.get() === 0) { - this._atoms.values.set(options.defaultValues) + this._atoms.values.set(resolvedOptions.defaultValues) } else { this._atoms.values.set((prev) => applyDefaultValuesPreservingTouchedFields( prev, - options.defaultValues, + resolvedOptions.defaultValues, this, ), ) @@ -447,7 +464,7 @@ export class InternalFormApi< applyServerState( this, this._options.serverState ?? null, - options.defaultValues, + resolvedOptions.defaultValues, ) if (didDefaultValuesChange) notifyDevtoolsDefaultValuesUpdate(this) devtools().updateForm?.(this) @@ -710,6 +727,7 @@ export class InternalFormApi< _getOrCreateFieldApi( options: Omit, + scope: FieldOptionsScope = 'field', ): AnyInternalFieldApi { const { name, ...restOpts } = options @@ -720,6 +738,7 @@ export class InternalFormApi< nameToFieldNodeSegments(name), this, fieldOptions, + scope, ) } @@ -755,7 +774,14 @@ export class InternalFormApi< } const target = - boundary ?? getOrCreateFieldApi(routingRoot, segments.slice(), this) + boundary ?? + getOrCreateFieldApi( + routingRoot, + segments.slice(), + this, + undefined, + 'internal', + ) resolvedFieldErrors.set( target, diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index 439591ce6d..012b878107 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -136,7 +136,10 @@ export class InternalFormGroupApi< ) { this._options = options this.form = options.form as never - this._groupField = this.form._getOrCreateFieldApi({ name: options.name }) + this._groupField = this.form._getOrCreateFieldApi( + { name: options.name }, + 'internal', + ) this._groupField._setFormGroup(this) this._validatorInstances = reconcileValidatorInstances< TGroupValidators[number], @@ -214,7 +217,7 @@ export class InternalFormGroupApi< /** Attaches this group to the trie node at the given path. */ _attachToFieldTrie(name: string): void { - const groupField = this.form._getOrCreateFieldApi({ name }) + const groupField = this.form._getOrCreateFieldApi({ name }, 'internal') if (groupField !== this._groupField) { this._groupField = groupField diff --git a/packages/form-core/src/defaultOptions.lib.ts b/packages/form-core/src/defaultOptions.lib.ts new file mode 100644 index 0000000000..f372d72be3 --- /dev/null +++ b/packages/form-core/src/defaultOptions.lib.ts @@ -0,0 +1,51 @@ +import type { + DefaultFieldOptions, + DefaultFormGroupOptions, + DefaultFormOptions, + DefaultListenersMergeMode, +} from './defaultOptions.public' + +type AnyDefaultOptions = + DefaultFormOptions | DefaultFieldOptions | DefaultFormGroupOptions + +interface OptionsWithListeners { + listeners?: Array +} + +interface RuntimeDefaultOptions extends OptionsWithListeners { + listenersMerge?: DefaultListenersMergeMode + [key: string]: unknown +} + +/** + * Resolves usage-site options against reusable defaults without mutating + * either input. + */ +export function resolveDefaultOptions( + options: TOptions, + defaultOptions?: AnyDefaultOptions, +): TOptions { + if (!defaultOptions) return options + + const { listenersMerge = 'replace', ...optionDefaults } = + defaultOptions as RuntimeDefaultOptions + const resolvedOptions = { ...optionDefaults, ...options } as TOptions + + if (!Object.hasOwn(options, 'listeners') || listenersMerge === 'replace') { + return resolvedOptions + } + + const incomingListeners = (options as OptionsWithListeners).listeners + const defaultListeners = optionDefaults.listeners + + if (incomingListeners === undefined || defaultListeners === undefined) { + return resolvedOptions + } + + const resolvedListeners = + listenersMerge === 'append' + ? [...defaultListeners, ...incomingListeners] + : [...incomingListeners, ...defaultListeners] + + return { ...resolvedOptions, listeners: resolvedListeners } +} diff --git a/packages/form-core/src/defaultOptions.public.ts b/packages/form-core/src/defaultOptions.public.ts new file mode 100644 index 0000000000..bb304aca08 --- /dev/null +++ b/packages/form-core/src/defaultOptions.public.ts @@ -0,0 +1,66 @@ +import type { FieldApiOptions } from './FieldApi/FieldApi.public' +import type { FormOptions } from './FormApi/FormApi.public' +import type { FormGroupOptions } from './FormGroupApi/FormGroupApi.public' +import type { + FieldValidators, + FormErrorTypes, + FormGroupValidators, + FormValidators, +} from './validation.public' + +/** Controls how usage-site listeners combine with default listeners. */ +export type DefaultListenersMergeMode = 'replace' | 'append' | 'prepend' + +interface DefaultListenersMergeOptions { + /** + * Controls how usage-site listeners combine with default listeners. + * + * - `'replace'`: Usage-site listeners replace default listeners. + * - `'append'`: Usage-site listeners run after default listeners. + * - `'prepend'`: Usage-site listeners run before default listeners. + * + * @default 'replace' + */ + listenersMerge?: DefaultListenersMergeMode +} + +/** Form options that can be configured as reusable defaults. */ +export type DefaultFormOptions = Pick< + FormOptions, unknown>, + 'errorVisibility' | 'listeners' | 'onSubmitInvalid' +> & + DefaultListenersMergeOptions + +/** Field options that can be configured as reusable defaults. */ +export type DefaultFieldOptions = Pick< + FieldApiOptions< + unknown, + string, + unknown, + FieldValidators, + never, + unknown, + FormErrorTypes + >, + 'errorVisibility' | 'errorBoundary' | 'listeners' +> & + DefaultListenersMergeOptions + +/** Form group options that can be configured as reusable defaults. */ +export type DefaultFormGroupOptions = Pick< + FormGroupOptions< + unknown, + string, + unknown, + FormGroupValidators, + FormErrorTypes + >, + 'onSubmitInvalid' +> + +/** Reusable defaults owned by a form and applied to opted-in APIs. */ +export interface DefaultOptions { + form?: DefaultFormOptions + field?: DefaultFieldOptions + formGroup?: DefaultFormGroupOptions +} diff --git a/packages/form-core/src/index.ts b/packages/form-core/src/index.ts index f2285110a0..854bf1cbf8 100644 --- a/packages/form-core/src/index.ts +++ b/packages/form-core/src/index.ts @@ -10,3 +10,4 @@ export * from './listeners.public' export * from './utils.public' export * from './deep-keys.public' export * from './ssr.public' +export * from './defaultOptions.public' diff --git a/packages/form-core/src/internals.ts b/packages/form-core/src/internals.ts index 8bdcd3addf..843ee40074 100644 --- a/packages/form-core/src/internals.ts +++ b/packages/form-core/src/internals.ts @@ -15,3 +15,4 @@ export * from './FieldApi/linked-fields.lib' export * from './standardSchema.lib' export * from './devtoolsBridge.lib' export * from './ssr.lib' +export * from './defaultOptions.lib' diff --git a/packages/form-core/src/utils.lib.ts b/packages/form-core/src/utils.lib.ts index 535485f5cf..2f65b90b2e 100644 --- a/packages/form-core/src/utils.lib.ts +++ b/packages/form-core/src/utils.lib.ts @@ -83,7 +83,7 @@ export function getTargetField( } else if (options._skipFieldCreation) { field = formApi._tryGetFieldApi(fieldName) } else { - field = formApi._getOrCreateFieldApi({ name: fieldName }) + field = formApi._getOrCreateFieldApi({ name: fieldName }, 'internal') } return field } diff --git a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts index 9c56c83647..6423e7d043 100644 --- a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts +++ b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts @@ -9,6 +9,109 @@ 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: '', group: '' } }, + { + field: { + errorBoundary: true, + listenersMerge: 'append', + listeners: [ + { triggers: ['change'], run: () => calls.push('default') }, + ], + }, + }, + ) + const internalField = form._getOrCreateFieldApi( + { name: 'internal' }, + 'internal', + ) + const groupField = form._getOrCreateFieldApi({ name: 'group' }, 'group') + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { triggers: ['change'], run: () => calls.push('incoming') }, + ], + }) + + expect(internalField._errorBoundary).toBe(false) + expect(groupField._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: '' } }) diff --git a/packages/form-core/tests/FormApi/lifecycle.spec.ts b/packages/form-core/tests/FormApi/lifecycle.spec.ts index 7d65926a2d..dfd2a5060f 100644 --- a/packages/form-core/tests/FormApi/lifecycle.spec.ts +++ b/packages/form-core/tests/FormApi/lifecycle.spec.ts @@ -254,6 +254,39 @@ describe('form - lifecycle', () => { // 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: '' } }) diff --git a/packages/form-core/tests/defaultOptions.spec.ts b/packages/form-core/tests/defaultOptions.spec.ts new file mode 100644 index 0000000000..b9cb6b7770 --- /dev/null +++ b/packages/form-core/tests/defaultOptions.spec.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { resolveDefaultOptions } from '../src/defaultOptions.lib' +import type { + DefaultFormOptions, + DefaultListenersMergeMode, +} from '../src/defaultOptions.public' + +const defaultListener = { + triggers: ['change'] as Array<'change'>, + run: () => undefined, +} +const incomingListener = { + triggers: ['change'] as Array<'change'>, + run: () => undefined, +} + +describe('resolveDefaultOptions', () => { + it('returns the original options when defaults are omitted', () => { + const options = { defaultValues: { name: '' } } + + const resolved = resolveDefaultOptions(options) + + expect(resolved).toBe(options) + }) + + it('applies defaults before incoming options and strips merge metadata', () => { + const defaultErrorVisibility = () => true + const options = { + defaultValues: { name: '' }, + errorVisibility: undefined, + } + const defaultOptions: DefaultFormOptions = { + errorVisibility: defaultErrorVisibility, + onSubmitInvalid: () => undefined, + listenersMerge: 'append', + } + + const resolved = resolveDefaultOptions(options, defaultOptions) + + expect(resolved).toMatchObject({ + defaultValues: { name: '' }, + errorVisibility: undefined, + onSubmitInvalid: defaultOptions.onSubmitInvalid, + }) + expect(resolved).not.toHaveProperty('listenersMerge') + }) + + it.each<{ + mode: DefaultListenersMergeMode + expected: Array + }>([ + { mode: 'replace', expected: [incomingListener] }, + { mode: 'append', expected: [defaultListener, incomingListener] }, + { mode: 'prepend', expected: [incomingListener, defaultListener] }, + ])('resolves listeners using $mode', ({ mode, expected }) => { + const defaultListeners = [defaultListener] + const incomingListeners = [incomingListener] + + const resolved = resolveDefaultOptions( + { defaultValues: {}, listeners: incomingListeners }, + { + listeners: defaultListeners, + listenersMerge: mode, + }, + ) + + expect(resolved.listeners).toEqual(expected) + expect(defaultListeners).toEqual([defaultListener]) + expect(incomingListeners).toEqual([incomingListener]) + }) + + it('uses defaults for omitted properties and respects explicit undefined', () => { + const defaults = { + listeners: [defaultListener], + listenersMerge: 'append' as const, + } + const inherited = resolveDefaultOptions({ defaultValues: {} }, defaults) + const suppressed = resolveDefaultOptions( + { defaultValues: {}, listeners: undefined }, + defaults, + ) + + expect( + (inherited as typeof inherited & { listeners: Array }).listeners, + ).toEqual([defaultListener]) + expect(suppressed.listeners).toBeUndefined() + }) +}) From e0dc421ec397d1c0d149e549652b9c021f80a33c Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:43:54 +0200 Subject: [PATCH 2/8] refactor: migrate React to new opts system --- .../form-core/src/FieldApi/FieldApi.lib.ts | 2 +- .../src/FormGroupApi/FormGroupApi.lib.ts | 15 +- .../form-core/src/defaultOptions.public.ts | 83 ++++++++++- .../tests/FieldApi/Lifecycle.spec.ts | 4 +- .../tests/FormGroupApi/FormGroupApi.spec.ts | 29 ++++ .../react-form/src/AppForm/Components.lib.tsx | 85 +++++------- .../src/AppForm/createFormHook.public.ts | 5 +- .../src/AppForm/createFormHookTypes.public.ts | 130 +++--------------- .../src/AppForm/initializeAppForm.lib.ts | 36 +++-- .../src/ReactForm/Components.lib.tsx | 34 ++++- .../react-form/src/ReactForm/useField.lib.ts | 17 ++- .../react-form/tests/createFormHook.spec.tsx | 111 +++++++++++++-- .../tests/createFormHook.test-d.tsx | 25 +++- 13 files changed, 350 insertions(+), 226 deletions(-) diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index e9fb53645a..d6c960f70b 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -272,7 +272,7 @@ export interface InternalFieldApiParams extends Omit< validators?: FieldValidators } -export type FieldOptionsScope = 'internal' | 'field' | 'group' +export type FieldOptionsScope = 'internal' | 'field' interface ListenToFieldsMeta { field: AnyInternalFieldApi diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index 012b878107..584b26c02e 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -20,6 +20,7 @@ import { import { parseStandardSchemaIssues } from '../standardSchema.lib' import { createErrorMap } from '../validation.public' import { reconcileValidatorInstances } from '../ValidatorInstance.lib' +import { resolveDefaultOptions } from '../defaultOptions.lib' import type { FormApi } from '../FormApi/FormApi.public' import type { InternalFormApi } from '../FormApi/FormApi.lib' import type { @@ -134,10 +135,15 @@ export class InternalFormGroupApi< TFormErrorTypes >, ) { - this._options = options this.form = options.form as never + const resolvedOptions = resolveDefaultOptions( + options, + this.form._defaultOptions?.formGroup, + ) + + this._options = resolvedOptions this._groupField = this.form._getOrCreateFieldApi( - { name: options.name }, + { name: resolvedOptions.name }, 'internal', ) this._groupField._setFormGroup(this) @@ -263,7 +269,10 @@ export class InternalFormGroupApi< >, ) => { const previousValidators = this._options.validators - this._options = options + this._options = resolveDefaultOptions( + options, + this.form._defaultOptions?.formGroup, + ) this._validatorInstances = reconcileValidatorInstances< TGroupValidators[number], AnyInternalFormGroupApi, diff --git a/packages/form-core/src/defaultOptions.public.ts b/packages/form-core/src/defaultOptions.public.ts index bb304aca08..4565b6e651 100644 --- a/packages/form-core/src/defaultOptions.public.ts +++ b/packages/form-core/src/defaultOptions.public.ts @@ -8,7 +8,15 @@ import type { FormValidators, } from './validation.public' -/** Controls how usage-site listeners combine with default listeners. */ +/** + * Determines how a supplied usage-site listener array combines with default + * listeners. + * + * `'append'` runs default listeners before usage-site listeners. `'prepend'` + * runs usage-site listeners first. `'replace'` uses only the usage-site + * listeners. Omitting the usage-site property keeps the defaults, while + * explicitly setting it to `undefined` suppresses them. + */ export type DefaultListenersMergeMode = 'replace' | 'append' | 'prepend' interface DefaultListenersMergeOptions { @@ -19,19 +27,58 @@ interface DefaultListenersMergeOptions { * - `'append'`: Usage-site listeners run after default listeners. * - `'prepend'`: Usage-site listeners run before default listeners. * + * Omitting `listeners` keeps the defaults. Explicitly setting `listeners` to + * `undefined` suppresses them for every merge mode. + * * @default 'replace' */ listenersMerge?: DefaultListenersMergeMode } -/** Form options that can be configured as reusable defaults. */ +/** + * Reusable form behavior that does not participate in form value inference. + * + * Only `errorVisibility`, `listeners`, `onSubmitInvalid`, and `listenersMerge` + * can be shared this way. Callback values are typed as `unknown`, so behavior + * that depends on the inferred form value should remain in the usage-site form + * options. Usage-site properties override defaults even when explicitly set + * to `undefined`; a supplied listener array instead follows `listenersMerge`. + * + * @example + * ```ts + * const formDefaults: DefaultFormOptions = { + * errorVisibility: ({ fieldState }) => fieldState.meta.isBlurred, + * listenersMerge: 'append', + * onSubmitInvalid: () => { + * document.querySelector('[aria-invalid="true"]')?.focus() + * }, + * } + * ``` + */ export type DefaultFormOptions = Pick< FormOptions, unknown>, 'errorVisibility' | 'listeners' | 'onSubmitInvalid' > & DefaultListenersMergeOptions -/** Field options that can be configured as reusable defaults. */ +/** + * Reusable field behavior that does not participate in form or field value + * inference. + * + * Only `errorVisibility`, `errorBoundary`, `listeners`, and `listenersMerge` + * can be shared this way. Listener values and APIs are typed with `unknown` + * values, so value-dependent behavior should remain in the usage-site field + * options. Usage-site properties override defaults even when explicitly set + * to `undefined`; a supplied listener array instead follows `listenersMerge`. + * + * @example + * ```ts + * const fieldDefaults: DefaultFieldOptions = { + * errorVisibility: ({ fieldState }) => fieldState.meta.isBlurred, + * errorBoundary: true, + * } + * ``` + */ export type DefaultFieldOptions = Pick< FieldApiOptions< unknown, @@ -46,7 +93,24 @@ export type DefaultFieldOptions = Pick< > & DefaultListenersMergeOptions -/** Form group options that can be configured as reusable defaults. */ +/** + * Reusable form-group behavior that does not participate in group value + * inference. + * + * Only `onSubmitInvalid` can be shared this way. Its callback receives + * `unknown` form and group values, so value-dependent behavior should remain + * in the usage-site form-group options. A usage-site `onSubmitInvalid` + * property overrides the default even when explicitly set to `undefined`. + * + * @example + * ```ts + * const formGroupDefaults: DefaultFormGroupOptions = { + * onSubmitInvalid: ({ groupApi }) => { + * console.error('Invalid group', groupApi.name) + * }, + * } + * ``` + */ export type DefaultFormGroupOptions = Pick< FormGroupOptions< unknown, @@ -58,9 +122,18 @@ export type DefaultFormGroupOptions = Pick< 'onSubmitInvalid' > -/** Reusable defaults owned by a form and applied to opted-in APIs. */ +/** + * Collects the reusable defaults owned by one form. + * + * Each API resolves its usage-site options against the corresponding entry. + * The defaults remain form-wide configuration rather than becoming part of + * form, field, or group value inference. + */ export interface DefaultOptions { + /** Defaults resolved against form options. */ form?: DefaultFormOptions + /** Defaults resolved against field options. */ field?: DefaultFieldOptions + /** Defaults resolved against form-group options. */ formGroup?: DefaultFormGroupOptions } diff --git a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts index 6423e7d043..a987b52390 100644 --- a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts +++ b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts @@ -13,7 +13,7 @@ describe('field - lifecycle', () => { it('resolves defaults during construction and updates', () => { const calls: Array = [] const form = new InternalFormApi( - { defaultValues: { name: '', internal: '', group: '' } }, + { defaultValues: { name: '', internal: '' } }, { field: { errorBoundary: true, @@ -28,7 +28,6 @@ describe('field - lifecycle', () => { { name: 'internal' }, 'internal', ) - const groupField = form._getOrCreateFieldApi({ name: 'group' }, 'group') const field = form._getOrCreateFieldApi({ name: 'name', listeners: [ @@ -37,7 +36,6 @@ describe('field - lifecycle', () => { }) expect(internalField._errorBoundary).toBe(false) - expect(groupField._errorBoundary).toBe(false) field.handleChange('initial') expect(field._errorBoundary).toBe(true) expect(calls).toEqual(['default', 'incoming']) diff --git a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts index 25c563bedb..90ae953e35 100644 --- a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts @@ -315,6 +315,35 @@ describe('FormGroupApi', () => { expect(group._options.onSubmit).toBe(onSubmit) }) + it('resolves default group options during construction and updates', () => { + const defaultOnSubmitInvalid = vi.fn() + const overriddenOnSubmitInvalid = vi.fn() + const form = new InternalFormApi( + { defaultValues: { guestDetails: { name: 'Tony' } } }, + { formGroup: { onSubmitInvalid: defaultOnSubmitInvalid } }, + ) + const group = new InternalFormGroupApi({ + form, + name: 'guestDetails', + }) + + expect(group._options.onSubmitInvalid).toBe(defaultOnSubmitInvalid) + + group.update({ + form, + name: 'guestDetails', + onSubmitInvalid: overriddenOnSubmitInvalid, + }) + expect(group._options.onSubmitInvalid).toBe(overriddenOnSubmitInvalid) + + group.update({ + form, + name: 'guestDetails', + onSubmitInvalid: undefined, + }) + expect(group._options.onSubmitInvalid).toBeUndefined() + }) + it('keeps group validator instances stable by slot across updates', () => { const form = new InternalFormApi({ defaultValues: { guestDetails: { name: 'Tony' } }, diff --git a/packages/react-form/src/AppForm/Components.lib.tsx b/packages/react-form/src/AppForm/Components.lib.tsx index 9375063927..ac18641cdb 100644 --- a/packages/react-form/src/AppForm/Components.lib.tsx +++ b/packages/react-form/src/AppForm/Components.lib.tsx @@ -1,12 +1,18 @@ import React from 'react' import { InternalFormGroupApi } from '@tanstack/form-core/internals' -import { attachReactFormComponents } from '../ReactForm/Components.lib' +import { + attachReactFormComponents, + createArrayFieldComponent, +} from '../ReactForm/Components.lib' import { useField } from '../ReactForm/useField.lib' import { useValueFieldSubscription } from '../ReactForm/fieldSubscriptions.lib' import { Subscribe } from '../Subscribe.public' import { FieldContext, FormContext } from './contexts.lib' -import type { AnyInternalFormApi } from '@tanstack/form-core/internals' +import type { + AnyInternalFormApi, + FieldOptionsScope, +} from '@tanstack/form-core/internals' import type { FunctionComponent, ReactNode } from 'react' import type { AppFormComponent, @@ -18,10 +24,6 @@ import type { ReactFormGroupProps, } from '../ReactForm/Components.public' import type { InternalReactFormApi } from '../ReactForm/ReactFormApi.lib' -import type { - CreateFormHookDefaultFieldOptions, - CreateFormHookDefaultFormGroupOptions, -} from './createFormHookTypes.public' type AnyReactAppFormApi = ReactAppFormApi @@ -29,53 +31,33 @@ export function attachReactAppFormComponents( form: AnyInternalFormApi, formComponents: Record>, fieldComponents: Record>, - defaultFieldOptions: CreateFormHookDefaultFieldOptions | undefined, - defaultFormGroupOptions: CreateFormHookDefaultFormGroupOptions | undefined, ): AnyReactAppFormApi { const resultForm = attachReactFormComponents( form, fieldComponents, ) as never as AnyReactAppFormApi - const fieldWithoutDefaults = createFieldWithContext(form, fieldComponents) - const arrayFieldWithoutDefaults = - resultForm.ArrayField as FunctionComponent - const formGroupWithoutDefaults = createFormGroupWithContext( + const field = createFieldWithContext(form, fieldComponents, 'field') + const arrayField = resultForm.ArrayField as FunctionComponent + const groupField = createFieldWithContext(form, fieldComponents, 'field') + const groupArrayField = createArrayFieldComponent( + form, + fieldComponents, + 'field', + ) as FunctionComponent + const formGroup = createFormGroupWithContext( resultForm as any, - fieldWithoutDefaults, - arrayFieldWithoutDefaults, + groupField, + groupArrayField, ) resultForm.AppForm = createAppForm(form) - resultForm.Field = withDefaultOptions( - fieldWithoutDefaults, - defaultFieldOptions, - ) as AnyReactAppFormApi['Field'] - resultForm.ArrayField = withDefaultOptions( - arrayFieldWithoutDefaults, - defaultFieldOptions, - ) as AnyReactAppFormApi['ArrayField'] - resultForm.FormGroup = withDefaultOptions( - formGroupWithoutDefaults, - defaultFormGroupOptions, - ) as AnyReactAppFormApi['FormGroup'] + resultForm.Field = field as AnyReactAppFormApi['Field'] + resultForm.ArrayField = arrayField as AnyReactAppFormApi['ArrayField'] + resultForm.FormGroup = formGroup as AnyReactAppFormApi['FormGroup'] return Object.assign(resultForm, formComponents) } -function withDefaultOptions( - Component: FunctionComponent, - defaultOptions: object | undefined, -): FunctionComponent { - if (!defaultOptions) return Component - - const ComponentWithDefaultOptions: FunctionComponent = (props) => ( - - ) - ComponentWithDefaultOptions.displayName = Component.displayName - - return ComponentWithDefaultOptions -} - function createAppForm(form: AnyInternalFormApi): AppFormComponent { const AppForm: FunctionComponent<{ children: Exclude> @@ -96,9 +78,10 @@ type AnyFieldComponent = FunctionComponent< function createFieldWithContext( form: AnyInternalFormApi, fieldComponents: Record>, + scope: FieldOptionsScope, ) { const TanStackFormField: AnyFieldComponent = (props) => { - const fieldApi = useField({ ...props, form }, fieldComponents) + const fieldApi = useField({ ...props, form }, fieldComponents, scope) const field = useValueFieldSubscription(fieldApi) return ( @@ -120,8 +103,8 @@ type AnyFormGroupComponent = FunctionComponent< function createFormGroupWithContext( form: InternalReactFormApi, - fieldWithoutDefaults: FunctionComponent, - arrayFieldWithoutDefaults: FunctionComponent, + groupField: FunctionComponent, + groupArrayField: FunctionComponent, ): AnyFormGroupComponent { const TanStackFormGroup: AnyFormGroupComponent = (props) => { const groupRef = @@ -130,8 +113,8 @@ function createFormGroupWithContext( if (!groupRef.current) { groupRef.current = attachAppFormGroupComponents( new InternalFormGroupApi({ ...props, form } as never), - fieldWithoutDefaults, - arrayFieldWithoutDefaults, + groupField, + groupArrayField, ) } @@ -153,8 +136,8 @@ function createFormGroupWithContext( function attachAppFormGroupComponents( group: InternalFormGroupApi, - fieldWithoutDefaults: FunctionComponent, - arrayFieldWithoutDefaults: FunctionComponent, + groupField: FunctionComponent, + groupArrayField: FunctionComponent, ) { type GroupWithComponents = InternalFormGroupApi & { Field: FunctionComponent @@ -163,12 +146,12 @@ function attachAppFormGroupComponents( } const resultGroup: GroupWithComponents = group as never - const FieldWithoutDefaults = fieldWithoutDefaults - const ArrayFieldWithoutDefaults = arrayFieldWithoutDefaults + const GroupField = groupField + const GroupArrayField = groupArrayField resultGroup.Field = function Field(props) { return ( - ({ ...base, ...overrides, @@ -180,7 +163,7 @@ function attachAppFormGroupComponents( resultGroup.ArrayField = function ArrayField(props) { return ( - ({ ...base, ...overrides, diff --git a/packages/react-form/src/AppForm/createFormHook.public.ts b/packages/react-form/src/AppForm/createFormHook.public.ts index 187c96eb9e..e22014d632 100644 --- a/packages/react-form/src/AppForm/createFormHook.public.ts +++ b/packages/react-form/src/AppForm/createFormHook.public.ts @@ -30,10 +30,7 @@ export function createFormHook< const initializeAppForm = createAppFormInitializer(createOptions) function useExtendedForm(hookOptions: FormOptions) { - const form = useInternalForm( - { ...createOptions.defaultFormOptions, ...hookOptions }, - initializeAppForm, - ) + const form = useInternalForm(hookOptions, initializeAppForm) return form } const useAppForm = useExtendedForm as never as UseAppFormHook<{ diff --git a/packages/react-form/src/AppForm/createFormHookTypes.public.ts b/packages/react-form/src/AppForm/createFormHookTypes.public.ts index a5ba8ebb17..583606d599 100644 --- a/packages/react-form/src/AppForm/createFormHookTypes.public.ts +++ b/packages/react-form/src/AppForm/createFormHookTypes.public.ts @@ -7,119 +7,22 @@ import type { ReactAppFormApi } from './ReactAppFormApi.public' import type { DefineFieldGroupFn } from '../FieldGroup/withFields.public' import type { FunctionComponent } from 'react' import type { - FieldApiOptions, - FieldValidators, - FormErrorTypes, - FormGroupOptions, - FormGroupValidators, + DefaultFieldOptions, + DefaultFormGroupOptions, + DefaultFormOptions, FormOptions, FormValidators, ToFormErrorTypes, } from '@tanstack/form-core' -/** - * Form defaults that do not participate in form value inference. - * - * This type is limited to `formId`, `errorVisibility`, `listeners`, and - * `onSubmitInvalid`. Callback contexts expose form values as `unknown`, so - * value-dependent behavior remains local to `useAppForm`. Options passed to - * `useAppForm` override these defaults, including when an option is explicitly - * `undefined`. - * - * @example - * ```tsx - * const { useAppForm } = createFormHook({ - * formComponents: {}, - * fieldComponents: {}, - * defaultFormOptions: { - * errorVisibility: ({ fieldState }) => fieldState.meta.isBlurred, - * onSubmitInvalid: () => { - * document.querySelector('[aria-invalid="true"]')?.focus() - * }, - * }, - * }) - * ``` - */ -export type CreateFormHookDefaultFormOptions = Pick< - FormOptions, unknown>, - 'formId' | 'errorVisibility' | 'listeners' | 'onSubmitInvalid' -> - -/** - * Direct field defaults that do not participate in field value inference. - * - * This type is limited to `errorVisibility`, `errorBoundary`, and `listeners`. - * Listener contexts expose form and field values as `unknown`, and callbacks - * do not receive the concrete value types inferred by the consuming field - * component. Options passed to a direct `form.Field` or `form.ArrayField` - * override these defaults, including when an option is explicitly - * `undefined`. - * - * @example - * ```tsx - * const { useAppForm } = createFormHook({ - * formComponents: {}, - * fieldComponents: {}, - * defaultFieldOptions: { - * errorVisibility: ({ fieldState }) => fieldState.meta.isBlurred, - * errorBoundary: true, - * }, - * }) - * ``` - */ -export type CreateFormHookDefaultFieldOptions = Pick< - FieldApiOptions< - unknown, - string, - unknown, - FieldValidators, - never, - unknown, - FormErrorTypes - >, - 'errorVisibility' | 'errorBoundary' | 'listeners' -> - -/** - * Form group defaults that do not participate in group value inference. - * - * This type is limited to `onSubmitInvalid`. Its callback context exposes the - * form and group values as `unknown`, so value-dependent behavior remains - * local to the `form.FormGroup` component. Options passed to - * `form.FormGroup` override these defaults, including when an option is - * explicitly `undefined`. - * - * @example - * ```tsx - * const { useAppForm } = createFormHook({ - * formComponents: {}, - * fieldComponents: {}, - * defaultFormGroupOptions: { - * onSubmitInvalid: ({ groupApi }) => { - * console.error('Invalid group', groupApi.name) - * }, - * }, - * }) - * ``` - */ -export type CreateFormHookDefaultFormGroupOptions = Pick< - FormGroupOptions< - unknown, - string, - unknown, - FormGroupValidators, - FormErrorTypes - >, - 'onSubmitInvalid' -> - /** * Configures the components and reusable defaults returned by * `createFormHook`. * - * Default objects are shallowly applied before the corresponding usage-site - * options. A usage-site property always takes precedence, including when its - * value is explicitly `undefined`. + * Default objects are resolved by form core before the corresponding + * usage-site options. Non-listener properties always take precedence, + * including when explicitly set to `undefined`. Listener arrays follow the + * configured `listenersMerge` strategy. * * @example * ```tsx @@ -147,8 +50,9 @@ export interface CreateFormHookOptions< /** * Defaults for every form created by `useAppForm`. * - * Options passed to `useAppForm` override these defaults, including when an - * option is explicitly `undefined`. + * Non-listener options passed to `useAppForm` override these defaults, + * including when explicitly set to `undefined`. Listener arrays follow + * `listenersMerge`. * * @example * ```tsx @@ -157,13 +61,13 @@ export interface CreateFormHookOptions< * }, * ``` */ - defaultFormOptions?: CreateFormHookDefaultFormOptions + defaultFormOptions?: DefaultFormOptions /** - * Defaults for direct `form.Field` and `form.ArrayField` components. + * Defaults for every field and array-field component owned by the form. * - * Options passed to the component override these defaults, including when - * an option is explicitly `undefined`. These defaults do not apply to - * `group.Field` or `group.ArrayField`. + * Non-listener options passed to the component override these defaults, + * including when explicitly set to `undefined`. Listener arrays follow + * `listenersMerge`. This includes `group.Field` and `group.ArrayField`. * * @example * ```tsx @@ -172,7 +76,7 @@ export interface CreateFormHookOptions< * }, * ``` */ - defaultFieldOptions?: CreateFormHookDefaultFieldOptions + defaultFieldOptions?: DefaultFieldOptions /** * Defaults for every `form.FormGroup` component. * @@ -188,7 +92,7 @@ export interface CreateFormHookOptions< * }, * ``` */ - defaultFormGroupOptions?: CreateFormHookDefaultFormGroupOptions + defaultFormGroupOptions?: DefaultFormGroupOptions } export type UseAppFormHook< diff --git a/packages/react-form/src/AppForm/initializeAppForm.lib.ts b/packages/react-form/src/AppForm/initializeAppForm.lib.ts index c27bf66d3b..a84ccd47f3 100644 --- a/packages/react-form/src/AppForm/initializeAppForm.lib.ts +++ b/packages/react-form/src/AppForm/initializeAppForm.lib.ts @@ -1,33 +1,45 @@ import { InternalFormApi } from '@tanstack/form-core/internals' import { attachReactAppFormComponents } from './Components.lib' -import type { FormOptions } from '@tanstack/form-core' +import type { + DefaultFieldOptions, + DefaultFormGroupOptions, + DefaultFormOptions, + DefaultOptions, + FormOptions, +} from '@tanstack/form-core' import type { InternalReactFormApi } from '../ReactForm/ReactFormApi.lib' import type { FunctionComponent } from 'react' -import type { - CreateFormHookDefaultFieldOptions, - CreateFormHookDefaultFormGroupOptions, - CreateFormHookDefaultFormOptions, -} from './createFormHookTypes.public' interface AnyCreateFormHookOptions { formComponents: Record> fieldComponents: Record> - defaultFormOptions?: CreateFormHookDefaultFormOptions - defaultFieldOptions?: CreateFormHookDefaultFieldOptions - defaultFormGroupOptions?: CreateFormHookDefaultFormGroupOptions + defaultFormOptions?: DefaultFormOptions + defaultFieldOptions?: DefaultFieldOptions + defaultFormGroupOptions?: DefaultFormGroupOptions } export function createAppFormInitializer( createOptions: AnyCreateFormHookOptions, ): (options: FormOptions) => InternalReactFormApi { + const hasDefaultOptions = + createOptions.defaultFormOptions || + createOptions.defaultFieldOptions || + createOptions.defaultFormGroupOptions + + const defaultOptions: DefaultOptions | undefined = hasDefaultOptions + ? { + form: createOptions.defaultFormOptions, + field: createOptions.defaultFieldOptions, + formGroup: createOptions.defaultFormGroupOptions, + } + : undefined + return (options) => { - const form = new InternalFormApi(options) + const form = new InternalFormApi(options, defaultOptions) const extendedForm = attachReactAppFormComponents( form, createOptions.formComponents, createOptions.fieldComponents, - createOptions.defaultFieldOptions, - createOptions.defaultFormGroupOptions, ) return extendedForm as never diff --git a/packages/react-form/src/ReactForm/Components.lib.tsx b/packages/react-form/src/ReactForm/Components.lib.tsx index 98744a76e4..48e70df5cf 100644 --- a/packages/react-form/src/ReactForm/Components.lib.tsx +++ b/packages/react-form/src/ReactForm/Components.lib.tsx @@ -6,7 +6,10 @@ import { useValueFieldSubscription, } from './fieldSubscriptions.lib' import { useField } from './useField.lib' -import type { AnyInternalFormApi } from '@tanstack/form-core/internals' +import type { + AnyInternalFormApi, + FieldOptionsScope, +} from '@tanstack/form-core/internals' import type { InternalReactFormApi } from './ReactFormApi.lib' import type { FunctionComponent, ReactNode } from 'react' import type { @@ -23,11 +26,17 @@ export function attachReactFormComponents( resultForm.Field = createFieldComponent( form, fieldComponents, + 'field', ) as InternalReactFormApi['Field'] - resultForm.ArrayField = createArrayFieldComponent(form, fieldComponents) + resultForm.ArrayField = createArrayFieldComponent( + form, + fieldComponents, + 'field', + ) resultForm.Subscribe = createSubscribeComponent(form) resultForm.FormGroup = createFormGroupComponent( resultForm, + fieldComponents, ) as InternalReactFormApi['FormGroup'] return resultForm @@ -40,9 +49,10 @@ type AnyFieldComponent = FunctionComponent< function createFieldComponent( form: AnyInternalFormApi, fieldComponents: Record> | null, + scope: FieldOptionsScope, ): AnyFieldComponent { const TanStackFormField: AnyFieldComponent = (props) => { - const fieldApi = useField({ ...props, form }, fieldComponents) + const fieldApi = useField({ ...props, form }, fieldComponents, scope) const field = useValueFieldSubscription(fieldApi) return props.children(field) @@ -58,12 +68,13 @@ type AnyArrayFieldComponent = { displayName?: string } -function createArrayFieldComponent( +export function createArrayFieldComponent( form: AnyInternalFormApi, fieldComponents: Record> | null, + scope: FieldOptionsScope, ): AnyArrayFieldComponent { const TanStackFormArrayField: AnyArrayFieldComponent = (props) => { - const fieldApi = useField({ ...props, form }, fieldComponents) + const fieldApi = useField({ ...props, form }, fieldComponents, scope) const field = useArrayFieldSubscription(fieldApi) return props.children(field) @@ -97,6 +108,7 @@ type AnyFormGroupComponent = FunctionComponent< function createFormGroupComponent( form: InternalReactFormApi, + fieldComponents: Record> | null, ): AnyFormGroupComponent { const TanStackFormGroup: AnyFormGroupComponent = (props) => { const groupRef = @@ -106,6 +118,7 @@ function createFormGroupComponent( groupRef.current = attachReactFormGroupComponents( new InternalFormGroupApi({ ...props, form } as never), form, + fieldComponents, ) } @@ -128,6 +141,7 @@ function createFormGroupComponent( function attachReactFormGroupComponents( group: InternalFormGroupApi, form: InternalReactFormApi, + fieldComponents: Record> | null, ) { type FormGroupComponents = InternalFormGroupApi & { Field: FunctionComponent @@ -136,10 +150,16 @@ function attachReactFormGroupComponents( } const resultGroup: FormGroupComponents = group as never + const GroupField = createFieldComponent(form, fieldComponents, 'field') + const GroupArrayField = createArrayFieldComponent( + form, + fieldComponents, + 'field', + ) resultGroup.Field = function Field(props) { return ( - ({ ...base, ...overrides, @@ -151,7 +171,7 @@ function attachReactFormGroupComponents( resultGroup.ArrayField = function ArrayField(props) { return ( - ({ ...base, ...overrides, diff --git a/packages/react-form/src/ReactForm/useField.lib.ts b/packages/react-form/src/ReactForm/useField.lib.ts index d4d7c9cc99..8f66c68737 100644 --- a/packages/react-form/src/ReactForm/useField.lib.ts +++ b/packages/react-form/src/ReactForm/useField.lib.ts @@ -3,6 +3,7 @@ import { useSelector } from '@tanstack/react-store' import type { FunctionComponent } from 'react' import type { AnyInternalFieldApi, + FieldOptionsScope, InternalFormApi, } from '@tanstack/form-core/internals' import type { ReactFormFieldProps } from './Components.public' @@ -23,6 +24,7 @@ interface InternalFieldProps extends ReactFormFieldProps< export function useField( options: InternalFieldProps, fieldComponents: Record> | null, + scope: FieldOptionsScope, ): AnyInternalFieldApi { const optionsRef = useRef(options) optionsRef.current = options @@ -31,17 +33,20 @@ export function useField( const fieldApi = useMemo(() => { void resetVersion - const field = options.form._getOrCreateFieldApi({ - ...optionsRef.current, - name: options.name, - }) + const field = options.form._getOrCreateFieldApi( + { + ...optionsRef.current, + name: options.name, + }, + scope, + ) if (fieldComponents === null) return field Object.assign(field, fieldComponents) return field - }, [options.name, options.form, resetVersion, fieldComponents]) + }, [options.name, options.form, resetVersion, fieldComponents, scope]) - useEffect(() => fieldApi._update(options)) + useEffect(() => fieldApi._update(options, scope)) useEffect(() => { const cleanup = fieldApi._register() diff --git a/packages/react-form/tests/createFormHook.spec.tsx b/packages/react-form/tests/createFormHook.spec.tsx index 9e9907f706..2e64812913 100644 --- a/packages/react-form/tests/createFormHook.spec.tsx +++ b/packages/react-form/tests/createFormHook.spec.tsx @@ -2,37 +2,57 @@ import React from 'react' import { fireEvent, render, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { createFormHook } from '../src' -import type { AnyInternalFieldApi } from '@tanstack/form-core/internals' +import type { + AnyInternalFieldApi, + AnyInternalFormApi, +} from '@tanstack/form-core/internals' describe('createFormHook defaults', () => { it('uses default form options and lets usage options override them', () => { + const defaultErrorVisibility = () => true + const overriddenErrorVisibility = () => false const { useAppForm } = createFormHook({ fieldComponents: {}, formComponents: {}, defaultFormOptions: { - formId: 'default-form-id', + errorVisibility: defaultErrorVisibility, }, }) + const getErrorVisibility = (form: unknown) => + (form as AnyInternalFormApi)._options.errorVisibility + function DefaultForm() { const form = useAppForm({ defaultValues: { name: '' } }) - return {form.formId} + return ( + + {String(getErrorVisibility(form) === defaultErrorVisibility)} + + ) } function OverriddenForm() { const form = useAppForm({ defaultValues: { name: '' }, - formId: 'overridden-form-id', + errorVisibility: overriddenErrorVisibility, }) - return {form.formId} + return ( + + {String(getErrorVisibility(form) === overriddenErrorVisibility)} + + ) } function UndefinedForm() { const form = useAppForm({ defaultValues: { name: '' }, - formId: undefined, + errorVisibility: undefined, }) - return {form.formId} + return ( + + {String(getErrorVisibility(form) === undefined)} + + ) } const { getByTestId } = render( @@ -43,13 +63,76 @@ describe('createFormHook defaults', () => { , ) - expect(getByTestId('default')).toHaveTextContent('default-form-id') - expect(getByTestId('overridden')).toHaveTextContent('overridden-form-id') - expect(getByTestId('undefined')).not.toHaveTextContent('default-form-id') - expect(getByTestId('undefined')).not.toBeEmptyDOMElement() + expect(getByTestId('default')).toHaveTextContent('true') + expect(getByTestId('overridden')).toHaveTextContent('true') + expect(getByTestId('undefined')).toHaveTextContent('true') + }) + + it('resolves form and field listener merge modes in core', () => { + const formCalls: Array = [] + const fieldCalls: Array = [] + const { useAppForm } = createFormHook({ + fieldComponents: {}, + formComponents: {}, + defaultFormOptions: { + listenersMerge: 'append', + listeners: [ + { + triggers: ['change'], + run: () => formCalls.push('default'), + }, + ], + }, + defaultFieldOptions: { + listenersMerge: 'prepend', + listeners: [ + { + triggers: ['change'], + run: () => fieldCalls.push('default'), + }, + ], + }, + }) + + function Component() { + const form = useAppForm({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + run: () => formCalls.push('local'), + }, + ], + }) + + return ( + fieldCalls.push('local'), + }, + ]} + > + {(field) => ( + + {/snippet} + + + + {#snippet children(field)} + + {/snippet} + + + 'Invalid group', + }, + ]} +> + {#snippet children(group)} + + {#snippet children(field)} + + {/snippet} + + + {#snippet children(field)} + + {/snippet} + + + {/snippet} + + +{formCalls} +{fieldCalls.join(',')} +{invalidCalls} diff --git a/packages/svelte-form/tests/createFormHook.test-d.ts b/packages/svelte-form/tests/createFormHook.test-d.ts new file mode 100644 index 0000000000..32aa61ec9b --- /dev/null +++ b/packages/svelte-form/tests/createFormHook.test-d.ts @@ -0,0 +1,63 @@ +import { expectTypeOf } from 'vitest' +import { createFormHook } from '../src' + +const { useAppForm } = createFormHook({ + fieldComponents: {}, + formComponents: {}, + defaultFormOptions: { + listenersMerge: 'append', + listeners: [ + { + triggers: [], + run: ({ value }) => { + expectTypeOf(value).toBeUnknown() + }, + }, + ], + }, + defaultFieldOptions: { + listenersMerge: 'prepend', + listeners: [ + { + triggers: [], + run: ({ value, fieldApi }) => { + expectTypeOf(value).toBeUnknown() + expectTypeOf(fieldApi.value).toBeUnknown() + }, + }, + ], + }, + defaultFormGroupOptions: { + onSubmitInvalid: ({ value, groupApi }) => { + expectTypeOf(value).toBeUnknown() + expectTypeOf(groupApi.value).toBeUnknown() + }, + }, +}) + +function InferenceRemainsLocal() { + const form = useAppForm(() => ({ + defaultValues: { + name: '', + tags: [''], + group: { count: 0 }, + }, + })) + + expectTypeOf(form.state.values).toEqualTypeOf<{ + name: string + tags: Array + group: { count: number } + }>() +} + +void InferenceRemainsLocal + +createFormHook({ + fieldComponents: {}, + formComponents: {}, + defaultFormOptions: { + // @ts-expect-error formId belongs to an individual form instance + formId: 'profile', + }, +}) diff --git a/packages/svelte-form/tests/createFormHook.test.ts b/packages/svelte-form/tests/createFormHook.test.ts new file mode 100644 index 0000000000..49834e1b5b --- /dev/null +++ b/packages/svelte-form/tests/createFormHook.test.ts @@ -0,0 +1,28 @@ +import { render } from '@testing-library/svelte' +import { userEvent } from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import DefaultOptions from './adapter/DefaultOptions.svelte' + +describe('createFormHook defaults', () => { + it('applies form, field, and form group defaults through public components', async () => { + const user = userEvent.setup() + const view = render(DefaultOptions) + + await user.click(view.getByRole('button', { name: 'Change direct field' })) + await user.click( + view.getByRole('button', { name: 'Change direct array field' }), + ) + await user.click(view.getByRole('button', { name: 'Change grouped field' })) + await user.click( + view.getByRole('button', { name: 'Change grouped array field' }), + ) + + expect(view.getByTestId('form-calls')).toHaveTextContent('4') + expect(view.getByTestId('field-calls')).toHaveTextContent( + 'direct,directArray,group.field,group.array', + ) + + await user.click(view.getByRole('button', { name: 'Submit group' })) + expect(view.getByTestId('invalid-calls')).toHaveTextContent('1') + }) +}) From fad9ff0a1e8ecf8cafecec25deb48e4b6d1d1120 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:51:04 +0200 Subject: [PATCH 7/8] chore: fix coverage unit tests --- .../preact-form/tests/createFormHook.spec.tsx | 42 +++++++++++++++---- .../solid-form/tests/createFormHook.spec.tsx | 42 +++++++++++++++---- .../tests/adapter/DefaultOptions.svelte | 33 ++++++++++++--- .../svelte-form/tests/createFormHook.test.ts | 6 ++- 4 files changed, 100 insertions(+), 23 deletions(-) diff --git a/packages/preact-form/tests/createFormHook.spec.tsx b/packages/preact-form/tests/createFormHook.spec.tsx index 1e6d781ece..7ff6f44829 100644 --- a/packages/preact-form/tests/createFormHook.spec.tsx +++ b/packages/preact-form/tests/createFormHook.spec.tsx @@ -16,7 +16,7 @@ describe('createFormHook defaults', () => { listeners: [ { triggers: ['change'], - run: () => formCalls.push('form'), + run: () => formCalls.push('default'), }, ], }, @@ -25,7 +25,8 @@ describe('createFormHook defaults', () => { listeners: [ { triggers: ['change'], - run: ({ fieldApi }) => fieldCalls.push(String(fieldApi.name)), + run: ({ fieldApi }) => + fieldCalls.push(`default:${String(fieldApi.name)}`), }, ], }, @@ -44,11 +45,26 @@ describe('createFormHook defaults', () => { array: ['one'], }, }, + listeners: [ + { + triggers: ['change'], + run: () => formCalls.push('local'), + }, + ], }) return ( <> - + + fieldCalls.push(`local:${String(fieldApi.name)}`), + }, + ]} + > {(field) => (