@tanstack/router-core 1.121.0-alpha.3 → 1.121.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/cjs/RouterProvider.d.cts +2 -2
  2. package/dist/cjs/index.d.cts +2 -2
  3. package/dist/cjs/link.cjs.map +1 -1
  4. package/dist/cjs/link.d.cts +1 -1
  5. package/dist/cjs/qss.cjs +2 -4
  6. package/dist/cjs/qss.cjs.map +1 -1
  7. package/dist/cjs/qss.d.cts +9 -0
  8. package/dist/cjs/redirect.cjs +7 -0
  9. package/dist/cjs/redirect.cjs.map +1 -1
  10. package/dist/cjs/route.cjs.map +1 -1
  11. package/dist/cjs/route.d.cts +11 -1
  12. package/dist/cjs/router.cjs +136 -148
  13. package/dist/cjs/router.cjs.map +1 -1
  14. package/dist/cjs/router.d.cts +3 -6
  15. package/dist/cjs/scroll-restoration.cjs +1 -1
  16. package/dist/cjs/scroll-restoration.cjs.map +1 -1
  17. package/dist/cjs/scroll-restoration.d.cts +1 -1
  18. package/dist/cjs/utils.cjs +13 -8
  19. package/dist/cjs/utils.cjs.map +1 -1
  20. package/dist/cjs/utils.d.cts +0 -12
  21. package/dist/esm/RouterProvider.d.ts +2 -2
  22. package/dist/esm/index.d.ts +2 -2
  23. package/dist/esm/link.d.ts +1 -1
  24. package/dist/esm/link.js.map +1 -1
  25. package/dist/esm/qss.d.ts +9 -0
  26. package/dist/esm/qss.js +2 -4
  27. package/dist/esm/qss.js.map +1 -1
  28. package/dist/esm/redirect.js +7 -0
  29. package/dist/esm/redirect.js.map +1 -1
  30. package/dist/esm/route.d.ts +11 -1
  31. package/dist/esm/route.js.map +1 -1
  32. package/dist/esm/router.d.ts +3 -6
  33. package/dist/esm/router.js +136 -148
  34. package/dist/esm/router.js.map +1 -1
  35. package/dist/esm/scroll-restoration.d.ts +1 -1
  36. package/dist/esm/scroll-restoration.js +1 -1
  37. package/dist/esm/scroll-restoration.js.map +1 -1
  38. package/dist/esm/utils.d.ts +0 -12
  39. package/dist/esm/utils.js +13 -8
  40. package/dist/esm/utils.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/RouterProvider.ts +2 -6
  43. package/src/index.ts +1 -1
  44. package/src/link.ts +1 -1
  45. package/src/qss.ts +2 -6
  46. package/src/redirect.ts +8 -0
  47. package/src/route.ts +27 -18
  48. package/src/router.ts +206 -193
  49. package/src/scroll-restoration.ts +8 -2
  50. package/src/utils.ts +24 -20
package/src/router.ts CHANGED
@@ -398,7 +398,7 @@ export interface RouterOptions<
398
398
  *
399
399
  * @default ['window']
400
400
  */
401
- scrollToTopSelectors?: Array<string>
401
+ scrollToTopSelectors?: Array<string | (() => Element | null | undefined)>
402
402
  }
403
403
 
404
404
  export interface RouterState<
@@ -433,8 +433,9 @@ export interface BuildNextOptions {
433
433
  unmaskOnReload?: boolean
434
434
  }
435
435
  from?: string
436
- _fromLocation?: ParsedLocation
437
436
  href?: string
437
+ _fromLocation?: ParsedLocation
438
+ unsafeRelative?: 'path'
438
439
  }
439
440
 
