Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/soft-views-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': patch
---

Improve performance for mounting/unmounting <form.Field>
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ size-plugin.json
stats-hydration.json
stats.json
stats.html
*.cpuprofile
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Figured I might as well leave this

.vscode/settings.json

*.log
Expand Down
118 changes: 85 additions & 33 deletions packages/form-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,12 @@ export function deleteBy(obj: any, _path: any) {
return doDelete(obj)
}

const reLineOfOnlyDigits = /^(\d+)$/gm
// the second dot must be in a lookahead or the engine
// will skip subsequent numbers (like foo.0.1.)
const reDigitsBetweenDots = /\.(\d+)(?=\.)/gm
const reStartWithDigitThenDot = /^(\d+)\./gm
const reDotWithDigitsToEnd = /\.(\d+$)/gm
const reMultipleDots = /\.{2,}/gm

const intPrefix = '__int__'
const intReplace = `${intPrefix}$1`
// Char codes used by the parser below.
const CC_DOT = 0x2e // '.'
const CC_OPEN = 0x5b // '['
const CC_CLOSE = 0x5d // ']'
const CC_ZERO = 0x30 // '0'
const CC_NINE = 0x39 // '9'

/**
* @private
Expand All @@ -164,31 +160,87 @@ export function makePathArray(str: string | Array<string | number>) {
throw new Error('Path must be a string.')
}

return (
str
// Leading `[` may lead to wrong parsing down the line
// (Example: '[0][1]' should be '0.1', not '.0.1')
.replace(/(^\[)|]/gm, '')
.replace(/\[/g, '.')
.replace(reLineOfOnlyDigits, intReplace)
.replace(reDigitsBetweenDots, `.${intReplace}.`)
.replace(reStartWithDigitThenDot, `${intReplace}.`)
.replace(reDotWithDigitsToEnd, `.${intReplace}`)
.replace(reMultipleDots, '.')
.split('.')
.map((d) => {
if (d.startsWith(intPrefix)) {
const numStr = d.substring(intPrefix.length)
const num = parseInt(numStr, 10)

if (String(num) === numStr) {
return num
const len = str.length
const result: Array<string | number> = []
// Location of the first character of the in-progress segment in `str`.
// The segment ends at the current `i` when we hit a separator.
//
// We strip an optional leading '[' so '[0]' parses as [0], not ['', 0].
// Doing this up front keeps the loop's backwards compatibility handling simpler.
let segStart = len > 0 && str.charCodeAt(0) === CC_OPEN ? 1 : 0
// Whether the in-progress segment has been all ASCII digits so far.
// Used together with the leading-zero check to decide if it should be
// pushed as a number instead of a string.
let allDigits = true
// Tracks the previous character. Only necessary to preserve the
// old behavior for malformed input.
let prev = -1
// Walk once. `i === len` is treated as a virtual final separator so the
// flush block handles both mid-string segments and the last one.
for (let i = segStart; i <= len; i++) {
const char = i < len ? str.charCodeAt(i) : -1

// Handle separators (including the virtual one at the end). Flush the in-progress segment.
if (i === len || char === CC_DOT || char === CC_OPEN || char === CC_CLOSE) {
const segLen = i - segStart
if (segLen > 0) {
// To treat the segment as a number...
const treatAsNumber =
// ...it must contain only digits...
allDigits &&
// ...and either be a single '0' or not start with '0'.
(segLen === 1 || str.charCodeAt(segStart) !== CC_ZERO)

const seg = str.slice(segStart, i)
if (treatAsNumber) {
const num = parseInt(seg, 10)
// Up to 15 digits, parseInt is always lossless (the max
// 15-digit decimal is below Number.MAX_SAFE_INTEGER). Beyond
// that, verify by round-trip: if parseInt lost precision
// (e.g., a 20-digit literal), fall back to the string so we
// don't silently change the value.
if (segLen <= 15 || String(num) === seg) {
result.push(num)
} else {
result.push(seg)
}
return numStr
} else {
result.push(seg)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return d
})
)
} else if (
// This branch, which handles empty segments, only exists to preserve
// the old behavior for malformed input.

// Push the empty segment unless this is a "phantom boundary" the
// old regex impl would have absorbed:
// 1. `]` was always stripped — `prev === ']'` means the real
// boundary already happened on the previous iteration.
// 2. A leading `]` was stripped too (the leading `[` strip
// above handles its counterpart for `[`).
// 3. `..` and `[[` collapse to a single boundary.
prev !== CC_CLOSE &&
!(prev === -1 && char === CC_CLOSE) &&
!(prev === char && (char === CC_DOT || char === CC_OPEN))
) {
result.push('')
}

// Start a new segment.
segStart = i + 1
allDigits = true
} else if (char < CC_ZERO || char > CC_NINE) {
allDigits = false
}

prev = char
}

// If the input was effectively all phantom chars (e.g. ']', '[]',
// '[]]'), the loop produces no segments. The old impl returned ['']
// for these because.
if (!result.length) result.push('')

return result
}

/**
Expand Down
54 changes: 54 additions & 0 deletions packages/form-core/tests/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,60 @@ describe('makePathArray', () => {
it('should still convert non-leading-zero numbers to number types', () => {
expect(makePathArray('12345')).toEqual([12345])
})

it('should keep digit-only segments past Number precision as strings', () => {
expect(makePathArray('99999999999999999999')).toEqual([
'99999999999999999999',
])
})

it('should treat lone "0" as the number 0', () => {
expect(makePathArray('0')).toEqual([0])
expect(makePathArray('a.0.b')).toEqual(['a', 0, 'b'])
})

it('should preserve leading zeros mid-path in both notations', () => {
expect(makePathArray('a.01.b')).toEqual(['a', '01', 'b'])
expect(makePathArray('a[01]')).toEqual(['a', '01'])
})

it('should return a defensive copy when given an array', () => {
const input: Array<string | number> = ['a', 0, 'b']
const out = makePathArray(input)
expect(out).toEqual(input)
expect(out).not.toBe(input)
})

it('should throw on non-string non-array input', () => {
expect(() => makePathArray(null as any)).toThrow('Path must be a string.')
expect(() => makePathArray(42 as any)).toThrow('Path must be a string.')
expect(() => makePathArray({} as any)).toThrow('Path must be a string.')
})

it('should handle malformed input', () => {
expect(makePathArray('a..b')).toEqual(['a', 'b'])
expect(makePathArray(']a')).toEqual(['a'])
expect(makePathArray('a]')).toEqual(['a'])
expect(makePathArray('a[b[c')).toEqual(['a', 'b', 'c'])
expect(makePathArray('a[b[c]')).toEqual(['a', 'b', 'c'])
expect(makePathArray('')).toEqual([''])
expect(makePathArray('.')).toEqual(['', ''])
expect(makePathArray('[')).toEqual([''])
expect(makePathArray('[]')).toEqual([''])
expect(makePathArray('.a')).toEqual(['', 'a'])
expect(makePathArray('a.')).toEqual(['a', ''])
expect(makePathArray('a[')).toEqual(['a', ''])
expect(makePathArray('..a')).toEqual(['', 'a'])
expect(makePathArray('a..')).toEqual(['a', ''])
expect(makePathArray('a[[')).toEqual(['a', ''])
expect(makePathArray(']')).toEqual([''])
expect(makePathArray('[[')).toEqual(['', ''])
expect(makePathArray('[[0]')).toEqual(['', 0])

// NOTE: This case differs from the previous implementation of makePathArray here:
// https://github.com/TanStack/form/blob/24ac6ca47074f5f1478db6744fb8004312ee5cbe/packages/form-core/src/utils.ts#L158
expect(makePathArray('a]b')).toEqual(['a', 'b'])
})
})

describe('determineFormLevelErrorSourceAndValue', () => {
Expand Down