From f6be72595bd2b00621607f48dcfab2a33b560c64 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:43:40 +0200 Subject: [PATCH 1/5] refactor: move listeners from index to instances --- .../form-core/src/FieldApi/FieldApi.lib.ts | 126 ++++++---- .../form-core/src/FieldApi/fieldTree.lib.ts | 122 +++------- .../src/FieldApi/linked-fields.lib.ts | 230 +++++------------- packages/form-core/src/FormApi/FormApi.lib.ts | 41 ++-- .../form-core/src/ListenerInstance.lib.ts | 188 ++++++++++++++ packages/form-core/src/devtoolsBridge.lib.ts | 3 +- packages/form-core/src/internals.ts | 1 + packages/form-core/src/listeners.lib.ts | 87 ++----- packages/form-core/src/ssr.lib.ts | 10 +- packages/form-core/src/utils.lib.ts | 19 -- .../tests/FieldApi/Lifecycle.spec.ts | 83 ++++++- .../tests/FieldApi/listeners.spec.ts | 86 ++++++- .../form-core/tests/FormApi/lifecycle.spec.ts | 39 +++ .../form-core/tests/FormApi/listeners.spec.ts | 36 +++ .../form-core/tests/ListenerInstance.spec.ts | 119 +++++++++ .../src/bridge/fields/detailSnapshot.ts | 81 +++--- .../form-devtools/src/bridge/fields/index.ts | 16 +- .../tests/bridgeComposition.test.ts | 7 +- 18 files changed, 827 insertions(+), 467 deletions(-) create mode 100644 packages/form-core/src/ListenerInstance.lib.ts create mode 100644 packages/form-core/tests/ListenerInstance.spec.ts diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index f5ccb95d35..de6143597f 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -1,5 +1,5 @@ import { batch, createAtom } from '@tanstack/store' -import { callUpdater, createPipelineCache, evaluate, getBy } from '../utils.lib' +import { callUpdater, evaluate, getBy } from '../utils.lib' import { clearValidationSourceErrorsFromEvent, isValidationTriggerEnabled, @@ -11,6 +11,7 @@ import { import { runFieldListenerPipeline } from '../listeners.lib' import { devtools } from '../devtoolsBridge.lib' import { reconcileValidatorInstances } from '../ValidatorInstance.lib' +import { reconcileListenerInstances } from '../ListenerInstance.lib' import { attachWatchingListenerField, attachWatchingValidatorField, @@ -42,7 +43,7 @@ import type { AnyFieldListener, FieldListenerTriggers, } from '../listeners.public' -import type { NameSegment, NameSegments, PipelineCache } from '../utils.lib' +import type { NameSegment, NameSegments } from '../utils.lib' import type { FieldValidatorPipelineResult, PipelineResult, @@ -57,6 +58,10 @@ import type { InternalValidatorInstance, InternalValidatorInstances, } from '../ValidatorInstance.lib' +import type { + InternalListenerInstance, + InternalListenerInstances, +} from '../ListenerInstance.lib' import type { FieldApi, FieldApiOptions } from './FieldApi.public' import type { ErrorVisibility, @@ -271,19 +276,21 @@ export interface InternalFieldApiParams extends Omit< validators?: FieldValidators } -interface ListenToFieldsMeta { - field: AnyInternalFieldApi - name: string -} - -export type FieldWatchingFields = Map> -export type FieldListenToFields = Array> +export type FieldWatchingListenerFields = Map< + AnyInternalFieldApi, + Set +> export type FieldWatchingValidatorFields = Map< AnyInternalFieldApi, Set > export type AnyInternalFieldApi = InternalFieldApi +export type InternalFieldListenerInstance = InternalListenerInstance< + AnyFieldListener, + AnyInternalFieldApi, + AnyInternalFieldApi +> export type InternalFieldValidatorInstance = InternalValidatorInstance< AnyFieldValidator, AnyInternalFieldApi, @@ -308,7 +315,12 @@ export class InternalFieldApi< AnyInternalFieldApi, AnyInternalFieldApi > - _listeners: Array | null + /** Stable runtime instances for this field's listener definitions. */ + _listenerInstances: InternalListenerInstances< + AnyFieldListener, + AnyInternalFieldApi, + AnyInternalFieldApi + > _errorVisibility: ErrorVisibility | undefined _errorBoundary: boolean /** The form group occupying this trie node. */ @@ -319,11 +331,8 @@ export class InternalFieldApi< * @private * Fields that are listening to this one. */ - _watchingFields: FieldWatchingFields | null - _listenToFields: FieldListenToFields | null + _watchingListenerFields: FieldWatchingListenerFields | null _watchingValidatorFields: FieldWatchingValidatorFields | null - /** Lazily allocated runtime state for debounced field listeners. */ - _pipelineCache: PipelineCache | null = null _isKilled = false _segmentValue: NameSegment @@ -395,15 +404,6 @@ export class InternalFieldApi< return required } - /** Returns the listener runtime cache, allocating it on first use. */ - _getOrCreatePipelineCache(): PipelineCache { - if (!this._pipelineCache) { - this._pipelineCache = createPipelineCache() - } - - return this._pipelineCache - } - get atom(): ReadonlyAtom { return this._getOrCreateAtoms().store } @@ -487,10 +487,17 @@ export class InternalFieldApi< this._errorVisibility = errorVisibility this._errorBoundary = errorBoundary ?? false this._atoms = {} - this._listeners = null - this._watchingFields = null - this._listenToFields = null + this._watchingListenerFields = null this._watchingValidatorFields = null + this._listenerInstances = reconcileListenerInstances< + AnyFieldListener, + AnyInternalFieldApi, + AnyInternalFieldApi + >({ + definitions: listeners, + instances: null, + owner: this, + }) this._validatorInstances = reconcileValidatorInstances< AnyFieldValidator, AnyInternalFieldApi, @@ -505,14 +512,11 @@ export class InternalFieldApi< const reconciledListeners = reconcileWatchedListenerFields({ field: this, - prevListenToFields: this._listenToFields, - nextListeners: listeners, + listenerInstances: this._listenerInstances, form, }) reconciledListeners.attach.forEach(attachWatchingListenerField) - this._listeners = reconciledListeners.items - this._listenToFields = reconciledListeners.listenToFields const reconciledValidators = reconcileWatchedValidatorFields({ field: this, @@ -529,10 +533,40 @@ export class InternalFieldApi< this._errorVisibility = options.errorVisibility this._errorBoundary = options.errorBoundary ?? false + const notifyDependencyChanges = devtools().fieldDependenciesChanged + const dependencyChanges: Array | null = + notifyDependencyChanges ? [] : null + + const previousListeners = this._listenerInstances?.map( + (instance) => instance.definition, + ) + this._listenerInstances = reconcileListenerInstances< + AnyFieldListener, + AnyInternalFieldApi, + AnyInternalFieldApi + >({ + definitions: options.listeners, + previousDefinitions: previousListeners ?? null, + instances: this._listenerInstances, + owner: this, + onBeforeDispose: (listenerInstance) => { + listenerInstance.resolvedWatchFields?.forEach((sourceField) => { + const operation = { + kind: 'listener' as const, + sourceField, + watchingField: this, + listenerInstance, + } + detachWatchingListenerField(operation) + dependencyChanges?.push(operation) + }) + listenerInstance.resolvedWatchFields = null + }, + }) + const reconciledListeners = reconcileWatchedListenerFields({ field: this, - prevListenToFields: this._listenToFields, - nextListeners: options.listeners, + listenerInstances: this._listenerInstances, form: this.form, }) @@ -540,14 +574,10 @@ export class InternalFieldApi< detachWatchingListenerField(operation), ) reconciledListeners.attach.forEach(attachWatchingListenerField) - - this._listeners = reconciledListeners.items - this._listenToFields = reconciledListeners.listenToFields - const notifyDependencyChanges = devtools().fieldDependenciesChanged - const dependencyChanges: Array | null = - notifyDependencyChanges - ? [...reconciledListeners.attach, ...reconciledListeners.detach] - : null + dependencyChanges?.push( + ...reconciledListeners.attach, + ...reconciledListeners.detach, + ) if (options.validators) { const previousValidators = this._validatorInstances?.map( @@ -954,7 +984,7 @@ export class InternalFieldApi< _notifyListener( trigger: FieldListenerTriggers, seenFields: WeakSet, - onlyRunListenerIndeces: Array | null = null, + onlyRunListenerInstances: ReadonlySet | null = null, ) { if (this._isKilled) return @@ -969,32 +999,32 @@ export class InternalFieldApi< seenFields.add(this) - if (this._listeners) { + if (this._listenerInstances) { runFieldListenerPipeline({ - pipeline: this._listeners, + pipeline: this._listenerInstances, context: { event: trigger, fieldApi: this, formApi: this.form, }, - listenerIndecesToRun: onlyRunListenerIndeces, + listenerInstancesToRun: onlyRunListenerInstances, }) } - const watchingFields = this._watchingFields + const watchingFields = this._watchingListenerFields if (!watchingFields) return - for (const [watchingField, listenerIndeces] of watchingFields) { + for (const [watchingField, listenerInstances] of watchingFields) { if (watchingField._isKilled) { watchingFields.delete(watchingField) continue } - watchingField._notifyListener(trigger, seenFields, [...listenerIndeces]) + watchingField._notifyListener(trigger, seenFields, listenerInstances) } if (watchingFields.size === 0) { - this._watchingFields = null + this._watchingListenerFields = null } } diff --git a/packages/form-core/src/FieldApi/fieldTree.lib.ts b/packages/form-core/src/FieldApi/fieldTree.lib.ts index 322605a645..98faeb593c 100644 --- a/packages/form-core/src/FieldApi/fieldTree.lib.ts +++ b/packages/form-core/src/FieldApi/fieldTree.lib.ts @@ -1,5 +1,4 @@ import { batch } from '@tanstack/store' -import { cancelPipelineCache } from '../utils.lib' import { devtools } from '../devtoolsBridge.lib' import { detachWatchingListenerField, @@ -20,8 +19,7 @@ import type { FieldListenerTriggers } from '../listeners.public' import type { FieldDependencyChange } from '../devtoolsBridge.lib' import type { AnyInternalFieldApi, - FieldListenToFields, - FieldWatchingFields, + FieldWatchingListenerFields, FieldWatchingValidatorFields, } from './FieldApi.lib' import type { @@ -31,123 +29,73 @@ import type { import type { ChildContributionStates } from './fieldState.lib' import type { NameSegment } from '../utils.lib' -type DetachWatchingFieldFn = ( - operation: Extract, - options?: { pruneSourceField?: boolean }, -) => void - const rootCounterContributionKeys: Array = [ 'touched', 'validating', ] -function clearWatchedSourceReference( - listenToFields: FieldListenToFields | null, - sourceField: AnyInternalFieldApi, - watcherIndex: number, -): FieldListenToFields | null { - if (!listenToFields) return null - - const sourceMetas = listenToFields[watcherIndex] - if (!sourceMetas) return listenToFields - - const nextSourceMetas = sourceMetas.filter( - (sourceMeta) => sourceMeta.field !== sourceField, - ) - if (nextSourceMetas.length === sourceMetas.length) { - return listenToFields - } - - if (nextSourceMetas.length > 0) { - listenToFields[watcherIndex] = nextSourceMetas - } else { - delete listenToFields[watcherIndex] - } - - return listenToFields.some( - (sourceMetasForIndex) => sourceMetasForIndex.length > 0, - ) - ? listenToFields - : null -} - -function detachOutgoingWatchedFields({ +function detachWatchedListenerFields({ field, - listenToFields, - detach, nodesToKill, fieldsToPruneAfterKill, dependencyChanges, }: { field: AnyInternalFieldApi - listenToFields: FieldListenToFields | null - detach: DetachWatchingFieldFn nodesToKill: Set fieldsToPruneAfterKill: Set dependencyChanges: Array | null }) { - listenToFields?.forEach((sourceMetas, watcherIndex) => { - for (const { field: sourceField } of sourceMetas) { + field._listenerInstances?.forEach((listenerInstance) => { + listenerInstance.resolvedWatchFields?.forEach((sourceField) => { const change = { kind: 'listener' as const, sourceField, watchingField: field, - watcherIndex, + listenerInstance, } - detach(change, { pruneSourceField: false }) + detachWatchingListenerField(change, { pruneSourceField: false }) dependencyChanges?.push(change) if (!nodesToKill.has(sourceField)) { fieldsToPruneAfterKill.add(sourceField) } - } + }) + listenerInstance.resolvedWatchFields = null }) } -function detachIncomingWatchedFields({ +function detachWatchingListenerFields({ sourceField, watchingFields, - detach, nodesToKill, fieldsToPruneAfterKill, dependencyChanges, - getListenToFields, - setListenToFields, }: { sourceField: AnyInternalFieldApi - watchingFields: FieldWatchingFields | null - detach: DetachWatchingFieldFn + watchingFields: FieldWatchingListenerFields | null nodesToKill: Set fieldsToPruneAfterKill: Set dependencyChanges: Array | null - getListenToFields: ( - watchingField: AnyInternalFieldApi, - ) => FieldListenToFields | null - setListenToFields: ( - watchingField: AnyInternalFieldApi, - listenToFields: FieldListenToFields | null, - ) => void }) { if (!watchingFields) return - for (const [watchingField, watcherIndexes] of Array.from(watchingFields)) { - for (const watcherIndex of Array.from(watcherIndexes)) { + for (const [watchingField, listenerInstances] of Array.from(watchingFields)) { + for (const listenerInstance of Array.from(listenerInstances)) { const change = { kind: 'listener' as const, sourceField, watchingField, - watcherIndex, + listenerInstance, } - detach(change, { pruneSourceField: false }) + detachWatchingListenerField(change, { pruneSourceField: false }) dependencyChanges?.push(change) - setListenToFields( - watchingField, - clearWatchedSourceReference( - getListenToFields(watchingField), - sourceField, - watcherIndex, - ), - ) + + listenerInstance.resolvedWatchFields?.forEach((resolvedField, name) => { + if (resolvedField === sourceField) { + listenerInstance.deleteResolvedWatchField(name) + } + }) + if (!nodesToKill.has(watchingField)) { fieldsToPruneAfterKill.add(watchingField) } @@ -254,15 +202,12 @@ function detachLinkedFieldReferences({ fieldsToPruneAfterKill: Set dependencyChanges: Array | null }) { - detachOutgoingWatchedFields({ + detachWatchedListenerFields({ field, - listenToFields: field._listenToFields, - detach: detachWatchingListenerField, nodesToKill, fieldsToPruneAfterKill, dependencyChanges, }) - field._listenToFields = null detachWatchedValidatorFields({ field, @@ -271,19 +216,14 @@ function detachLinkedFieldReferences({ dependencyChanges, }) - detachIncomingWatchedFields({ + detachWatchingListenerFields({ sourceField: field, - watchingFields: field._watchingFields, - detach: detachWatchingListenerField, + watchingFields: field._watchingListenerFields, nodesToKill, fieldsToPruneAfterKill, dependencyChanges, - getListenToFields: (watchingField) => watchingField._listenToFields, - setListenToFields: (watchingField, listenToFields) => { - watchingField._listenToFields = listenToFields - }, }) - field._watchingFields = null + field._watchingListenerFields = null detachWatchingValidatorFields({ sourceField: field, @@ -465,10 +405,8 @@ export function killField( node._formGroup = null node._defaultValueCache = null node._atoms.store = undefined - if (node._pipelineCache) { - cancelPipelineCache(node._pipelineCache) - node._pipelineCache = null - } + node._listenerInstances?.forEach((instance) => instance.dispose()) + node._listenerInstances = null node._validatorInstances?.forEach((instance) => instance.dispose()) node._validatorInstances = null node._childrenMap.clear() @@ -518,11 +456,13 @@ export function canPruneField(field: AnyInternalFieldApi): boolean { if (field._refCount > 0) return false if (field._formGroup) return false if (field._childrenMap.size > 0) return false - if (field._watchingFields) return false + if (field._watchingListenerFields) return false if (field._watchingValidatorFields) return false // Watched source maps retain and notify this field, so keep both endpoints // reachable from the form trie while an outgoing link is active. - if (field._listenToFields) return false + if (field._listenerInstances?.some((v) => v.resolvedWatchFields)) { + return false + } if (field._validatorInstances?.some((v) => v.resolvedWatchFields)) { return false } diff --git a/packages/form-core/src/FieldApi/linked-fields.lib.ts b/packages/form-core/src/FieldApi/linked-fields.lib.ts index d548ac1f29..1c391cf80a 100644 --- a/packages/form-core/src/FieldApi/linked-fields.lib.ts +++ b/packages/form-core/src/FieldApi/linked-fields.lib.ts @@ -1,23 +1,15 @@ import type { AnyInternalFieldApi, - FieldListenToFields, + InternalFieldListenerInstance, InternalFieldValidatorInstance, } from './FieldApi.lib' import type { AnyInternalFormApi } from '../FormApi/FormApi.lib' -import type { AnyFieldListener } from '../listeners.public' -type WatcherIndex = number - -interface ListenToFieldsMeta { - field: AnyInternalFieldApi - name: string -} - -interface WatchFieldOperation { +export interface ListenerWatchFieldOperation { kind: 'listener' sourceField: AnyInternalFieldApi watchingField: AnyInternalFieldApi - watcherIndex: WatcherIndex + listenerInstance: InternalFieldListenerInstance } export interface ValidatorWatchFieldOperation { @@ -31,119 +23,65 @@ export interface DetachWatchingFieldOptions { pruneSourceField?: boolean } -interface ReconciledWatchedFields { - items: Array | null - listenToFields: FieldListenToFields | null - attach: Array - detach: Array -} - -type WatcherKey = `${number}:${string}` - -function toWatcherKey(watcherIndex: number, name: string): WatcherKey { - return `${watcherIndex}:${name}` -} -function ofWatcherKey(key: WatcherKey): [watcherIndex: number, name: string] { - const [watcherIndex, name] = key.split(':') as [number, string] - return [Number(watcherIndex), name] -} - -function reconcileWatchedFields }>({ - nextItems, - prevListenToFields, +export function reconcileWatchedListenerFields({ + listenerInstances, field, form, }: { - nextItems: Array | null | undefined - prevListenToFields: FieldListenToFields | null + listenerInstances: ReadonlyArray | null field: AnyInternalFieldApi form: AnyInternalFormApi -}): ReconciledWatchedFields { - const normalizedItems = nextItems && nextItems.length > 0 ? nextItems : null - const prevByKey = new Map() +}): { + attach: Array + detach: Array +} { + const attach: Array = [] + const detach: Array = [] - prevListenToFields?.forEach((prevMetas, watcherIndex) => { - for (const prevMeta of prevMetas) { - prevByKey.set(toWatcherKey(watcherIndex, prevMeta.name), prevMeta) - } - }) + listenerInstances?.forEach((listenerInstance) => { + const previous = listenerInstance.resolvedWatchFields + const next = new Map() + const names = [...new Set(listenerInstance.definition.watchFields ?? [])] + + for (const name of names) { + const sourceField = form._getOrCreateFieldApi({ name }) + next.set(name, sourceField) + + const previousField = previous?.get(name) + if (previousField === sourceField) continue - const nextListenToFields: FieldListenToFields = [] - const attach: Array = [] - const detach: Array = [] - - if (normalizedItems) { - normalizedItems.forEach(({ watchFields = [] }, watcherIndex) => { - const names = [...new Set(watchFields)] - if (names.length === 0) return - - nextListenToFields[watcherIndex] = names.map((name) => { - const sourceField = form._getOrCreateFieldApi({ name }) - const key = toWatcherKey(watcherIndex, name) - const prevMeta = prevByKey.get(key) - - // Changed or unchanged name, it resolved back to the same field - if (prevMeta?.field === sourceField) { - prevByKey.delete(key) - return prevMeta - } - - // Field reference and name are mismatched, so detach to reattach to actual - if (prevMeta) { - detach.push({ - kind: 'listener', - sourceField: prevMeta.field, - watchingField: field, - watcherIndex, - }) - prevByKey.delete(key) - } - - attach.push({ + if (previousField) { + detach.push({ kind: 'listener', - sourceField, + sourceField: previousField, watchingField: field, - watcherIndex, + listenerInstance, }) - return { name, field: sourceField } + } + + attach.push({ + kind: 'listener', + sourceField, + watchingField: field, + listenerInstance, }) - }) - } + } - for (const [key, prevMeta] of prevByKey.entries()) { - detach.push({ - kind: 'listener', - sourceField: prevMeta.field, - watchingField: field, - watcherIndex: ofWatcherKey(key)[0], - }) - } + previous?.forEach((sourceField, name) => { + if (next.has(name)) return - return { - items: normalizedItems, - listenToFields: nextListenToFields.length > 0 ? nextListenToFields : null, - attach, - detach, - } -} + detach.push({ + kind: 'listener', + sourceField, + watchingField: field, + listenerInstance, + }) + }) -export function reconcileWatchedListenerFields({ - nextListeners, - prevListenToFields, - field, - form, -}: { - nextListeners: Array | null | undefined - prevListenToFields: FieldListenToFields | null - field: AnyInternalFieldApi - form: AnyInternalFormApi -}): ReconciledWatchedFields { - return reconcileWatchedFields({ - nextItems: nextListeners, - prevListenToFields, - field, - form, + listenerInstance.resolvedWatchFields = next.size > 0 ? next : null }) + + return { attach, detach } } export function reconcileWatchedValidatorFields({ @@ -207,52 +145,42 @@ export function reconcileWatchedValidatorFields({ return { attach, detach } } -function attachWatchingField( - getWatchingFields: ( - sourceField: AnyInternalFieldApi, - ) => Map> | null, - setWatchingFields: ( - sourceField: AnyInternalFieldApi, - watchingFields: Map>, - ) => void, - { sourceField, watchingField, watcherIndex }: WatchFieldOperation, -) { - let watchingFields = getWatchingFields(sourceField) +export function attachWatchingListenerField({ + sourceField, + watchingField, + listenerInstance, +}: ListenerWatchFieldOperation) { + let watchingFields = sourceField._watchingListenerFields if (!watchingFields) { watchingFields = new Map() - setWatchingFields(sourceField, watchingFields) + sourceField._watchingListenerFields = watchingFields } - let indices = watchingFields.get(watchingField) - - if (!indices) { - indices = new Set() - watchingFields.set(watchingField, indices) + let instances = watchingFields.get(watchingField) + if (!instances) { + instances = new Set() + watchingFields.set(watchingField, instances) } - indices.add(watcherIndex) + instances.add(listenerInstance) } -function detachWatchingField( - getWatchingFields: ( - sourceField: AnyInternalFieldApi, - ) => Map> | null, - clearWatchingFields: (sourceField: AnyInternalFieldApi) => void, - { sourceField, watchingField, watcherIndex }: WatchFieldOperation, +export function detachWatchingListenerField( + { sourceField, watchingField, listenerInstance }: ListenerWatchFieldOperation, options: DetachWatchingFieldOptions = {}, ) { - const watchingFields = getWatchingFields(sourceField) + const watchingFields = sourceField._watchingListenerFields if (!watchingFields) return - const indices = watchingFields.get(watchingField) - if (!indices) return + const instances = watchingFields.get(watchingField) + if (!instances) return - indices.delete(watcherIndex) + instances.delete(listenerInstance) - if (indices.size === 0) { + if (instances.size === 0) { watchingFields.delete(watchingField) if (watchingFields.size === 0) { - clearWatchingFields(sourceField) + sourceField._watchingListenerFields = null } } @@ -261,30 +189,6 @@ function detachWatchingField( } } -export function attachWatchingListenerField(operation: WatchFieldOperation) { - attachWatchingField( - (source) => source._watchingFields, - (source, watchingFields) => { - source._watchingFields = watchingFields - }, - operation, - ) -} - -export function detachWatchingListenerField( - operation: WatchFieldOperation, - options?: DetachWatchingFieldOptions, -) { - detachWatchingField( - (source) => source._watchingFields, - (source) => { - source._watchingFields = null - }, - operation, - options, - ) -} - export function attachWatchingValidatorField({ sourceField, watchingField, diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index 54d2cb04d8..6df6ea1336 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -7,8 +7,6 @@ import { } from '../FieldApi/FieldApi.lib' import { callUpdater, - cancelPipelineCache, - createPipelineCache, evaluate, getBy, getTargetField, @@ -38,6 +36,7 @@ import { runFormListenerPipeline } from '../listeners.lib' import { applyServerState } from '../ssr.lib' import { devtools } from '../devtoolsBridge.lib' import { reconcileValidatorInstances } from '../ValidatorInstance.lib' +import { reconcileListenerInstances } from '../ListenerInstance.lib' import { InternalValidationSourceInstance } from '../ValidationSourceInstance.lib' import { runSubmissionProcess } from './handleSubmit.lib' import { ArrayMethods } from './array-methods.lib' @@ -55,7 +54,6 @@ import type { } from './FormApi.public' import type { FormErrorMeta } from './formState.lib' import type { DeepKeys } from '../deep-keys.public' -import type { PipelineCache } from '../utils.lib' import type { FormValidatorPipelineResult, PipelineResult } from '../validation' import type { AnyFieldApiOptions, @@ -82,7 +80,8 @@ import type { ValidationIssue, ValidationTrigger, } from '../validation.public' -import type { FormListenerTriggers } from '../listeners.public' +import type { AnyFormListener, FormListenerTriggers } from '../listeners.public' +import type { InternalListenerInstances } from '../ListenerInstance.lib' import type { ServerFormState } from '../ssr.public' import type { InternalValidatorInstance, @@ -229,7 +228,11 @@ export class InternalFormApi< AnyInternalFieldApi > _lastUpdateDefaultValues: TFormData - _pipelineCache: PipelineCache + /** Stable runtime instances correlated with `_options.listeners` by slot. */ + _listenerInstances: InternalListenerInstances< + AnyFormListener, + AnyInternalFormApi + > _lastServerState: ServerFormState | null = null get state(): FormState< @@ -294,7 +297,6 @@ export class InternalFormApi< constructor(options: FormOptions) { this._options = { ...options, formId: options.formId ?? uuid() } this._lastUpdateDefaultValues = options.defaultValues - this._pipelineCache = createPipelineCache() this._atoms = { values: createAtom(options.defaultValues), meta: createInitialFormMetaAtoms(), @@ -310,6 +312,11 @@ export class InternalFormApi< this.atom = createAtom(() => getFormStateSnapshot(this), { compare: compareFormStateSnapshots, }) + this._listenerInstances = reconcileListenerInstances({ + definitions: this._options.listeners, + instances: null, + owner: this, + }) this._validatorInstances = reconcileValidatorInstances< TFormValidators[number], AnyInternalFormApi, @@ -367,8 +374,7 @@ export class InternalFormApi< this._options = { ...this._options, defaultValues: values } } - cancelPipelineCache(this._pipelineCache) - this._pipelineCache = createPipelineCache() + this._listenerInstances?.forEach((instance) => instance.resetRuntime()) this._validatorInstances?.forEach((instance) => instance.resetRuntime()) this._onSubmitSource.resetRuntime() this._defaultValueCache = null @@ -413,6 +419,13 @@ export class InternalFormApi< formId: options.formId ?? oldOptions.formId, } + this._listenerInstances = reconcileListenerInstances({ + definitions: this._options.listeners, + previousDefinitions: oldOptions.listeners ?? null, + instances: this._listenerInstances, + owner: this, + }) + this._validatorInstances = reconcileValidatorInstances< TFormValidators[number], AnyInternalFormApi, @@ -496,10 +509,9 @@ export class InternalFormApi< const current = fields[index]! current._defaultValueCache = null - if (current._pipelineCache) { - cancelPipelineCache(current._pipelineCache) - current._pipelineCache = null - } + current._listenerInstances?.forEach((instance) => + instance.resetRuntime(), + ) current._validatorInstances?.forEach((instance) => instance.resetRuntime(), ) @@ -633,11 +645,10 @@ export class InternalFormApi< trigger: FormListenerTriggers, triggerFieldApi: AnyInternalFieldApi | null, ) { - if (!this._options.listeners) return - if (this._options.listeners.length === 0) return + if (!this._listenerInstances) return runFormListenerPipeline({ - pipeline: this._options.listeners, + pipeline: this._listenerInstances, context: { event: trigger, formApi: this, diff --git a/packages/form-core/src/ListenerInstance.lib.ts b/packages/form-core/src/ListenerInstance.lib.ts new file mode 100644 index 0000000000..df06dea1c9 --- /dev/null +++ b/packages/form-core/src/ListenerInstance.lib.ts @@ -0,0 +1,188 @@ +import { LiteDebouncer } from '@tanstack/pacer-lite' +import type { AnyFieldListener, AnyFormListener } from './listeners.public' + +export type InternalListenerDefinition = AnyFormListener | AnyFieldListener + +export type ListenerInstanceDebouncedFn = (...args: Array) => any + +export type AnyInternalListenerInstance< + TDebouncedFn extends ListenerInstanceDebouncedFn = + ListenerInstanceDebouncedFn, +> = InternalListenerInstance + +export type InternalListenerInstances< + TDefinition extends InternalListenerDefinition, + TOwner, + TWatchedField = unknown, +> = Array> | null + +export interface ReconcileListenerInstancesOptions< + TDefinition extends InternalListenerDefinition, + TOwner, + TWatchedField = unknown, +> { + definitions: ReadonlyArray | null | undefined + previousDefinitions?: ReadonlyArray | null + instances: InternalListenerInstances + owner: TOwner + onBeforeDispose?: ( + instance: InternalListenerInstance, + ) => void +} + +/** Runtime state owned by one installed listener occurrence. */ +export class InternalListenerInstance< + TDefinition extends InternalListenerDefinition, + TOwner, + TWatchedField = unknown, + TDebouncedFn extends ListenerInstanceDebouncedFn = + ListenerInstanceDebouncedFn, +> { + /** The boundary that owns this listener. */ + readonly owner: TOwner + /** This listener's stable position within its owner's listener pipeline. */ + readonly index: number + /** The current listener definition associated with this stable instance. */ + definition: TDefinition + /** The lazily created debouncer for this listener's pending execution. */ + debouncer: LiteDebouncer | null = null + /** Resolved fields referenced by this listener's `watchFields` definition. */ + resolvedWatchFields: Map | null = null + /** Number of definition updates applied while preserving this instance. */ + revision = 0 + /** Whether this listener has been permanently disposed. */ + disposed = false + + constructor({ + definition, + owner, + index = 0, + }: { + definition: TDefinition + owner: TOwner + index?: number + }) { + this.definition = definition + this.owner = owner + this.index = index + } + + /** Replaces the definition while preserving this instance and runtime state. */ + updateDefinition(definition: TDefinition): void { + if (this.disposed) return + + this.definition = definition + this.revision++ + } + + /** Returns this listener's debouncer, creating it on first use. */ + getOrCreateDebouncer( + fn: TDebouncedFn, + wait: number, + ): LiteDebouncer | null { + if (this.disposed) return null + + let debouncer = this.debouncer + if (!debouncer) { + debouncer = new LiteDebouncer(fn, { wait }) + this.debouncer = debouncer + } else { + debouncer.fn = fn + debouncer.options.wait = wait + } + + return debouncer + } + + /** Associates a configured watched-field name with its resolved field. */ + setResolvedWatchField(name: string, field: TWatchedField): void { + if (this.disposed) return + + if (!this.resolvedWatchFields) { + this.resolvedWatchFields = new Map() + } + this.resolvedWatchFields.set(name, field) + } + + /** Removes a resolved watched field. */ + deleteResolvedWatchField(name: string): void { + if (this.disposed) return + + this.resolvedWatchFields?.delete(name) + if (this.resolvedWatchFields?.size === 0) { + this.resolvedWatchFields = null + } + } + + /** Cancels transient execution while preserving identity and dependencies. */ + resetRuntime(): void { + if (this.disposed) return + + this.debouncer?.cancel() + this.debouncer = null + } + + /** Permanently disposes this listener and releases its runtime state. */ + dispose(onBeforeDispose?: (instance: this) => void): void { + if (this.disposed) return + + onBeforeDispose?.(this) + this.resetRuntime() + this.resolvedWatchFields = null + this.disposed = true + } +} + +/** Correlates listener definitions with stable runtime instances by slot. */ +export function reconcileListenerInstances< + TDefinition extends InternalListenerDefinition, + TOwner, + TWatchedField = unknown, +>({ + definitions, + previousDefinitions, + instances, + owner, + onBeforeDispose, +}: ReconcileListenerInstancesOptions< + TDefinition, + TOwner, + TWatchedField +>): InternalListenerInstances { + if ( + previousDefinitions !== undefined && + (previousDefinitions?.length ?? 0) !== (definitions?.length ?? 0) + ) { + console.warn( + 'TanStack Form: The length of the listener array should not change after initialization', + ) + } + + if (!definitions || definitions.length === 0) { + instances?.forEach((instance) => instance.dispose(onBeforeDispose)) + return null + } + + const nextInstances = instances ?? [] + + definitions.forEach((definition, index) => { + const instance = nextInstances[index] + + if (instance) { + instance.updateDefinition(definition) + } else { + nextInstances[index] = new InternalListenerInstance({ + definition, + owner, + index, + }) + } + }) + + for (let index = definitions.length; index < nextInstances.length; index++) { + nextInstances[index]?.dispose(onBeforeDispose) + } + nextInstances.length = definitions.length + + return nextInstances +} diff --git a/packages/form-core/src/devtoolsBridge.lib.ts b/packages/form-core/src/devtoolsBridge.lib.ts index c34c2b30d1..97279603e0 100644 --- a/packages/form-core/src/devtoolsBridge.lib.ts +++ b/packages/form-core/src/devtoolsBridge.lib.ts @@ -1,6 +1,7 @@ import type { AnyInternalFormApi } from './FormApi/FormApi.lib' import type { AnyInternalFieldApi, + InternalFieldListenerInstance, InternalFieldValidatorInstance, } from './FieldApi/FieldApi.lib' @@ -14,7 +15,7 @@ interface BaseFieldDependencyChange { export interface FieldListenerDependencyChange extends BaseFieldDependencyChange { kind: 'listener' - watcherIndex: number + listenerInstance: InternalFieldListenerInstance } export interface FieldValidatorDependencyChange extends BaseFieldDependencyChange { diff --git a/packages/form-core/src/internals.ts b/packages/form-core/src/internals.ts index 8bdcd3addf..bc52ba63bc 100644 --- a/packages/form-core/src/internals.ts +++ b/packages/form-core/src/internals.ts @@ -9,6 +9,7 @@ export * from './types.lib' export * from './FieldApi/RootFieldApi.lib' export * from './validation' export * from './ValidatorInstance.lib' +export * from './ListenerInstance.lib' export * from './ValidationSourceInstance.lib' export * from './listeners.lib' export * from './FieldApi/linked-fields.lib' diff --git a/packages/form-core/src/listeners.lib.ts b/packages/form-core/src/listeners.lib.ts index e53630fd74..3bda6b8132 100644 --- a/packages/form-core/src/listeners.lib.ts +++ b/packages/form-core/src/listeners.lib.ts @@ -1,7 +1,6 @@ -import { LiteDebouncer } from '@tanstack/pacer-lite' -import type { PipelineCache } from './utils.lib' import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' import type { InternalFormApi } from './FormApi/FormApi.lib' +import type { AnyInternalListenerInstance } from './ListenerInstance.lib' import type { AnyFieldListener, AnyFormListener, @@ -33,10 +32,6 @@ type ListenerContext = FormListenerContext | FieldListenerContext type AnyListener = AnyFormListener | AnyFieldListener -export type ListenerDebouncer = LiteDebouncer< - (context: ListenerContext) => void -> - function isFormContext(ctx: InputContext): ctx is FormInputContext { return 'triggerFieldApi' in ctx } @@ -114,28 +109,6 @@ function getListenerDebounceMs( return getDebounceMs(triggerDebounceMs, context) } -function getOrCreateDebouncer( - cache: PipelineCache, - cacheKey: number, - fn: (context: ListenerContext) => void, - wait: number, -): ListenerDebouncer { - let debouncer = cache.listenerDebouncers.get(cacheKey) - - if (!debouncer) { - debouncer = new LiteDebouncer(fn, { - wait, - }) - - cache.listenerDebouncers.set(cacheKey, debouncer) - } else { - debouncer.fn = fn - debouncer.options.wait = wait - } - - return debouncer -} - function executeListener( listener: AnyListener, context: ListenerContext, @@ -146,19 +119,15 @@ function executeListener( } function runListener({ - listener, + listenerInstance, context, - listenerIndex, - cache, getContext, }: { - listener: AnyListener + listenerInstance: AnyInternalListenerInstance context: TContext - listenerIndex: number - cache: PipelineCache getContext: (inputContext: TContext) => ListenerContext }): void { - const cacheKey = listenerIndex + const listener = listenerInstance.definition as AnyListener const debounceMs = getListenerDebounceMs(listener, context) const listenerContext = getContext(context) @@ -167,44 +136,48 @@ function runListener({ return } - const debouncer = getOrCreateDebouncer( - cache, - cacheKey, - (ctx) => executeListener(listener, ctx), + const debouncer = listenerInstance.getOrCreateDebouncer( + (ctx: ListenerContext) => executeListener(listener, ctx), debounceMs, ) - debouncer.maybeExecute(listenerContext) + debouncer?.maybeExecute(listenerContext) } function runListenerPipeline({ pipeline, context, - cache, getContext, + listenerInstancesToRun = null, }: { - pipeline: ReadonlyArray + pipeline: ReadonlyArray context: TContext - cache: PipelineCache getContext: (inputContext: TContext) => ListenerContext + listenerInstancesToRun?: ReadonlySet | null }): void { - pipeline.forEach((listener, listenerIndex) => { + pipeline.forEach((listenerInstance) => { + if ( + listenerInstancesToRun && + !listenerInstancesToRun.has(listenerInstance) + ) { + return + } + + const listener = listenerInstance.definition as AnyListener if (!shouldRunListener(listener, context)) { return } runListener({ - listener, + listenerInstance, context, - listenerIndex, - cache, getContext, }) }) } interface FormListenerPipelineArgs { - pipeline: ReadonlyArray + pipeline: ReadonlyArray context: FormInputContext } @@ -212,11 +185,9 @@ export function runFormListenerPipeline({ pipeline, context, }: FormListenerPipelineArgs): void { - const cache = context.formApi._pipelineCache return runListenerPipeline({ pipeline, context, - cache, getContext: (ctx) => ({ formApi: ctx.formApi, triggerFieldApi: ctx.triggerFieldApi, @@ -226,33 +197,27 @@ export function runFormListenerPipeline({ } interface FieldListenerPipelineArgs { - pipeline: ReadonlyArray + pipeline: ReadonlyArray context: FieldInputContext /** * @private * When an incoming watched field notifies, we should only run listeners * that are actually interested in it. */ - listenerIndecesToRun: Array | null + listenerInstancesToRun: ReadonlySet | null } export function runFieldListenerPipeline({ - pipeline: incomingPipeline, + pipeline, context, - listenerIndecesToRun, + listenerInstancesToRun, }: FieldListenerPipelineArgs): void { if (context.fieldApi._isKilled) return - const cache = context.fieldApi._getOrCreatePipelineCache() - - const pipeline = listenerIndecesToRun - ? incomingPipeline.filter((_, i) => listenerIndecesToRun.includes(i)) - : incomingPipeline - return runListenerPipeline({ pipeline, context, - cache, + listenerInstancesToRun, getContext: (ctx) => ({ value: ctx.fieldApi.value, fieldApi: context.fieldApi, diff --git a/packages/form-core/src/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts index 7768f53341..d2d1c85085 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -2,7 +2,7 @@ import { batch } from '@tanstack/store' import { defaultInternalBaseFieldMeta } from './FieldApi/fieldState.lib' import { visitAllFormFields } from './FieldApi/fieldTraversal.lib' import { parseStandardSchemaIssues } from './standardSchema.lib' -import { cancelPipelineCache, createPipelineCache, evaluate } from './utils.lib' +import { evaluate } from './utils.lib' import { runValidatorPipeline } from './validation' import { createErrorMap } from './validation.public' import { devtools } from './devtoolsBridge.lib' @@ -36,10 +36,7 @@ function resetFieldMetaForServerState(form: InternalFormApi) { visitAllFormFields(form._fieldRootNode, (field) => { field._defaultValueCache = null - if (field._pipelineCache) { - cancelPipelineCache(field._pipelineCache) - field._pipelineCache = null - } + field._listenerInstances?.forEach((instance) => instance.resetRuntime()) field._validatorInstances?.forEach((instance) => instance.resetRuntime()) const metaAtom = field._atoms.meta @@ -61,8 +58,7 @@ function resetToServerState( form._options.defaultValues, ) - cancelPipelineCache(form._pipelineCache) - form._pipelineCache = createPipelineCache() + form._listenerInstances?.forEach((instance) => instance.resetRuntime()) form._validatorInstances?.forEach((instance) => instance.resetRuntime()) form._onSubmitSource.resetRuntime() form._defaultValueCache = null diff --git a/packages/form-core/src/utils.lib.ts b/packages/form-core/src/utils.lib.ts index 535485f5cf..2bdb8ba7f9 100644 --- a/packages/form-core/src/utils.lib.ts +++ b/packages/form-core/src/utils.lib.ts @@ -2,7 +2,6 @@ import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' // type import type { FieldUpdateOptions, OneOrMany, Updater } from './types.public' -import type { ListenerDebouncer } from './listeners.lib' import type { InternalFieldUpdateOptions, ResolvedInternalFieldUpdateOptions, @@ -88,24 +87,6 @@ export function getTargetField( return field } -export interface PipelineCache { - listenerDebouncers: Map -} - -export function createPipelineCache(): PipelineCache { - return { - listenerDebouncers: new Map(), - } -} - -export function cancelPipelineCache(cache: PipelineCache): void { - for (const debouncer of cache.listenerDebouncers.values()) { - debouncer.cancel() - } - - cache.listenerDebouncers.clear() -} - /* / credit is due to https://github.com/lukeed/uuid for this code, with current npm / attacks we didn't feel comfortable installing directly from npm. But big appreciation diff --git a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts index 9c56c83647..fe60e9c423 100644 --- a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts +++ b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts @@ -91,6 +91,78 @@ describe('field - lifecycle', () => { }) }) + describe('listener instances', () => { + it('keeps instances stable by slot and warns when the length changes', () => { + const form = new InternalFormApi({ defaultValues: { x: '' } }) + const firstDefinition = { + run: () => {}, + triggers: ['change'] as Array<'change'>, + } + const field = form._getOrCreateFieldApi({ + name: 'x', + listeners: [firstDefinition], + }) + const instance = field._listenerInstances?.[0] + const initialRevision = instance?.revision + const nextDefinition = { + run: () => {}, + triggers: ['blur'] as Array<'blur'>, + } + + field._update({ listeners: [nextDefinition] }) + + expect(field._listenerInstances?.[0]).toBe(instance) + expect(instance?.definition).toBe(nextDefinition) + expect(instance?.owner).toBe(field) + expect(instance?.index).toBe(0) + expect(instance?.revision).toBe((initialRevision ?? 0) + 1) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + field._update({ listeners: [] }) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'length of the listener array should not change', + ), + ) + expect(field._listenerInstances).toBeNull() + expect(instance?.disposed).toBe(true) + warn.mockRestore() + }) + + it('resets runtime on field reset and disposes instances on kill', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { x: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'x', + listeners: [ + { + run: listener, + triggers: ['change'], + triggerDebounceMs: 100, + }, + ], + }) + const instance = field._listenerInstances?.[0] + + field.handleChange('pending') + field.reset() + await vi.advanceTimersByTimeAsync(100) + + expect(listener).not.toHaveBeenCalled() + expect(field._listenerInstances?.[0]).toBe(instance) + expect(instance?.debouncer).toBeNull() + expect(instance?.disposed).toBe(false) + + field._kill() + + expect(field._listenerInstances).toBeNull() + expect(instance?.disposed).toBe(true) + vi.useRealTimers() + }) + }) + describe('devtools bridge notifications', () => { it('notifies field mount and final unmount transitions only', () => { const form = new InternalFormApi({ defaultValues: { name: '' } }) @@ -257,13 +329,14 @@ describe('field - lifecycle', () => { }, ], }) + const listenerInstance = target._listenerInstances![0]! expect(fieldDependenciesChanged).toHaveBeenCalledWith([ { kind: 'listener', sourceField: source, watchingField: target, - watcherIndex: 0, + listenerInstance, }, ]) @@ -275,10 +348,10 @@ describe('field - lifecycle', () => { kind: 'listener', sourceField: source, watchingField: target, - watcherIndex: 0, + listenerInstance, }, ]) - expect(source._watchingFields).toBeNull() + expect(source._watchingListenerFields).toBeNull() } finally { uninstallBridge() } @@ -584,7 +657,9 @@ describe('field - lifecycle', () => { sourceField._kill() expect(form._tryGetFieldApi('target')).toBe(targetField) - expect(targetField._listenToFields).toBeNull() + expect( + targetField._listenerInstances?.[0]?.resolvedWatchFields, + ).toBeNull() } finally { unregisterTarget() } diff --git a/packages/form-core/tests/FieldApi/listeners.spec.ts b/packages/form-core/tests/FieldApi/listeners.spec.ts index facffd62b3..2dd951729a 100644 --- a/packages/form-core/tests/FieldApi/listeners.spec.ts +++ b/packages/form-core/tests/FieldApi/listeners.spec.ts @@ -720,6 +720,82 @@ describe('field - listeners', () => { expect(listener3).toHaveBeenCalledOnce() }) + it('keeps debounced watched listeners isolated by stable instance', async () => { + vi.useFakeTimers() + const firstListener = vi.fn() + const secondListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { firstSource: '', secondSource: '', target: '' }, + }) + form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { + run: firstListener, + triggers: ['change'], + watchFields: ['firstSource'], + triggerDebounceMs: 100, + }, + { + run: secondListener, + triggers: ['change'], + watchFields: ['secondSource'], + triggerDebounceMs: 100, + }, + ], + }) + const firstSource = form._getOrCreateFieldApi({ name: 'firstSource' }) + const secondSource = form._getOrCreateFieldApi({ name: 'secondSource' }) + + secondSource.handleChange('second') + firstSource.handleChange('first') + await vi.advanceTimersByTimeAsync(100) + + expect(firstListener).toHaveBeenCalledOnce() + expect(secondListener).toHaveBeenCalledOnce() + vi.useRealTimers() + }) + + it('cancels a removed watched listener pending execution', async () => { + vi.useFakeTimers() + const firstListener = vi.fn() + const removedListener = vi.fn() + const firstDefinition = { + run: firstListener, + triggers: ['change'] as Array<'change'>, + watchFields: ['firstSource'], + } + const form = new InternalFormApi({ + defaultValues: { firstSource: '', removedSource: '', target: '' }, + }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + firstDefinition, + { + run: removedListener, + triggers: ['change'], + watchFields: ['removedSource'], + triggerDebounceMs: 100, + }, + ], + }) + const removedSource = form._getOrCreateFieldApi({ name: 'removedSource' }) + const removedInstance = target._listenerInstances![1]! + + removedSource.handleChange('pending') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + target._update({ listeners: [firstDefinition] }) + await vi.advanceTimersByTimeAsync(100) + + expect(warn).toHaveBeenCalled() + expect(removedListener).not.toHaveBeenCalled() + expect(removedInstance.disposed).toBe(true) + expect(removedSource._watchingListenerFields).toBeNull() + warn.mockRestore() + vi.useRealTimers() + }) + it('clears watched listener links when reset kills fields', () => { const form = new InternalFormApi({ defaultValues: { source: '', target: '' }, @@ -732,13 +808,15 @@ describe('field - listeners', () => { }) const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - expect(sourceField._watchingFields?.has(targetField)).toBe(true) - expect(targetField._listenToFields?.[0]?.[0]?.field).toBe(sourceField) + expect(sourceField._watchingListenerFields?.has(targetField)).toBe(true) + expect( + targetField._listenerInstances?.[0]?.resolvedWatchFields?.get('source'), + ).toBe(sourceField) form.reset() - expect(sourceField._watchingFields).toBeNull() - expect(targetField._listenToFields).toBeNull() + expect(sourceField._watchingListenerFields).toBeNull() + expect(targetField._listenerInstances).toBeNull() expect(sourceField._isKilled).toBe(true) expect(targetField._isKilled).toBe(true) expect(form._tryGetFieldApi('source')).toBeNull() diff --git a/packages/form-core/tests/FormApi/lifecycle.spec.ts b/packages/form-core/tests/FormApi/lifecycle.spec.ts index 7d65926a2d..4a64ef0a4f 100644 --- a/packages/form-core/tests/FormApi/lifecycle.spec.ts +++ b/packages/form-core/tests/FormApi/lifecycle.spec.ts @@ -189,6 +189,45 @@ describe('form - lifecycle', () => { expect(instance?.revision).toBe(1) }) + it('keeps form listener instances stable and warns on length changes', () => { + const firstDefinition = { + run: () => {}, + triggers: ['change'] as Array<'change'>, + } + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [firstDefinition], + }) + const instance = form._listenerInstances?.[0] + const nextDefinition = { + run: () => {}, + triggers: ['blur'] as Array<'blur'>, + } + + form._update({ + defaultValues: { name: '' }, + listeners: [nextDefinition], + }) + + expect(form._listenerInstances?.[0]).toBe(instance) + expect(instance?.definition).toBe(nextDefinition) + expect(instance?.owner).toBe(form) + expect(instance?.index).toBe(0) + expect(instance?.revision).toBe(1) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + form._update({ defaultValues: { name: '' }, listeners: [] }) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'length of the listener array should not change', + ), + ) + expect(form._listenerInstances).toBeNull() + expect(instance?.disposed).toBe(true) + warn.mockRestore() + }) + it('keeps the onSubmit source stable across callback updates', () => { const form = new InternalFormApi({ defaultValues: { name: '' }, diff --git a/packages/form-core/tests/FormApi/listeners.spec.ts b/packages/form-core/tests/FormApi/listeners.spec.ts index e9f8a5d86e..0c7e5a2ada 100644 --- a/packages/form-core/tests/FormApi/listeners.spec.ts +++ b/packages/form-core/tests/FormApi/listeners.spec.ts @@ -210,6 +210,42 @@ describe('form - listeners', () => { vi.useRealTimers() }) + it('keeps a pending debounce on a retained listener slot', async () => { + vi.useFakeTimers() + const firstListener = vi.fn() + const nextListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: 100, + run: firstListener, + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const instance = form._listenerInstances![0]! + + field.handleChange('pending') + form._update({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: 100, + run: nextListener, + }, + ], + }) + await vi.advanceTimersByTimeAsync(100) + + expect(form._listenerInstances![0]).toBe(instance) + expect(firstListener).toHaveBeenCalledOnce() + expect(nextListener).not.toHaveBeenCalled() + vi.useRealTimers() + }) + it('logs rejected async form listener errors', async () => { const error = new Error('listener failed') const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/form-core/tests/ListenerInstance.spec.ts b/packages/form-core/tests/ListenerInstance.spec.ts new file mode 100644 index 0000000000..069bad0be9 --- /dev/null +++ b/packages/form-core/tests/ListenerInstance.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { + InternalListenerInstance, + reconcileListenerInstances, +} from '../src/ListenerInstance.lib' +import type { AnyFieldListener } from '../src/listeners.public' + +function createDefinition(label: string): AnyFieldListener { + return { + triggers: ['change'], + run: () => label, + } +} + +describe('InternalListenerInstance', () => { + it('updates its definition without replacing runtime identity', () => { + const owner = { name: 'field' } + const initialDefinition = createDefinition('initial') + const instance = new InternalListenerInstance({ + definition: initialDefinition, + owner, + index: 2, + }) + const source = { name: 'source' } + const nextDefinition = createDefinition('next') + + instance.setResolvedWatchField('source', source) + instance.updateDefinition(nextDefinition) + + expect(instance.definition).toBe(nextDefinition) + expect(instance.owner).toBe(owner) + expect(instance.index).toBe(2) + expect(instance.revision).toBe(1) + expect(instance.resolvedWatchFields?.get('source')).toBe(source) + }) + + it('resets and disposes pending execution safely', async () => { + vi.useFakeTimers() + const instance = new InternalListenerInstance({ + definition: createDefinition('listener'), + owner: {}, + }) + const run = vi.fn((_value: string) => {}) + const debouncer = instance.getOrCreateDebouncer(run, 100) + debouncer?.maybeExecute('cancelled') + + instance.resetRuntime() + await vi.advanceTimersByTimeAsync(100) + + expect(run).not.toHaveBeenCalled() + expect(instance.debouncer).toBeNull() + expect(instance.disposed).toBe(false) + + instance.setResolvedWatchField('source', {}) + const onBeforeDispose = vi.fn() + instance.dispose(onBeforeDispose) + instance.dispose(onBeforeDispose) + + expect(onBeforeDispose).toHaveBeenCalledOnce() + expect(instance.resolvedWatchFields).toBeNull() + expect(instance.disposed).toBe(true) + expect(instance.getOrCreateDebouncer(run, 100)).toBeNull() + vi.useRealTimers() + }) +}) + +describe('reconcileListenerInstances', () => { + it('preserves retained slots and disposes removed slots', () => { + const owner = { name: 'field' } + const initial = reconcileListenerInstances({ + definitions: [createDefinition('first'), createDefinition('second')], + instances: null, + owner, + }) + const firstInstance = initial![0]! + const secondInstance = initial![1]! + const nextDefinition = createDefinition('next') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const next = reconcileListenerInstances({ + definitions: [nextDefinition], + previousDefinitions: initial!.map((instance) => instance.definition), + instances: initial, + owner, + }) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('length of the listener array should not change'), + ) + expect(next).toBe(initial) + expect(next).toEqual([firstInstance]) + expect(firstInstance.definition).toBe(nextDefinition) + expect(firstInstance.revision).toBe(1) + expect(secondInstance.disposed).toBe(true) + warn.mockRestore() + }) + + it('normalizes empty definitions and runs owner cleanup', () => { + const owner = { name: 'form' } + const instances = reconcileListenerInstances({ + definitions: [createDefinition('listener')], + instances: null, + owner, + }) + const instance = instances![0]! + const onBeforeDispose = vi.fn() + + expect( + reconcileListenerInstances({ + definitions: [], + instances, + owner, + onBeforeDispose, + }), + ).toBeNull() + expect(onBeforeDispose).toHaveBeenCalledWith(instance) + expect(instance.disposed).toBe(true) + }) +}) diff --git a/packages/form-devtools/src/bridge/fields/detailSnapshot.ts b/packages/form-devtools/src/bridge/fields/detailSnapshot.ts index f8455af3da..9a000ec96c 100644 --- a/packages/form-devtools/src/bridge/fields/detailSnapshot.ts +++ b/packages/form-devtools/src/bridge/fields/detailSnapshot.ts @@ -3,8 +3,7 @@ import { compareFieldPaths } from '../utils' import type { AnyInternalFieldApi, AnyInternalValidatorInstance, - FieldListenToFields, - FieldWatchingFields, + FieldWatchingListenerFields, FieldWatchingValidatorFields, InternalFieldState, ValidationSourceErrorMap, @@ -234,61 +233,55 @@ function addRelation( }) } -function addListensToRelations( +function addListenerListensToRelations( relations: Map, - listenToFields: FieldListenToFields | null, - kind: DevtoolsFieldRelationKind, + field: AnyInternalFieldApi, identity: Pick, ): void { - listenToFields?.forEach((sourceMetas, itemIndex) => { - for (const sourceMeta of sourceMetas) { - addRelation( - relations, - sourceMeta.field, - getRelationCause( - kind, - itemIndex, - sourceMeta.name, - sourceMeta.field.name, - ), - identity, - ) - } + field._listenerInstances?.forEach((listenerInstance, itemIndex) => { + listenerInstance.resolvedWatchFields?.forEach( + (sourceField, configuredPath) => { + addRelation( + relations, + sourceField, + getRelationCause( + 'listener', + itemIndex, + configuredPath, + sourceField.name, + ), + identity, + ) + }, + ) }) } -function getConfiguredPath( - sourceField: AnyInternalFieldApi, - listenToFields: FieldListenToFields | null, - itemIndex: number, -): string | undefined { - return listenToFields?.[itemIndex]?.find( - (sourceMeta) => sourceMeta.field === sourceField, - )?.name -} - -function addListenedToByRelations( +function addListenerListenedToByRelations( relations: Map, sourceField: AnyInternalFieldApi, - watchingFields: FieldWatchingFields | null, - getListenToFields: ( - watchingField: AnyInternalFieldApi, - ) => FieldListenToFields | null, - kind: DevtoolsFieldRelationKind, + watchingFields: FieldWatchingListenerFields | null, identity: Pick, ): void { - watchingFields?.forEach((itemIndexes, watchingField) => { + watchingFields?.forEach((listenerInstances, watchingField) => { if (watchingField._isKilled) return - const listenToFields = getListenToFields(watchingField) - for (const itemIndex of itemIndexes) { + for (const listenerInstance of listenerInstances) { + const itemIndex = + watchingField._listenerInstances?.indexOf(listenerInstance) ?? -1 + if (itemIndex < 0) continue + + let configuredPath: string | undefined + listenerInstance.resolvedWatchFields?.forEach((field, path) => { + if (field === sourceField) configuredPath = path + }) addRelation( relations, watchingField, getRelationCause( - kind, + 'listener', itemIndex, - getConfiguredPath(sourceField, listenToFields, itemIndex), + configuredPath, sourceField.name, ), identity, @@ -388,14 +381,12 @@ function getDevtoolsFieldRelations( const listensTo = new Map() const listenedToBy = new Map() - addListensToRelations(listensTo, field._listenToFields, 'listener', identity) + addListenerListensToRelations(listensTo, field, identity) addValidatorListensToRelations(listensTo, field, identity) - addListenedToByRelations( + addListenerListenedToByRelations( listenedToBy, field, - field._watchingFields, - (watchingField) => watchingField._listenToFields, - 'listener', + field._watchingListenerFields, identity, ) addValidatorListenedToByRelations( diff --git a/packages/form-devtools/src/bridge/fields/index.ts b/packages/form-devtools/src/bridge/fields/index.ts index 123701f9ae..735b83ddfe 100644 --- a/packages/form-devtools/src/bridge/fields/index.ts +++ b/packages/form-devtools/src/bridge/fields/index.ts @@ -35,12 +35,14 @@ interface FieldsController { ) => Array } -function addForwardRelations( +function addListenerForwardRelations( fields: Set, - relationGroups: AnyInternalFieldApi['_listenToFields'], + field: AnyInternalFieldApi, ): void { - relationGroups?.forEach((relations) => { - for (const relation of relations) fields.add(relation.field) + field._listenerInstances?.forEach((listenerInstance) => { + listenerInstance.resolvedWatchFields?.forEach((sourceField) => + fields.add(sourceField), + ) }) } @@ -58,7 +60,7 @@ function addValidatorForwardRelations( function addReverseRelations( fields: Set, relationGroups: - | AnyInternalFieldApi['_watchingFields'] + | AnyInternalFieldApi['_watchingListenerFields'] | AnyInternalFieldApi['_watchingValidatorFields'], ): void { relationGroups?.forEach((_indexes, watchingField) => { @@ -71,9 +73,9 @@ function addRelationNeighborhood( field: AnyInternalFieldApi, ): void { fields.add(field) - addForwardRelations(fields, field._listenToFields) + addListenerForwardRelations(fields, field) addValidatorForwardRelations(fields, field) - addReverseRelations(fields, field._watchingFields) + addReverseRelations(fields, field._watchingListenerFields) addReverseRelations(fields, field._watchingValidatorFields) } diff --git a/packages/form-devtools/tests/bridgeComposition.test.ts b/packages/form-devtools/tests/bridgeComposition.test.ts index e91527b6d4..b6231864d9 100644 --- a/packages/form-devtools/tests/bridgeComposition.test.ts +++ b/packages/form-devtools/tests/bridgeComposition.test.ts @@ -10,7 +10,10 @@ import type { FormId } from '../src/types/branded' describe('form devtools bridge composition', () => { it('routes form and field lifecycle events to their purpose controllers', () => { const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ triggers: ['change'], run: () => {} }], + }) const formInstanceId = 'form-instance' as FormId const mountedForms: MountedFormsBridgeController = { mountForm: vi.fn(() => true), @@ -38,7 +41,7 @@ describe('form devtools bridge composition', () => { kind: 'listener' as const, sourceField: field, watchingField: field, - watcherIndex: 0, + listenerInstance: field._listenerInstances![0]!, }, ] From a5e9fba1d61594d0c815090e723e7443f3cd025f Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:44:00 +0200 Subject: [PATCH 2/5] chore: add changeset --- .changeset/stable-listeners-rest.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/stable-listeners-rest.md diff --git a/.changeset/stable-listeners-rest.md b/.changeset/stable-listeners-rest.md new file mode 100644 index 0000000000..9eab863e92 --- /dev/null +++ b/.changeset/stable-listeners-rest.md @@ -0,0 +1,6 @@ +--- +'@tanstack/form-core': patch +'@tanstack/form-devtools': patch +--- + +Refactor: Use stable listener identity instead of index From ebe14382e1bae1cd8e3ce8756820fcf9e9c0ee64 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:43:14 +0200 Subject: [PATCH 3/5] fix: make listener reconciliation optional prop compatible --- .../form-core/src/FieldApi/FieldApi.lib.ts | 84 ++++++++++--------- .../tests/FieldApi/Lifecycle.spec.ts | 5 +- 2 files changed, 47 insertions(+), 42 deletions(-) diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index 8de479fb13..36e276e599 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -571,49 +571,51 @@ export class InternalFieldApi< const dependencyChanges: Array | null = notifyDependencyChanges ? [] : null - const previousListeners = this._listenerInstances?.map( - (instance) => instance.definition, - ) - this._listenerInstances = reconcileListenerInstances< - AnyFieldListener, - AnyInternalFieldApi, - AnyInternalFieldApi - >({ - definitions: resolvedOptions.listeners, - previousDefinitions: isInitializing - ? undefined - : (previousListeners ?? null), - instances: this._listenerInstances, - owner: this, - onBeforeDispose: (listenerInstance) => { - listenerInstance.resolvedWatchFields?.forEach((sourceField) => { - const operation = { - kind: 'listener' as const, - sourceField, - watchingField: this, - listenerInstance, - } - detachWatchingListenerField(operation) - dependencyChanges?.push(operation) - }) - listenerInstance.resolvedWatchFields = null - }, - }) + if (resolvedOptions.listeners) { + const previousListeners = this._listenerInstances?.map( + (instance) => instance.definition, + ) + this._listenerInstances = reconcileListenerInstances< + AnyFieldListener, + AnyInternalFieldApi, + AnyInternalFieldApi + >({ + definitions: resolvedOptions.listeners, + previousDefinitions: isInitializing + ? undefined + : (previousListeners ?? null), + instances: this._listenerInstances, + owner: this, + onBeforeDispose: (listenerInstance) => { + listenerInstance.resolvedWatchFields?.forEach((sourceField) => { + const operation = { + kind: 'listener' as const, + sourceField, + watchingField: this, + listenerInstance, + } + detachWatchingListenerField(operation) + dependencyChanges?.push(operation) + }) + listenerInstance.resolvedWatchFields = null + }, + }) - const reconciledListeners = reconcileWatchedListenerFields({ - field: this, - listenerInstances: this._listenerInstances, - form: this.form, - }) + const reconciledListeners = reconcileWatchedListenerFields({ + field: this, + listenerInstances: this._listenerInstances, + form: this.form, + }) - reconciledListeners.detach.forEach((operation) => - detachWatchingListenerField(operation), - ) - reconciledListeners.attach.forEach(attachWatchingListenerField) - dependencyChanges?.push( - ...reconciledListeners.attach, - ...reconciledListeners.detach, - ) + reconciledListeners.detach.forEach((operation) => + detachWatchingListenerField(operation), + ) + reconciledListeners.attach.forEach(attachWatchingListenerField) + dependencyChanges?.push( + ...reconciledListeners.attach, + ...reconciledListeners.detach, + ) + } if (resolvedOptions.validators) { const previousValidators = this._validatorInstances?.map( diff --git a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts index 870f1e53fc..9aca44432c 100644 --- a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts +++ b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts @@ -193,7 +193,7 @@ describe('field - lifecycle', () => { }) describe('listener instances', () => { - it('keeps instances stable by slot and warns when the length changes', () => { + it('keeps instances stable by slot and distinguishes omitted listeners from an empty array', () => { const form = new InternalFormApi({ defaultValues: { x: '' } }) const firstDefinition = { run: () => {}, @@ -218,6 +218,9 @@ describe('field - lifecycle', () => { expect(instance?.index).toBe(0) expect(instance?.revision).toBe((initialRevision ?? 0) + 1) + field._update({}) + expect(field._listenerInstances?.[0]).toBe(instance) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) field._update({ listeners: [] }) From 67746ed6482b5c5816bc48becdf50d809af96e42 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:43:41 +0200 Subject: [PATCH 4/5] chore: remove nonsense tests These check things that are actively going against the new plan. They'd be removed sooner or later, so might as well not add them. --- .../tests/FieldApi/listeners.spec.ts | 40 ------------------- .../form-core/tests/FormApi/listeners.spec.ts | 36 ----------------- .../form-core/tests/ListenerInstance.spec.ts | 30 -------------- 3 files changed, 106 deletions(-) diff --git a/packages/form-core/tests/FieldApi/listeners.spec.ts b/packages/form-core/tests/FieldApi/listeners.spec.ts index 2dd951729a..21abb2e0c1 100644 --- a/packages/form-core/tests/FieldApi/listeners.spec.ts +++ b/packages/form-core/tests/FieldApi/listeners.spec.ts @@ -756,46 +756,6 @@ describe('field - listeners', () => { vi.useRealTimers() }) - it('cancels a removed watched listener pending execution', async () => { - vi.useFakeTimers() - const firstListener = vi.fn() - const removedListener = vi.fn() - const firstDefinition = { - run: firstListener, - triggers: ['change'] as Array<'change'>, - watchFields: ['firstSource'], - } - const form = new InternalFormApi({ - defaultValues: { firstSource: '', removedSource: '', target: '' }, - }) - const target = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - firstDefinition, - { - run: removedListener, - triggers: ['change'], - watchFields: ['removedSource'], - triggerDebounceMs: 100, - }, - ], - }) - const removedSource = form._getOrCreateFieldApi({ name: 'removedSource' }) - const removedInstance = target._listenerInstances![1]! - - removedSource.handleChange('pending') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - target._update({ listeners: [firstDefinition] }) - await vi.advanceTimersByTimeAsync(100) - - expect(warn).toHaveBeenCalled() - expect(removedListener).not.toHaveBeenCalled() - expect(removedInstance.disposed).toBe(true) - expect(removedSource._watchingListenerFields).toBeNull() - warn.mockRestore() - vi.useRealTimers() - }) - it('clears watched listener links when reset kills fields', () => { const form = new InternalFormApi({ defaultValues: { source: '', target: '' }, diff --git a/packages/form-core/tests/FormApi/listeners.spec.ts b/packages/form-core/tests/FormApi/listeners.spec.ts index 0c7e5a2ada..e9f8a5d86e 100644 --- a/packages/form-core/tests/FormApi/listeners.spec.ts +++ b/packages/form-core/tests/FormApi/listeners.spec.ts @@ -210,42 +210,6 @@ describe('form - listeners', () => { vi.useRealTimers() }) - it('keeps a pending debounce on a retained listener slot', async () => { - vi.useFakeTimers() - const firstListener = vi.fn() - const nextListener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: ['change'], - triggerDebounceMs: 100, - run: firstListener, - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const instance = form._listenerInstances![0]! - - field.handleChange('pending') - form._update({ - defaultValues: { name: '' }, - listeners: [ - { - triggers: ['change'], - triggerDebounceMs: 100, - run: nextListener, - }, - ], - }) - await vi.advanceTimersByTimeAsync(100) - - expect(form._listenerInstances![0]).toBe(instance) - expect(firstListener).toHaveBeenCalledOnce() - expect(nextListener).not.toHaveBeenCalled() - vi.useRealTimers() - }) - it('logs rejected async form listener errors', async () => { const error = new Error('listener failed') const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/form-core/tests/ListenerInstance.spec.ts b/packages/form-core/tests/ListenerInstance.spec.ts index 069bad0be9..ee0a0b979c 100644 --- a/packages/form-core/tests/ListenerInstance.spec.ts +++ b/packages/form-core/tests/ListenerInstance.spec.ts @@ -65,36 +65,6 @@ describe('InternalListenerInstance', () => { }) describe('reconcileListenerInstances', () => { - it('preserves retained slots and disposes removed slots', () => { - const owner = { name: 'field' } - const initial = reconcileListenerInstances({ - definitions: [createDefinition('first'), createDefinition('second')], - instances: null, - owner, - }) - const firstInstance = initial![0]! - const secondInstance = initial![1]! - const nextDefinition = createDefinition('next') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const next = reconcileListenerInstances({ - definitions: [nextDefinition], - previousDefinitions: initial!.map((instance) => instance.definition), - instances: initial, - owner, - }) - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('length of the listener array should not change'), - ) - expect(next).toBe(initial) - expect(next).toEqual([firstInstance]) - expect(firstInstance.definition).toBe(nextDefinition) - expect(firstInstance.revision).toBe(1) - expect(secondInstance.disposed).toBe(true) - warn.mockRestore() - }) - it('normalizes empty definitions and runs owner cleanup', () => { const owner = { name: 'form' } const instances = reconcileListenerInstances({ From 6af2f5c8afdb08c8cf85a10d32133cd237d1657a Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:20:00 +0200 Subject: [PATCH 5/5] chore: add unit test to cover updates and listeners --- packages/form-core/src/listeners.lib.ts | 2 +- .../form-core/tests/FormApi/listeners.spec.ts | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/form-core/src/listeners.lib.ts b/packages/form-core/src/listeners.lib.ts index 3bda6b8132..251b87220c 100644 --- a/packages/form-core/src/listeners.lib.ts +++ b/packages/form-core/src/listeners.lib.ts @@ -137,7 +137,7 @@ function runListener({ } const debouncer = listenerInstance.getOrCreateDebouncer( - (ctx: ListenerContext) => executeListener(listener, ctx), + (ctx: ListenerContext) => executeListener(listenerInstance.definition, ctx), debounceMs, ) diff --git a/packages/form-core/tests/FormApi/listeners.spec.ts b/packages/form-core/tests/FormApi/listeners.spec.ts index e9f8a5d86e..863c23de17 100644 --- a/packages/form-core/tests/FormApi/listeners.spec.ts +++ b/packages/form-core/tests/FormApi/listeners.spec.ts @@ -210,6 +210,55 @@ describe('form - listeners', () => { vi.useRealTimers() }) + it('uses the updated listener definition when a pending debounce executes', async () => { + vi.useFakeTimers() + const listenerA = vi.fn() + const listenerB = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: 300, + run: listenerA, + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const listenerInstance = form._listenerInstances?.[0] + + field.handleChange('Alice') + await vi.advanceTimersByTimeAsync(100) + + form._update({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: 300, + run: listenerB, + }, + ], + }) + + expect(form._listenerInstances?.[0]).toBe(listenerInstance) + + await vi.advanceTimersByTimeAsync(199) + expect(listenerA).not.toHaveBeenCalled() + expect(listenerB).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(listenerA).not.toHaveBeenCalled() + expect(listenerB).toHaveBeenCalledOnce() + expect(listenerB).toHaveBeenCalledWith({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + }) + + vi.useRealTimers() + }) + it('logs rejected async form listener errors', async () => { const error = new Error('listener failed') const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})