Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
2 changes: 2 additions & 0 deletions packages/form-core/package.json
Comment thread
GiacoCorsiglia marked this conversation as resolved.
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
109 changes: 75 additions & 34 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,76 @@ 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
}
return numStr
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)

if (treatAsNumber) {
result.push(parseInt(str.slice(segStart, i), 10))
} else {
result.push(str.slice(segStart, i))
}
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
Comment thread
GiacoCorsiglia marked this conversation as resolved.
Outdated
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 thread
GiacoCorsiglia marked this conversation as resolved.
Outdated
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)
})
})
}
71 changes: 71 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,77 @@ describe('makePathArray', () => {
it('should still convert non-leading-zero numbers to number types', () => {
expect(makePathArray('12345')).toEqual([12345])
})

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
Comment thread
GiacoCorsiglia marked this conversation as resolved.
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