From 21dab5b1e29ccd097114e6d5a67917c90a324728 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 21 May 2026 22:21:19 +0200 Subject: [PATCH] replaceEqualDeep optimization --- packages/router-core/src/utils.ts | 67 ++-- .../tests/replace-equal-deep.bench.ts | 310 ++++++++++++++++++ packages/router-core/tests/utils.test.ts | 79 +++++ 3 files changed, 431 insertions(+), 25 deletions(-) create mode 100644 packages/router-core/tests/replace-equal-deep.bench.ts diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 81a3de6a8f..a9397c1e6b 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -234,29 +234,41 @@ export const nullReplaceEqualDeep: typeof replaceEqualDeep = (prev, next) => */ export function replaceEqualDeep( prev: any, - _next: T, + next: T, + _makeObj?: () => any, + _depth?: number, +): T +export function replaceEqualDeep( + prev: any, + next: any, _makeObj = () => ({}), _depth = 0, -): T { +): any { if (isServer) { - return _next + return next } - if (prev === _next) { - return prev + if (prev === next) { + return next } - if (_depth > 500) return _next - - const next = _next as any + if (_depth > 500) { + return next + } const array = isPlainArray(prev) && isPlainArray(next) - if (!array && !(isPlainObject(prev) && isPlainObject(next))) return next + if (!array && !(isPlainObject(prev) && isPlainObject(next))) { + return next + } const prevItems = array ? prev : getEnumerableOwnKeys(prev) - if (!prevItems) return next + if (!prevItems) { + return next + } const nextItems = array ? next : getEnumerableOwnKeys(next) - if (!nextItems) return next + if (!nextItems) { + return next + } const prevSize = prevItems.length const nextSize = nextItems.length const copy: any = array ? new Array(nextSize) : _makeObj() @@ -270,23 +282,22 @@ export function replaceEqualDeep( if (p === n) { copy[key] = p - if (array ? i < prevSize : hasOwn.call(prev, key)) equalItems++ + if (array || hasOwn.call(prev, key)) { + equalItems++ + } continue } - if ( - p === null || - n === null || - typeof p !== 'object' || - typeof n !== 'object' - ) { - copy[key] = n + if (p && n && typeof p === 'object' && typeof n === 'object') { + const v = replaceEqualDeep(p, n, _makeObj, _depth + 1) + copy[key] = v + if (v === p) { + equalItems++ + } continue } - const v = replaceEqualDeep(p, n, _makeObj, _depth + 1) - copy[key] = v - if (v === p) equalItems++ + copy[key] = n } return prevSize === nextSize && equalItems === prevSize ? prev : copy @@ -303,19 +314,25 @@ function getEnumerableOwnKeys(o: object) { // Fast path: check all string property names are enumerable for (const name of names) { - if (!isEnumerable.call(o, name)) return false + if (!isEnumerable.call(o, name)) { + return false + } } // Only check symbols if the object has any (most plain objects don't) const symbols = Object.getOwnPropertySymbols(o) // Fast path: no symbols, return names directly (avoids array allocation/concat) - if (symbols.length === 0) return names + if (symbols.length === 0) { + return names + } // Slow path: has symbols, need to check and merge const keys: Array = names for (const symbol of symbols) { - if (!isEnumerable.call(o, symbol)) return false + if (!isEnumerable.call(o, symbol)) { + return false + } keys.push(symbol) } return keys diff --git a/packages/router-core/tests/replace-equal-deep.bench.ts b/packages/router-core/tests/replace-equal-deep.bench.ts new file mode 100644 index 0000000000..3c43ecc574 --- /dev/null +++ b/packages/router-core/tests/replace-equal-deep.bench.ts @@ -0,0 +1,310 @@ +import { bench, describe, expect } from 'vitest' +import { + isPlainObject, + nullReplaceEqualDeep, + replaceEqualDeep, +} from '../src/utils' + +const hasOwn = Object.prototype.hasOwnProperty +const isEnumerable = Object.prototype.propertyIsEnumerable + +function baselineIsPlainArray(value: unknown): value is Array { + return Array.isArray(value) && value.length === Object.keys(value).length +} + +function baselineGetEnumerableOwnKeys(o: object) { + const names = Object.getOwnPropertyNames(o) + + for (const name of names) { + if (!isEnumerable.call(o, name)) { + return false + } + } + + const symbols = Object.getOwnPropertySymbols(o) + + if (symbols.length === 0) { + return names + } + + const keys: Array = names + for (const symbol of symbols) { + if (!isEnumerable.call(o, symbol)) { + return false + } + keys.push(symbol) + } + return keys +} + +function baselineReplaceEqualDeep( + prev: any, + next: any, + makeObj = () => ({}), + depth = 0, +): any { + if (prev === next) { + return prev + } + + if (depth > 500) { + return next + } + + const array = baselineIsPlainArray(prev) && baselineIsPlainArray(next) + + if (!array && !(isPlainObject(prev) && isPlainObject(next))) { + return next + } + + const prevItems = array ? prev : baselineGetEnumerableOwnKeys(prev) + if (!prevItems) { + return next + } + const nextItems = array ? next : baselineGetEnumerableOwnKeys(next) + if (!nextItems) { + return next + } + const prevSize = prevItems.length + const nextSize = nextItems.length + const copy: any = array ? new Array(nextSize) : makeObj() + + let equalItems = 0 + + for (let i = 0; i < nextSize; i++) { + const key = array ? i : (nextItems[i] as any) + const p = prev[key] + const n = next[key] + + if (p === n) { + copy[key] = p + if (array ? i < prevSize : hasOwn.call(prev, key)) { + equalItems++ + } + continue + } + + if ( + p === null || + n === null || + typeof p !== 'object' || + typeof n !== 'object' + ) { + copy[key] = n + continue + } + + const v = baselineReplaceEqualDeep(p, n, makeObj, depth + 1) + copy[key] = v + if (v === p) { + equalItems++ + } + } + + return prevSize === nextSize && equalItems === prevSize ? prev : copy +} + +const symbolKey = Symbol('meta') + +const locationPrev = { + pathname: '/users/123', + search: { tab: 'settings', sort: 'asc' }, + hash: '#profile', + state: { key: 'abc123', __TSR_key: 'abc123' }, +} + +const locationNext = { + pathname: '/users/123', + search: { tab: 'settings', sort: 'asc' }, + hash: '#profile', + state: { key: 'abc123', __TSR_key: 'abc123' }, +} + +const changedLocationNext = { + ...locationNext, + search: { tab: 'activity', sort: 'asc' }, +} + +const matchesPrev = [ + { id: '1', routeId: '__root__', pathname: '/', params: {} }, + { id: '2', routeId: '/users', pathname: '/users', params: {} }, + { + id: '3', + routeId: '/users/$userId', + pathname: '/users/123', + params: { userId: '123' }, + }, +] + +const matchesNext = [ + { id: '1', routeId: '__root__', pathname: '/', params: {} }, + { id: '2', routeId: '/users', pathname: '/users', params: {} }, + { + id: '3', + routeId: '/users/$userId', + pathname: '/users/123', + params: { userId: '123' }, + }, +] + +const longMatchesPrev = Array.from({ length: 60 }, (_, i) => ({ + id: String(i), + routeId: i === 0 ? '__root__' : `/routes/${i}`, + pathname: `/routes/${i}`, + params: { id: String(i) }, + loaderDeps: { page: i, filter: 'all' }, + loaderData: { user: { id: String(i), name: `User ${i}` } }, +})) + +const longMatchesNext = longMatchesPrev.map((match) => ({ + ...match, + params: { ...match.params }, + loaderDeps: { ...match.loaderDeps }, + loaderData: { user: { ...match.loaderData.user } }, +})) + +const longMatchesChangedNext = longMatchesNext.map((match, i) => + i === longMatchesNext.length - 1 + ? { + ...match, + loaderData: { user: { ...match.loaderData.user, name: 'Changed' } }, + } + : match, +) + +const symbolPrev = { routeId: '/users/$userId', [symbolKey]: { stable: true } } +const symbolNext = { routeId: '/users/$userId', [symbolKey]: { stable: true } } + +const nonEnumerablePrev: Record = { visible: 1 } +Object.defineProperty(nonEnumerablePrev, 'hidden', { + value: 2, + enumerable: false, +}) +const nonEnumerableNext: Record = { visible: 1 } +Object.defineProperty(nonEnumerableNext, 'hidden', { + value: 2, + enumerable: false, +}) + +const primitivePrev = { a: 1, b: 2, c: null, d: 'same', e: undefined } +const primitiveNext = { a: 1, b: 3, c: undefined, d: 'same', e: null } + +const wideSearchPrev = Object.fromEntries( + Array.from({ length: 80 }, (_, i) => [`key${i}`, `value${i}`]), +) +const wideSearchNext = { ...wideSearchPrev, key79: 'changed' } + +const nullSearchPrev = Object.create(null) +const nullSearchNext = Object.create(null) +for (let i = 0; i < 50; i++) { + nullSearchPrev[`key${i}`] = `value${i}` + nullSearchNext[`key${i}`] = `value${i}` +} +nullSearchNext.key49 = 'changed' + +const mixedCases = [ + [locationPrev, locationNext], + [locationPrev, changedLocationNext], + [matchesPrev, matchesNext], + [longMatchesPrev, longMatchesNext], + [longMatchesPrev, longMatchesChangedNext], + [primitivePrev, primitiveNext], + [wideSearchPrev, wideSearchNext], + [symbolPrev, symbolNext], + [nonEnumerablePrev, nonEnumerableNext], +] as const + +const nullCases = [[nullSearchPrev, nullSearchNext]] as const + +for (const [prev, next] of mixedCases) { + const baseline = baselineReplaceEqualDeep(prev, next) + const current = replaceEqualDeep(prev, next) + + expect(current).toEqual(baseline) + expect(current === prev).toBe(baseline === prev) +} + +for (const [prev, next] of nullCases) { + const baseline = baselineReplaceEqualDeep(prev, next, () => + Object.create(null), + ) + const current = nullReplaceEqualDeep(prev, next) + + expect(current).toEqual(baseline) + expect(Object.getPrototypeOf(current)).toBe(null) +} + +let sink: unknown + +function runBatch( + fn: (prev: any, next: any) => any, + cases: ReadonlyArray, +) { + for (let i = 0; i < 200; i++) { + for (const [prev, next] of cases) { + sink = fn(prev, next) + } + } +} + +describe('replaceEqualDeep', () => { + bench('baseline mixed router state batch', () => { + runBatch(baselineReplaceEqualDeep, mixedCases) + }) + + bench('current mixed router state batch', () => { + runBatch(replaceEqualDeep, mixedCases) + }) + + bench('baseline plain object equal batch', () => { + runBatch(baselineReplaceEqualDeep, [[locationPrev, locationNext]]) + }) + + bench('current plain object equal batch', () => { + runBatch(replaceEqualDeep, [[locationPrev, locationNext]]) + }) + + bench('baseline array matches equal batch', () => { + runBatch(baselineReplaceEqualDeep, [[matchesPrev, matchesNext]]) + }) + + bench('current array matches equal batch', () => { + runBatch(replaceEqualDeep, [[matchesPrev, matchesNext]]) + }) + + bench('baseline long matches mixed batch', () => { + runBatch(baselineReplaceEqualDeep, [ + [longMatchesPrev, longMatchesNext], + [longMatchesPrev, longMatchesChangedNext], + ]) + }) + + bench('current long matches mixed batch', () => { + runBatch(replaceEqualDeep, [ + [longMatchesPrev, longMatchesNext], + [longMatchesPrev, longMatchesChangedNext], + ]) + }) + + bench('baseline primitive mismatch batch', () => { + runBatch(baselineReplaceEqualDeep, [[primitivePrev, primitiveNext]]) + }) + + bench('current primitive mismatch batch', () => { + runBatch(replaceEqualDeep, [[primitivePrev, primitiveNext]]) + }) + + bench('baseline null search batch', () => { + runBatch( + (prev, next) => + baselineReplaceEqualDeep(prev, next, () => Object.create(null)), + nullCases, + ) + }) + + bench('current null search batch', () => { + runBatch(nullReplaceEqualDeep, nullCases) + }) +}) + +void sink diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts index 95874f230e..a238ee1bfd 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -5,6 +5,7 @@ import { encodePathLikeUrl, escapeHtml, isPlainArray, + nullReplaceEqualDeep, replaceEqualDeep, } from '../src/utils' @@ -334,6 +335,53 @@ describe('replaceEqualDeep', () => { expect(next).toBe(current) }) + it('should not reuse arrays when undefined is appended or removed', () => { + const current = [1] + const appended = replaceEqualDeep(current, [1, undefined]) + + expect(appended).toEqual([1, undefined]) + expect(appended).not.toBe(current) + + const withUndefined = [1, undefined] + const removed = replaceEqualDeep(withUndefined, [1]) + + expect(removed).toEqual([1]) + expect(removed).not.toBe(withUndefined) + }) + + it('should not reuse objects when an undefined key is removed', () => { + const current = { a: 1, b: undefined } + const next = replaceEqualDeep(current, { a: 1 }) + + expect(next).toEqual({ a: 1 }) + expect(next).not.toBe(current) + }) + + it('should track undefined symbol key existence', () => { + const key = Symbol('optional') + const current = { [key]: undefined } + const removed = replaceEqualDeep(current, {}) + + expect(removed).toEqual({}) + expect(removed).not.toBe(current) + expect(Object.getOwnPropertySymbols(removed)).toHaveLength(0) + + const added = replaceEqualDeep({}, { [key]: undefined }) + + expect(added).not.toBe(current) + expect(Object.getOwnPropertySymbols(added)).toEqual([key]) + }) + + it('should reuse equal nested values behind symbol keys', () => { + const key = Symbol('nested') + const prev = { other: 1, [key]: { deep: 'value' } } + const next = { other: 2, [key]: { deep: 'value' } } + const result = replaceEqualDeep(prev, next) + + expect(result).not.toBe(prev) + expect(result[key]).toBe(prev[key]) + }) + it('works w/ null prototype objects', () => { const current = Object.create(null) const next = Object.create(null) @@ -347,6 +395,37 @@ describe('replaceEqualDeep', () => { }) }) +describe('nullReplaceEqualDeep', () => { + it('should return the previous null-prototype object when equal', () => { + const current = Object.create(null) + const next = Object.create(null) + + current.foo = 'bar' + next.foo = 'bar' + + expect(nullReplaceEqualDeep(current, next)).toBe(current) + }) + + it('should create null-prototype copies and reuse equal nested values', () => { + const current = Object.create(null) + current.search = Object.create(null) + current.search.q = 'tanstack' + current.page = '1' + + const next = Object.create(null) + next.search = Object.create(null) + next.search.q = 'tanstack' + next.page = '2' + + const result = nullReplaceEqualDeep(current, next) + + expect(result).toEqual(next) + expect(result).not.toBe(current) + expect(Object.getPrototypeOf(result)).toBe(null) + expect(result.search).toBe(current.search) + }) +}) + describe('isPlainArray', () => { it('should return `true` for plain arrays', () => { expect(isPlainArray([1, 2])).toEqual(true)