@tanstack/router-core 1.114.24 → 1.114.29

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.
package/src/router.ts CHANGED
@@ -1,19 +1,62 @@
1
- import type { ParsedLocation } from './location'
2
- import type { DeferredPromiseState } from './defer'
1
+ import { Store, batch } from '@tanstack/store'
2
+ import {
3
+ createBrowserHistory,
4
+ createMemoryHistory,
5
+ parseHref,
6
+ } from '@tanstack/history'
7
+ import invariant from 'tiny-invariant'
8
+ import {
9
+ createControlledPromise,
10
+ deepEqual,
11
+ functionalUpdate,
12
+ last,
13
+ pick,
14
+ replaceEqualDeep,
15
+ } from './utils'
16
+ import {
17
+ cleanPath,
18
+ interpolatePath,
19
+ joinPaths,
20
+ matchPathname,
21
+ parsePathname,
22
+ resolvePath,
23
+ trimPath,
24
+ trimPathLeft,
25
+ trimPathRight,
26
+ } from './path'
27
+ import { isNotFound } from './not-found'
28
+ import { setupScrollRestoration } from './scroll-restoration'
29
+ import { defaultParseSearch, defaultStringifySearch } from './searchParams'
30
+ import { rootRouteId } from './root'
31
+ import { isRedirect, isResolvedRedirect } from './redirect'
32
+ import type { SearchParser, SearchSerializer } from './searchParams'
33
+ import type { AnyRedirect, ResolvedRedirect } from './redirect'
34
+ import type {
35
+ HistoryLocation,
36
+ HistoryState,
37
+ ParsedHistoryState,
38
+ RouterHistory,
39
+ } from '@tanstack/history'
3
40
  import type {
4
41
  ControlledPromise,
5
42
  NoInfer,
6
43
  NonNullableUpdater,
44
+ PickAsRequired,
7
45
  Updater,
8
46
  } from './utils'
47
+ import type { ParsedLocation } from './location'
48
+ import type { DeferredPromiseState } from './defer'
9
49
  import type {
10
50
  AnyContext,
11
51
  AnyRoute,
12
52
  AnyRouteWithContext,
53
+ BeforeLoadContextOptions,
54
+ LoaderFnContext,
13
55
  MakeRemountDepsOptionsUnion,
56
+ RouteContextOptions,
14
57
  RouteMask,
58
+ SearchMiddleware,
15
59
  } from './route'
