Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading