diff --git a/.changeset/soft-views-tease.md b/.changeset/soft-views-tease.md
new file mode 100644
index 000000000..7889b0206
--- /dev/null
+++ b/.changeset/soft-views-tease.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/form-core': patch
+---
+
+Improve performance for mounting/unmounting
diff --git a/.gitignore b/.gitignore
index 22096f2e0..885eed7cc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,7 @@ size-plugin.json
stats-hydration.json
stats.json
stats.html
+*.cpuprofile
.vscode/settings.json
*.log
diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts
index 031736e9e..1cc9466df 100644
--- a/packages/form-core/src/utils.ts
+++ b/packages/form-core/src/utils.ts
@@ -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
@@ -164,31 +160,87 @@ export function makePathArray(str: string | Array) {
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 = []
+ // 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)
}
- 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
}
/**
diff --git a/packages/form-core/tests/utils.spec.ts b/packages/form-core/tests/utils.spec.ts
index c85672b08..29d9a53a0 100644
--- a/packages/form-core/tests/utils.spec.ts
+++ b/packages/form-core/tests/utils.spec.ts
@@ -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 = ['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', () => {