From b5bc87b9a642442bf060d51b174321466ce764b2 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:40:42 +0200 Subject: [PATCH] perf(router-core): pass deepEqual flags positionally `deepEqual(a, b, partial?, explicitUndefined?)` replaces the options object. The hot callers (Link option stabilization and active-state checks, matchRoute) no longer allocate `{ partial }` / `{ ignoreUndefined: false }` per comparison, and the comparator reads two booleans instead of an options parameter that saw four object shapes. `explicitUndefined` (falsy default) replaces `ignoreUndefined: false` and reuses the `activeOptions` vocabulary. deepEqual is an internal helper; two-argument callers are unaffected. react-router.minimal shrinks by 48 B gzip (129 raw) on top of the previous commit; the Link stabilization micro-benchmark improves by ~10% and the paired Link component runner resolves three cases as faster. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit d6f2c1ca55a4900b33868cfc115ac7120d68d26b) --- .changeset/deep-equal-positional-flags.md | 8 ++ packages/react-router/src/link.tsx | 16 ++-- packages/router-core/src/router.ts | 4 +- packages/router-core/src/utils.ts | 35 +++++--- .../tests/deep-equal-contract.test.ts | 79 ++++++++++--------- .../router-core/tests/deep-equal.bench.ts | 32 ++++---- packages/router-core/tests/utils.test.ts | 50 ++++++------ packages/solid-router/src/link.tsx | 10 ++- packages/vue-router/src/link.tsx | 10 ++- 9 files changed, 135 insertions(+), 109 deletions(-) create mode 100644 .changeset/deep-equal-positional-flags.md diff --git a/.changeset/deep-equal-positional-flags.md b/.changeset/deep-equal-positional-flags.md new file mode 100644 index 0000000000..42df0bc798 --- /dev/null +++ b/.changeset/deep-equal-positional-flags.md @@ -0,0 +1,8 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +`deepEqual` now takes its flags as positional arguments — `deepEqual(a, b, partial?, explicitUndefined?)` — instead of an options object. The router's hot callers (Link option stabilization and active-state checks, `matchRoute`) no longer allocate an options object per comparison, and the comparator reads two booleans instead of a polymorphic object. `explicitUndefined` replaces `ignoreUndefined: false`. `deepEqual` is an internal helper; it stays exported for compatibility of two-argument calls. diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 0116b1893c..4d5e13164a 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -45,14 +45,14 @@ type LinkState = [href: string | undefined, isActive?: boolean] // mutated in place is not re-read. `deepEqual` short-circuits on reference // equality, so an unchanged reference costs nothing. // -// `ignoreUndefined: false` is required: an explicit `undefined` clears an +// `explicitUndefined` is required: an explicit `undefined` clears an // inherited param or search key, so `{}` and `{ category: undefined }` build // different locations and must not be treated as equal here. function useStableValues>(...values: T): T { const ref = React.useRef>(values) const stable = ref.current as Array values.forEach((value, index) => { - if (!deepEqual(stable[index], value, { ignoreUndefined: false })) { + if (!deepEqual(stable[index], value, false, true)) { stable[index] = value } }) @@ -111,10 +111,12 @@ function resolveIsActive( } if (activeOptions?.includeSearch ?? true) { - const searchTest = deepEqual(location.search, next.search, { - partial: !activeOptions?.exact, - ignoreUndefined: !activeOptions?.explicitUndefined, - }) + const searchTest = deepEqual( + location.search, + next.search, + !activeOptions?.exact, + activeOptions?.explicitUndefined, + ) if (!searchTest) { return false } @@ -852,7 +854,7 @@ function areLinkPropsEqual( } if ( !ROUTER_OPTION_KEYS.has(key) || - !deepEqual(prev[key], next[key], { ignoreUndefined: false }) + !deepEqual(prev[key], next[key], false, true) ) { return false } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index fc2b76d979..2eff5b2bed 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2720,13 +2720,13 @@ export class RouterCore< } if (location.params) { - if (!deepEqual(match.rawParams, location.params, { partial: true })) { + if (!deepEqual(match.rawParams, location.params, true)) { return false } } if (opts?.includeSearch ?? true) { - return deepEqual(baseLocation.search, next.search, { partial: true }) + return deepEqual(baseLocation.search, next.search, true) ? match.rawParams : false } diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 3b84c4dc3f..e9b2295cb8 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -363,13 +363,19 @@ export function isPlainArray(value: unknown): value is Array { } /** - * Perform a deep equality check with options for partial comparison and - * ignoring `undefined` values. Optimized for router state comparisons. + * Perform a deep equality check optimized for router state comparisons. + * + * - `partial`: `b` may omit keys that `a` has (arrays stay length-exact). + * - `explicitUndefined`: keys holding `undefined` take part in the comparison + * instead of being ignored. + * + * Internal: the flags are positional so hot callers pass no options object. */ export function deepEqual( a: any, b: any, - opts?: { partial?: boolean; ignoreUndefined?: boolean }, + partial?: boolean, + explicitUndefined?: boolean, ): boolean { if (a === b) { return true @@ -380,25 +386,25 @@ export function deepEqual( for (let i = 0, l = a.length; i < l; i++) { const av = a[i] const bv = b[i] - if (av !== bv && !deepEqual(av, bv, opts)) return false + if (av !== bv && !deepEqual(av, bv, partial, explicitUndefined)) { + return false + } } return true } if (isPlainObject(a) && isPlainObject(b)) { - const ignoreUndefined = opts?.ignoreUndefined ?? true - - if (opts?.partial) { + if (partial) { for (const k in b) { - if (!ignoreUndefined || b[k] !== undefined) { - if (!deepEqual(a[k], b[k], opts)) return false + if (explicitUndefined || b[k] !== undefined) { + if (!deepEqual(a[k], b[k], partial, explicitUndefined)) return false } } return true } let aCount = 0 - if (!ignoreUndefined) { + if (explicitUndefined) { aCount = Object.keys(a).length } else { for (const k in a) { @@ -407,8 +413,13 @@ export function deepEqual( } for (const k in b) { - if (!ignoreUndefined || b[k] !== undefined) { - if (aCount-- === 0 || !deepEqual(a[k], b[k], opts)) return false + if (explicitUndefined || b[k] !== undefined) { + if ( + aCount-- === 0 || + !deepEqual(a[k], b[k], partial, explicitUndefined) + ) { + return false + } } } diff --git a/packages/router-core/tests/deep-equal-contract.test.ts b/packages/router-core/tests/deep-equal-contract.test.ts index e4dba5917a..70f0adda6f 100644 --- a/packages/router-core/tests/deep-equal-contract.test.ts +++ b/packages/router-core/tests/deep-equal-contract.test.ts @@ -5,24 +5,40 @@ import { deepEqual } from '../src/utils' // performance work on its loops cannot change it silently. describe('deepEqual contract', () => { it.each([ - undefined, - {}, - { partial: true }, - { ignoreUndefined: false }, - { partial: true, ignoreUndefined: false }, - ])('compares nested records and arrays with %j', (opts) => { - const a = Object.freeze({ - page: 1, - nested: Object.freeze({ ids: Object.freeze([1, 2]) }), - }) - expect(deepEqual(a, { page: 1, nested: { ids: [1, 2] } }, opts)).toBe(true) - expect(deepEqual(a, { page: 1, nested: { ids: [1, 3] } }, opts)).toBe(false) - }) + [undefined, undefined], + [true, undefined], + [undefined, true], + [true, true], + ] as const)( + 'compares nested records and arrays with partial=%s explicitUndefined=%s', + (partial, explicitUndefined) => { + const a = Object.freeze({ + page: 1, + nested: Object.freeze({ ids: Object.freeze([1, 2]) }), + }) + expect( + deepEqual( + a, + { page: 1, nested: { ids: [1, 2] } }, + partial, + explicitUndefined, + ), + ).toBe(true) + expect( + deepEqual( + a, + { page: 1, nested: { ids: [1, 3] } }, + partial, + explicitUndefined, + ), + ).toBe(false) + }, + ) it('keeps partial comparison directional and arrays length-exact', () => { - expect(deepEqual({ a: 1, b: 2 }, { a: 1 }, { partial: true })).toBe(true) - expect(deepEqual({ a: 1 }, { a: 1, b: 2 }, { partial: true })).toBe(false) - expect(deepEqual([1, 2], [1], { partial: true })).toBe(false) + expect(deepEqual({ a: 1, b: 2 }, { a: 1 }, true)).toBe(true) + expect(deepEqual({ a: 1 }, { a: 1, b: 2 }, true)).toBe(false) + expect(deepEqual([1, 2], [1], true)).toBe(false) }) it('retains inherited enumeration, symbols, and hidden-property policy', () => { @@ -37,20 +53,12 @@ describe('deepEqual contract', () => { it('retains current undefined-key behavior rather than changing the contract', () => { expect(deepEqual({ a: undefined }, {})).toBe(true) - expect(deepEqual({ a: undefined }, {}, { ignoreUndefined: false })).toBe( - false, - ) + expect(deepEqual({ a: undefined }, {}, false, true)).toBe(false) // Existing quirk: this performance patch deliberately does NOT repair it. - expect( - deepEqual({ a: undefined }, { b: undefined }, { ignoreUndefined: false }), - ).toBe(true) - expect( - deepEqual( - {}, - { a: undefined }, - { partial: true, ignoreUndefined: false }, - ), - ).toBe(true) + expect(deepEqual({ a: undefined }, { b: undefined }, false, true)).toBe( + true, + ) + expect(deepEqual({}, { a: undefined }, true, true)).toBe(true) }) it('retains numeric equality', () => { @@ -85,18 +93,13 @@ describe('deepEqual contract', () => { throw new Error('must not read') }, } - expect(deepEqual({}, b, { ignoreUndefined: false })).toBe(false) + expect(deepEqual({}, b, false, true)).toBe(false) }) - it('does not inspect options on an identical child', () => { + it('short-circuits identical children', () => { const shared = {} - const opts = { - get partial(): boolean { - throw new Error('must not read') - }, - } - expect(deepEqual(shared, shared, opts)).toBe(true) - expect(deepEqual([shared], [shared], opts)).toBe(true) + expect(deepEqual(shared, shared, true, true)).toBe(true) + expect(deepEqual([shared], [shared], true, true)).toBe(true) }) it('keeps different class instances opaque', () => { diff --git a/packages/router-core/tests/deep-equal.bench.ts b/packages/router-core/tests/deep-equal.bench.ts index 7a8d8d0b93..2b2e788e16 100644 --- a/packages/router-core/tests/deep-equal.bench.ts +++ b/packages/router-core/tests/deep-equal.bench.ts @@ -2,7 +2,7 @@ import { bench, describe, expect } from 'vitest' import { deepEqual } from '../src/utils' // Workloads modeled on the router's callers: Link inline-option stabilization -// (`ignoreUndefined: false`), active-state search comparison (`partial`), +// (`explicitUndefined`), active-state search comparison (`partial`), // matchRoute params (`partial`), and search-middleware value comparisons. // Inputs rotate over a pool so a single hidden-class or cached shape does not // dominate; the "fresh" cases build their incoming value inside the timed op. @@ -66,9 +66,12 @@ expect(deepEqual(search, shared)).toBe(true) for (let index = 0; index < linkOptions.length; index++) { for (let slot = 0; slot < 3; slot++) { expect( - deepEqual(linkOptions[index]![slot], linkOptionsEqual[index]![slot], { - ignoreUndefined: false, - }), + deepEqual( + linkOptions[index]![slot], + linkOptionsEqual[index]![slot], + false, + true, + ), ).toBe(true) } } @@ -93,9 +96,9 @@ describe('deepEqual', () => { ) bench( - 'equal flat record (ignoreUndefined: false)', + 'equal flat record (explicitUndefined)', () => { - sink += +deepEqual(search, next(searchEqual), { ignoreUndefined: false }) + sink += +deepEqual(search, next(searchEqual), false, true) }, options, ) @@ -103,7 +106,7 @@ describe('deepEqual', () => { bench( 'equal flat record (partial)', () => { - sink += +deepEqual(search, next(searchEqual), { partial: true }) + sink += +deepEqual(search, next(searchEqual), true) }, options, ) @@ -141,12 +144,13 @@ describe('deepEqual', () => { ) bench( - 'fresh equal flat record (ignoreUndefined: false)', + 'fresh equal flat record (explicitUndefined)', () => { sink += +deepEqual( search, { page: 1, sort: 'asc', filter: 'open', tags: search.tags }, - { ignoreUndefined: false }, + false, + true, ) }, options, @@ -155,11 +159,7 @@ describe('deepEqual', () => { bench( 'fresh partial mismatch', () => { - sink += +deepEqual( - search, - { page: 1, sort: 'desc' }, - { partial: true, ignoreUndefined: true }, - ) + sink += +deepEqual(search, { page: 1, sort: 'desc' }, true) }, options, ) @@ -171,9 +171,7 @@ describe('deepEqual', () => { const previous = linkOptions[index]! const incoming = linkOptionsEqual[index]! for (let slot = 0; slot < 3; slot++) { - sink += +deepEqual(previous[slot], incoming[slot], { - ignoreUndefined: false, - }) + sink += +deepEqual(previous[slot], incoming[slot], false, true) } } }, diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts index 3a5bfa7030..c5156adef8 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -362,43 +362,43 @@ describe('deepEqual', () => { it('should return `true` for equal objects', () => { const a = { a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] } const b = { a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] } - expect(deepEqual(a, b, { partial })).toEqual(true) - expect(deepEqual(b, a, { partial })).toEqual(true) + expect(deepEqual(a, b, partial)).toEqual(true) + expect(deepEqual(b, a, partial)).toEqual(true) }) it('should return `false` for non equal objects', () => { const a = { a: { b: 'b' }, c: 'c' } const b = { a: { b: 'c' }, c: 'c' } - expect(deepEqual(a, b, { partial })).toEqual(false) - expect(deepEqual(b, a, { partial })).toEqual(false) + expect(deepEqual(a, b, partial)).toEqual(false) + expect(deepEqual(b, a, partial)).toEqual(false) }) it('should return `true` for equal objects and ignore `undefined` properties', () => { const a = { a: 'a', b: undefined, c: 'c' } const b = { a: 'a', c: 'c' } - expect(deepEqual(a, b, { partial })).toEqual(true) - expect(deepEqual(b, a, { partial })).toEqual(true) + expect(deepEqual(a, b, partial)).toEqual(true) + expect(deepEqual(b, a, partial)).toEqual(true) }) it('should return `true` for equal objects and ignore `undefined` nested properties', () => { const a = { a: { b: 'b', x: undefined }, c: 'c' } const b = { a: { b: 'b' }, c: 'c', d: undefined } - expect(deepEqual(a, b, { partial })).toEqual(true) - expect(deepEqual(b, a, { partial })).toEqual(true) + expect(deepEqual(a, b, partial)).toEqual(true) + expect(deepEqual(b, a, partial)).toEqual(true) }) it('should return `true` for equal arrays and ignore `undefined` object properties', () => { const a = { a: { b: 'b' }, c: undefined } const b = { a: { b: 'b' } } - expect(deepEqual([a], [b], { partial })).toEqual(true) - expect(deepEqual([b], [a], { partial })).toEqual(true) + expect(deepEqual([a], [b], partial)).toEqual(true) + expect(deepEqual([b], [a], partial)).toEqual(true) }) it('should return `true` for equal arrays and ignore nested `undefined` object properties', () => { const a = { a: { b: 'b', x: undefined }, c: 'c' } const b = { a: { b: 'b' }, c: 'c' } - expect(deepEqual([a], [b], { partial })).toEqual(true) - expect(deepEqual([b], [a], { partial })).toEqual(true) + expect(deepEqual([a], [b], partial)).toEqual(true) + expect(deepEqual([b], [a], partial)).toEqual(true) }) }) @@ -409,15 +409,15 @@ describe('deepEqual', () => { it('should return `false` for objects', () => { const a = { a: { b: 'b', x: undefined }, c: 'c' } const b = { a: { b: 'b' }, c: 'c', d: undefined } - expect(deepEqual(a, b, { partial, ignoreUndefined })).toEqual(false) - expect(deepEqual(b, a, { partial, ignoreUndefined })).toEqual(false) + expect(deepEqual(a, b, partial, !ignoreUndefined)).toEqual(false) + expect(deepEqual(b, a, partial, !ignoreUndefined)).toEqual(false) }) it('should return `false` for arrays', () => { const a = { a: { b: 'b', x: undefined }, c: 'c' } const b = { a: { b: 'b' }, c: 'c' } - expect(deepEqual([a], [b], { partial, ignoreUndefined })).toEqual(false) - expect(deepEqual([b], [a], { partial, ignoreUndefined })).toEqual(false) + expect(deepEqual([a], [b], partial, !ignoreUndefined)).toEqual(false) + expect(deepEqual([b], [a], partial, !ignoreUndefined)).toEqual(false) }) }) describe('partial = true', () => { @@ -425,15 +425,15 @@ describe('deepEqual', () => { it('should return `true` for objects', () => { const a = { a: { b: 'b' }, c: 'c' } const b = { a: { b: 'b' }, c: 'c', d: undefined } - expect(deepEqual(a, b, { partial, ignoreUndefined })).toEqual(true) - expect(deepEqual(b, a, { partial, ignoreUndefined })).toEqual(true) + expect(deepEqual(a, b, partial, !ignoreUndefined)).toEqual(true) + expect(deepEqual(b, a, partial, !ignoreUndefined)).toEqual(true) }) it('should return `true` for arrays', () => { const a = { a: { b: 'b', x: undefined }, c: 'c' } const b = { a: { b: 'b' }, c: 'c' } - expect(deepEqual([a], [b], { partial, ignoreUndefined })).toEqual(true) - expect(deepEqual([b], [a], { partial, ignoreUndefined })).toEqual(true) + expect(deepEqual([a], [b], partial, !ignoreUndefined)).toEqual(true) + expect(deepEqual([b], [a], partial, !ignoreUndefined)).toEqual(true) }) }) }) @@ -442,15 +442,15 @@ describe('deepEqual', () => { it('correctly compares partially equal objects', () => { const a = { a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] } const b = { a: { b: 'b' }, c: 'c' } - expect(deepEqual(a, b, { partial: true })).toEqual(true) - expect(deepEqual(b, a, { partial: true })).toEqual(false) + expect(deepEqual(a, b, true)).toEqual(true) + expect(deepEqual(b, a, true)).toEqual(false) }) it('correctly compares partially equal objects and ignores `undefined` object properties', () => { const a = { a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }], e: undefined } const b = { a: { b: 'b' }, c: 'c', d: undefined } - expect(deepEqual(a, b, { partial: true })).toEqual(true) - expect(deepEqual(b, a, { partial: true })).toEqual(false) + expect(deepEqual(a, b, true)).toEqual(true) + expect(deepEqual(b, a, true)).toEqual(false) }) }) @@ -487,7 +487,7 @@ describe('deepEqual', () => { Object.prototype.x = 'x' const a = { a: 1 } const b = { a: 1 } - expect(deepEqual(a, b, { ignoreUndefined: false })).toEqual(true) + expect(deepEqual(a, b, false, true)).toEqual(true) }, ) diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index aeaf6a4a20..a5fc4bca57 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -212,10 +212,12 @@ export function useLinkProps< } if (activeOptions?.includeSearch ?? true) { - const searchTest = deepEqual(current.search, nextLocation.search, { - partial: !activeOptions?.exact, - ignoreUndefined: !activeOptions?.explicitUndefined, - }) + const searchTest = deepEqual( + current.search, + nextLocation.search, + !activeOptions?.exact, + activeOptions?.explicitUndefined, + ) if (!searchTest) { return false } diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index e0a0789524..6d2fea690c 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -681,10 +681,12 @@ function getIsActive( } if (activeOptions?.includeSearch ?? true) { - const searchTest = deepEqual(loc.search, nextLoc.search, { - partial: !activeOptions?.exact, - ignoreUndefined: !activeOptions?.explicitUndefined, - }) + const searchTest = deepEqual( + loc.search, + nextLoc.search, + !activeOptions?.exact, + activeOptions?.explicitUndefined, + ) if (!searchTest) { return false }