Skip to content

Commit fc424c0

Browse files
authored
[v2] Refactor: Move Form Groups to trie (#2326)
* refactor: use stop symbol instead of boolean for traversal * refactor: move form groups into trie structure * add changeset * fix: apply suggestions from coderabbit * fix: do second pass of fixes
1 parent 629f700 commit fc424c0

20 files changed

Lines changed: 758 additions & 320 deletions

.changeset/tasty-humans-joke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/form-core': patch
3+
---
4+
5+
Refactor: Store Form Groups on trie nodes instead of the form instance

packages/form-core/src/FieldApi/FieldApi.lib.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import type {
5050
import type { ResolvedInternalFieldUpdateOptions } from '../types.lib'
5151
import type { FieldUpdateOptions, Updater } from '../types.public'
5252
import type { AnyInternalFormApi } from '../FormApi/FormApi.lib'
53+
import type { AnyInternalFormGroupApi } from '../FormGroupApi/FormGroupApi.lib'
5354
import type { ReadonlyAtom } from '@tanstack/store'
5455
import type { FieldApi, FieldApiOptions } from './FieldApi.public'
5556
import type {
@@ -310,6 +311,8 @@ export class InternalFieldApi<
310311
_listeners: Array<AnyFieldListener> | null
311312
_errorVisibility: ErrorVisibility<any, any> | undefined
312313
_errorBoundary: boolean
314+
/** The form group occupying this trie node. */
315+
_formGroup: AnyInternalFormGroupApi | null = null
313316

314317
// TODO implement
315318
/**
@@ -651,15 +654,15 @@ export class InternalFieldApi<
651654

652655
const seenValidatorFields = new WeakSet<AnyInternalFieldApi>()
653656

654-
visitFieldAndAncestors(this, (current) => {
655-
if (current._isKilled) return false
657+
visitFieldAndAncestors(this, (current, stop) => {
658+
if (current._isKilled) return stop
656659

657660
current._runFieldValidation(event)
658661
current._notifyValidator(event, seenValidatorFields)
659662
return undefined
660663
})
661664

662-
const group = this.form._getNearestFormGroupForField(this.name)
665+
const group = this._getFormGroup()
663666
if (group) {
664667
group.validate(event, { triggerFieldApi: this })
665668
return
@@ -677,6 +680,7 @@ export class InternalFieldApi<
677680
options?: {
678681
onResult?: boolean
679682
onlyRunValidatorIndeces?: Array<number> | null
683+
_startValidation?: () => () => void
680684
},
681685
): Promise<FieldValidatorPipelineResult> {
682686
if (this._isKilled)
@@ -695,7 +699,14 @@ export class InternalFieldApi<
695699
thrownError: null,
696700
}
697701

698-
this._setValidationCount((count) => count + 1)
702+
let finishValidation = options?._startValidation?.()
703+
if (!finishValidation) {
704+
this._setValidationCount((count) => count + 1)
705+
finishValidation = () => {
706+
this._setValidationCount((count) => Math.max(0, count - 1))
707+
}
708+
}
709+
699710
try {
700711
return await runFieldValidatorPipeline({
701712
pipeline: validators,
@@ -712,7 +723,7 @@ export class InternalFieldApi<
712723
validatorIndecesToRun: options?.onlyRunValidatorIndeces ?? null,
713724
})
714725
} finally {
715-
this._setValidationCount((count) => Math.max(0, count - 1))
726+
finishValidation()
716727
}
717728
}
718729

@@ -848,7 +859,7 @@ export class InternalFieldApi<
848859
batch(() => {
849860
const seenListenerFields = new WeakSet<AnyInternalFieldApi>()
850861

851-
visitFieldAndAncestors(this, (currNode) => {
862+
visitFieldAndAncestors(this, (currNode, stop) => {
852863
const isOriginalField = currNode === originalField
853864
const { isSelfDirty, isSelfTouched, isBlurred } = currNode.meta
854865
const shouldUpdateDirty = isOriginalField && markAsDirty && !isSelfDirty
@@ -868,7 +879,7 @@ export class InternalFieldApi<
868879

869880
currNode._notifyListener(event, seenListenerFields)
870881

871-
if (!doPropagate) return false
882+
if (!doPropagate) return stop
872883
return undefined
873884
})
874885
})
@@ -1084,6 +1095,30 @@ export class InternalFieldApi<
10841095
pruneFieldIfUnused(this)
10851096
}
10861097

1098+
/** Updates the form group occupying this trie node. */
1099+
_setFormGroup(formGroup: AnyInternalFormGroupApi | null): void {
1100+
if (this._formGroup === formGroup) return
1101+
1102+
this._formGroup = formGroup
1103+
devtools().updateField?.(this)
1104+
1105+
if (!formGroup) this._pruneIfUnused()
1106+
}
1107+
1108+
/** Returns the form group containing this trie node, if one exists. */
1109+
_getFormGroup(): AnyInternalFormGroupApi | null {
1110+
let formGroup: AnyInternalFormGroupApi | null = null
1111+
1112+
visitFieldAndAncestors(this, (field, stop) => {
1113+
if (!field._formGroup) return
1114+
1115+
formGroup = field._formGroup
1116+
return stop
1117+
})
1118+
1119+
return formGroup
1120+
}
1121+
10871122
_getValue(): any {
10881123
return this.form.getFieldValue(this.name)
10891124
}

packages/form-core/src/FieldApi/fieldState.lib.ts

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const childContributionKeys: Array<ChildContributionKey> = [
2929
interface MetaExtension {
3030
_formValidatorErrors: Array<Array<ValidationIssue>>
3131
_formValidatorErrorSourceEvents: Array<string | null>
32-
_formGroupValidatorErrors: Map<object, FormGroupFieldErrorMeta>
32+
_formGroupValidatorErrors: FormGroupFieldErrorMeta | null
3333
_fieldValidatorErrors: Array<Array<ValidationIssue>>
3434
_fieldValidatorErrorSourceEvents: Array<string | null>
3535
childContributionCounts: ChildContributionCounts
@@ -87,7 +87,7 @@ export const defaultInternalBaseFieldMeta: InternalBaseFieldMeta = {
8787
_fieldValidatorErrorSourceEvents: [],
8888
_formValidatorErrors: [],
8989
_formValidatorErrorSourceEvents: [],
90-
_formGroupValidatorErrors: new Map(),
90+
_formGroupValidatorErrors: null,
9191
_arrayVersion: 0,
9292
}
9393

@@ -232,7 +232,7 @@ function shouldDisplayErrors(
232232
isDefaultValue = true,
233233
): boolean {
234234
if (!field || !errorVisibility) return true
235-
const group = field.form._getNearestFormGroupForField(field.name)
235+
const group = field._getFormGroup()
236236
const stateOverrides = group?._getScopedFormStateOverrides()
237237

238238
return errorVisibility({
@@ -295,8 +295,10 @@ export function getChildContributionStates(
295295
}
296296
}
297297

298-
function hasValidatorErrors(errors: Array<Array<ValidationIssue>>): boolean {
299-
return errors.some((validatorErrors) => validatorErrors.length > 0)
298+
function hasValidatorErrors(
299+
errors: Array<Array<ValidationIssue>> | undefined,
300+
): boolean {
301+
return errors?.some((validatorErrors) => validatorErrors.length > 0) ?? false
300302
}
301303

302304
export function isPrunableMeta(meta: InternalBaseFieldMeta): boolean {
@@ -307,7 +309,7 @@ export function isPrunableMeta(meta: InternalBaseFieldMeta): boolean {
307309
if (meta._validationCount !== 0) return false
308310
if (meta._arrayVersion !== 0) return false
309311
if (hasValidatorErrors(meta._fieldValidatorErrors)) return false
310-
if (hasFormGroupValidatorErrors(meta._formGroupValidatorErrors)) return false
312+
if (hasValidatorErrors(meta._formGroupValidatorErrors?.errors)) return false
311313
if (hasValidatorErrors(meta._formValidatorErrors)) return false
312314

313315
return childContributionKeys.every(
@@ -329,11 +331,7 @@ function getErrorsFromBaseMeta(
329331
result = previousMeta.original.errors
330332
} else {
331333
result = baseMeta._fieldValidatorErrors
332-
.concat(
333-
Array.from(baseMeta._formGroupValidatorErrors.values()).flatMap(
334-
(groupErrors) => groupErrors.errors,
335-
),
336-
)
334+
.concat(baseMeta._formGroupValidatorErrors?.errors ?? [])
337335
.concat(baseMeta._formValidatorErrors)
338336
// ValidationError is OneOrMany, TypeScript doesn't realize that
339337
// flat also takes care of that
@@ -342,15 +340,6 @@ function getErrorsFromBaseMeta(
342340
return result
343341
}
344342

345-
export function hasFormGroupValidatorErrors(
346-
groupErrors: Map<object, FormGroupFieldErrorMeta>,
347-
): boolean {
348-
for (const { errors } of groupErrors.values()) {
349-
if (hasValidatorErrors(errors)) return true
350-
}
351-
return false
352-
}
353-
354343
export function hasFieldMetaErrors(meta: InternalBaseFieldMeta): boolean {
355344
return (
356345
getErrorsFromBaseMeta(meta).length > 0 ||

packages/form-core/src/FieldApi/fieldTraversal.lib.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import type { AnyInternalFieldApi } from './FieldApi.lib'
22
import type { InternalRootFieldApi } from './RootFieldApi.lib'
33

4-
type FieldVisitor = (field: AnyInternalFieldApi) => void | false
4+
const stop = Symbol('stop field traversal')
5+
type FieldTraversalStop = typeof stop
6+
7+
type FieldVisitor = (
8+
field: AnyInternalFieldApi,
9+
stop: FieldTraversalStop,
10+
) => void | FieldTraversalStop
511

612
/**
713
* Visits a field node followed by each of its ancestors, stopping before the
8-
* synthetic root node. Return `false` from the visitor to stop the traversal.
14+
* synthetic root node. Return the visitor's `stop` argument to stop the
15+
* traversal.
916
*
1017
* The next parent is captured before the visitor runs, so removing or
1118
* reparenting the current node does not change the ancestor chain being walked.
@@ -18,7 +25,7 @@ export function visitFieldAndAncestors(
1825

1926
while (!current._isRoot) {
2027
const parent: AnyInternalFieldApi | InternalRootFieldApi = current._parent
21-
if (visitor(current) === false) return
28+
if (visitor(current, stop) === stop) return
2229
current = parent
2330
}
2431
}
@@ -39,7 +46,7 @@ function visitFields(
3946

4047
while (stack.length > 0) {
4148
const field = stack.pop()!
42-
if (visitor(field) === false) return
49+
if (visitor(field, stop) === stop) return
4350

4451
const children = field._children
4552
for (let index = children.length - 1; index >= 0; index--) {
@@ -50,7 +57,7 @@ function visitFields(
5057

5158
/**
5259
* Visits a field node and its descendants in insertion-order preorder. The
53-
* starting field is included. Return `false` from the visitor to stop the
60+
* starting field is included. Return the visitor's `stop` argument to stop the
5461
* entire traversal, not only the current branch.
5562
*
5663
* A node's children are read after its visitor runs, so structural mutations
@@ -66,7 +73,7 @@ export function visitFieldSubtree(
6673
/**
6774
* Visits every field in a form trie in insertion-order preorder. The synthetic
6875
* root node is excluded; traversal starts at each of its field children. Return
69-
* `false` from the visitor to stop the entire traversal.
76+
* the visitor's `stop` argument to stop the entire traversal.
7077
*
7178
* A node's children are read after its visitor runs, so structural mutations
7279
* made by the visitor affect which descendants are visited.

packages/form-core/src/FieldApi/fieldTree.lib.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,41 @@ function notifyFieldSubtreeListeners(
262262
}
263263
}
264264

265+
function prepareFormGroupsForFieldReplacement(
266+
fields: ReadonlyArray<AnyInternalFieldApi>,
267+
): () => void {
268+
const fieldsToReplace = new Set(fields)
269+
const formGroups = fields.flatMap((field) =>
270+
field._formGroup ? [{ group: field._formGroup, name: field.name }] : [],
271+
)
272+
const affectedFormGroups = new Set(formGroups.map(({ group }) => group))
273+
274+
const replacementRoot = fields[0]
275+
if (replacementRoot) {
276+
visitFieldAndAncestors(replacementRoot, (field) => {
277+
if (field._formGroup) affectedFormGroups.add(field._formGroup)
278+
})
279+
}
280+
281+
for (const group of affectedFormGroups) {
282+
group._removeRoutedErrorFields(fieldsToReplace)
283+
}
284+
285+
for (const { group } of formGroups) {
286+
group._cancelValidation()
287+
}
288+
289+
return () => {
290+
if (formGroups.length === 0) return
291+
292+
batch(() => {
293+
for (const { group, name } of formGroups) {
294+
group._attachToFieldTrie(name)
295+
}
296+
})
297+
}
298+
}
299+
265300
export function killField(
266301
field: AnyInternalFieldApi,
267302
options: {
@@ -276,6 +311,7 @@ export function killField(
276311
field: AnyInternalFieldApi
277312
previousPath: string
278313
}> = []
314+
let reattachFormGroups = () => {}
279315

280316
batch(() => {
281317
const nodesToKill = collectFieldSubtree(field)
@@ -286,6 +322,8 @@ export function killField(
286322
const nodesToKillSet = new Set(nodesToKill)
287323
const fieldsToPruneAfterKill = new Set<AnyInternalFieldApi>()
288324

325+
reattachFormGroups = prepareFormGroupsForFieldReplacement(nodesToKill)
326+
289327
if (options.listenerEvent) {
290328
notifyFieldSubtreeListeners(field, options.listenerEvent)
291329
}
@@ -330,6 +368,7 @@ export function killField(
330368

331369
node._isKilled = true
332370
node._refCount = 0
371+
node._formGroup = null
333372
node._defaultValueCache = null
334373
node._atoms.store = undefined
335374
if (node._pipelineCache) {
@@ -403,12 +442,14 @@ export function killField(
403442
if (dependencyChanges && dependencyChanges.length > 0) {
404443
bridge.fieldDependenciesChanged?.(dependencyChanges)
405444
}
445+
reattachFormGroups()
406446
}
407447

408448
export function canPruneField(field: AnyInternalFieldApi): boolean {
409449
if (field._isKilled) return false
410450

411451
if (field._refCount > 0) return false
452+
if (field._formGroup) return false
412453
if (field._childrenMap.size > 0) return false
413454
if (field._watchingFields) return false
414455
if (field._watchingValidatorFields) return false
@@ -428,8 +469,8 @@ export function pruneFieldIfUnused(field: AnyInternalFieldApi): void {
428469
? new Array<{ field: AnyInternalFieldApi; previousPath: string }>()
429470
: null
430471

431-
visitFieldAndAncestors(field, (node) => {
432-
if (!canPruneField(node)) return false
472+
visitFieldAndAncestors(field, (node, stop) => {
473+
if (!canPruneField(node)) return stop
433474

434475
removedFields?.push({ field: node, previousPath: node.name })
435476
node._parent._removeChild(node._segment)

0 commit comments

Comments
 (0)