440
441
  type NavigationEventInfo = {
@@ -521,11 +522,6 @@ export interface RouterErrorSerializer<TSerializedError> {
521
522
  deserialize: (err: TSerializedError) => unknown
522
523
  }
523
524
 
524
- export interface MatchedRoutesResult {
525
- matchedRoutes: Array<AnyRoute>
526
- routeParams: Record<string, string>
527
- }
528
-
529
525
  export type PreloadRouteFn<
530
526
  TRouteTree extends AnyRoute,
531
527
  TTrailingSlashOption extends TrailingSlashOption,
@@ -1071,9 +1067,9 @@ export class RouterCore<
1071
1067
  } as ParsedLocation,
1072
1068
  opts,
1073
1069
  )
1074
- } else {
1075
- return this.matchRoutesInternal(pathnameOrNext, locationSearchOrOpts)
1076
1070
  }
1071
+
1072
+ return this.matchRoutesInternal(pathnameOrNext, locationSearchOrOpts)
1077
1073
  }
1078
1074
 
1079
1075
  private matchRoutesInternal(
@@ -1334,7 +1330,8 @@ export class RouterCore<
1334
1330
  const route = this.looseRoutesById[match.routeId]!
1335
1331
  const existingMatch = this.getMatch(match.id)
1336
1332
 
1337
- // only execute `context` if we are not just building a location
1333
+ // only execute `context` if we are not calling from router.buildLocation
1334
+
1338
1335
  if (!existingMatch && opts?._buildLocation !== true) {
1339
1336
  const parentMatch = matches[index - 1]
1340
1337
  const parentContext = getParentContext(parentMatch)
@@ -1403,75 +1400,67 @@ export class RouterCore<
1403
1400
  dest: BuildNextOptions & {
1404
1401
  unmaskOnReload?: boolean
1405
1402
  } = {},
1406
- matchedRoutesResult?: MatchedRoutesResult,
1407
1403
  ): ParsedLocation => {
1408
- const fromMatches = dest._fromLocation
1409
- ? this.matchRoutes(dest._fromLocation, { _buildLocation: true })
1410
- : this.state.matches
1411
-
1412
- const fromMatch =
1413
- dest.from != null
1414
- ? fromMatches.find((d) =>
1415
- matchPathname(this.basepath, trimPathRight(d.pathname), {
1416
- to: dest.from,
1417
- caseSensitive: false,
1418
- fuzzy: false,
1419
- }),
1420
- )
1421
- : undefined
1422
-
1423
- const fromPath = fromMatch?.pathname || this.latestLocation.pathname
1404
+ // We allow the caller to override the current location
1405
+ const currentLocation = dest._fromLocation || this.latestLocation
1424
1406
 
1425
- invariant(
1426
- dest.from == null || fromMatch != null,
1427
- 'Could not find match for from: ' + dest.from,
1428
- )
1407
+ const allFromMatches = this.matchRoutes(currentLocation, {
1408
+ _buildLocation: true,
1409
+ })
1429
1410
 
1430
- const fromSearch = this.state.pendingMatches?.length
1431
- ? last(this.state.pendingMatches)?.search
1432
- : last(fromMatches)?.search || this.latestLocation.search
1411
+ const lastMatch = last(allFromMatches)!
1412
+
1413
+ // First let's find the starting pathname
1414
+ // By default, start with the current location
1415
+ let fromPath = lastMatch.fullPath
1416
+
1417
+ // If there is a to, it means we are changing the path in some way
1418
+ // So we need to find the relative fromPath
1419
+ if (dest.unsafeRelative === 'path') {
1420
+ fromPath = currentLocation.pathname
1421
+ } else if (dest.to && dest.from) {
1422
+ fromPath = dest.from
1423
+ const existingFrom = [...allFromMatches].reverse().find((d) => {
1424
+ return (
1425
+ d.fullPath === fromPath || d.fullPath === joinPaths([fromPath, '/'])
1426
+ )
1427
+ })
1433
1428
 
1434
- const stayingMatches = matchedRoutesResult?.matchedRoutes.filter((d) =>
1435
- fromMatches.find((e) => e.routeId === d.id),
1436
- )
1437
- let pathname: string
1438
- if (dest.to) {
1439
- const resolvePathTo =
1440
- fromMatch?.fullPath ||
1441
- last(fromMatches)?.fullPath ||
1442
- this.latestLocation.pathname
1443
- pathname = this.resolvePathWithBase(resolvePathTo, `${dest.to}`)
1444
- } else {
1445
- const fromRouteByFromPathRouteId =
1446
- this.routesById[
1447
- stayingMatches?.find((route) => {
1448
- const interpolatedPath = interpolatePath({
1449
- path: route.fullPath,
1450
- params: matchedRoutesResult?.routeParams ?? {},
1451
- decodeCharMap: this.pathParamsDecodeCharMap,
1452
- }).interpolatedPath
1453
- const pathname = joinPaths([this.basepath, interpolatedPath])
1454
- return pathname === fromPath
1455
- })?.id as keyof this['routesById']
1456
- ]
1457
- pathname = this.resolvePathWithBase(
1458
- fromPath,
1459
- fromRouteByFromPathRouteId?.to ?? fromPath,
1460
- )
1429
+ if (!existingFrom) {
1430
+ console.warn(`Could not find match for from: ${dest.from}`)
1431
+ }
1461
1432
  }
1462
1433
 
1463
- const prevParams = { ...last(fromMatches)?.params }
1434
+ // From search should always use the current location
1435
+ const fromSearch = lastMatch.search
1436
+ // Same with params. It can't hurt to provide as many as possible
1437
+ const fromParams = { ...lastMatch.params }
1438
+
1439
+ // Resolve the next to
1440
+ const nextTo = dest.to
1441
+ ? this.resolvePathWithBase(fromPath, `${dest.to}`)
1442
+ : fromPath
1464
1443
 
1444
+ // Resolve the next params
1465
1445
  let nextParams =
1466
1446
  (dest.params ?? true) === true
1467
- ? prevParams
1447
+ ? fromParams
1468
1448
  : {
1469
- ...prevParams,
1470
- ...functionalUpdate(dest.params as any, prevParams),
1449
+ ...fromParams,
1450
+ ...functionalUpdate(dest.params as any, fromParams),
1471
1451
  }
1472
1452
 
1453
+ const destRoutes = this.matchRoutes(
1454
+ nextTo,
1455
+ {},
1456
+ {
1457
+ _buildLocation: true,
1458
+ },
1459
+ ).map((d) => this.looseRoutesById[d.routeId]!)
1460
+
1461
+ // If there are any params, we need to stringify them
1473
1462
  if (Object.keys(nextParams).length > 0) {
1474
- matchedRoutesResult?.matchedRoutes
1463
+ destRoutes
1475
1464
  .map((route) => {
1476
1465
  return (
1477
1466
  route.options.params?.stringify ?? route.options.stringifyParams
@@ -1483,25 +1472,27 @@ export class RouterCore<
1483
1472
  })
1484
1473
  }
1485
1474
 
1486
- pathname = interpolatePath({
1487
- path: pathname,
1475
+ // Interpolate the next to into the next pathname
1476
+ const nextPathname = interpolatePath({
1477
+ path: nextTo,
1488
1478
  params: nextParams ?? {},
1489
1479
  leaveWildcards: false,
1490
1480
  leaveParams: opts.leaveParams,
1491
1481
  decodeCharMap: this.pathParamsDecodeCharMap,
1492
1482
  }).interpolatedPath
1493
1483
 
1494
- let search = fromSearch
1484
+ // Resolve the next search
1485
+ let nextSearch = fromSearch
1495
1486
  if (opts._includeValidateSearch && this.options.search?.strict) {
1496
1487
  let validatedSearch = {}
1497
- matchedRoutesResult?.matchedRoutes.forEach((route) => {
1488
+ destRoutes.forEach((route) => {
1498
1489
  try {
1499
1490
  if (route.options.validateSearch) {
1500
1491
  validatedSearch = {
1501
1492
  ...validatedSearch,
1502
1493
  ...(validateSearch(route.options.validateSearch, {
1503
1494
  ...validatedSearch,
1504
- ...search,
1495
+ ...nextSearch,
1505
1496
  }) ?? {}),
1506
1497
  }
1507
1498
  }
@@ -1509,137 +1500,52 @@ export class RouterCore<
1509
1500
  // ignore errors here because they are already handled in matchRoutes
1510
1501
  }
1511
1502
  })
1512
- search = validatedSearch
1503
+ nextSearch = validatedSearch
1513
1504
  }
1514
1505
 
1515
- const applyMiddlewares = (search: any) => {
1516
- const allMiddlewares =
1517
- matchedRoutesResult?.matchedRoutes.reduce(
1518
- (acc, route) => {
1519
- const middlewares: Array<SearchMiddleware<any>> = []
1520
- if ('search' in route.options) {
1521
- if (route.options.search?.middlewares) {
1522
- middlewares.push(...route.options.search.middlewares)
1523
- }
1524
- }
1525
- // TODO remove preSearchFilters and postSearchFilters in v2
1526
- else if (
1527
- route.options.preSearchFilters ||
1528
- route.options.postSearchFilters
1529
- ) {
1530
- const legacyMiddleware: SearchMiddleware<any> = ({
1531
- search,
1532
- next,
1533
- }) => {
1534
- let nextSearch = search
1535
- if (
1536
- 'preSearchFilters' in route.options &&
1537
- route.options.preSearchFilters
1538
- ) {
1539
- nextSearch = route.options.preSearchFilters.reduce(
1540
- (prev, next) => next(prev),
1541
- search,
1542
- )
1543
- }
1544
- const result = next(nextSearch)
1545
- if (
1546
- 'postSearchFilters' in route.options &&
1547
- route.options.postSearchFilters
1548
- ) {
1549
- return route.options.postSearchFilters.reduce(
1550
- (prev, next) => next(prev),
1551
- result,
1552
- )
1553
- }
1554
- return result
1555
- }
1556
- middlewares.push(legacyMiddleware)
1557
- }
1558
- if (opts._includeValidateSearch && route.options.validateSearch) {
1559
- const validate: SearchMiddleware<any> = ({ search, next }) => {
1560
- const result = next(search)
1561
- try {
1562
- const validatedSearch = {
1563
- ...result,
1564
- ...(validateSearch(
1565
- route.options.validateSearch,
1566
- result,
1567
- ) ?? {}),
1568
- }
1569
- return validatedSearch
1570
- } catch {
1571
- // ignore errors here because they are already handled in matchRoutes
1572
- return result
1573
- }
1574
- }
1575
- middlewares.push(validate)
1576
- }
1577
- return acc.concat(middlewares)
1578
- },
1579
- [] as Array<SearchMiddleware<any>>,
1580
- ) ?? []
1581
-
1582
- // the chain ends here since `next` is not called
1583
- const final: SearchMiddleware<any> = ({ search }) => {
1584
- if (!dest.search) {
1585
- return {}
1586
- }
1587
- if (dest.search === true) {
1588
- return search
1589
- }
1590
- return functionalUpdate(dest.search, search)
1591
- }
1592
- allMiddlewares.push(final)
1593
-
1594
- const applyNext = (index: number, currentSearch: any): any => {
1595
- // no more middlewares left, return the current search
1596
- if (index >= allMiddlewares.length) {
1597
- return currentSearch
1598
- }
1599
-
1600
- const middleware = allMiddlewares[index]!
1601
-
1602
- const next = (newSearch: any): any => {
1603
- return applyNext(index + 1, newSearch)
1604
- }
1605
-
1606
- return middleware({ search: currentSearch, next })
1607
- }
1608
-
1609
- // Start applying middlewares
1610
- return applyNext(0, search)
1611
- }
1506
+ nextSearch = applySearchMiddleware({
1507
+ search: nextSearch,
1508
+ dest,
1509
+ destRoutes,
1510
+ _includeValidateSearch: opts._includeValidateSearch,
1511
+ })
1612
1512
 
1613
- search = applyMiddlewares(search)
1513
+ // Replace the equal deep
1514
+ nextSearch = replaceEqualDeep(fromSearch, nextSearch)
1614
1515
 
1615
- search = replaceEqualDeep(fromSearch, search)
1616
- const searchStr = this.options.stringifySearch(search)
1516
+ // Stringify the next search
1517
+ const searchStr = this.options.stringifySearch(nextSearch)
1617
1518
 
1519
+ // Resolve the next hash
1618
1520
  const hash =
1619
1521
  dest.hash === true
1620
- ? this.latestLocation.hash
1522
+ ? currentLocation.hash
1621
1523
  : dest.hash
1622
- ? functionalUpdate(dest.hash, this.latestLocation.hash)
1524
+ ? functionalUpdate(dest.hash, currentLocation.hash)
1623
1525
  : undefined
1624
1526
 
1527
+ // Resolve the next hash string
1625
1528
  const hashStr = hash ? `#${hash}` : ''
1626
1529
 
1530
+ // Resolve the next state
1627
1531
  let nextState =
1628
1532
  dest.state === true
1629
- ? this.latestLocation.state
1533
+ ? currentLocation.state
1630
1534
  : dest.state
1631
- ? functionalUpdate(dest.state, this.latestLocation.state)
1535
+ ? functionalUpdate(dest.state, currentLocation.state)
1632
1536
  : {}
1633
1537
 
1634
- nextState = replaceEqualDeep(this.latestLocation.state, nextState)
1538
+ // Replace the equal deep
1539
+ nextState = replaceEqualDeep(currentLocation.state, nextState)
1635
1540
 
1541
+ // Return the next location
1636
1542
  return {
1637
- pathname,
1638
- search,
1543
+ pathname: nextPathname,
1544
+ search: nextSearch,
1639
1545
  searchStr,
1640
1546
  state: nextState as any,
1641
1547
  hash: hash ?? '',
1642
- href: `${pathname}${searchStr}${hashStr}`,
1548
+ href: `${nextPathname}${searchStr}${hashStr}`,
1643
1549
  unmaskOnReload: dest.unmaskOnReload,
1644
1550
  }
1645
1551
  }
@@ -1649,6 +1555,7 @@ export class RouterCore<
1649
1555
  maskedDest?: BuildNextOptions,
1650
1556
  ) => {
1651
1557
  const next = build(dest)
1558
+
1652
1559
  let maskedNext = maskedDest ? build(maskedDest) : undefined
1653
1560
 
1654
1561
  if (!maskedNext) {
@@ -1680,22 +1587,12 @@ export class RouterCore<
1680
1587
  }
1681
1588
  }
1682
1589
 
1683
- const nextMatches = this.getMatchedRoutes(
1684
- next.pathname,
1685
- dest.to as string,
1686
- )
1687
- const final = build(dest, nextMatches)
1688
-
1689
1590
  if (maskedNext) {
1690
- const maskedMatches = this.getMatchedRoutes(
1691
- maskedNext.pathname,
1692
- maskedDest?.to as string,
1693
- )
1694
- const maskedFinal = build(maskedDest, maskedMatches)
1695
- final.maskedLocation = maskedFinal
1591
+ const maskedFinal = build(maskedDest)
1592
+ next.maskedLocation = maskedFinal
1696
1593
  }
1697
1594
 
1698
- return final
1595
+ return next
1699
1596
  }
1700
1597
 
1701
1598
  if (opts.mask) {
@@ -2725,6 +2622,7 @@ export class RouterCore<
2725
2622
  pendingMatches: s.pendingMatches?.map(invalidate),
2726
2623
  }))
2727
2624
 
2625
+ this.shouldViewTransition = false
2728
2626
  return this.load({ sync: opts?.sync })
2729
2627
  }
2730
2628
 
@@ -2873,6 +2771,7 @@ export class RouterCore<
2873
2771
  if (err.options.reloadDocument) {
2874
2772
  return undefined
2875
2773
  }
2774
+
2876
2775
  return await this.preloadRoute({
2877
2776
  ...err.options,
2878
2777
  _fromLocation: next,
@@ -3329,3 +3228,117 @@ export function getMatchedRoutes<TRouteLike extends RouteLike>({
3329
3228
 
3330
3229
  return { matchedRoutes, routeParams, foundRoute }
3331
3230
  }
3231
+
3232
+ function applySearchMiddleware({
3233
+ search,
3234
+ dest,
3235
+ destRoutes,
3236
+ _includeValidateSearch,
3237
+ }: {
3238
+ search: any
3239
+ dest: BuildNextOptions
3240
+ destRoutes: Array<AnyRoute>
3241
+ _includeValidateSearch: boolean | undefined
3242
+ }) {
3243
+ const allMiddlewares =
3244
+ destRoutes.reduce(
3245
+ (acc, route) => {
3246
+ const middlewares: Array<SearchMiddleware<any>> = []
3247
+
3248
+ if ('search' in route.options) {
3249
+ if (route.options.search?.middlewares) {
3250
+ middlewares.push(...route.options.search.middlewares)
3251
+ }
3252
+ }
3253
+ // TODO remove preSearchFilters and postSearchFilters in v2
3254
+ else if (
3255
+ route.options.preSearchFilters ||
3256
+ route.options.postSearchFilters
3257
+ ) {
3258
+ const legacyMiddleware: SearchMiddleware<any> = ({
3259
+ search,
3260
+ next,
3261
+ }) => {
3262
+ let nextSearch = search
3263
+
3264
+ if (
3265
+ 'preSearchFilters' in route.options &&
3266
+ route.options.preSearchFilters
3267
+ ) {
3268
+ nextSearch = route.options.preSearchFilters.reduce(
3269
+ (prev, next) => next(prev),
3270
+ search,
3271
+ )
3272
+ }
3273
+
3274
+ const result = next(nextSearch)
3275
+
3276
+ if (
3277
+ 'postSearchFilters' in route.options &&
3278
+ route.options.postSearchFilters
3279
+ ) {
3280
+ return route.options.postSearchFilters.reduce(
3281
+ (prev, next) => next(prev),
3282
+ result,
3283
+ )
3284
+ }
3285
+
3286
+ return result
3287
+ }
3288
+ middlewares.push(legacyMiddleware)
3289
+ }
3290
+
3291
+ if (_includeValidateSearch && route.options.validateSearch) {
3292
+ const validate: SearchMiddleware<any> = ({ search, next }) => {
3293
+ const result = next(search)
3294
+ try {
3295
+ const validatedSearch = {
3296
+ ...result,
3297
+ ...(validateSearch(route.options.validateSearch, result) ?? {}),
3298
+ }
3299
+ return validatedSearch
3300
+ } catch {
3301
+ // ignore errors here because they are already handled in matchRoutes
3302
+ return result
3303
+ }
3304
+ }
3305
+
3306
+ middlewares.push(validate)
3307
+ }
3308
+
3309
+ return acc.concat(middlewares)
3310
+ },
3311
+ [] as Array<SearchMiddleware<any>>,
3312
+ ) ?? []
3313
+
3314
+ // the chain ends here since `next` is not called
3315
+ const final: SearchMiddleware<any> = ({ search }) => {
3316
+ if (!dest.search) {
3317
+ return {}
3318
+ }
3319
+ if (dest.search === true) {
3320
+ return search
3321
+ }
3322
+ return functionalUpdate(dest.search, search)
3323
+ }
3324
+
3325
+ allMiddlewares.push(final)
3326
+
3327
+ const applyNext = (index: number, currentSearch: any): any => {
3328
+ // no more middlewares left, return the current search
3329
+ if (index >= allMiddlewares.length) {
3330
+ return currentSearch
3331
+ }
3332
+
3333
+ const middleware = allMiddlewares[index]!
3334
+
3335
+ const next = (newSearch: any): any => {
3336
+ return applyNext(index + 1, newSearch)
3337
+ }
3338
+
3339
+ return middleware({ search: currentSearch, next })
3340
+ }
3341
+
3342
+ // Start applying middlewares
3343
+ return applyNext(0, search)
3344
+ }
@@ -105,7 +105,9 @@ export function restoreScroll(
105
105
  key: string | undefined,
106
106
  behavior: ScrollToOptions['behavior'] | undefined,
107
107
  shouldScrollRestoration: boolean | undefined,
108
- scrollToTopSelectors: Array<string> | undefined,
108
+ scrollToTopSelectors:
109
+ | Array<string | (() => Element | null | undefined)>
110
+ | undefined,
109
111
  ) {
110
112
  let byKey: ScrollRestorationByKey
111
113
 
@@ -174,7 +176,11 @@ export function restoreScroll(
174
176
  ...(scrollToTopSelectors?.filter((d) => d !== 'window') ?? []),
175
177
  ].forEach((selector) => {
176
178
  const element =
177
- selector === 'window' ? window : document.querySelector(selector)
179
+ selector === 'window'
180
+ ? window
181
+ : typeof selector === 'function'
182
+ ? selector()
183
+ : document.querySelector(selector)
178
184
  if (element) {
179
185
  element.scrollTo({
180
186
  top: 0,
package/src/utils.ts CHANGED
@@ -228,10 +228,18 @@ export function replaceEqualDeep<T>(prev: any, _next: T): T {
228
228
 
229
229
  const array = isPlainArray(prev) && isPlainArray(next)
230
230
 
231
- if (array || (isPlainObject(prev) && isPlainObject(next))) {
232
- const prevItems = array ? prev : Object.keys(prev)
231
+ if (array || (isSimplePlainObject(prev) && isSimplePlainObject(next))) {
232
+ const prevItems = array
233
+ ? prev
234
+ : (Object.keys(prev) as Array<unknown>).concat(
235
+ Object.getOwnPropertySymbols(prev),
236
+ )
233
237
  const prevSize = prevItems.length
234
- const nextItems = array ? next : Object.keys(next)
238
+ const nextItems = array
239
+ ? next
240
+ : (Object.keys(next) as Array<unknown>).concat(
241
+ Object.getOwnPropertySymbols(next),
242
+ )
235
243
  const nextSize = nextItems.length
236
244
  const copy: any = array ? [] : {}
237
245
 
@@ -260,6 +268,19 @@ export function replaceEqualDeep<T>(prev: any, _next: T): T {
260
268
  return next
261
269
  }
262
270
 
271
+ /**
272
+ * A wrapper around `isPlainObject` with additional checks to ensure that it is not
273
+ * only a plain object, but also one that is "clone-friendly" (doesn't have any
274
+ * non-enumerable properties).
275
+ */
276
+ function isSimplePlainObject(o: any) {
277
+ return (
278
+ // all the checks from isPlainObject are more likely to hit so we perform them first
279
+ isPlainObject(o) &&
280
+ Object.getOwnPropertyNames(o).length === Object.keys(o).length
281
+ )
282
+ }
283
+
263
284
  // Copied from: https://github.com/jonschlinkert/is-plain-object
264
285
  export function isPlainObject(o: any) {
265
286
  if (!hasObjectPrototype(o)) {
@@ -440,20 +461,3 @@ export function shallow<T>(objA: T, objB: T) {
440
461
  }
441
462
  return true
442
463
  }
443
-
444
- /**
445
- * Checks if a string contains URI-encoded special characters (e.g., %3F, %20).
446
- *
447
- * @param {string} inputString The string to check.
448
- * @returns {boolean} True if the string contains URI-encoded characters, false otherwise.
449
- * @example
450
- * ```typescript
451
- * const str1 = "foo%3Fbar";
452
- * const hasEncodedChars = hasUriEncodedChars(str1); // returns true
453
- * ```
454
- */
455
- export function hasUriEncodedChars(inputString: string): boolean {
456
- // This regex looks for a percent sign followed by two hexadecimal digits
457
- const pattern = /%[0-9A-Fa-f]{2}/
458
- return pattern.test(inputString)
459
- }