16
- import type { Store } from '@tanstack/store'
17
60
  import type {
18
61
  FullSearchSchema,
19
62
  RouteById,
@@ -23,26 +66,20 @@ import type {
23
66
  } from './routeInfo'
24
67
  import type {
25
68
  AnyRouteMatch,
69
+ MakeRouteMatch,
26
70
  MakeRouteMatchUnion,
27
71
  MatchRouteOptions,
28
72
  } from './Matches'
29
- import type { AnyRedirect, ResolvedRedirect } from './redirect'
30
73
  import type {
31
74
  BuildLocationFn,
32
75
  CommitLocationOptions,
33
76
  NavigateFn,
34
77
  } from './RouterProvider'
35
- import type {
36
- HistoryLocation,
37
- HistoryState,
38
- ParsedHistoryState,
39
- RouterHistory,
40
- } from '@tanstack/history'
41
78
  import type { Manifest } from './manifest'
42
79
  import type { StartSerializer } from './serializer'
43
- import type { AnySchema } from './validators'
80
+ import type { AnySchema, AnyValidator } from './validators'
44
81
  import type { NavigateOptions, ResolveRelativePath, ToOptions } from './link'
45
- import type { SearchParser, SearchSerializer } from './searchParams'
82
+ import type { NotFoundError } from './not-found'
46
83
 
47
84
  declare global {
48
85
  interface Window {
@@ -493,7 +530,7 @@ export type PreloadRouteFn<
493
530
  TMaskTo extends string = '',
494
531
  >(
495
532
  opts: NavigateOptions<
496
- Router<
533
+ RouterCore<
497
534
  TRouteTree,
498
535
  TTrailingSlashOption,
499
536
  TDefaultStructuralSharingOption,
@@ -517,7 +554,7 @@ export type MatchRouteFn<
517
554
  TResolved = ResolveRelativePath<TFrom, NoInfer<TTo>>,
518
555
  >(
519
556
  location: ToOptions<
520
- Router<
557
+ RouterCore<
521
558
  TRouteTree,
522
559
  TTrailingSlashOption,
523
560
  TDefaultStructuralSharingOption,
@@ -622,97 +659,7 @@ export interface ServerSrr {
622
659
  onMatchSettled: (opts: { router: AnyRouter; match: AnyRouteMatch }) => any
623
660
  }
624
661
 
625
- export interface Router<
626
- in out TRouteTree extends AnyRoute,
627
- in out TTrailingSlashOption extends TrailingSlashOption,
628
- in out TDefaultStructuralSharingOption extends boolean,
629
- in out TRouterHistory extends RouterHistory = RouterHistory,
630
- in out TDehydrated extends Record<string, any> = Record<string, any>,
631
- > {
632
- routeTree: TRouteTree
633
- options: RouterOptions<
634
- TRouteTree,
635
- TTrailingSlashOption,
636
- TDefaultStructuralSharingOption,
637
- TRouterHistory,
638
- TDehydrated
639
- >
640
- __store: Store<RouterState<TRouteTree>>
641
- navigate: NavigateFn
642
- history: TRouterHistory
643
- state: RouterState<TRouteTree>
644
- isServer: boolean
645
- clientSsr?: {
646
- getStreamedValue: <T>(key: string) => T | undefined
647
- }
648
- looseRoutesById: Record<string, AnyRoute>
649
- latestLocation: ParsedLocation<FullSearchSchema<TRouteTree>>
650
- isScrollRestoring: boolean
651
- resetNextScroll: boolean
652
- isScrollRestorationSetup: boolean
653
- ssr?: {
654
- manifest: Manifest | undefined
655
- serializer: StartSerializer
656
- }
657
- serverSsr?: ServerSrr
658
- basepath: string
659
- routesById: RoutesById<TRouteTree>
660
- routesByPath: RoutesByPath<TRouteTree>
661
- flatRoutes: Array<AnyRoute>
662
- parseLocation: ParseLocationFn<TRouteTree>
663
- getMatchedRoutes: GetMatchRoutesFn
664
- emit: EmitFn
665
- load: LoadFn
666
- commitLocation: CommitLocationFn
667
- buildLocation: BuildLocationFn
668
- startTransition: StartTransitionFn
669
- subscribe: SubscribeFn
670
- matchRoutes: MatchRoutesFn
671
- preloadRoute: PreloadRouteFn<
672
- TRouteTree,
673
- TTrailingSlashOption,
674
- TDefaultStructuralSharingOption,
675
- TRouterHistory
676
- >
677
- getMatch: GetMatchFn
678
- updateMatch: UpdateMatchFn
679
- matchRoute: MatchRouteFn<
680
- TRouteTree,
681
- TTrailingSlashOption,
682
- TDefaultStructuralSharingOption,
683
- TRouterHistory
684
- >
685
- update: UpdateFn<
686
- TRouteTree,
687
- TTrailingSlashOption,
688
- TDefaultStructuralSharingOption,
689
- TRouterHistory,
690
- TDehydrated
691
- >
692
- invalidate: InvalidateFn<
693
- Router<
694
- TRouteTree,
695
- TTrailingSlashOption,
696
- TDefaultStructuralSharingOption,
697
- TRouterHistory,
698
- TDehydrated
699
- >
700
- >
701
- loadRouteChunk: LoadRouteChunkFn
702
- resolveRedirect: ResolveRedirect
703
- buildRouteTree: () => void
704
- clearCache: ClearCacheFn<
705
- Router<
706
- TRouteTree,
707
- TTrailingSlashOption,
708
- TDefaultStructuralSharingOption,
709
- TRouterHistory,
710
- TDehydrated
711
- >
712
- >
713
- }
714
-
715
- export type AnyRouterWithContext<TContext> = Router<
662
+ export type AnyRouterWithContext<TContext> = RouterCore<
716
663
  AnyRouteWithContext<TContext>,
717
664
  any,
718
665
  any,
@@ -720,7 +667,7 @@ export type AnyRouterWithContext<TContext> = Router<
720
667
  any
721
668
  >
722
669
 
723
- export type AnyRouter = Router<any, any, any, any, any>
670
+ export type AnyRouter = RouterCore<any, any, any, any, any>
724
671
 
725
672
  export interface ViewTransitionOptions {
726
673
  types: Array<string>
@@ -781,3 +728,2461 @@ export function getLocationChangeInfo(routerState: {
781
728
  const hashChanged = fromLocation?.hash !== toLocation.hash
782
729
  return { fromLocation, toLocation, pathChanged, hrefChanged, hashChanged }
783
730
  }
731
+
732
+ export type CreateRouterFn = <
733
+ TRouteTree extends AnyRoute,
734
+ TTrailingSlashOption extends TrailingSlashOption = 'never',
735
+ TDefaultStructuralSharingOption extends boolean = false,
736
+ TRouterHistory extends RouterHistory = RouterHistory,
737
+ TDehydrated extends Record<string, any> = Record<string, any>,
738
+ >(
739
+ options: undefined extends number
740
+ ? 'strictNullChecks must be enabled in tsconfig.json'
741
+ : RouterConstructorOptions<
742
+ TRouteTree,
743
+ TTrailingSlashOption,
744
+ TDefaultStructuralSharingOption,
745
+ TRouterHistory,
746
+ TDehydrated
747
+ >,
748
+ ) => RouterCore<
749
+ TRouteTree,
750
+ TTrailingSlashOption,
751
+ TDefaultStructuralSharingOption,
752
+ TRouterHistory,
753
+ TDehydrated
754
+ >
755
+
756
+ export class RouterCore<
757
+ in out TRouteTree extends AnyRoute,
758
+ in out TTrailingSlashOption extends TrailingSlashOption,
759
+ in out TDefaultStructuralSharingOption extends boolean,
760
+ in out TRouterHistory extends RouterHistory = RouterHistory,
761
+ in out TDehydrated extends Record<string, any> = Record<string, any>,
762
+ > {
763
+ // Option-independent properties
764
+ tempLocationKey: string | undefined = `${Math.round(
765
+ Math.random() * 10000000,
766
+ )}`
767
+ resetNextScroll = true
768
+ shouldViewTransition?: boolean | ViewTransitionOptions = undefined
769
+ isViewTransitionTypesSupported?: boolean = undefined
770
+ subscribers = new Set<RouterListener<RouterEvent>>()
771
+ viewTransitionPromise?: ControlledPromise<true>
772
+ isScrollRestoring = false
773
+ isScrollRestorationSetup = false
774
+
775
+ // Must build in constructor
776
+ __store!: Store<RouterState<TRouteTree>>
777
+ options!: PickAsRequired<
778
+ RouterOptions<
779
+ TRouteTree,
780
+ TTrailingSlashOption,
781
+ TDefaultStructuralSharingOption,
782
+ TRouterHistory,
783
+ TDehydrated
784
+ >,
785
+ 'stringifySearch' | 'parseSearch' | 'context'
786
+ >
787
+ history!: TRouterHistory
788
+ latestLocation!: ParsedLocation<FullSearchSchema<TRouteTree>>
789
+ basepath!: string
790
+ routeTree!: TRouteTree
791
+ routesById!: RoutesById<TRouteTree>
792
+ routesByPath!: RoutesByPath<TRouteTree>
793
+ flatRoutes!: Array<AnyRoute>
794
+ isServer!: boolean
795
+ pathParamsDecodeCharMap?: Map<string, string>
796
+
797
+ /**
798
+ * @deprecated Use the `createRouter` function instead
799
+ */
800
+ constructor(
801
+ options: RouterConstructorOptions<
802
+ TRouteTree,
803
+ TTrailingSlashOption,
804
+ TDefaultStructuralSharingOption,
805
+ TRouterHistory,
806
+ TDehydrated
807
+ >,
808
+ ) {
809
+ this.update({
810
+ defaultPreloadDelay: 50,
811
+ defaultPendingMs: 1000,
812
+ defaultPendingMinMs: 500,
813
+ context: undefined!,
814
+ ...options,
815
+ caseSensitive: options.caseSensitive ?? false,
816
+ notFoundMode: options.notFoundMode ?? 'fuzzy',
817
+ stringifySearch: options.stringifySearch ?? defaultStringifySearch,
818
+ parseSearch: options.parseSearch ?? defaultParseSearch,
819
+ })
820
+
821
+ if (typeof document !== 'undefined') {
822
+ ;(window as any).__TSR_ROUTER__ = this
823
+ }
824
+ }
825
+
826
+ // These are default implementations that can optionally be overridden
827
+ // by the router provider once rendered. We provide these so that the
828
+ // router can be used in a non-react environment if necessary
829
+ startTransition: StartTransitionFn = (fn) => fn()
830
+
831
+ update: UpdateFn<
832
+ TRouteTree,
833
+ TTrailingSlashOption,
834
+ TDefaultStructuralSharingOption,
835
+ TRouterHistory,
836
+ TDehydrated
837
+ > = (newOptions) => {
838
+ if (newOptions.notFoundRoute) {
839
+ console.warn(
840
+ 'The notFoundRoute API is deprecated and will be removed in the next major version. See https://tanstack.com/router/v1/docs/framework/react/guide/not-found-errors#migrating-from-notfoundroute for more info.',
841
+ )
842
+ }
843
+
844
+ const previousOptions = this.options
845
+ this.options = {
846
+ ...this.options,
847
+ ...newOptions,
848
+ }
849
+
850
+ this.isServer = this.options.isServer ?? typeof document === 'undefined'
851
+
852
+ this.pathParamsDecodeCharMap = this.options.pathParamsAllowedCharacters
853
+ ? new Map(
854
+ this.options.pathParamsAllowedCharacters.map((char) => [
855
+ encodeURIComponent(char),
856
+ char,
857
+ ]),
858
+ )
859
+ : undefined
860
+
861
+ if (
862
+ !this.basepath ||
863
+ (newOptions.basepath && newOptions.basepath !== previousOptions.basepath)
864
+ ) {
865
+ if (
866
+ newOptions.basepath === undefined ||
867
+ newOptions.basepath === '' ||
868
+ newOptions.basepath === '/'
869
+ ) {
870
+ this.basepath = '/'
871
+ } else {
872
+ this.basepath = `/${trimPath(newOptions.basepath)}`
873
+ }
874
+ }
875
+
876
+ if (
877
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
878
+ !this.history ||
879
+ (this.options.history && this.options.history !== this.history)
880
+ ) {
881
+ this.history =
882
+ this.options.history ??
883
+ ((this.isServer
884
+ ? createMemoryHistory({
885
+ initialEntries: [this.basepath || '/'],
886
+ })
887
+ : createBrowserHistory()) as TRouterHistory)
888
+ this.latestLocation = this.parseLocation()
889
+ }
890
+
891
+ if (this.options.routeTree !== this.routeTree) {
892
+ this.routeTree = this.options.routeTree as TRouteTree
893
+ this.buildRouteTree()
894
+ }
895
+
896
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
897
+ if (!this.__store) {
898
+ this.__store = new Store(getInitialRouterState(this.latestLocation), {
899
+ onUpdate: () => {
900
+ this.__store.state = {
901
+ ...this.state,
902
+ cachedMatches: this.state.cachedMatches.filter(
903
+ (d) => !['redirected'].includes(d.status),
904
+ ),
905
+ }
906
+ },
907
+ })
908
+
909
+ setupScrollRestoration(this)
910
+ }
911
+
912
+ if (
913
+ typeof window !== 'undefined' &&
914
+ 'CSS' in window &&
915
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
916
+ typeof window.CSS?.supports === 'function'
917
+ ) {
918
+ this.isViewTransitionTypesSupported = window.CSS.supports(
919
+ 'selector(:active-view-transition-type(a)',
920
+ )
921
+ }
922
+ }
923
+
924
+ get state() {
925
+ return this.__store.state
926
+ }
927
+
928
+ buildRouteTree = () => {
929
+ this.routesById = {} as RoutesById<TRouteTree>
930
+ this.routesByPath = {} as RoutesByPath<TRouteTree>
931
+
932
+ const notFoundRoute = this.options.notFoundRoute
933
+ if (notFoundRoute) {
934
+ notFoundRoute.init({
935
+ originalIndex: 99999999999,
936
+ defaultSsr: this.options.defaultSsr,
937
+ })
938
+ ;(this.routesById as any)[notFoundRoute.id] = notFoundRoute
939
+ }
940
+
941
+ const recurseRoutes = (childRoutes: Array<AnyRoute>) => {
942
+ childRoutes.forEach((childRoute, i) => {
943
+ childRoute.init({
944
+ originalIndex: i,
945
+ defaultSsr: this.options.defaultSsr,
946
+ })
947
+
948
+ const existingRoute = (this.routesById as any)[childRoute.id]
949
+
950
+ invariant(
951
+ !existingRoute,
952
+ `Duplicate routes found with id: ${String(childRoute.id)}`,
953
+ )
954
+ ;(this.routesById as any)[childRoute.id] = childRoute
955
+
956
+ if (!childRoute.isRoot && childRoute.path) {
957
+ const trimmedFullPath = trimPathRight(childRoute.fullPath)
958
+ if (
959
+ !(this.routesByPath as any)[trimmedFullPath] ||
960
+ childRoute.fullPath.endsWith('/')
961
+ ) {
962
+ ;(this.routesByPath as any)[trimmedFullPath] = childRoute
963
+ }
964
+ }
965
+
966
+ const children = childRoute.children
967
+
968
+ if (children?.length) {
969
+ recurseRoutes(children)
970
+ }
971
+ })
972
+ }
973
+
974
+ recurseRoutes([this.routeTree])
975
+
976
+ const scoredRoutes: Array<{
977
+ child: AnyRoute
978
+ trimmed: string
979
+ parsed: ReturnType<typeof parsePathname>
980
+ index: number
981
+ scores: Array<number>
982
+ }> = []
983
+
984
+ const routes: Array<AnyRoute> = Object.values(this.routesById)
985
+
986
+ routes.forEach((d, i) => {
987
+ if (d.isRoot || !d.path) {
988
+ return
989
+ }
990
+
991
+ const trimmed = trimPathLeft(d.fullPath)
992
+ const parsed = parsePathname(trimmed)
993
+
994
+ while (parsed.length > 1 && parsed[0]?.value === '/') {
995
+ parsed.shift()
996
+ }
997
+
998
+ const scores = parsed.map((segment) => {
999
+ if (segment.value === '/') {
1000
+ return 0.75
1001
+ }
1002
+
1003
+ if (segment.type === 'param') {
1004
+ return 0.5
1005
+ }
1006
+
1007
+ if (segment.type === 'wildcard') {
1008
+ return 0.25
1009
+ }
1010
+
1011
+ return 1
1012
+ })
1013
+
1014
+ scoredRoutes.push({ child: d, trimmed, parsed, index: i, scores })
1015
+ })
1016
+
1017
+ this.flatRoutes = scoredRoutes
1018
+ .sort((a, b) => {
1019
+ const minLength = Math.min(a.scores.length, b.scores.length)
1020
+
1021
+ // Sort by min available score
1022
+ for (let i = 0; i < minLength; i++) {
1023
+ if (a.scores[i] !== b.scores[i]) {
1024
+ return b.scores[i]! - a.scores[i]!
1025
+ }
1026
+ }
1027
+
1028
+ // Sort by length of score
1029
+ if (a.scores.length !== b.scores.length) {
1030
+ return b.scores.length - a.scores.length
1031
+ }
1032
+
1033
+ // Sort by min available parsed value
1034
+ for (let i = 0; i < minLength; i++) {
1035
+ if (a.parsed[i]!.value !== b.parsed[i]!.value) {
1036
+ return a.parsed[i]!.value > b.parsed[i]!.value ? 1 : -1
1037
+ }
1038
+ }
1039
+
1040
+ // Sort by original index
1041
+ return a.index - b.index
1042
+ })
1043
+ .map((d, i) => {
1044
+ d.child.rank = i
1045
+ return d.child
1046
+ })
1047
+ }
1048
+
1049
+ subscribe: SubscribeFn = (eventType, fn) => {
1050
+ const listener: RouterListener<any> = {
1051
+ eventType,
1052
+ fn,
1053
+ }
1054
+
1055
+ this.subscribers.add(listener)
1056
+
1057
+ return () => {
1058
+ this.subscribers.delete(listener)
1059
+ }
1060
+ }
1061
+
1062
+ emit: EmitFn = (routerEvent) => {
1063
+ this.subscribers.forEach((listener) => {
1064
+ if (listener.eventType === routerEvent.type) {
1065
+ listener.fn(routerEvent)
1066
+ }
1067
+ })
1068
+ }
1069
+
1070
+ parseLocation: ParseLocationFn<TRouteTree> = (
1071
+ previousLocation,
1072
+ locationToParse,
1073
+ ) => {
1074
+ const parse = ({
1075
+ pathname,
1076
+ search,
1077
+ hash,
1078
+ state,
1079
+ }: HistoryLocation): ParsedLocation<FullSearchSchema<TRouteTree>> => {
1080
+ const parsedSearch = this.options.parseSearch(search)
1081
+ const searchStr = this.options.stringifySearch(parsedSearch)
1082
+
1083
+ return {
1084
+ pathname,
1085
+ searchStr,
1086
+ search: replaceEqualDeep(previousLocation?.search, parsedSearch) as any,
1087
+ hash: hash.split('#').reverse()[0] ?? '',
1088
+ href: `${pathname}${searchStr}${hash}`,
1089
+ state: replaceEqualDeep(previousLocation?.state, state),
1090
+ }
1091
+ }
1092
+
1093
+ const location = parse(locationToParse ?? this.history.location)
1094
+
1095
+ const { __tempLocation, __tempKey } = location.state
1096
+
1097
+ if (__tempLocation && (!__tempKey || __tempKey === this.tempLocationKey)) {
1098
+ // Sync up the location keys
1099
+ const parsedTempLocation = parse(__tempLocation) as any
1100
+ parsedTempLocation.state.key = location.state.key
1101
+
1102
+ delete parsedTempLocation.state.__tempLocation
1103
+
1104
+ return {
1105
+ ...parsedTempLocation,
1106
+ maskedLocation: location,
1107
+ }
1108
+ }
1109
+
1110
+ return location
1111
+ }
1112
+
1113
+ resolvePathWithBase = (from: string, path: string) => {
1114
+ const resolvedPath = resolvePath({
1115
+ basepath: this.basepath,
1116
+ base: from,
1117
+ to: cleanPath(path),
1118
+ trailingSlash: this.options.trailingSlash,
1119
+ caseSensitive: this.options.caseSensitive,
1120
+ })
1121
+ return resolvedPath
1122
+ }
1123
+
1124
+ get looseRoutesById() {
1125
+ return this.routesById as Record<string, AnyRoute>
1126
+ }
1127
+
1128
+ /**
1129
+ @deprecated use the following signature instead
1130
+ ```ts
1131
+ matchRoutes (
1132
+ next: ParsedLocation,
1133
+ opts?: { preload?: boolean; throwOnError?: boolean },
1134
+ ): Array<AnyRouteMatch>;
1135
+ ```
1136
+ */
1137
+ matchRoutes: MatchRoutesFn = (
1138
+ pathnameOrNext: string | ParsedLocation,
1139
+ locationSearchOrOpts?: AnySchema | MatchRoutesOpts,
1140
+ opts?: MatchRoutesOpts,
1141
+ ) => {
1142
+ if (typeof pathnameOrNext === 'string') {
1143
+ return this.matchRoutesInternal(
1144
+ {
1145
+ pathname: pathnameOrNext,
1146
+ search: locationSearchOrOpts,
1147
+ } as ParsedLocation,
1148
+ opts,
1149
+ )
1150
+ } else {
1151
+ return this.matchRoutesInternal(pathnameOrNext, locationSearchOrOpts)
1152
+ }
1153
+ }
1154
+
1155
+ private matchRoutesInternal(
1156
+ next: ParsedLocation,
1157
+ opts?: MatchRoutesOpts,
1158
+ ): Array<AnyRouteMatch> {
1159
+ const { foundRoute, matchedRoutes, routeParams } = this.getMatchedRoutes(
1160
+ next,
1161
+ opts?.dest,
1162
+ )
1163
+ let isGlobalNotFound = false
1164
+
1165
+ // Check to see if the route needs a 404 entry
1166
+ if (
1167
+ // If we found a route, and it's not an index route and we have left over path
1168
+ foundRoute
1169
+ ? foundRoute.path !== '/' && routeParams['**']
1170
+ : // Or if we didn't find a route and we have left over path
1171
+ trimPathRight(next.pathname)
1172
+ ) {
1173
+ // If the user has defined an (old) 404 route, use it
1174
+ if (this.options.notFoundRoute) {
1175
+ matchedRoutes.push(this.options.notFoundRoute)
1176
+ } else {
1177
+ // If there is no routes found during path matching
1178
+ isGlobalNotFound = true
1179
+ }
1180
+ }
1181
+
1182
+ const globalNotFoundRouteId = (() => {
1183
+ if (!isGlobalNotFound) {
1184
+ return undefined
1185
+ }
1186
+
1187
+ if (this.options.notFoundMode !== 'root') {
1188
+ for (let i = matchedRoutes.length - 1; i >= 0; i--) {
1189
+ const route = matchedRoutes[i]!
1190
+ if (route.children) {
1191
+ return route.id
1192
+ }
1193
+ }
1194
+ }
1195
+
1196
+ return rootRouteId
1197
+ })()
1198
+
1199
+ const parseErrors = matchedRoutes.map((route) => {
1200
+ let parsedParamsError
1201
+
1202
+ const parseParams =
1203
+ route.options.params?.parse ?? route.options.parseParams
1204
+
1205
+ if (parseParams) {
1206
+ try {
1207
+ const parsedParams = parseParams(routeParams)
1208
+ // Add the parsed params to the accumulated params bag
1209
+ Object.assign(routeParams, parsedParams)
1210
+ } catch (err: any) {
1211
+ parsedParamsError = new PathParamError(err.message, {
1212
+ cause: err,
1213
+ })
1214
+
1215
+ if (opts?.throwOnError) {
1216
+ throw parsedParamsError
1217
+ }
1218
+
1219
+ return parsedParamsError
1220
+ }
1221
+ }
1222
+
1223
+ return
1224
+ })
1225
+
1226
+ const matches: Array<AnyRouteMatch> = []
1227
+
1228
+ const getParentContext = (parentMatch?: AnyRouteMatch) => {
1229
+ const parentMatchId = parentMatch?.id
1230
+
1231
+ const parentContext = !parentMatchId
1232
+ ? ((this.options.context as any) ?? {})
1233
+ : (parentMatch.context ?? this.options.context ?? {})
1234
+
1235
+ return parentContext
1236
+ }
1237
+
1238
+ matchedRoutes.forEach((route, index) => {
1239
+ // Take each matched route and resolve + validate its search params
1240
+ // This has to happen serially because each route's search params
1241
+ // can depend on the parent route's search params
1242
+ // It must also happen before we create the match so that we can
1243
+ // pass the search params to the route's potential key function
1244
+ // which is used to uniquely identify the route match in state
1245
+
1246
+ const parentMatch = matches[index - 1]
1247
+
1248
+ const [preMatchSearch, strictMatchSearch, searchError]: [
1249
+ Record<string, any>,
1250
+ Record<string, any>,
1251
+ any,
1252
+ ] = (() => {
1253
+ // Validate the search params and stabilize them
1254
+ const parentSearch = parentMatch?.search ?? next.search
1255
+ const parentStrictSearch = parentMatch?._strictSearch ?? {}
1256
+
1257
+ try {
1258
+ const strictSearch =
1259
+ validateSearch(route.options.validateSearch, { ...parentSearch }) ??
1260
+ {}
1261
+
1262
+ return [
1263
+ {
1264
+ ...parentSearch,
1265
+ ...strictSearch,
1266
+ },
1267
+ { ...parentStrictSearch, ...strictSearch },
1268
+ undefined,
1269
+ ]
1270
+ } catch (err: any) {
1271
+ let searchParamError = err
1272
+ if (!(err instanceof SearchParamError)) {
1273
+ searchParamError = new SearchParamError(err.message, {
1274
+ cause: err,
1275
+ })
1276
+ }
1277
+
1278
+ if (opts?.throwOnError) {
1279
+ throw searchParamError
1280
+ }
1281
+
1282
+ return [parentSearch, {}, searchParamError]
1283
+ }
1284
+ })()
1285
+
1286
+ // This is where we need to call route.options.loaderDeps() to get any additional
1287
+ // deps that the route's loader function might need to run. We need to do this
1288
+ // before we create the match so that we can pass the deps to the route's
1289
+ // potential key function which is used to uniquely identify the route match in state
1290
+
1291
+ const loaderDeps =
1292
+ route.options.loaderDeps?.({
1293
+ search: preMatchSearch,
1294
+ }) ?? ''
1295
+
1296
+ const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : ''
1297
+
1298
+ const { usedParams, interpolatedPath } = interpolatePath({
1299
+ path: route.fullPath,
1300
+ params: routeParams,
1301
+ decodeCharMap: this.pathParamsDecodeCharMap,
1302
+ })
1303
+
1304
+ const matchId =
1305
+ interpolatePath({
1306
+ path: route.id,
1307
+ params: routeParams,
1308
+ leaveWildcards: true,
1309
+ decodeCharMap: this.pathParamsDecodeCharMap,
1310
+ }).interpolatedPath + loaderDepsHash
1311
+
1312
+ // Waste not, want not. If we already have a match for this route,
1313
+ // reuse it. This is important for layout routes, which might stick
1314
+ // around between navigation actions that only change leaf routes.
1315
+
1316
+ // Existing matches are matches that are already loaded along with
1317
+ // pending matches that are still loading
1318
+ const existingMatch = this.getMatch(matchId)
1319
+
1320
+ const previousMatch = this.state.matches.find(
1321
+ (d) => d.routeId === route.id,
1322
+ )
1323
+
1324
+ const cause = previousMatch ? 'stay' : 'enter'
1325
+
1326
+ let match: AnyRouteMatch
1327
+
1328
+ if (existingMatch) {
1329
+ match = {
1330
+ ...existingMatch,
1331
+ cause,
1332
+ params: previousMatch
1333
+ ? replaceEqualDeep(previousMatch.params, routeParams)
1334
+ : routeParams,
1335
+ _strictParams: usedParams,
1336
+ search: previousMatch
1337
+ ? replaceEqualDeep(previousMatch.search, preMatchSearch)
1338
+ : replaceEqualDeep(existingMatch.search, preMatchSearch),
1339
+ _strictSearch: strictMatchSearch,
1340
+ }
1341
+ } else {
1342
+ const status =
1343
+ route.options.loader ||
1344
+ route.options.beforeLoad ||
1345
+ route.lazyFn ||
1346
+ routeNeedsPreload(route)
1347
+ ? 'pending'
1348
+ : 'success'
1349
+
1350
+ match = {
1351
+ id: matchId,
1352
+ index,
1353
+ routeId: route.id,
1354
+ params: previousMatch
1355
+ ? replaceEqualDeep(previousMatch.params, routeParams)
1356
+ : routeParams,
1357
+ _strictParams: usedParams,
1358
+ pathname: joinPaths([this.basepath, interpolatedPath]),
1359
+ updatedAt: Date.now(),
1360
+ search: previousMatch
1361
+ ? replaceEqualDeep(previousMatch.search, preMatchSearch)
1362
+ : preMatchSearch,
1363
+ _strictSearch: strictMatchSearch,
1364
+ searchError: undefined,
1365
+ status,
1366
+ isFetching: false,
1367
+ error: undefined,
1368
+ paramsError: parseErrors[index],
1369
+ __routeContext: {},
1370
+ __beforeLoadContext: {},
1371
+ context: {},
1372
+ abortController: new AbortController(),
1373
+ fetchCount: 0,
1374
+ cause,
1375
+ loaderDeps: previousMatch
1376
+ ? replaceEqualDeep(previousMatch.loaderDeps, loaderDeps)
1377
+ : loaderDeps,
1378
+ invalid: false,
1379
+ preload: false,
1380
+ links: undefined,
1381
+ scripts: undefined,
1382
+ headScripts: undefined,
1383
+ meta: undefined,
1384
+ staticData: route.options.staticData || {},
1385
+ loadPromise: createControlledPromise(),
1386
+ fullPath: route.fullPath,
1387
+ }
1388
+ }
1389
+
1390
+ if (!opts?.preload) {
1391
+ // If we have a global not found, mark the right match as global not found
1392
+ match.globalNotFound = globalNotFoundRouteId === route.id
1393
+ }
1394
+
1395
+ // update the searchError if there is one
1396
+ match.searchError = searchError
1397
+
1398
+ const parentContext = getParentContext(parentMatch)
1399
+
1400
+ match.context = {
1401
+ ...parentContext,
1402
+ ...match.__routeContext,
1403
+ ...match.__beforeLoadContext,
1404
+ }
1405
+
1406
+ matches.push(match)
1407
+ })
1408
+
1409
+ matches.forEach((match, index) => {
1410
+ const route = this.looseRoutesById[match.routeId]!
1411
+ const existingMatch = this.getMatch(match.id)
1412
+
1413
+ // only execute `context` if we are not just building a location
1414
+ if (!existingMatch && opts?._buildLocation !== true) {
1415
+ const parentMatch = matches[index - 1]
1416
+ const parentContext = getParentContext(parentMatch)
1417
+
1418
+ // Update the match's context
1419
+ const contextFnContext: RouteContextOptions<any, any, any, any> = {
1420
+ deps: match.loaderDeps,
1421
+ params: match.params,
1422
+ context: parentContext,
1423
+ location: next,
1424
+ navigate: (opts: any) =>
1425
+ this.navigate({ ...opts, _fromLocation: next }),
1426
+ buildLocation: this.buildLocation,
1427
+ cause: match.cause,
1428
+ abortController: match.abortController,
1429
+ preload: !!match.preload,
1430
+ matches,
1431
+ }
1432
+
1433
+ // Get the route context
1434
+ match.__routeContext = route.options.context?.(contextFnContext) ?? {}
1435
+
1436
+ match.context = {
1437
+ ...parentContext,
1438
+ ...match.__routeContext,
1439
+ ...match.__beforeLoadContext,
1440
+ }
1441
+ }
1442
+
1443
+ // If it's already a success, update headers and head content
1444
+ // These may get updated again if the match is refreshed
1445
+ // due to being stale
1446
+ if (match.status === 'success') {
1447
+ match.headers = route.options.headers?.({
1448
+ loaderData: match.loaderData,
1449
+ })
1450
+ const assetContext = {
1451
+ matches,
1452
+ match,
1453
+ params: match.params,
1454
+ loaderData: match.loaderData,
1455
+ }
1456
+ const headFnContent = route.options.head?.(assetContext)
1457
+ match.links = headFnContent?.links
1458
+ match.headScripts = headFnContent?.scripts
1459
+ match.meta = headFnContent?.meta
1460
+ match.scripts = route.options.scripts?.(assetContext)
1461
+ }
1462
+ })
1463
+
1464
+ return matches
1465
+ }
1466
+
1467
+ getMatchedRoutes: GetMatchRoutesFn = (next, dest) => {
1468
+ let routeParams: Record<string, string> = {}
1469
+ const trimmedPath = trimPathRight(next.pathname)
1470
+ const getMatchedParams = (route: AnyRoute) => {
1471
+ const result = matchPathname(this.basepath, trimmedPath, {
1472
+ to: route.fullPath,
1473
+ caseSensitive:
1474
+ route.options.caseSensitive ?? this.options.caseSensitive,
1475
+ fuzzy: true,
1476
+ })
1477
+ return result
1478
+ }
1479
+
1480
+ let foundRoute: AnyRoute | undefined =
1481
+ dest?.to !== undefined ? this.routesByPath[dest.to!] : undefined
1482
+ if (foundRoute) {
1483
+ routeParams = getMatchedParams(foundRoute)!
1484
+ } else {
1485
+ foundRoute = this.flatRoutes.find((route) => {
1486
+ const matchedParams = getMatchedParams(route)
1487
+
1488
+ if (matchedParams) {
1489
+ routeParams = matchedParams
1490
+ return true
1491
+ }
1492
+
1493
+ return false
1494
+ })
1495
+ }
1496
+
1497
+ let routeCursor: AnyRoute =
1498
+ foundRoute || (this.routesById as any)[rootRouteId]
1499
+
1500
+ const matchedRoutes: Array<AnyRoute> = [routeCursor]
1501
+
1502
+ while (routeCursor.parentRoute) {
1503
+ routeCursor = routeCursor.parentRoute
1504
+ matchedRoutes.unshift(routeCursor)
1505
+ }
1506
+
1507
+ return { matchedRoutes, routeParams, foundRoute }
1508
+ }
1509
+
1510
+ cancelMatch = (id: string) => {
1511
+ const match = this.getMatch(id)
1512
+
1513
+ if (!match) return
1514
+
1515
+ match.abortController.abort()
1516
+ clearTimeout(match.pendingTimeout)
1517
+ }
1518
+
1519
+ cancelMatches = () => {
1520
+ this.state.pendingMatches?.forEach((match) => {
1521
+ this.cancelMatch(match.id)
1522
+ })
1523
+ }
1524
+
1525
+ buildLocation: BuildLocationFn = (opts) => {
1526
+ const build = (
1527
+ dest: BuildNextOptions & {
1528
+ unmaskOnReload?: boolean
1529
+ } = {},
1530
+ matchedRoutesResult?: MatchedRoutesResult,
1531
+ ): ParsedLocation => {
1532
+ const fromMatches = dest._fromLocation
1533
+ ? this.matchRoutes(dest._fromLocation, { _buildLocation: true })
1534
+ : this.state.matches
1535
+
1536
+ const fromMatch =
1537
+ dest.from != null
1538
+ ? fromMatches.find((d) =>
1539
+ matchPathname(this.basepath, trimPathRight(d.pathname), {
1540
+ to: dest.from,
1541
+ caseSensitive: false,
1542
+ fuzzy: false,
1543
+ }),
1544
+ )
1545
+ : undefined
1546
+
1547
+ const fromPath = fromMatch?.pathname || this.latestLocation.pathname
1548
+
1549
+ invariant(
1550
+ dest.from == null || fromMatch != null,
1551
+ 'Could not find match for from: ' + dest.from,
1552
+ )
1553
+
1554
+ const fromSearch = this.state.pendingMatches?.length
1555
+ ? last(this.state.pendingMatches)?.search
1556
+ : last(fromMatches)?.search || this.latestLocation.search
1557
+
1558
+ const stayingMatches = matchedRoutesResult?.matchedRoutes.filter((d) =>
1559
+ fromMatches.find((e) => e.routeId === d.id),
1560
+ )
1561
+ let pathname: string
1562
+ if (dest.to) {
1563
+ const resolvePathTo =
1564
+ fromMatch?.fullPath ||
1565
+ last(fromMatches)?.fullPath ||
1566
+ this.latestLocation.pathname
1567
+ pathname = this.resolvePathWithBase(resolvePathTo, `${dest.to}`)
1568
+ } else {
1569
+ const fromRouteByFromPathRouteId =
1570
+ this.routesById[
1571
+ stayingMatches?.find((route) => {
1572
+ const interpolatedPath = interpolatePath({
1573
+ path: route.fullPath,
1574
+ params: matchedRoutesResult?.routeParams ?? {},
1575
+ decodeCharMap: this.pathParamsDecodeCharMap,
1576
+ }).interpolatedPath
1577
+ const pathname = joinPaths([this.basepath, interpolatedPath])
1578
+ return pathname === fromPath
1579
+ })?.id as keyof this['routesById']
1580
+ ]
1581
+ pathname = this.resolvePathWithBase(
1582
+ fromPath,
1583
+ fromRouteByFromPathRouteId?.to ?? fromPath,
1584
+ )
1585
+ }
1586
+
1587
+ const prevParams = { ...last(fromMatches)?.params }
1588
+
1589
+ let nextParams =
1590
+ (dest.params ?? true) === true
1591
+ ? prevParams
1592
+ : {
1593
+ ...prevParams,
1594
+ ...functionalUpdate(dest.params as any, prevParams),
1595
+ }
1596
+
1597
+ if (Object.keys(nextParams).length > 0) {
1598
+ matchedRoutesResult?.matchedRoutes
1599
+ .map((route) => {
1600
+ return (
1601
+ route.options.params?.stringify ?? route.options.stringifyParams
1602
+ )
1603
+ })
1604
+ .filter(Boolean)
1605
+ .forEach((fn) => {
1606
+ nextParams = { ...nextParams!, ...fn!(nextParams) }
1607
+ })
1608
+ }
1609
+
1610
+ pathname = interpolatePath({
1611
+ path: pathname,
1612
+ params: nextParams ?? {},
1613
+ leaveWildcards: false,
1614
+ leaveParams: opts.leaveParams,
1615
+ decodeCharMap: this.pathParamsDecodeCharMap,
1616
+ }).interpolatedPath
1617
+
1618
+ let search = fromSearch
1619
+ if (opts._includeValidateSearch && this.options.search?.strict) {
1620
+ let validatedSearch = {}
1621
+ matchedRoutesResult?.matchedRoutes.forEach((route) => {
1622
+ try {
1623
+ if (route.options.validateSearch) {
1624
+ validatedSearch = {
1625
+ ...validatedSearch,
1626
+ ...(validateSearch(route.options.validateSearch, {
1627
+ ...validatedSearch,
1628
+ ...search,
1629
+ }) ?? {}),
1630
+ }
1631
+ }
1632
+ } catch {
1633
+ // ignore errors here because they are already handled in matchRoutes
1634
+ }
1635
+ })
1636
+ search = validatedSearch
1637
+ }
1638
+
1639
+ const applyMiddlewares = (search: any) => {
1640
+ const allMiddlewares =
1641
+ matchedRoutesResult?.matchedRoutes.reduce(
1642
+ (acc, route) => {
1643
+ const middlewares: Array<SearchMiddleware<any>> = []
1644
+ if ('search' in route.options) {
1645
+ if (route.options.search?.middlewares) {
1646
+ middlewares.push(...route.options.search.middlewares)
1647
+ }
1648
+ }
1649
+ // TODO remove preSearchFilters and postSearchFilters in v2
1650
+ else if (
1651
+ route.options.preSearchFilters ||
1652
+ route.options.postSearchFilters
1653
+ ) {
1654
+ const legacyMiddleware: SearchMiddleware<any> = ({
1655
+ search,
1656
+ next,
1657
+ }) => {
1658
+ let nextSearch = search
1659
+ if (
1660
+ 'preSearchFilters' in route.options &&
1661
+ route.options.preSearchFilters
1662
+ ) {
1663
+ nextSearch = route.options.preSearchFilters.reduce(
1664
+ (prev, next) => next(prev),
1665
+ search,
1666
+ )
1667
+ }
1668
+ const result = next(nextSearch)
1669
+ if (
1670
+ 'postSearchFilters' in route.options &&
1671
+ route.options.postSearchFilters
1672
+ ) {
1673
+ return route.options.postSearchFilters.reduce(
1674
+ (prev, next) => next(prev),
1675
+ result,
1676
+ )
1677
+ }
1678
+ return result
1679
+ }
1680
+ middlewares.push(legacyMiddleware)
1681
+ }
1682
+ if (opts._includeValidateSearch && route.options.validateSearch) {
1683
+ const validate: SearchMiddleware<any> = ({ search, next }) => {
1684
+ const result = next(search)
1685
+ try {
1686
+ const validatedSearch = {
1687
+ ...result,
1688
+ ...(validateSearch(
1689
+ route.options.validateSearch,
1690
+ result,
1691
+ ) ?? {}),
1692
+ }
1693
+ return validatedSearch
1694
+ } catch {
1695
+ // ignore errors here because they are already handled in matchRoutes
1696
+ return result
1697
+ }
1698
+ }
1699
+ middlewares.push(validate)
1700
+ }
1701
+ return acc.concat(middlewares)
1702
+ },
1703
+ [] as Array<SearchMiddleware<any>>,
1704
+ ) ?? []
1705
+
1706
+ // the chain ends here since `next` is not called
1707
+ const final: SearchMiddleware<any> = ({ search }) => {
1708
+ if (!dest.search) {
1709
+ return {}
1710
+ }
1711
+ if (dest.search === true) {
1712
+ return search
1713
+ }
1714
+ return functionalUpdate(dest.search, search)
1715
+ }
1716
+ allMiddlewares.push(final)
1717
+
1718
+ const applyNext = (index: number, currentSearch: any): any => {
1719
+ // no more middlewares left, return the current search
1720
+ if (index >= allMiddlewares.length) {
1721
+ return currentSearch
1722
+ }
1723
+
1724
+ const middleware = allMiddlewares[index]!
1725
+
1726
+ const next = (newSearch: any): any => {
1727
+ return applyNext(index + 1, newSearch)
1728
+ }
1729
+
1730
+ return middleware({ search: currentSearch, next })
1731
+ }
1732
+
1733
+ // Start applying middlewares
1734
+ return applyNext(0, search)
1735
+ }
1736
+
1737
+ search = applyMiddlewares(search)
1738
+
1739
+ search = replaceEqualDeep(fromSearch, search)
1740
+ const searchStr = this.options.stringifySearch(search)
1741
+
1742
+ const hash =
1743
+ dest.hash === true
1744
+ ? this.latestLocation.hash
1745
+ : dest.hash
1746
+ ? functionalUpdate(dest.hash, this.latestLocation.hash)
1747
+ : undefined
1748
+
1749
+ const hashStr = hash ? `#${hash}` : ''
1750
+
1751
+ let nextState =
1752
+ dest.state === true
1753
+ ? this.latestLocation.state
1754
+ : dest.state
1755
+ ? functionalUpdate(dest.state, this.latestLocation.state)
1756
+ : {}
1757
+
1758
+ nextState = replaceEqualDeep(this.latestLocation.state, nextState)
1759
+
1760
+ return {
1761
+ pathname,
1762
+ search,
1763
+ searchStr,
1764
+ state: nextState as any,
1765
+ hash: hash ?? '',
1766
+ href: `${pathname}${searchStr}${hashStr}`,
1767
+ unmaskOnReload: dest.unmaskOnReload,
1768
+ }
1769
+ }
1770
+
1771
+ const buildWithMatches = (
1772
+ dest: BuildNextOptions = {},
1773
+ maskedDest?: BuildNextOptions,
1774
+ ) => {
1775
+ const next = build(dest)
1776
+ let maskedNext = maskedDest ? build(maskedDest) : undefined
1777
+
1778
+ if (!maskedNext) {
1779
+ let params = {}
1780
+
1781
+ const foundMask = this.options.routeMasks?.find((d) => {
1782
+ const match = matchPathname(this.basepath, next.pathname, {
1783
+ to: d.from,
1784
+ caseSensitive: false,
1785
+ fuzzy: false,
1786
+ })
1787
+
1788
+ if (match) {
1789
+ params = match
1790
+ return true
1791
+ }
1792
+
1793
+ return false
1794
+ })
1795
+
1796
+ if (foundMask) {
1797
+ const { from: _from, ...maskProps } = foundMask
1798
+ maskedDest = {
1799
+ ...pick(opts, ['from']),
1800
+ ...maskProps,
1801
+ params,
1802
+ }
1803
+ maskedNext = build(maskedDest)
1804
+ }
1805
+ }
1806
+
1807
+ const nextMatches = this.getMatchedRoutes(next, dest)
1808
+ const final = build(dest, nextMatches)
1809
+
1810
+ if (maskedNext) {
1811
+ const maskedMatches = this.getMatchedRoutes(maskedNext, maskedDest)
1812
+ const maskedFinal = build(maskedDest, maskedMatches)
1813
+ final.maskedLocation = maskedFinal
1814
+ }
1815
+
1816
+ return final
1817
+ }
1818
+
1819
+ if (opts.mask) {
1820
+ return buildWithMatches(opts, {
1821
+ ...pick(opts, ['from']),
1822
+ ...opts.mask,
1823
+ })
1824
+ }
1825
+
1826
+ return buildWithMatches(opts)
1827
+ }
1828
+
1829
+ commitLocationPromise: undefined | ControlledPromise<void>
1830
+
1831
+ commitLocation: CommitLocationFn = ({
1832
+ viewTransition,
1833
+ ignoreBlocker,
1834
+ ...next
1835
+ }) => {
1836
+ const isSameState = () => {
1837
+ // the following props are ignored but may still be provided when navigating,
1838
+ // temporarily add the previous values to the next state so they don't affect
1839
+ // the comparison
1840
+ const ignoredProps = [
1841
+ 'key',
1842
+ '__TSR_index',
1843
+ '__hashScrollIntoViewOptions',
1844
+ ] as const
1845
+ ignoredProps.forEach((prop) => {
1846
+ ;(next.state as any)[prop] = this.latestLocation.state[prop]
1847
+ })
1848
+ const isEqual = deepEqual(next.state, this.latestLocation.state)
1849
+ ignoredProps.forEach((prop) => {
1850
+ delete next.state[prop]
1851
+ })
1852
+ return isEqual
1853
+ }
1854
+
1855
+ const isSameUrl = this.latestLocation.href === next.href
1856
+
1857
+ const previousCommitPromise = this.commitLocationPromise
1858
+ this.commitLocationPromise = createControlledPromise<void>(() => {
1859
+ previousCommitPromise?.resolve()
1860
+ })
1861
+
1862
+ // Don't commit to history if nothing changed
1863
+ if (isSameUrl && isSameState()) {
1864
+ this.load()
1865
+ } else {
1866
+ // eslint-disable-next-line prefer-const
1867
+ let { maskedLocation, hashScrollIntoView, ...nextHistory } = next
1868
+
1869
+ if (maskedLocation) {
1870
+ nextHistory = {
1871
+ ...maskedLocation,
1872
+ state: {
1873
+ ...maskedLocation.state,
1874
+ __tempKey: undefined,
1875
+ __tempLocation: {
1876
+ ...nextHistory,
1877
+ search: nextHistory.searchStr,
1878
+ state: {
1879
+ ...nextHistory.state,
1880
+ __tempKey: undefined!,
1881
+ __tempLocation: undefined!,
1882
+ key: undefined!,
1883
+ },
1884
+ },
1885
+ },
1886
+ }
1887
+
1888
+ if (
1889
+ nextHistory.unmaskOnReload ??
1890
+ this.options.unmaskOnReload ??
1891
+ false
1892
+ ) {
1893
+ nextHistory.state.__tempKey = this.tempLocationKey
1894
+ }
1895
+ }
1896
+
1897
+ nextHistory.state.__hashScrollIntoViewOptions =
1898
+ hashScrollIntoView ?? this.options.defaultHashScrollIntoView ?? true
1899
+
1900
+ this.shouldViewTransition = viewTransition
1901
+
1902
+ this.history[next.replace ? 'replace' : 'push'](
1903
+ nextHistory.href,
1904
+ nextHistory.state,
1905
+ { ignoreBlocker },
1906
+ )
1907
+ }
1908
+
1909
+ this.resetNextScroll = next.resetScroll ?? true
1910
+
1911
+ if (!this.history.subscribers.size) {
1912
+ this.load()
1913
+ }
1914
+
1915
+ return this.commitLocationPromise
1916
+ }
1917
+
1918
+ buildAndCommitLocation = ({
1919
+ replace,
1920
+ resetScroll,
1921
+ hashScrollIntoView,
1922
+ viewTransition,
1923
+ ignoreBlocker,
1924
+ href,
1925
+ ...rest
1926
+ }: BuildNextOptions & CommitLocationOptions = {}) => {
1927
+ if (href) {
1928
+ const currentIndex = this.history.location.state.__TSR_index
1929
+ const parsed = parseHref(href, {
1930
+ __TSR_index: replace ? currentIndex : currentIndex + 1,
1931
+ })
1932
+ rest.to = parsed.pathname
1933
+ rest.search = this.options.parseSearch(parsed.search)
1934
+ // remove the leading `#` from the hash
1935
+ rest.hash = parsed.hash.slice(1)
1936
+ }
1937
+
1938
+ const location = this.buildLocation({
1939
+ ...(rest as any),
1940
+ _includeValidateSearch: true,
1941
+ })
1942
+ return this.commitLocation({
1943
+ ...location,
1944
+ viewTransition,
1945
+ replace,
1946
+ resetScroll,
1947
+ hashScrollIntoView,
1948
+ ignoreBlocker,
1949
+ })
1950
+ }
1951
+
1952
+ navigate: NavigateFn = ({ to, reloadDocument, href, ...rest }) => {
1953
+ if (reloadDocument) {
1954
+ if (!href) {
1955
+ const location = this.buildLocation({ to, ...rest } as any)
1956
+ href = this.history.createHref(location.href)
1957
+ }
1958
+ if (rest.replace) {
1959
+ window.location.replace(href)
1960
+ } else {
1961
+ window.location.href = href
1962
+ }
1963
+ return
1964
+ }
1965
+
1966
+ return this.buildAndCommitLocation({
1967
+ ...rest,
1968
+ href,
1969
+ to: to as string,
1970
+ })
1971
+ }
1972
+
1973
+ latestLoadPromise: undefined | Promise<void>
1974
+
1975
+ load: LoadFn = async (opts?: { sync?: boolean }): Promise<void> => {
1976
+ this.latestLocation = this.parseLocation(this.latestLocation)
1977
+
1978
+ let redirect: ResolvedRedirect | undefined
1979
+ let notFound: NotFoundError | undefined
1980
+
1981
+ let loadPromise: Promise<void>
1982
+
1983
+ // eslint-disable-next-line prefer-const
1984
+ loadPromise = new Promise<void>((resolve) => {
1985
+ this.startTransition(async () => {
1986
+ try {
1987
+ const next = this.latestLocation
1988
+ const prevLocation = this.state.resolvedLocation
1989
+
1990
+ // Cancel any pending matches
1991
+ this.cancelMatches()
1992
+
1993
+ let pendingMatches!: Array<AnyRouteMatch>
1994
+
1995
+ batch(() => {
1996
+ // this call breaks a route context of destination route after a redirect
1997
+ // we should be fine not eagerly calling this since we call it later
1998
+ // this.clearExpiredCache()
1999
+
2000
+ // Match the routes
2001
+ pendingMatches = this.matchRoutes(next)
2002
+
2003
+ // Ingest the new matches
2004
+ this.__store.setState((s) => ({
2005
+ ...s,
2006
+ status: 'pending',
2007
+ isLoading: true,
2008
+ location: next,
2009
+ pendingMatches,
2010
+ // If a cached moved to pendingMatches, remove it from cachedMatches
2011
+ cachedMatches: s.cachedMatches.filter((d) => {
2012
+ return !pendingMatches.find((e) => e.id === d.id)
2013
+ }),
2014
+ }))
2015
+ })
2016
+
2017
+ if (!this.state.redirect) {
2018
+ this.emit({
2019
+ type: 'onBeforeNavigate',
2020
+ ...getLocationChangeInfo({
2021
+ resolvedLocation: prevLocation,
2022
+ location: next,
2023
+ }),
2024
+ })
2025
+ }
2026
+
2027
+ this.emit({
2028
+ type: 'onBeforeLoad',
2029
+ ...getLocationChangeInfo({
2030
+ resolvedLocation: prevLocation,
2031
+ location: next,
2032
+ }),
2033
+ })
2034
+
2035
+ await this.loadMatches({
2036
+ sync: opts?.sync,
2037
+ matches: pendingMatches,
2038
+ location: next,
2039
+ // eslint-disable-next-line @typescript-eslint/require-await
2040
+ onReady: async () => {
2041
+ // eslint-disable-next-line @typescript-eslint/require-await
2042
+ this.startViewTransition(async () => {
2043
+ // this.viewTransitionPromise = createControlledPromise<true>()
2044
+
2045
+ // Commit the pending matches. If a previous match was
2046
+ // removed, place it in the cachedMatches
2047
+ let exitingMatches!: Array<AnyRouteMatch>
2048
+ let enteringMatches!: Array<AnyRouteMatch>
2049
+ let stayingMatches!: Array<AnyRouteMatch>
2050
+
2051
+ batch(() => {
2052
+ this.__store.setState((s) => {
2053
+ const previousMatches = s.matches
2054
+ const newMatches = s.pendingMatches || s.matches
2055
+
2056
+ exitingMatches = previousMatches.filter(
2057
+ (match) => !newMatches.find((d) => d.id === match.id),
2058
+ )
2059
+ enteringMatches = newMatches.filter(
2060
+ (match) =>
2061
+ !previousMatches.find((d) => d.id === match.id),
2062
+ )
2063
+ stayingMatches = previousMatches.filter((match) =>
2064
+ newMatches.find((d) => d.id === match.id),
2065
+ )
2066
+
2067
+ return {
2068
+ ...s,
2069
+ isLoading: false,
2070
+ loadedAt: Date.now(),
2071
+ matches: newMatches,
2072
+ pendingMatches: undefined,
2073
+ cachedMatches: [
2074
+ ...s.cachedMatches,
2075
+ ...exitingMatches.filter((d) => d.status !== 'error'),
2076
+ ],
2077
+ }
2078
+ })
2079
+ this.clearExpiredCache()
2080
+ })
2081
+
2082
+ //
2083
+ ;(
2084
+ [
2085
+ [exitingMatches, 'onLeave'],
2086
+ [enteringMatches, 'onEnter'],
2087
+ [stayingMatches, 'onStay'],
2088
+ ] as const
2089
+ ).forEach(([matches, hook]) => {
2090
+ matches.forEach((match) => {
2091
+ this.looseRoutesById[match.routeId]!.options[hook]?.(match)
2092
+ })
2093
+ })
2094
+ })
2095
+ },
2096
+ })
2097
+ } catch (err) {
2098
+ if (isResolvedRedirect(err)) {
2099
+ redirect = err
2100
+ if (!this.isServer) {
2101
+ this.navigate({
2102
+ ...redirect,
2103
+ replace: true,
2104
+ ignoreBlocker: true,
2105
+ })
2106
+ }
2107
+ } else if (isNotFound(err)) {
2108
+ notFound = err
2109
+ }
2110
+
2111
+ this.__store.setState((s) => ({
2112
+ ...s,
2113
+ statusCode: redirect
2114
+ ? redirect.statusCode
2115
+ : notFound
2116
+ ? 404
2117
+ : s.matches.some((d) => d.status === 'error')
2118
+ ? 500
2119
+ : 200,
2120
+ redirect,
2121
+ }))
2122
+ }
2123
+
2124
+ if (this.latestLoadPromise === loadPromise) {
2125
+ this.commitLocationPromise?.resolve()
2126
+ this.latestLoadPromise = undefined
2127
+ this.commitLocationPromise = undefined
2128
+ }
2129
+ resolve()
2130
+ })
2131
+ })
2132
+
2133
+ this.latestLoadPromise = loadPromise
2134
+
2135
+ await loadPromise
2136
+
2137
+ while (
2138
+ (this.latestLoadPromise as any) &&
2139
+ loadPromise !== this.latestLoadPromise
2140
+ ) {
2141
+ await this.latestLoadPromise
2142
+ }
2143
+
2144
+ if (this.hasNotFoundMatch()) {
2145
+ this.__store.setState((s) => ({
2146
+ ...s,
2147
+ statusCode: 404,
2148
+ }))
2149
+ }
2150
+ }
2151
+
2152
+ startViewTransition = (fn: () => Promise<void>) => {
2153
+ // Determine if we should start a view transition from the navigation
2154
+ // or from the router default
2155
+ const shouldViewTransition =
2156
+ this.shouldViewTransition ?? this.options.defaultViewTransition
2157
+
2158
+ // Reset the view transition flag
2159
+ delete this.shouldViewTransition
2160
+ // Attempt to start a view transition (or just apply the changes if we can't)
2161
+ if (
2162
+ shouldViewTransition &&
2163
+ typeof document !== 'undefined' &&
2164
+ 'startViewTransition' in document &&
2165
+ typeof document.startViewTransition === 'function'
2166
+ ) {
2167
+ // lib.dom.ts doesn't support viewTransition types variant yet.
2168
+ // TODO: Fix this when dom types are updated
2169
+ let startViewTransitionParams: any
2170
+
2171
+ if (
2172
+ typeof shouldViewTransition === 'object' &&
2173
+ this.isViewTransitionTypesSupported
2174
+ ) {
2175
+ startViewTransitionParams = {
2176
+ update: fn,
2177
+ types: shouldViewTransition.types,
2178
+ }
2179
+ } else {
2180
+ startViewTransitionParams = fn
2181
+ }
2182
+
2183
+ document.startViewTransition(startViewTransitionParams)
2184
+ } else {
2185
+ fn()
2186
+ }
2187
+ }
2188
+
2189
+ updateMatch: UpdateMatchFn = (id, updater) => {
2190
+ let updated!: AnyRouteMatch
2191
+ const isPending = this.state.pendingMatches?.find((d) => d.id === id)
2192
+ const isMatched = this.state.matches.find((d) => d.id === id)
2193
+ const isCached = this.state.cachedMatches.find((d) => d.id === id)
2194
+
2195
+ const matchesKey = isPending
2196
+ ? 'pendingMatches'
2197
+ : isMatched
2198
+ ? 'matches'
2199
+ : isCached
2200
+ ? 'cachedMatches'
2201
+ : ''
2202
+
2203
+ if (matchesKey) {
2204
+ this.__store.setState((s) => ({
2205
+ ...s,
2206
+ [matchesKey]: s[matchesKey]?.map((d) =>
2207
+ d.id === id ? (updated = updater(d)) : d,
2208
+ ),
2209
+ }))
2210
+ }
2211
+
2212
+ return updated
2213
+ }
2214
+
2215
+ getMatch: GetMatchFn = (matchId: string) => {
2216
+ return [
2217
+ ...this.state.cachedMatches,
2218
+ ...(this.state.pendingMatches ?? []),
2219
+ ...this.state.matches,
2220
+ ].find((d) => d.id === matchId)
2221
+ }
2222
+
2223
+ loadMatches = async ({
2224
+ location,
2225
+ matches,
2226
+ preload: allPreload,
2227
+ onReady,
2228
+ updateMatch = this.updateMatch,
2229
+ sync,
2230
+ }: {
2231
+ location: ParsedLocation
2232
+ matches: Array<AnyRouteMatch>
2233
+ preload?: boolean
2234
+ onReady?: () => Promise<void>
2235
+ updateMatch?: (
2236
+ id: string,
2237
+ updater: (match: AnyRouteMatch) => AnyRouteMatch,
2238
+ ) => void
2239
+ getMatch?: (matchId: string) => AnyRouteMatch | undefined
2240
+ sync?: boolean
2241
+ }): Promise<Array<MakeRouteMatch>> => {
2242
+ let firstBadMatchIndex: number | undefined
2243
+ let rendered = false
2244
+
2245
+ const triggerOnReady = async () => {
2246
+ if (!rendered) {
2247
+ rendered = true
2248
+ await onReady?.()
2249
+ }
2250
+ }
2251
+
2252
+ const resolvePreload = (matchId: string) => {
2253
+ return !!(allPreload && !this.state.matches.find((d) => d.id === matchId))
2254
+ }
2255
+
2256
+ if (!this.isServer && !this.state.matches.length) {
2257
+ triggerOnReady()
2258
+ }
2259
+
2260
+ const handleRedirectAndNotFound = (match: AnyRouteMatch, err: any) => {
2261
+ if (isResolvedRedirect(err)) {
2262
+ if (!err.reloadDocument) {
2263
+ throw err
2264
+ }
2265
+ }
2266
+
2267
+ if (isRedirect(err) || isNotFound(err)) {
2268
+ updateMatch(match.id, (prev) => ({
2269
+ ...prev,
2270
+ status: isRedirect(err)
2271
+ ? 'redirected'
2272
+ : isNotFound(err)
2273
+ ? 'notFound'
2274
+ : 'error',
2275
+ isFetching: false,
2276
+ error: err,
2277
+ beforeLoadPromise: undefined,
2278
+ loaderPromise: undefined,
2279
+ }))
2280
+
2281
+ if (!(err as any).routeId) {
2282
+ ;(err as any).routeId = match.routeId
2283
+ }
2284
+
2285
+ match.beforeLoadPromise?.resolve()
2286
+ match.loaderPromise?.resolve()
2287
+ match.loadPromise?.resolve()
2288
+
2289
+ if (isRedirect(err)) {
2290
+ rendered = true
2291
+ err = this.resolveRedirect({ ...err, _fromLocation: location })
2292
+ throw err
2293
+ } else if (isNotFound(err)) {
2294
+ this._handleNotFound(matches, err, {
2295
+ updateMatch,
2296
+ })
2297
+ this.serverSsr?.onMatchSettled({
2298
+ router: this,
2299
+ match: this.getMatch(match.id)!,
2300
+ })
2301
+ throw err
2302
+ }
2303
+ }
2304
+ }
2305
+
2306
+ try {
2307
+ await new Promise<void>((resolveAll, rejectAll) => {
2308
+ ;(async () => {
2309
+ try {
2310
+ const handleSerialError = (
2311
+ index: number,
2312
+ err: any,
2313
+ routerCode: string,
2314
+ ) => {
2315
+ const { id: matchId, routeId } = matches[index]!
2316
+ const route = this.looseRoutesById[routeId]!
2317
+
2318
+ // Much like suspense, we use a promise here to know if
2319
+ // we've been outdated by a new loadMatches call and
2320
+ // should abort the current async operation
2321
+ if (err instanceof Promise) {
2322
+ throw err
2323
+ }
2324
+
2325
+ err.routerCode = routerCode
2326
+ firstBadMatchIndex = firstBadMatchIndex ?? index
2327
+ handleRedirectAndNotFound(this.getMatch(matchId)!, err)
2328
+
2329
+ try {
2330
+ route.options.onError?.(err)
2331
+ } catch (errorHandlerErr) {
2332
+ err = errorHandlerErr
2333
+ handleRedirectAndNotFound(this.getMatch(matchId)!, err)
2334
+ }
2335
+
2336
+ updateMatch(matchId, (prev) => {
2337
+ prev.beforeLoadPromise?.resolve()
2338
+ prev.loadPromise?.resolve()
2339
+
2340
+ return {
2341
+ ...prev,
2342
+ error: err,
2343
+ status: 'error',
2344
+ isFetching: false,
2345
+ updatedAt: Date.now(),
2346
+ abortController: new AbortController(),
2347
+ beforeLoadPromise: undefined,
2348
+ }
2349
+ })
2350
+ }
2351
+
2352
+ for (const [index, { id: matchId, routeId }] of matches.entries()) {
2353
+ const existingMatch = this.getMatch(matchId)!
2354
+ const parentMatchId = matches[index - 1]?.id
2355
+
2356
+ const route = this.looseRoutesById[routeId]!
2357
+
2358
+ const pendingMs =
2359
+ route.options.pendingMs ?? this.options.defaultPendingMs
2360
+
2361
+ const shouldPending = !!(
2362
+ onReady &&
2363
+ !this.isServer &&
2364
+ !resolvePreload(matchId) &&
2365
+ (route.options.loader || route.options.beforeLoad) &&
2366
+ typeof pendingMs === 'number' &&
2367
+ pendingMs !== Infinity &&
2368
+ (route.options.pendingComponent ??
2369
+ (this.options as any)?.defaultPendingComponent)
2370
+ )
2371
+
2372
+ let executeBeforeLoad = true
2373
+ if (
2374
+ // If we are in the middle of a load, either of these will be present
2375
+ // (not to be confused with `loadPromise`, which is always defined)
2376
+ existingMatch.beforeLoadPromise ||
2377
+ existingMatch.loaderPromise
2378
+ ) {
2379
+ if (shouldPending) {
2380
+ setTimeout(() => {
2381
+ try {
2382
+ // Update the match and prematurely resolve the loadMatches promise so that
2383
+ // the pending component can start rendering
2384
+ triggerOnReady()
2385
+ } catch {}
2386
+ }, pendingMs)
2387
+ }
2388
+
2389
+ // Wait for the beforeLoad to resolve before we continue
2390
+ await existingMatch.beforeLoadPromise
2391
+ executeBeforeLoad = this.getMatch(matchId)!.status !== 'success'
2392
+ }
2393
+ if (executeBeforeLoad) {
2394
+ // If we are not in the middle of a load OR the previous load failed, start it
2395
+ try {
2396
+ updateMatch(matchId, (prev) => {
2397
+ // explicitly capture the previous loadPromise
2398
+ const prevLoadPromise = prev.loadPromise
2399
+ return {
2400
+ ...prev,
2401
+ loadPromise: createControlledPromise<void>(() => {
2402
+ prevLoadPromise?.resolve()
2403
+ }),
2404
+ beforeLoadPromise: createControlledPromise<void>(),
2405
+ }
2406
+ })
2407
+ const abortController = new AbortController()
2408
+
2409
+ let pendingTimeout: ReturnType<typeof setTimeout>
2410
+
2411
+ if (shouldPending) {
2412
+ // If we might show a pending component, we need to wait for the
2413
+ // pending promise to resolve before we start showing that state
2414
+ pendingTimeout = setTimeout(() => {
2415
+ try {
2416
+ // Update the match and prematurely resolve the loadMatches promise so that
2417
+ // the pending component can start rendering
2418
+ triggerOnReady()
2419
+ } catch {}
2420
+ }, pendingMs)
2421
+ }
2422
+
2423
+ const { paramsError, searchError } = this.getMatch(matchId)!
2424
+
2425
+ if (paramsError) {
2426
+ handleSerialError(index, paramsError, 'PARSE_PARAMS')
2427
+ }
2428
+
2429
+ if (searchError) {
2430
+ handleSerialError(index, searchError, 'VALIDATE_SEARCH')
2431
+ }
2432
+
2433
+ const getParentMatchContext = () =>
2434
+ parentMatchId
2435
+ ? this.getMatch(parentMatchId)!.context
2436
+ : (this.options.context ?? {})
2437
+
2438
+ updateMatch(matchId, (prev) => ({
2439
+ ...prev,
2440
+ isFetching: 'beforeLoad',
2441
+ fetchCount: prev.fetchCount + 1,
2442
+ abortController,
2443
+ pendingTimeout,
2444
+ context: {
2445
+ ...getParentMatchContext(),
2446
+ ...prev.__routeContext,
2447
+ },
2448
+ }))
2449
+
2450
+ const { search, params, context, cause } =
2451
+ this.getMatch(matchId)!
2452
+
2453
+ const preload = resolvePreload(matchId)
2454
+
2455
+ const beforeLoadFnContext: BeforeLoadContextOptions<
2456
+ any,
2457
+ any,
2458
+ any,
2459
+ any,
2460
+ any
2461
+ > = {
2462
+ search,
2463
+ abortController,
2464
+ params,
2465
+ preload,
2466
+ context,
2467
+ location,
2468
+ navigate: (opts: any) =>
2469
+ this.navigate({ ...opts, _fromLocation: location }),
2470
+ buildLocation: this.buildLocation,
2471
+ cause: preload ? 'preload' : cause,
2472
+ matches,
2473
+ }
2474
+
2475
+ const beforeLoadContext =
2476
+ (await route.options.beforeLoad?.(beforeLoadFnContext)) ??
2477
+ {}
2478
+
2479
+ if (
2480
+ isRedirect(beforeLoadContext) ||
2481
+ isNotFound(beforeLoadContext)
2482
+ ) {
2483
+ handleSerialError(index, beforeLoadContext, 'BEFORE_LOAD')
2484
+ }
2485
+
2486
+ updateMatch(matchId, (prev) => {
2487
+ return {
2488
+ ...prev,
2489
+ __beforeLoadContext: beforeLoadContext,
2490
+ context: {
2491
+ ...getParentMatchContext(),
2492
+ ...prev.__routeContext,
2493
+ ...beforeLoadContext,
2494
+ },
2495
+ abortController,
2496
+ }
2497
+ })
2498
+ } catch (err) {
2499
+ handleSerialError(index, err, 'BEFORE_LOAD')
2500
+ }
2501
+
2502
+ updateMatch(matchId, (prev) => {
2503
+ prev.beforeLoadPromise?.resolve()
2504
+
2505
+ return {
2506
+ ...prev,
2507
+ beforeLoadPromise: undefined,
2508
+ isFetching: false,
2509
+ }
2510
+ })
2511
+ }
2512
+ }
2513
+
2514
+ const validResolvedMatches = matches.slice(0, firstBadMatchIndex)
2515
+ const matchPromises: Array<Promise<AnyRouteMatch>> = []
2516
+
2517
+ validResolvedMatches.forEach(({ id: matchId, routeId }, index) => {
2518
+ matchPromises.push(
2519
+ (async () => {
2520
+ const { loaderPromise: prevLoaderPromise } =
2521
+ this.getMatch(matchId)!
2522
+
2523
+ let loaderShouldRunAsync = false
2524
+ let loaderIsRunningAsync = false
2525
+
2526
+ if (prevLoaderPromise) {
2527
+ await prevLoaderPromise
2528
+ const match = this.getMatch(matchId)!
2529
+ if (match.error) {
2530
+ handleRedirectAndNotFound(match, match.error)
2531
+ }
2532
+ } else {
2533
+ const parentMatchPromise = matchPromises[index - 1] as any
2534
+ const route = this.looseRoutesById[routeId]!
2535
+
2536
+ const getLoaderContext = (): LoaderFnContext => {
2537
+ const {
2538
+ params,
2539
+ loaderDeps,
2540
+ abortController,
2541
+ context,
2542
+ cause,
2543
+ } = this.getMatch(matchId)!
2544
+
2545
+ const preload = resolvePreload(matchId)
2546
+
2547
+ return {
2548
+ params,
2549
+ deps: loaderDeps,
2550
+ preload: !!preload,
2551
+ parentMatchPromise,
2552
+ abortController: abortController,
2553
+ context,
2554
+ location,
2555
+ navigate: (opts) =>
2556
+ this.navigate({ ...opts, _fromLocation: location }),
2557
+ cause: preload ? 'preload' : cause,
2558
+ route,
2559
+ }
2560
+ }
2561
+
2562
+ // This is where all of the stale-while-revalidate magic happens
2563
+ const age = Date.now() - this.getMatch(matchId)!.updatedAt
2564
+
2565
+ const preload = resolvePreload(matchId)
2566
+
2567
+ const staleAge = preload
2568
+ ? (route.options.preloadStaleTime ??
2569
+ this.options.defaultPreloadStaleTime ??
2570
+ 30_000) // 30 seconds for preloads by default
2571
+ : (route.options.staleTime ??
2572
+ this.options.defaultStaleTime ??
2573
+ 0)
2574
+
2575
+ const shouldReloadOption = route.options.shouldReload
2576
+
2577
+ // Default to reloading the route all the time
2578
+ // Allow shouldReload to get the last say,
2579
+ // if provided.
2580
+ const shouldReload =
2581
+ typeof shouldReloadOption === 'function'
2582
+ ? shouldReloadOption(getLoaderContext())
2583
+ : shouldReloadOption
2584
+
2585
+ updateMatch(matchId, (prev) => ({
2586
+ ...prev,
2587
+ loaderPromise: createControlledPromise<void>(),
2588
+ preload:
2589
+ !!preload &&
2590
+ !this.state.matches.find((d) => d.id === matchId),
2591
+ }))
2592
+
2593
+ const runLoader = async () => {
2594
+ try {
2595
+ // If the Matches component rendered
2596
+ // the pending component and needs to show it for
2597
+ // a minimum duration, we''ll wait for it to resolve
2598
+ // before committing to the match and resolving
2599
+ // the loadPromise
2600
+ const potentialPendingMinPromise = async () => {
2601
+ const latestMatch = this.getMatch(matchId)!
2602
+
2603
+ if (latestMatch.minPendingPromise) {
2604
+ await latestMatch.minPendingPromise
2605
+ }
2606
+ }
2607
+
2608
+ // Actually run the loader and handle the result
2609
+ try {
2610
+ this.loadRouteChunk(route)
2611
+
2612
+ updateMatch(matchId, (prev) => ({
2613
+ ...prev,
2614
+ isFetching: 'loader',
2615
+ }))
2616
+
2617
+ // Kick off the loader!
2618
+ const loaderData =
2619
+ await route.options.loader?.(getLoaderContext())
2620
+
2621
+ handleRedirectAndNotFound(
2622
+ this.getMatch(matchId)!,
2623
+ loaderData,
2624
+ )
2625
+
2626
+ // Lazy option can modify the route options,
2627
+ // so we need to wait for it to resolve before
2628
+ // we can use the options
2629
+ await route._lazyPromise
2630
+
2631
+ await potentialPendingMinPromise()
2632
+
2633
+ const assetContext = {
2634
+ matches,
2635
+ match: this.getMatch(matchId)!,
2636
+ params: this.getMatch(matchId)!.params,
2637
+ loaderData,
2638
+ }
2639
+ const headFnContent =
2640
+ route.options.head?.(assetContext)
2641
+ const meta = headFnContent?.meta
2642
+ const links = headFnContent?.links
2643
+ const headScripts = headFnContent?.scripts
2644
+
2645
+ const scripts = route.options.scripts?.(assetContext)
2646
+ const headers = route.options.headers?.({
2647
+ loaderData,
2648
+ })
2649
+
2650
+ updateMatch(matchId, (prev) => ({
2651
+ ...prev,
2652
+ error: undefined,
2653
+ status: 'success',
2654
+ isFetching: false,
2655
+ updatedAt: Date.now(),
2656
+ loaderData,
2657
+ meta,
2658
+ links,
2659
+ headScripts,
2660
+ headers,
2661
+ scripts,
2662
+ }))
2663
+ } catch (e) {
2664
+ let error = e
2665
+
2666
+ await potentialPendingMinPromise()
2667
+
2668
+ handleRedirectAndNotFound(this.getMatch(matchId)!, e)
2669
+
2670
+ try {
2671
+ route.options.onError?.(e)
2672
+ } catch (onErrorError) {
2673
+ error = onErrorError
2674
+ handleRedirectAndNotFound(
2675
+ this.getMatch(matchId)!,
2676
+ onErrorError,
2677
+ )
2678
+ }
2679
+
2680
+ updateMatch(matchId, (prev) => ({
2681
+ ...prev,
2682
+ error,
2683
+ status: 'error',
2684
+ isFetching: false,
2685
+ }))
2686
+ }
2687
+
2688
+ this.serverSsr?.onMatchSettled({
2689
+ router: this,
2690
+ match: this.getMatch(matchId)!,
2691
+ })
2692
+
2693
+ // Last but not least, wait for the the components
2694
+ // to be preloaded before we resolve the match
2695
+ await route._componentsPromise
2696
+ } catch (err) {
2697
+ updateMatch(matchId, (prev) => ({
2698
+ ...prev,
2699
+ loaderPromise: undefined,
2700
+ }))
2701
+ handleRedirectAndNotFound(this.getMatch(matchId)!, err)
2702
+ }
2703
+ }
2704
+
2705
+ // If the route is successful and still fresh, just resolve
2706
+ const { status, invalid } = this.getMatch(matchId)!
2707
+ loaderShouldRunAsync =
2708
+ status === 'success' &&
2709
+ (invalid || (shouldReload ?? age > staleAge))
2710
+ if (preload && route.options.preload === false) {
2711
+ // Do nothing
2712
+ } else if (loaderShouldRunAsync && !sync) {
2713
+ loaderIsRunningAsync = true
2714
+ ;(async () => {
2715
+ try {
2716
+ await runLoader()
2717
+ const { loaderPromise, loadPromise } =
2718
+ this.getMatch(matchId)!
2719
+ loaderPromise?.resolve()
2720
+ loadPromise?.resolve()
2721
+ updateMatch(matchId, (prev) => ({
2722
+ ...prev,
2723
+ loaderPromise: undefined,
2724
+ }))
2725
+ } catch (err) {
2726
+ if (isResolvedRedirect(err)) {
2727
+ await this.navigate(err)
2728
+ }
2729
+ }
2730
+ })()
2731
+ } else if (
2732
+ status !== 'success' ||
2733
+ (loaderShouldRunAsync && sync)
2734
+ ) {
2735
+ await runLoader()
2736
+ }
2737
+ }
2738
+ if (!loaderIsRunningAsync) {
2739
+ const { loaderPromise, loadPromise } =
2740
+ this.getMatch(matchId)!
2741
+ loaderPromise?.resolve()
2742
+ loadPromise?.resolve()
2743
+ }
2744
+
2745
+ updateMatch(matchId, (prev) => ({
2746
+ ...prev,
2747
+ isFetching: loaderIsRunningAsync ? prev.isFetching : false,
2748
+ loaderPromise: loaderIsRunningAsync
2749
+ ? prev.loaderPromise
2750
+ : undefined,
2751
+ invalid: false,
2752
+ }))
2753
+ return this.getMatch(matchId)!
2754
+ })(),
2755
+ )
2756
+ })
2757
+
2758
+ await Promise.all(matchPromises)
2759
+
2760
+ resolveAll()
2761
+ } catch (err) {
2762
+ rejectAll(err)
2763
+ }
2764
+ })()
2765
+ })
2766
+ await triggerOnReady()
2767
+ } catch (err) {
2768
+ if (isRedirect(err) || isNotFound(err)) {
2769
+ if (isNotFound(err) && !allPreload) {
2770
+ await triggerOnReady()
2771
+ }
2772
+
2773
+ throw err
2774
+ }
2775
+ }
2776
+
2777
+ return matches
2778
+ }
2779
+
2780
+ invalidate: InvalidateFn<
2781
+ RouterCore<
2782
+ TRouteTree,
2783
+ TTrailingSlashOption,
2784
+ TDefaultStructuralSharingOption,
2785
+ TRouterHistory,
2786
+ TDehydrated
2787
+ >
2788
+ > = (opts) => {
2789
+ const invalidate = (d: MakeRouteMatch<TRouteTree>) => {
2790
+ if (opts?.filter?.(d as MakeRouteMatchUnion<this>) ?? true) {
2791
+ return {
2792
+ ...d,
2793
+ invalid: true,
2794
+ ...(d.status === 'error'
2795
+ ? ({ status: 'pending', error: undefined } as const)
2796
+ : {}),
2797
+ }
2798
+ }
2799
+ return d
2800
+ }
2801
+
2802
+ this.__store.setState((s) => ({
2803
+ ...s,
2804
+ matches: s.matches.map(invalidate),
2805
+ cachedMatches: s.cachedMatches.map(invalidate),
2806
+ pendingMatches: s.pendingMatches?.map(invalidate),
2807
+ }))
2808
+
2809
+ return this.load({ sync: opts?.sync })
2810
+ }
2811
+
2812
+ resolveRedirect = (err: AnyRedirect): ResolvedRedirect => {
2813
+ const redirect = err as ResolvedRedirect
2814
+
2815
+ if (!redirect.href) {
2816
+ redirect.href = this.buildLocation(redirect as any).href
2817
+ }
2818
+
2819
+ return redirect
2820
+ }
2821
+
2822
+ clearCache: ClearCacheFn<this> = (opts) => {
2823
+ const filter = opts?.filter
2824
+ if (filter !== undefined) {
2825
+ this.__store.setState((s) => {
2826
+ return {
2827
+ ...s,
2828
+ cachedMatches: s.cachedMatches.filter(
2829
+ (m) => !filter(m as MakeRouteMatchUnion<this>),
2830
+ ),
2831
+ }
2832
+ })
2833
+ } else {
2834
+ this.__store.setState((s) => {
2835
+ return {
2836
+ ...s,
2837
+ cachedMatches: [],
2838
+ }
2839
+ })
2840
+ }
2841
+ }
2842
+
2843
+ clearExpiredCache = () => {
2844
+ // This is where all of the garbage collection magic happens
2845
+ const filter = (d: MakeRouteMatch<TRouteTree>) => {
2846
+ const route = this.looseRoutesById[d.routeId]!
2847
+
2848
+ if (!route.options.loader) {
2849
+ return true
2850
+ }
2851
+
2852
+ // If the route was preloaded, use the preloadGcTime
2853
+ // otherwise, use the gcTime
2854
+ const gcTime =
2855
+ (d.preload
2856
+ ? (route.options.preloadGcTime ?? this.options.defaultPreloadGcTime)
2857
+ : (route.options.gcTime ?? this.options.defaultGcTime)) ??
2858
+ 5 * 60 * 1000
2859
+
2860
+ return !(d.status !== 'error' && Date.now() - d.updatedAt < gcTime)
2861
+ }
2862
+ this.clearCache({ filter })
2863
+ }
2864
+
2865
+ loadRouteChunk = (route: AnyRoute) => {
2866
+ if (route._lazyPromise === undefined) {
2867
+ if (route.lazyFn) {
2868
+ route._lazyPromise = route.lazyFn().then((lazyRoute) => {
2869
+ // explicitly don't copy over the lazy route's id
2870
+ const { id: _id, ...options } = lazyRoute.options
2871
+ Object.assign(route.options, options)
2872
+ })
2873
+ } else {
2874
+ route._lazyPromise = Promise.resolve()
2875
+ }
2876
+ }
2877
+
2878
+ // If for some reason lazy resolves more lazy components...
2879
+ // We'll wait for that before pre attempt to preload any
2880
+ // components themselves.
2881
+ if (route._componentsPromise === undefined) {
2882
+ route._componentsPromise = route._lazyPromise.then(() =>
2883
+ Promise.all(
2884
+ componentTypes.map(async (type) => {
2885
+ const component = route.options[type]
2886
+ if ((component as any)?.preload) {
2887
+ await (component as any).preload()
2888
+ }
2889
+ }),
2890
+ ),
2891
+ )
2892
+ }
2893
+ return route._componentsPromise
2894
+ }
2895
+
2896
+ preloadRoute: PreloadRouteFn<
2897
+ TRouteTree,
2898
+ TTrailingSlashOption,
2899
+ TDefaultStructuralSharingOption,
2900
+ TRouterHistory
2901
+ > = async (opts) => {
2902
+ const next = this.buildLocation(opts as any)
2903
+
2904
+ let matches = this.matchRoutes(next, {
2905
+ throwOnError: true,
2906
+ preload: true,
2907
+ dest: opts,
2908
+ })
2909
+
2910
+ const activeMatchIds = new Set(
2911
+ [...this.state.matches, ...(this.state.pendingMatches ?? [])].map(
2912
+ (d) => d.id,
2913
+ ),
2914
+ )
2915
+
2916
+ const loadedMatchIds = new Set([
2917
+ ...activeMatchIds,
2918
+ ...this.state.cachedMatches.map((d) => d.id),
2919
+ ])
2920
+
2921
+ // If the matches are already loaded, we need to add them to the cachedMatches
2922
+ batch(() => {
2923
+ matches.forEach((match) => {
2924
+ if (!loadedMatchIds.has(match.id)) {
2925
+ this.__store.setState((s) => ({
2926
+ ...s,
2927
+ cachedMatches: [...(s.cachedMatches as any), match],
2928
+ }))
2929
+ }
2930
+ })
2931
+ })
2932
+
2933
+ try {
2934
+ matches = await this.loadMatches({
2935
+ matches,
2936
+ location: next,
2937
+ preload: true,
2938
+ updateMatch: (id, updater) => {
2939
+ // Don't update the match if it's currently loaded
2940
+ if (activeMatchIds.has(id)) {
2941
+ matches = matches.map((d) => (d.id === id ? updater(d) : d))
2942
+ } else {
2943
+ this.updateMatch(id, updater)
2944
+ }
2945
+ },
2946
+ })
2947
+
2948
+ return matches
2949
+ } catch (err) {
2950
+ if (isRedirect(err)) {
2951
+ if (err.reloadDocument) {
2952
+ return undefined
2953
+ }
2954
+ return await this.preloadRoute({
2955
+ ...(err as any),
2956
+ _fromLocation: next,
2957
+ })
2958
+ }
2959
+ if (!isNotFound(err)) {
2960
+ // Preload errors are not fatal, but we should still log them
2961
+ console.error(err)
2962
+ }
2963
+ return undefined
2964
+ }
2965
+ }
2966
+
2967
+ matchRoute: MatchRouteFn<
2968
+ TRouteTree,
2969
+ TTrailingSlashOption,
2970
+ TDefaultStructuralSharingOption,
2971
+ TRouterHistory
2972
+ > = (location, opts) => {
2973
+ const matchLocation = {
2974
+ ...location,
2975
+ to: location.to
2976
+ ? this.resolvePathWithBase(
2977
+ (location.from || '') as string,
2978
+ location.to as string,
2979
+ )
2980
+ : undefined,
2981
+ params: location.params || {},
2982
+ leaveParams: true,
2983
+ }
2984
+ const next = this.buildLocation(matchLocation as any)
2985
+
2986
+ if (opts?.pending && this.state.status !== 'pending') {
2987
+ return false
2988
+ }
2989
+
2990
+ const pending =
2991
+ opts?.pending === undefined ? !this.state.isLoading : opts.pending
2992
+
2993
+ const baseLocation = pending
2994
+ ? this.latestLocation
2995
+ : this.state.resolvedLocation || this.state.location
2996
+
2997
+ const match = matchPathname(this.basepath, baseLocation.pathname, {
2998
+ ...opts,
2999
+ to: next.pathname,
3000
+ }) as any
3001
+
3002
+ if (!match) {
3003
+ return false
3004
+ }
3005
+ if (location.params) {
3006
+ if (!deepEqual(match, location.params, { partial: true })) {
3007
+ return false
3008
+ }
3009
+ }
3010
+
3011
+ if (match && (opts?.includeSearch ?? true)) {
3012
+ return deepEqual(baseLocation.search, next.search, { partial: true })
3013
+ ? match
3014
+ : false
3015
+ }
3016
+
3017
+ return match
3018
+ }
3019
+
3020
+ ssr?: {
3021
+ manifest: Manifest | undefined
3022
+ serializer: StartSerializer
3023
+ }
3024
+
3025
+ serverSsr?: {
3026
+ injectedHtml: Array<InjectedHtmlEntry>
3027
+ injectHtml: (getHtml: () => string | Promise<string>) => Promise<void>
3028
+ injectScript: (
3029
+ getScript: () => string | Promise<string>,
3030
+ opts?: { logScript?: boolean },
3031
+ ) => Promise<void>
3032
+ streamValue: (key: string, value: any) => void
3033
+ streamedKeys: Set<string>
3034
+ onMatchSettled: (opts: { router: AnyRouter; match: AnyRouteMatch }) => any
3035
+ }
3036
+
3037
+ clientSsr?: {
3038
+ getStreamedValue: <T>(key: string) => T | undefined
3039
+ }
3040
+
3041
+ _handleNotFound = (
3042
+ matches: Array<AnyRouteMatch>,
3043
+ err: NotFoundError,
3044
+ {
3045
+ updateMatch = this.updateMatch,
3046
+ }: {
3047
+ updateMatch?: (
3048
+ id: string,
3049
+ updater: (match: AnyRouteMatch) => AnyRouteMatch,
3050
+ ) => void
3051
+ } = {},
3052
+ ) => {
3053
+ // Find the route that should handle the not found error
3054
+ // First check if a specific route is requested to show the error
3055
+ const routeCursor = this.routesById[err.routeId ?? ''] ?? this.routeTree
3056
+ const matchesByRouteId: Record<string, AnyRouteMatch> = {}
3057
+
3058
+ // Setup routesByRouteId object for quick access
3059
+ for (const match of matches) {
3060
+ matchesByRouteId[match.routeId] = match
3061
+ }
3062
+
3063
+ // Ensure a NotFoundComponent exists on the route
3064
+ if (
3065
+ !routeCursor.options.notFoundComponent &&
3066
+ (this.options as any)?.defaultNotFoundComponent
3067
+ ) {
3068
+ routeCursor.options.notFoundComponent = (
3069
+ this.options as any
3070
+ ).defaultNotFoundComponent
3071
+ }
3072
+
3073
+ // Ensure we have a notFoundComponent
3074
+ invariant(
3075
+ routeCursor.options.notFoundComponent,
3076
+ 'No notFoundComponent found. Please set a notFoundComponent on your route or provide a defaultNotFoundComponent to the router.',
3077
+ )
3078
+
3079
+ // Find the match for this route
3080
+ const matchForRoute = matchesByRouteId[routeCursor.id]
3081
+
3082
+ invariant(
3083
+ matchForRoute,
3084
+ 'Could not find match for route: ' + routeCursor.id,
3085
+ )
3086
+
3087
+ // Assign the error to the match - using non-null assertion since we've checked with invariant
3088
+ updateMatch(matchForRoute.id, (prev) => ({
3089
+ ...prev,
3090
+ status: 'notFound',
3091
+ error: err,
3092
+ isFetching: false,
3093
+ }))
3094
+
3095
+ if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) {
3096
+ err.routeId = routeCursor.parentRoute.id
3097
+ this._handleNotFound(matches, err, {
3098
+ updateMatch,
3099
+ })
3100
+ }
3101
+ }
3102
+
3103
+ hasNotFoundMatch = () => {
3104
+ return this.__store.state.matches.some(
3105
+ (d) => d.status === 'notFound' || d.globalNotFound,
3106
+ )
3107
+ }
3108
+ }
3109
+
3110
+ export class SearchParamError extends Error {}
3111
+
3112
+ export class PathParamError extends Error {}
3113
+
3114
+ // A function that takes an import() argument which is a function and returns a new function that will
3115
+ // proxy arguments from the caller to the imported function, retaining all type
3116
+ // information along the way
3117
+ export function lazyFn<
3118
+ T extends Record<string, (...args: Array<any>) => any>,
3119
+ TKey extends keyof T = 'default',
3120
+ >(fn: () => Promise<T>, key?: TKey) {
3121
+ return async (
3122
+ ...args: Parameters<T[TKey]>
3123
+ ): Promise<Awaited<ReturnType<T[TKey]>>> => {
3124
+ const imported = await fn()
3125
+ return imported[key || 'default'](...args)
3126
+ }
3127
+ }
3128
+
3129
+ export function getInitialRouterState(
3130
+ location: ParsedLocation,
3131
+ ): RouterState<any> {
3132
+ return {
3133
+ loadedAt: 0,
3134
+ isLoading: false,
3135
+ isTransitioning: false,
3136
+ status: 'idle',
3137
+ resolvedLocation: undefined,
3138
+ location,
3139
+ matches: [],
3140
+ pendingMatches: [],
3141
+ cachedMatches: [],
3142
+ statusCode: 200,
3143
+ }
3144
+ }
3145
+
3146
+ function validateSearch(validateSearch: AnyValidator, input: unknown): unknown {
3147
+ if (validateSearch == null) return {}
3148
+
3149
+ if ('~standard' in validateSearch) {
3150
+ const result = validateSearch['~standard'].validate(input)
3151
+
3152
+ if (result instanceof Promise)
3153
+ throw new SearchParamError('Async validation not supported')
3154
+
3155
+ if (result.issues)
3156
+ throw new SearchParamError(JSON.stringify(result.issues, undefined, 2), {
3157
+ cause: result,
3158
+ })
3159
+
3160
+ return result.value
3161
+ }
3162
+
3163
+ if ('parse' in validateSearch) {
3164
+ return validateSearch.parse(input)
3165
+ }
3166
+
3167
+ if (typeof validateSearch === 'function') {
3168
+ return validateSearch(input)
3169
+ }
3170
+
3171
+ return {}
3172
+ }
3173
+
3174
+ export const componentTypes = [
3175
+ 'component',
3176
+ 'errorComponent',
3177
+ 'pendingComponent',
3178
+ 'notFoundComponent',
3179
+ ] as const
3180
+
3181
+ function routeNeedsPreload(route: AnyRoute) {
3182
+ for (const componentType of componentTypes) {
3183
+ if ((route.options[componentType] as any)?.preload) {
3184
+ return true
3185
+ }
3186
+ }
3187
+ return false
3188
+ }