diff --git a/.changeset/cyan-papayas-search.md b/.changeset/cyan-papayas-search.md new file mode 100644 index 0000000000..d3ac150b55 --- /dev/null +++ b/.changeset/cyan-papayas-search.md @@ -0,0 +1,10 @@ +--- +'@tanstack/preact-form': minor +'@tanstack/svelte-form': minor +'@tanstack/react-form': minor +'@tanstack/solid-form': minor +'@tanstack/form-core': minor +'@tanstack/vue-form': minor +--- + +Feature: Specify default options for `createFormHook` diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index f5ccb95d35..8e931de43f 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, @@ -130,20 +131,32 @@ export function transformFieldOptionsFieldNames< get name() { return transformFieldName(options.name) }, - get validators() { - return transformFieldOptionItemsWithWatchedFields( - options.validators, - transformFieldName, - ) - }, - get listeners() { - return transformFieldOptionItemsWithWatchedFields( - options.listeners, - transformFieldName, - ) - }, } as Partial + if (Object.hasOwn(options, 'validators')) { + Object.defineProperty(overrides, 'validators', { + enumerable: true, + get() { + return transformFieldOptionItemsWithWatchedFields( + options.validators, + transformFieldName, + ) + }, + }) + } + + if (Object.hasOwn(options, 'listeners')) { + Object.defineProperty(overrides, 'listeners', { + enumerable: true, + get() { + return transformFieldOptionItemsWithWatchedFields( + options.listeners, + transformFieldName, + ) + }, + }) + } + return mergeOptions(fieldOptions, overrides) } @@ -193,6 +206,7 @@ export function getOrCreateFieldApi( segments: NameSegments, form: AnyInternalFormApi, options?: Omit, + scope: FieldOptionsScope = 'field', ): AnyInternalFieldApi { const segment = segments.shift() if (segment === undefined) { @@ -200,39 +214,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 +284,8 @@ export interface InternalFieldApiParams extends Omit< validators?: FieldValidators } +export type FieldOptionsScope = 'internal' | 'field' + interface ListenToFieldsMeta { field: AnyInternalFieldApi name: string @@ -324,6 +339,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 +485,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 +548,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 +583,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 +598,9 @@ export class InternalFieldApi< AnyInternalFieldApi >({ definitions: nextValidators, - previousDefinitions: previousValidators ?? null, + previousDefinitions: isInitializing + ? undefined + : (previousValidators ?? null), instances: this._validatorInstances, owner: this, scope: 'field', @@ -603,6 +639,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..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,9 +135,17 @@ export class InternalFormGroupApi< TFormErrorTypes >, ) { - this._options = options this.form = options.form as never - this._groupField = this.form._getOrCreateFieldApi({ name: options.name }) + const resolvedOptions = resolveDefaultOptions( + options, + this.form._defaultOptions?.formGroup, + ) + + this._options = resolvedOptions + this._groupField = this.form._getOrCreateFieldApi( + { name: resolvedOptions.name }, + 'internal', + ) this._groupField._setFormGroup(this) this._validatorInstances = reconcileValidatorInstances< TGroupValidators[number], @@ -214,7 +223,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 @@ -260,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.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..4565b6e651 --- /dev/null +++ b/packages/form-core/src/defaultOptions.public.ts @@ -0,0 +1,139 @@ +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' + +/** + * 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 { + /** + * 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. + * + * Omitting `listeners` keeps the defaults. Explicitly setting `listeners` to + * `undefined` suppresses them for every merge mode. + * + * @default 'replace' + */ + listenersMerge?: DefaultListenersMergeMode +} + +/** + * 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 + +/** + * 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, + string, + unknown, + FieldValidators, + never, + unknown, + FormErrorTypes + >, + 'errorVisibility' | 'errorBoundary' | 'listeners' +> & + DefaultListenersMergeOptions + +/** + * 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, + string, + unknown, + FormGroupValidators, + FormErrorTypes + >, + 'onSubmitInvalid' +> + +/** + * 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/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..a987b52390 100644 --- a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts +++ b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts @@ -9,6 +9,107 @@ 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: '' } }) 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/FormGroupApi/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts index 25c563bedb..afa3e168e8 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' } }, @@ -561,7 +590,7 @@ describe('FormGroupApi', () => { expect(options.listeners?.[1]).not.toHaveProperty('watchFields') }) - it('leaves absent watched-field lists undefined when prefixing field options', () => { + it('preserves omitted field options while prefixing field names', () => { const form = new InternalFormApi({ defaultValues: { guestDetails: { name: '' } }, }) @@ -578,8 +607,20 @@ describe('FormGroupApi', () => { ) expect(options.name).toBe('guestDetails.name') - expect(options.validators).toBeUndefined() - expect(options.listeners).toBeUndefined() + expect(options).not.toHaveProperty('validators') + expect(options).not.toHaveProperty('listeners') + + const explicitUndefined = group._getFormFieldOptions( + { + name: 'name', + validators: undefined, + listeners: undefined, + }, + (props, overrides) => ({ ...props, ...overrides }), + ) + + expect(explicitUndefined).toHaveProperty('validators', undefined) + expect(explicitUndefined).toHaveProperty('listeners', undefined) }) it('exposes subtree values and field meta', () => { 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() + }) +}) diff --git a/packages/preact-form/src/AppForm/createFormHook.public.ts b/packages/preact-form/src/AppForm/createFormHook.public.ts index fc0024e274..b81e889cae 100644 --- a/packages/preact-form/src/AppForm/createFormHook.public.ts +++ b/packages/preact-form/src/AppForm/createFormHook.public.ts @@ -3,11 +3,12 @@ import { defineFieldGroup } from '../FieldGroup/withFields.public' import { createAppFormInitializer } from './initializeAppForm.lib' import { useFormContext } from './contexts.lib' import type { AppFormOptionsApi } from './appFormOptions.public' -import type { AnyPreactFormComponentMap } from './componentMap.public' import type { AppFormHookResult, + CreateFormHookOptions, UseAppFormHook, } from './createFormHookTypes.public' +import type { FunctionComponent } from 'preact/compat' import type { FormOptions } from '@tanstack/form-core' const appFormOptions = ((opts) => { @@ -18,20 +19,29 @@ appFormOptions.strictSchema = (opts) => opts as never appFormOptions.looseSchema = (opts) => opts as never export function createFormHook< - const TComponents extends AnyPreactFormComponentMap, ->(createOptions: TComponents): AppFormHookResult { + const TFormComponents extends Record>, + const TFieldComponents extends Record>, +>( + createOptions: CreateFormHookOptions, +): AppFormHookResult<{ + formComponents: TFormComponents + fieldComponents: TFieldComponents +}> { const initializeAppForm = createAppFormInitializer(createOptions) function useExtendedForm(hookOptions: FormOptions) { const form = useInternalForm(hookOptions, initializeAppForm) return form } - const useAppForm = useExtendedForm as never as UseAppFormHook + const useAppForm = useExtendedForm as never as UseAppFormHook<{ + formComponents: TFormComponents + fieldComponents: TFieldComponents + }> return { useFormContext: useFormContext as never, appFormOptions, - defineAppFieldGroup: defineFieldGroup, + defineAppFieldGroup: defineFieldGroup as never, useAppForm, } } diff --git a/packages/preact-form/src/AppForm/createFormHookTypes.public.ts b/packages/preact-form/src/AppForm/createFormHookTypes.public.ts index d32d3ec56c..07905ef12f 100644 --- a/packages/preact-form/src/AppForm/createFormHookTypes.public.ts +++ b/packages/preact-form/src/AppForm/createFormHookTypes.public.ts @@ -1,13 +1,100 @@ import type { AppFormOptionsApi } from './appFormOptions.public' -import type { AnyPreactFormComponentMap } from './componentMap.public' +import type { + AnyPreactFormComponentMap, + PreactFormComponentMap, +} from './componentMap.public' import type { PreactAppFormApi } from './PreactAppFormApi.public' import type { DefineFieldGroupFn } from '../FieldGroup/withFields.public' +import type { FunctionComponent } from 'preact/compat' import type { + DefaultFieldOptions, + DefaultFormGroupOptions, + DefaultFormOptions, FormOptions, FormValidators, ToFormErrorTypes, } from '@tanstack/form-core' +/** + * Configures the components and reusable defaults returned by + * `createFormHook`. + * + * Form core resolves each default object before the corresponding usage-site + * options. Non-listener properties passed at the usage site take precedence, + * including when explicitly set to `undefined`. Listener arrays follow the + * configured `listenersMerge` strategy. + * + * @example + * ```tsx + * const { useAppForm } = createFormHook({ + * formComponents: {}, + * fieldComponents: { + * TextField, + * }, + * defaultFormOptions: { + * errorVisibility: ({ fieldState }) => fieldState.meta.isBlurred, + * }, + * defaultFieldOptions: { + * errorBoundary: true, + * }, + * }) + * ``` + * + * @typeParam TFormComponents - Library-managed. Do not specify explicitly. + * @typeParam TFieldComponents - Library-managed. Do not specify explicitly. + */ +export interface CreateFormHookOptions< + in out TFormComponents extends Record>, + in out TFieldComponents extends Record>, +> extends PreactFormComponentMap { + /** + * Defaults for every form created by `useAppForm`. + * + * Non-listener options passed to `useAppForm` override these defaults, + * including when explicitly set to `undefined`. Listener arrays follow + * `listenersMerge`. + * + * @example + * ```tsx + * defaultFormOptions: { + * errorVisibility: ({ state }) => state.submissionAttempts > 0, + * }, + * ``` + */ + defaultFormOptions?: DefaultFormOptions + /** + * Defaults for every field and array-field component owned by the form. + * + * 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 + * defaultFieldOptions: { + * errorVisibility: ({ fieldState }) => fieldState.meta.isBlurred, + * }, + * ``` + */ + defaultFieldOptions?: DefaultFieldOptions + /** + * Defaults for every `form.FormGroup` component. + * + * Options passed to the component override these defaults, including when + * an option is explicitly `undefined`. + * + * @example + * ```tsx + * defaultFormGroupOptions: { + * onSubmitInvalid: ({ groupApi }) => { + * console.error('Invalid group', groupApi.name) + * }, + * }, + * ``` + */ + defaultFormGroupOptions?: DefaultFormGroupOptions +} + export type UseAppFormHook< in out TComponents extends AnyPreactFormComponentMap, > = < diff --git a/packages/preact-form/src/AppForm/initializeAppForm.lib.ts b/packages/preact-form/src/AppForm/initializeAppForm.lib.ts index 0716df2453..108abf0999 100644 --- a/packages/preact-form/src/AppForm/initializeAppForm.lib.ts +++ b/packages/preact-form/src/AppForm/initializeAppForm.lib.ts @@ -1,16 +1,41 @@ import { InternalFormApi } from '@tanstack/form-core/internals' import { attachPreactAppFormComponents } from './Components.lib' -import type { FormOptions } from '@tanstack/form-core' +import type { + DefaultFieldOptions, + DefaultFormGroupOptions, + DefaultFormOptions, + DefaultOptions, + FormOptions, +} from '@tanstack/form-core' import type { InternalPreactFormApi } from '../PreactForm/PreactFormApi.lib' -import type { AnyPreactFormComponentMap } from './componentMap.public' +import type { FunctionComponent } from 'preact/compat' -export function createAppFormInitializer< - TComponents extends AnyPreactFormComponentMap, ->( - createOptions: TComponents, +interface AnyCreateFormHookOptions { + formComponents: Record> + fieldComponents: Record> + defaultFormOptions?: DefaultFormOptions + defaultFieldOptions?: DefaultFieldOptions + defaultFormGroupOptions?: DefaultFormGroupOptions +} + +export function createAppFormInitializer( + createOptions: AnyCreateFormHookOptions, ): (options: FormOptions) => InternalPreactFormApi { + 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 = attachPreactAppFormComponents( form, createOptions.formComponents, diff --git a/packages/preact-form/src/PreactForm/useField.lib.ts b/packages/preact-form/src/PreactForm/useField.lib.ts index a4829adc0e..b649fa347b 100644 --- a/packages/preact-form/src/PreactForm/useField.lib.ts +++ b/packages/preact-form/src/PreactForm/useField.lib.ts @@ -31,17 +31,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, + }, + 'field', + ) if (fieldComponents === null) return field Object.assign(field, fieldComponents) return field }, [options.name, options.form, resetVersion, fieldComponents]) - useEffect(() => fieldApi._update(options)) + useEffect(() => fieldApi._update(options, 'field')) useEffect(() => { const cleanup = fieldApi._register() diff --git a/packages/preact-form/tests/createFormHook.spec.tsx b/packages/preact-form/tests/createFormHook.spec.tsx new file mode 100644 index 0000000000..7ff6f44829 --- /dev/null +++ b/packages/preact-form/tests/createFormHook.spec.tsx @@ -0,0 +1,151 @@ +import { fireEvent, render, waitFor } from '@testing-library/preact' +import Preact from 'preact/compat' +import { describe, expect, it, vi } from 'vitest' +import { createFormHook } from '../src' + +describe('createFormHook defaults', () => { + it('applies form, field, and form group defaults through public components', async () => { + const formCalls: Array = [] + const fieldCalls: Array = [] + const onSubmitInvalid = vi.fn() + const { useAppForm } = createFormHook({ + fieldComponents: {}, + formComponents: {}, + defaultFormOptions: { + listenersMerge: 'append', + listeners: [ + { + triggers: ['change'], + run: () => formCalls.push('default'), + }, + ], + }, + defaultFieldOptions: { + listenersMerge: 'prepend', + listeners: [ + { + triggers: ['change'], + run: ({ fieldApi }) => + fieldCalls.push(`default:${String(fieldApi.name)}`), + }, + ], + }, + defaultFormGroupOptions: { + onSubmitInvalid, + }, + }) + + function Component() { + const form = useAppForm({ + defaultValues: { + direct: '', + directArray: ['one'], + group: { + field: '', + array: ['one'], + }, + }, + listeners: [ + { + triggers: ['change'], + run: () => formCalls.push('local'), + }, + ], + }) + + return ( + <> + + fieldCalls.push(`local:${String(fieldApi.name)}`), + }, + ]} + > + {(field) => ( + + {/snippet} + + + + {#snippet children(field)} + + {/snippet} + + + 'Invalid group', + }, + ]} +> + {#snippet children(group)} + + {#snippet children(field)} + + {/snippet} + + + {#snippet children(field)} + + {/snippet} + + + {/snippet} + + +{formCalls.join(',')} +{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..2729455317 --- /dev/null +++ b/packages/svelte-form/tests/createFormHook.test.ts @@ -0,0 +1,30 @@ +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( + 'default,local,default,local,default,local,default,local', + ) + expect(view.getByTestId('field-calls')).toHaveTextContent( + 'local:direct,default:direct,default:directArray,default:group.field,default:group.array', + ) + + await user.click(view.getByRole('button', { name: 'Submit group' })) + expect(view.getByTestId('invalid-calls')).toHaveTextContent('1') + }) +}) diff --git a/packages/vue-form/src/AppForm/createFormHook.public.ts b/packages/vue-form/src/AppForm/createFormHook.public.ts index 00f07c8245..878c41256e 100644 --- a/packages/vue-form/src/AppForm/createFormHook.public.ts +++ b/packages/vue-form/src/AppForm/createFormHook.public.ts @@ -3,11 +3,12 @@ import { defineFieldGroup } from '../FieldGroup/withFields.public' import { createAppFormInitializer } from './initializeAppForm.lib' import { useFormContext } from './contexts.lib' import type { AppFormOptionsApi } from './appFormOptions.public' -import type { AnyVueFormComponentMap } from './componentMap.public' import type { AppFormHookResult, + CreateFormHookOptions, UseAppFormHook, } from './createFormHookTypes.public' +import type { Component } from 'vue' import type { FormOptions } from '@tanstack/form-core' const appFormOptions = ((opts: unknown) => opts) as AppFormOptionsApi @@ -15,8 +16,14 @@ appFormOptions.strictSchema = (opts) => opts as never appFormOptions.looseSchema = (opts) => opts as never export function createFormHook< - const TComponents extends AnyVueFormComponentMap, ->(createOptions: TComponents): AppFormHookResult { + const TFormComponents extends Record, + const TFieldComponents extends Record, +>( + createOptions: CreateFormHookOptions, +): AppFormHookResult<{ + formComponents: TFormComponents + fieldComponents: TFieldComponents +}> { const initializeAppForm = createAppFormInitializer(createOptions) function useExtendedForm(options: FormOptions) { @@ -26,7 +33,10 @@ export function createFormHook< return { useFormContext: useFormContext as never, appFormOptions, - defineAppFieldGroup: defineFieldGroup, - useAppForm: useExtendedForm as never as UseAppFormHook, + defineAppFieldGroup: defineFieldGroup as never, + useAppForm: useExtendedForm as never as UseAppFormHook<{ + formComponents: TFormComponents + fieldComponents: TFieldComponents + }>, } } diff --git a/packages/vue-form/src/AppForm/createFormHookTypes.public.ts b/packages/vue-form/src/AppForm/createFormHookTypes.public.ts index 256a3df70a..3195a4f4f0 100644 --- a/packages/vue-form/src/AppForm/createFormHookTypes.public.ts +++ b/packages/vue-form/src/AppForm/createFormHookTypes.public.ts @@ -1,13 +1,100 @@ import type { AppFormOptionsApi } from './appFormOptions.public' -import type { AnyVueFormComponentMap } from './componentMap.public' +import type { + AnyVueFormComponentMap, + VueFormComponentMap, +} from './componentMap.public' import type { VueAppFormApi } from './VueAppFormApi.public' import type { DefineFieldGroupFn } from '../FieldGroup/withFields.public' +import type { Component } from 'vue' import type { + DefaultFieldOptions, + DefaultFormGroupOptions, + DefaultFormOptions, FormOptions, FormValidators, ToFormErrorTypes, } from '@tanstack/form-core' +/** + * Configures the components and reusable defaults returned by + * `createFormHook`. + * + * Form core resolves each default object before the corresponding usage-site + * options. Non-listener properties passed at the usage site take precedence, + * including when explicitly set to `undefined`. Listener arrays follow the + * configured `listenersMerge` strategy. + * + * @example + * ```ts + * const { useAppForm } = createFormHook({ + * formComponents: {}, + * fieldComponents: { + * TextField, + * }, + * defaultFormOptions: { + * errorVisibility: ({ fieldState }) => fieldState.meta.isBlurred, + * }, + * defaultFieldOptions: { + * errorBoundary: true, + * }, + * }) + * ``` + * + * @typeParam TFormComponents - Library-managed. Do not specify explicitly. + * @typeParam TFieldComponents - Library-managed. Do not specify explicitly. + */ +export interface CreateFormHookOptions< + TFormComponents extends Record, + TFieldComponents extends Record, +> extends VueFormComponentMap { + /** + * Defaults for every form created by `useAppForm`. + * + * Non-listener options passed to `useAppForm` override these defaults, + * including when explicitly set to `undefined`. Listener arrays follow + * `listenersMerge`. + * + * @example + * ```ts + * defaultFormOptions: { + * errorVisibility: ({ state }) => state.submissionAttempts > 0, + * }, + * ``` + */ + defaultFormOptions?: DefaultFormOptions + /** + * Defaults for every field and array-field component owned by the form. + * + * 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 + * ```ts + * defaultFieldOptions: { + * errorVisibility: ({ fieldState }) => fieldState.meta.isBlurred, + * }, + * ``` + */ + defaultFieldOptions?: DefaultFieldOptions + /** + * Defaults for every `form.FormGroup` component. + * + * Options passed to the component override these defaults, including when + * an option is explicitly `undefined`. + * + * @example + * ```ts + * defaultFormGroupOptions: { + * onSubmitInvalid: ({ groupApi }) => { + * console.error('Invalid group', groupApi.name) + * }, + * }, + * ``` + */ + defaultFormGroupOptions?: DefaultFormGroupOptions +} + export type UseAppFormHook = < TFormData, const TFormValidators extends FormValidators, diff --git a/packages/vue-form/src/AppForm/initializeAppForm.lib.ts b/packages/vue-form/src/AppForm/initializeAppForm.lib.ts index f42527978a..f256ea80bf 100644 --- a/packages/vue-form/src/AppForm/initializeAppForm.lib.ts +++ b/packages/vue-form/src/AppForm/initializeAppForm.lib.ts @@ -1,17 +1,42 @@ import { InternalFormApi } from '@tanstack/form-core/internals' import { attachVueAppFormComponents } from './Components.lib' -import type { FormOptions } from '@tanstack/form-core' +import type { + DefaultFieldOptions, + DefaultFormGroupOptions, + DefaultFormOptions, + DefaultOptions, + FormOptions, +} from '@tanstack/form-core' import type { InternalVueFormApi } from '../VueForm/VueFormApi.lib' -import type { AnyVueFormComponentMap } from './componentMap.public' +import type { Component } from 'vue' -export function createAppFormInitializer< - TComponents extends AnyVueFormComponentMap, ->( - createOptions: TComponents, +interface AnyCreateFormHookOptions { + formComponents: Record + fieldComponents: Record + defaultFormOptions?: DefaultFormOptions + defaultFieldOptions?: DefaultFieldOptions + defaultFormGroupOptions?: DefaultFormGroupOptions +} + +export function createAppFormInitializer( + createOptions: AnyCreateFormHookOptions, ): (options: FormOptions) => InternalVueFormApi { + 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) => attachVueAppFormComponents( - new InternalFormApi(options), + new InternalFormApi(options, defaultOptions), createOptions.formComponents, createOptions.fieldComponents, ) as never diff --git a/packages/vue-form/src/VueForm/useField.lib.ts b/packages/vue-form/src/VueForm/useField.lib.ts index 358f8636be..20bc7f7886 100644 --- a/packages/vue-form/src/VueForm/useField.lib.ts +++ b/packages/vue-form/src/VueForm/useField.lib.ts @@ -21,10 +21,13 @@ export function useField( const createField = () => { const current = options() - const field = current.form._getOrCreateFieldApi({ - ...current, - name: current.name, - } as never) + const field = current.form._getOrCreateFieldApi( + { + ...current, + name: current.name, + } as never, + 'field', + ) if (fieldComponents !== null) Object.assign(field, fieldComponents) return field } @@ -40,7 +43,7 @@ export function useField( ) watchEffect(() => { - fieldApi.value._update(options() as never) + fieldApi.value._update(options() as never, 'field') }) let mounted = false diff --git a/packages/vue-form/tests/createFormHook.spec.tsx b/packages/vue-form/tests/createFormHook.spec.tsx new file mode 100644 index 0000000000..8a5610f820 --- /dev/null +++ b/packages/vue-form/tests/createFormHook.spec.tsx @@ -0,0 +1,131 @@ +import { fireEvent, render, waitFor } from '@testing-library/vue' +import { Fragment, defineComponent, h } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import { createFormHook } from '../src' +import type { AnyFieldApi } from '../src' + +describe('createFormHook defaults', () => { + it('applies form, field, and form group defaults through public components', async () => { + const formCalls: Array = [] + const fieldCalls: Array = [] + const onSubmitInvalid = vi.fn() + const { useAppForm } = createFormHook({ + fieldComponents: {}, + formComponents: {}, + defaultFormOptions: { + listenersMerge: 'append', + listeners: [ + { + triggers: ['change'], + run: () => formCalls.push('form'), + }, + ], + }, + defaultFieldOptions: { + listenersMerge: 'prepend', + listeners: [ + { + triggers: ['change'], + run: ({ fieldApi }) => fieldCalls.push(String(fieldApi.name)), + }, + ], + }, + defaultFormGroupOptions: { + onSubmitInvalid, + }, + }) + + const Component = defineComponent(() => { + const form = useAppForm({ + defaultValues: { + direct: '', + directArray: ['one'], + group: { + field: '', + array: ['one'], + }, + }, + }) + + return () => ( + <> + + {({ field }: { field: AnyFieldApi }) => ( +