Skip to content
Open
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
.vscode/settings.json

*.log
Expand Down
2 changes: 2 additions & 0 deletions packages/form-core/package.json
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.

This can be dropped if we drop the benchmarks

Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"test:types:ts58": "tsc",
"test:lib": "vitest",
"test:lib:dev": "pnpm run test:lib --watch",
"test:bench": "vitest bench --run",
"test:bench:dev": "vitest bench --watch",
"test:build": "publint --strict",
"build": "vite build"
},
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
68 changes: 68 additions & 0 deletions packages/form-core/tests/utils.bench.ts
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.

I imagine this will be removed before this PR is merged (should that happen)

Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { bench, describe } from 'vitest'
import { makePathArray } from '../src/utils'

// Snapshot of the original implementation for side-by-side comparison.
// Remove this and the paired benches once the new implementation is merged.
const reLineOfOnlyDigits = /^(\d+)$/gm
Comment on lines +4 to +6
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 8, 2026

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

makePathArrayOld and the "remove this" comment indicate this file (or at least lines 4–68) should not land on main as-is.

The inline makePathArrayOld exists purely for pre/post comparison and is explicitly marked for removal. If the benchmark file is kept post-merge, makePathArrayOld should be removed and the benchmarks restructured to measure only the live implementation (or dropped entirely if the intent was never to ship them).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/form-core/tests/utils.bench.ts` around lines 4 - 6, The file
contains a temporary snapshot and an inline legacy function makePathArrayOld
plus a "remove this" comment and paired benches that must not land on main;
remove the makePathArrayOld function and the accompanying comment and any
benchmark comparisons that reference it (e.g., paired benches) and restructure
the test to benchmark only the current live implementation (or delete the whole
bench file if benchmarking was never intended to be shipped); ensure any helper
regex like reLineOfOnlyDigits and tests only used by the old implementation are
also removed or repurposed so the file contains solely the intended, production
benchmark code.

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.

Correct.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@GiacoCorsiglia, thanks for confirming! Would you like me to help clean up the benchmark file by removing makePathArrayOld, the "remove this" comment, and the paired comparison benches — leaving only the benchmarks for the current makePathArray implementation? I can plan those changes if that's helpful.

(ノ◕ヮ◕)ノ*:・゚✧

const reDigitsBetweenDots = /\.(\d+)(?=\.)/gm
const reStartWithDigitThenDot = /^(\d+)\./gm
const reDotWithDigitsToEnd = /\.(\d+$)/gm
const reMultipleDots = /\.{2,}/gm
const intPrefix = '__int__'
const intReplace = `${intPrefix}$1`

function makePathArrayOld(
str: string | Array<string | number>,
): Array<string | number> {
if (Array.isArray(str)) {
return [...str]
}

if (typeof str !== 'string') {
throw new Error('Path must be a string.')
}

return str
.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
}
return numStr
}
return d
})
}

const cases: Array<[label: string, input: string | Array<string | number>]> = [
['array input (fast path, no parsing)', ['a', 'b', 0, 'c']],
['simple key (no nesting)', 'key'],
['uuid key', '550e8400-e29b-41d4-a716-446655440000'],
['dot notation', 'foo.bar.baz'],
['mixed dot and bracket notation', 'a[0].b[1]'],
['deeply nested mixed path', 'a.b[0][1].c.d[2][3].e'],
['numeric string with leading zeros (kept as string)', '01234'],
['numeric string (converted to number)', '12345'],
]

for (const [label, input] of cases) {
describe(label, () => {
bench('old', () => {
makePathArrayOld(input)
})

bench('new', () => {
makePathArray(input)
})
})
}
77 changes: 77 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,83 @@ 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', () => {
// Backwards compatible:

// Previous output: ['a', 'b']
expect(makePathArray('a..b')).toEqual(['a', 'b'])
// Previous output: ['a']
expect(makePathArray(']a')).toEqual(['a'])
// Previous output: ['a']
expect(makePathArray('a]')).toEqual(['a'])
// Previous output: ['a', 'b', 'c']
expect(makePathArray('a[b[c')).toEqual(['a', 'b', 'c'])
// Previous output: ['a', 'b', 'c']
expect(makePathArray('a[b[c]')).toEqual(['a', 'b', 'c'])
// Previous output: ['']
expect(makePathArray('')).toEqual([''])
// Previous output: ['', '']
expect(makePathArray('.')).toEqual(['', ''])
// Previous output: ['']
expect(makePathArray('[')).toEqual([''])
// Previous output: ['']
expect(makePathArray('[]')).toEqual([''])
// Previous output: ['', 'a']
expect(makePathArray('.a')).toEqual(['', 'a'])
// Previous output: ['a', '']
expect(makePathArray('a.')).toEqual(['a', ''])
// Previous output: ['a', '']
expect(makePathArray('a[')).toEqual(['a', ''])
// Previous output: ['', 'a']
expect(makePathArray('..a')).toEqual(['', 'a'])
// Previous output: ['a', '']
expect(makePathArray('a..')).toEqual(['a', ''])
// Previous output: ['a', '']
expect(makePathArray('a[[')).toEqual(['a', ''])
// Previous output: ['']
expect(makePathArray(']')).toEqual([''])
// Previous output: ['', '']
expect(makePathArray('[[')).toEqual(['', ''])
// Previous output: ['', 0]
expect(makePathArray('[[0]')).toEqual(['', 0])

// Breaking changes:

// This case is impossible to reproduce without allocating a new string that
// completely elides `]` or maintaining an array buffer in the loop to do so.
// Previous output: ['ab']
expect(makePathArray('a]b')).toEqual(['a', 'b'])
})
})

describe('determineFormLevelErrorSourceAndValue', () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/form-core/vite.config.ts
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.

This can be dropped if we drop the benchmarks

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const config = defineConfig({
environment: 'jsdom',
coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] },
typecheck: { enabled: true },
benchmark: {
include: ['**/*.bench.ts'],
exclude: ['node_modules'],
},
},
})

Expand Down
Loading