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', () => {