Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/deep-equal-positional-flags.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 9 additions & 7 deletions packages/react-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends ReadonlyArray<unknown>>(...values: T): T {
const ref = React.useRef<ReadonlyArray<unknown>>(values)
const stable = ref.current as Array<unknown>
values.forEach((value, index) => {
if (!deepEqual(stable[index], value, { ignoreUndefined: false })) {
if (!deepEqual(stable[index], value, false, true)) {
stable[index] = value
}
})
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
35 changes: 23 additions & 12 deletions packages/router-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,13 +363,19 @@ export function isPlainArray(value: unknown): value is Array<unknown> {
}

/**
* 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
Expand All @@ -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) {
Expand All @@ -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
}
}
}

Expand Down
79 changes: 41 additions & 38 deletions packages/router-core/tests/deep-equal-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
32 changes: 15 additions & 17 deletions packages/router-core/tests/deep-equal.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -93,17 +96,17 @@ 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,
)

bench(
'equal flat record (partial)',
() => {
sink += +deepEqual(search, next(searchEqual), { partial: true })
sink += +deepEqual(search, next(searchEqual), true)
},
options,
)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand All @@ -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)
}
}
},
Expand Down
Loading
Loading