Skip to content

Commit 850b963

Browse files
authored
refactor(router-core): getEnumerableOwnKeys fast path for 'no symbol key' common case, 6-11% faster replaceEqualDeep (#6448)
1 parent b65984e commit 850b963

File tree

2 files changed

+331
-4
lines changed

2 files changed

+331
-4
lines changed

packages/router-core/src/utils.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ export function functionalUpdate<TPrevious, TResult = TPrevious>(
212212
}
213213

214214
const hasOwn = Object.prototype.hasOwnProperty
215+
const isEnumerable = Object.prototype.propertyIsEnumerable
215216

216217
/**
217218
* This function returns `prev` if `_next` is deeply equal.
@@ -274,17 +275,27 @@ export function replaceEqualDeep<T>(prev: any, _next: T, _depth = 0): T {
274275
/**
275276
* Equivalent to `Reflect.ownKeys`, but ensures that objects are "clone-friendly":
276277
* will return false if object has any non-enumerable properties.
278+
*
279+
* Optimized for the common case where objects have no symbol properties.
277280
*/
278281
function getEnumerableOwnKeys(o: object) {
279-
const keys = []
280282
const names = Object.getOwnPropertyNames(o)
283+
284+
// Fast path: check all string property names are enumerable
281285
for (const name of names) {
282-
if (!Object.prototype.propertyIsEnumerable.call(o, name)) return false
283-
keys.push(name)
286+
if (!isEnumerable.call(o, name)) return false
284287
}
288+
289+
// Only check symbols if the object has any (most plain objects don't)
285290
const symbols = Object.getOwnPropertySymbols(o)
291+
292+
// Fast path: no symbols, return names directly (avoids array allocation/concat)
293+
if (symbols.length === 0) return names
294+
295+
// Slow path: has symbols, need to check and merge
296+
const keys: Array<string | symbol> = names
286297
for (const symbol of symbols) {
287-
if (!Object.prototype.propertyIsEnumerable.call(o, symbol)) return false
298+
if (!isEnumerable.call(o, symbol)) return false
288299
keys.push(symbol)
289300
}
290301
return keys

packages/router-core/tests/utils.test.ts

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,322 @@ describe('decodePath', () => {
666666
})
667667
})
668668

669+
/**
670+
* Tests for getEnumerableOwnKeys behavior (internal function).
671+
* Tested indirectly through replaceEqualDeep since getEnumerableOwnKeys is not exported.
672+
*
673+
* getEnumerableOwnKeys should:
674+
* 1. Return array of all enumerable own keys (strings + symbols)
675+
* 2. Return false if any property is non-enumerable
676+
* 3. Handle objects with no symbols efficiently (optimization target)
677+
*/
678+
describe('getEnumerableOwnKeys behavior (via replaceEqualDeep)', () => {
679+
describe('plain objects with string keys only', () => {
680+
it('should handle empty objects', () => {
681+
const prev = {}
682+
const next = {}
683+
expect(replaceEqualDeep(prev, next)).toBe(prev)
684+
})
685+
686+
it('should handle objects with single key', () => {
687+
const prev = { a: 1 }
688+
const next = { a: 1 }
689+
expect(replaceEqualDeep(prev, next)).toBe(prev)
690+
})
691+
692+
it('should handle objects with many keys', () => {
693+
const prev = {
694+
a: 1,
695+
b: 2,
696+
c: 3,
697+
d: 4,
698+
e: 5,
699+
f: 6,
700+
g: 7,
701+
h: 8,
702+
i: 9,
703+
j: 10,
704+
}
705+
const next = {
706+
a: 1,
707+
b: 2,
708+
c: 3,
709+
d: 4,
710+
e: 5,
711+
f: 6,
712+
g: 7,
713+
h: 8,
714+
i: 9,
715+
j: 10,
716+
}
717+
expect(replaceEqualDeep(prev, next)).toBe(prev)
718+
})
719+
720+
it('should handle objects with numeric string keys', () => {
721+
const prev = { '0': 'a', '1': 'b', '2': 'c' }
722+
const next = { '0': 'a', '1': 'b', '2': 'c' }
723+
expect(replaceEqualDeep(prev, next)).toBe(prev)
724+
})
725+
726+
it('should handle objects with special string keys', () => {
727+
const prev = {
728+
'key-with-dash': 1,
729+
'key.with.dot': 2,
730+
'key with space': 3,
731+
}
732+
const next = {
733+
'key-with-dash': 1,
734+
'key.with.dot': 2,
735+
'key with space': 3,
736+
}
737+
expect(replaceEqualDeep(prev, next)).toBe(prev)
738+
})
739+
740+
it('should detect differences in objects with string keys', () => {
741+
const prev = { a: 1, b: 2, c: 3 }
742+
const next = { a: 1, b: 99, c: 3 }
743+
const result = replaceEqualDeep(prev, next)
744+
expect(result).not.toBe(prev)
745+
expect(result).toEqual(next)
746+
})
747+
})
748+
749+
describe('objects with symbol keys', () => {
750+
it('should handle objects with single symbol key', () => {
751+
const sym = Symbol('test')
752+
const prev = { [sym]: 1 }
753+
const next = { [sym]: 1 }
754+
expect(replaceEqualDeep(prev, next)).toBe(prev)
755+
})
756+
757+
it('should handle objects with multiple symbol keys', () => {
758+
const sym1 = Symbol('a')
759+
const sym2 = Symbol('b')
760+
const sym3 = Symbol('c')
761+
const prev = { [sym1]: 1, [sym2]: 2, [sym3]: 3 }
762+
const next = { [sym1]: 1, [sym2]: 2, [sym3]: 3 }
763+
expect(replaceEqualDeep(prev, next)).toBe(prev)
764+
})
765+
766+
it('should detect differences in symbol values', () => {
767+
const sym = Symbol('test')
768+
const prev = { [sym]: 1 }
769+
const next = { [sym]: 2 }
770+
const result = replaceEqualDeep(prev, next)
771+
expect(result).not.toBe(prev)
772+
expect(result[sym]).toBe(2)
773+
})
774+
775+
it('should handle global symbols', () => {
776+
const sym = Symbol.for('global.test.key')
777+
const prev = { [sym]: 'value' }
778+
const next = { [sym]: 'value' }
779+
expect(replaceEqualDeep(prev, next)).toBe(prev)
780+
})
781+
})
782+
783+
describe('objects with mixed string and symbol keys', () => {
784+
it('should handle objects with both string and symbol keys', () => {
785+
const sym = Symbol('test')
786+
const prev = { a: 1, b: 2, [sym]: 3 }
787+
const next = { a: 1, b: 2, [sym]: 3 }
788+
expect(replaceEqualDeep(prev, next)).toBe(prev)
789+
})
790+
791+
it('should detect differences in string keys when symbols present', () => {
792+
const sym = Symbol('test')
793+
const prev = { a: 1, b: 2, [sym]: 3 }
794+
const next = { a: 1, b: 99, [sym]: 3 }
795+
const result = replaceEqualDeep(prev, next)
796+
expect(result).not.toBe(prev)
797+
expect(result.b).toBe(99)
798+
expect(result[sym]).toBe(3)
799+
})
800+
801+
it('should detect differences in symbol keys when strings present', () => {
802+
const sym = Symbol('test')
803+
const prev = { a: 1, b: 2, [sym]: 3 }
804+
const next = { a: 1, b: 2, [sym]: 99 }
805+
const result = replaceEqualDeep(prev, next)
806+
expect(result).not.toBe(prev)
807+
expect(result.a).toBe(1)
808+
expect(result[sym]).toBe(99)
809+
})
810+
811+
it('should handle complex nested objects with symbols', () => {
812+
const sym = Symbol('nested')
813+
const prev = { outer: { inner: 1, [sym]: { deep: 'value' } } }
814+
const next = { outer: { inner: 1, [sym]: { deep: 'value' } } }
815+
expect(replaceEqualDeep(prev, next)).toBe(prev)
816+
})
817+
})
818+
819+
describe('non-enumerable properties', () => {
820+
it('should treat objects with non-enumerable string property as non-plain', () => {
821+
const prev: Record<string, number> = { a: 1 }
822+
Object.defineProperty(prev, 'hidden', { value: 2, enumerable: false })
823+
const next: Record<string, number> = { a: 1 }
824+
Object.defineProperty(next, 'hidden', { value: 2, enumerable: false })
825+
826+
// Non-plain objects should return next, not prev (no structural sharing)
827+
const result = replaceEqualDeep(prev, next)
828+
expect(result).toBe(next)
829+
})
830+
831+
it('should treat objects with non-enumerable symbol property as non-plain', () => {
832+
const sym = Symbol('hidden')
833+
const prev: Record<string | symbol, number> = { a: 1 }
834+
Object.defineProperty(prev, sym, { value: 2, enumerable: false })
835+
const next: Record<string | symbol, number> = { a: 1 }
836+
Object.defineProperty(next, sym, { value: 2, enumerable: false })
837+
838+
// Non-plain objects should return next, not prev
839+
const result = replaceEqualDeep(prev, next)
840+
expect(result).toBe(next)
841+
})
842+
843+
it('should handle mix of enumerable and non-enumerable properties', () => {
844+
const prev: Record<string, number> = { visible: 1 }
845+
Object.defineProperty(prev, 'hidden', { value: 2, enumerable: false })
846+
const next = { visible: 1 }
847+
848+
// prev is non-plain (has non-enumerable), next is plain
849+
const result = replaceEqualDeep(prev, next)
850+
expect(result).toBe(next)
851+
})
852+
853+
it('should handle non-enumerable property that shadows a string key', () => {
854+
const prev = Object.create(null)
855+
prev.a = 1
856+
Object.defineProperty(prev, 'b', { value: 2, enumerable: false })
857+
858+
const next = Object.create(null)
859+
next.a = 1
860+
next.b = 2 // enumerable version
861+
862+
const result = replaceEqualDeep(prev, next)
863+
expect(result).toBe(next)
864+
})
865+
})
866+
867+
describe('edge cases for key enumeration', () => {
868+
it('should handle frozen objects as non-plain (configurable is false)', () => {
869+
const prev = Object.freeze({ a: 1, b: 2 })
870+
const next = Object.freeze({ a: 1, b: 2 })
871+
872+
// Frozen objects have all properties as non-configurable but still enumerable
873+
// They should still work with replaceEqualDeep
874+
const result = replaceEqualDeep(prev, next)
875+
expect(result).toBe(prev)
876+
})
877+
878+
it('should handle sealed objects', () => {
879+
const prev = Object.seal({ a: 1, b: 2 })
880+
const next = Object.seal({ a: 1, b: 2 })
881+
882+
const result = replaceEqualDeep(prev, next)
883+
expect(result).toBe(prev)
884+
})
885+
886+
it('should handle objects created with Object.create(null)', () => {
887+
const prev = Object.create(null)
888+
prev.a = 1
889+
prev.b = 2
890+
891+
const next = Object.create(null)
892+
next.a = 1
893+
next.b = 2
894+
895+
const result = replaceEqualDeep(prev, next)
896+
expect(result).toBe(prev)
897+
})
898+
899+
it('should handle objects with inherited properties (only own props checked)', () => {
900+
const proto = { inherited: 'value' }
901+
const prev = Object.create(proto)
902+
prev.own = 1
903+
904+
const next = Object.create(proto)
905+
next.own = 1
906+
907+
const result = replaceEqualDeep(prev, next)
908+
expect(result).toBe(prev)
909+
})
910+
911+
it('should not be confused by Object.prototype properties', () => {
912+
// Ensure hasOwnProperty, toString, etc. don't interfere
913+
const prev = { hasOwnProperty: 1, toString: 2, valueOf: 3 }
914+
const next = { hasOwnProperty: 1, toString: 2, valueOf: 3 }
915+
const result = replaceEqualDeep(prev, next)
916+
expect(result).toBe(prev)
917+
})
918+
})
919+
920+
describe('performance-critical scenarios (typical router state)', () => {
921+
it('should efficiently handle typical router location object', () => {
922+
const prev = {
923+
pathname: '/users/123',
924+
search: '?tab=settings',
925+
hash: '#section',
926+
state: { key: 'abc123' },
927+
}
928+
const next = {
929+
pathname: '/users/123',
930+
search: '?tab=settings',
931+
hash: '#section',
932+
state: { key: 'abc123' },
933+
}
934+
expect(replaceEqualDeep(prev, next)).toBe(prev)
935+
})
936+
937+
it('should efficiently handle typical router match object', () => {
938+
const prev = {
939+
id: 'route-1',
940+
routeId: '/users/$userId',
941+
pathname: '/users/123',
942+
params: { userId: '123' },
943+
search: {},
944+
fullPath: '/users/$userId',
945+
loaderData: { user: { name: 'John' } },
946+
}
947+
const next = {
948+
id: 'route-1',
949+
routeId: '/users/$userId',
950+
pathname: '/users/123',
951+
params: { userId: '123' },
952+
search: {},
953+
fullPath: '/users/$userId',
954+
loaderData: { user: { name: 'John' } },
955+
}
956+
expect(replaceEqualDeep(prev, next)).toBe(prev)
957+
})
958+
959+
it('should efficiently handle array of matches', () => {
960+
const prev = [
961+
{ id: '1', routeId: '__root__', pathname: '/', params: {} },
962+
{ id: '2', routeId: '/users', pathname: '/users', params: {} },
963+
{
964+
id: '3',
965+
routeId: '/users/$userId',
966+
pathname: '/users/123',
967+
params: { userId: '123' },
968+
},
969+
]
970+
const next = [
971+
{ id: '1', routeId: '__root__', pathname: '/', params: {} },
972+
{ id: '2', routeId: '/users', pathname: '/users', params: {} },
973+
{
974+
id: '3',
975+
routeId: '/users/$userId',
976+
pathname: '/users/123',
977+
params: { userId: '123' },
978+
},
979+
]
980+
expect(replaceEqualDeep(prev, next)).toBe(prev)
981+
})
982+
})
983+
})
984+
669985
describe('escapeHtml', () => {
670986
it('should escape less-than sign', () => {
671987
expect(escapeHtml('<')).toBe('\\u003c')

0 commit comments

Comments
 (0)