@tanstack/router-core 0.0.1-beta.45 → 0.0.1-beta.49

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 (44) hide show
  1. package/build/cjs/actions.js +94 -0
  2. package/build/cjs/actions.js.map +1 -0
  3. package/build/cjs/history.js +163 -0
  4. package/build/cjs/history.js.map +1 -0
  5. package/build/cjs/index.js +18 -20
  6. package/build/cjs/index.js.map +1 -1
  7. package/build/cjs/interop.js +175 -0
  8. package/build/cjs/interop.js.map +1 -0
  9. package/build/cjs/path.js +4 -5
  10. package/build/cjs/path.js.map +1 -1
  11. package/build/cjs/route.js +16 -138
  12. package/build/cjs/route.js.map +1 -1
  13. package/build/cjs/routeConfig.js +1 -7
  14. package/build/cjs/routeConfig.js.map +1 -1
  15. package/build/cjs/routeMatch.js +194 -199
  16. package/build/cjs/routeMatch.js.map +1 -1
  17. package/build/cjs/router.js +726 -703
  18. package/build/cjs/router.js.map +1 -1
  19. package/build/cjs/store.js +54 -0
  20. package/build/cjs/store.js.map +1 -0
  21. package/build/esm/index.js +1305 -1114
  22. package/build/esm/index.js.map +1 -1
  23. package/build/stats-html.html +1 -1
  24. package/build/stats-react.json +229 -192
  25. package/build/types/index.d.ts +172 -109
  26. package/build/umd/index.development.js +1381 -2331
  27. package/build/umd/index.development.js.map +1 -1
  28. package/build/umd/index.production.js +1 -1
  29. package/build/umd/index.production.js.map +1 -1
  30. package/package.json +3 -3
  31. package/src/actions.ts +157 -0
  32. package/src/history.ts +199 -0
  33. package/src/index.ts +4 -7
  34. package/src/interop.ts +169 -0
  35. package/src/link.ts +2 -2
  36. package/src/route.ts +34 -239
  37. package/src/routeConfig.ts +3 -34
  38. package/src/routeInfo.ts +6 -21
  39. package/src/routeMatch.ts +270 -285
  40. package/src/router.ts +967 -963
  41. package/src/store.ts +52 -0
  42. package/build/cjs/sharedClone.js +0 -122
  43. package/build/cjs/sharedClone.js.map +0 -1
  44. package/src/sharedClone.ts +0 -118
package/src/router.ts CHANGED
@@ -1,11 +1,3 @@
1
- import {
2
- BrowserHistory,
3
- createBrowserHistory,
4
- createMemoryHistory,
5
- HashHistory,
6
- History,
7
- MemoryHistory,
8
- } from 'history'
9
1
  import invariant from 'tiny-invariant'
10
2
  import { GetFrameworkGeneric } from './frameworks'
11
3
 
@@ -25,7 +17,7 @@ import {
25
17
  resolvePath,
26
18
  trimPath,
27
19
  } from './path'
28
- import { AnyRoute, createRoute, Route } from './route'
20
+ import { AnyRoute, Route } from './route'
29
21
  import {
30
22
  AnyLoaderData,
31
23
  AnyPathParams,
@@ -38,13 +30,12 @@ import {
38
30
  import {
39
31
  AllRouteInfo,
40
32
  AnyAllRouteInfo,
41
- AnyRouteInfo,
42
33
  RouteInfo,
43
34
  RoutesById,
44
35
  } from './routeInfo'
45
- import { createRouteMatch, RouteMatch, RouteMatchStore } from './routeMatch'
36
+ import { RouteMatch, RouteMatchStore } from './routeMatch'
46
37
  import { defaultParseSearch, defaultStringifySearch } from './searchParams'
47
- import { createStore, batch, SetStoreFunction } from '@solidjs/reactivity'
38
+ import { createStore, batch, Store } from './store'
48
39
  import {
49
40
  functionalUpdate,
50
41
  last,
@@ -55,12 +46,19 @@ import {
55
46
  Timeout,
56
47
  Updater,
57
48
  } from './utils'
58
- import { sharedClone } from './sharedClone'
49
+ import { replaceEqualDeep } from './interop'
50
+ import {
51
+ createBrowserHistory,
52
+ createMemoryHistory,
53
+ RouterHistory,
54
+ } from './history'
59
55
 
60
56
  export interface RegisterRouter {
61
57
  // router: Router
62
58
  }
63
59
 
60
+ export type AnyRouter = Router<any, any, any>
61
+
64
62
  export type RegisteredRouter = RegisterRouter extends {
65
63
  router: Router<infer TRouteConfig, infer TAllRouteInfo, infer TRouterContext>
66
64
  }
@@ -75,7 +73,7 @@ export type RegisteredAllRouteInfo = RegisterRouter extends {
75
73
 
76
74
  export interface LocationState {}
77
75
 
78
- export interface Location<
76
+ export interface ParsedLocation<
79
77
  TSearchObj extends AnySearchSchema = {},
80
78
  TState extends LocationState = LocationState,
81
79
  > {
@@ -105,7 +103,7 @@ export interface RouterOptions<
105
103
  TRouteConfig extends AnyRouteConfig,
106
104
  TRouterContext,
107
105
  > {
108
- history?: BrowserHistory | MemoryHistory | HashHistory
106
+ history?: RouterHistory
109
107
  stringifySearch?: SearchSerializer
110
108
  parseSearch?: SearchParser
111
109
  filterRoutes?: FilterRoutesFn
@@ -122,43 +120,20 @@ export interface RouterOptions<
122
120
  routeConfig?: TRouteConfig
123
121
  basepath?: string
124
122
  useServerData?: boolean
125
- createRouter?: (router: Router<any, any, any>) => void
126
- createRoute?: (opts: {
127
- route: AnyRoute
128
- router: Router<any, any, any>
129
- }) => void
123
+ createRouter?: (router: AnyRouter) => void
124
+ createRoute?: (opts: { route: AnyRoute; router: AnyRouter }) => void
130
125
  context?: TRouterContext
131
126
  loadComponent?: (
132
127
  component: GetFrameworkGeneric<'Component'>,
133
128
  ) => Promise<GetFrameworkGeneric<'Component'>>
129
+ onRouteChange?: () => void
130
+ fetchServerDataFn?: FetchServerDataFn
134
131
  }
135
132
 
136
- export interface Action<
137
- TPayload = unknown,
138
- TResponse = unknown,
139
- // TError = unknown,
140
- > {
141
- submit: (
142
- submission?: TPayload,
143
- actionOpts?: { invalidate?: boolean; multi?: boolean },
144
- ) => Promise<TResponse>
145
- current?: ActionState<TPayload, TResponse>
146
- latest?: ActionState<TPayload, TResponse>
147
- submissions: ActionState<TPayload, TResponse>[]
148
- }
149
-
150
- export interface ActionState<
151
- TPayload = unknown,
152
- TResponse = unknown,
153
- // TError = unknown,
154
- > {
155
- submittedAt: number
156
- status: 'idle' | 'pending' | 'success' | 'error'
157
- submission: TPayload
158
- isMulti: boolean
159
- data?: TResponse
160
- error?: unknown
161
- }
133
+ type FetchServerDataFn = (ctx: {
134
+ router: AnyRouter
135
+ routeMatch: RouteMatch
136
+ }) => Promise<any>
162
137
 
163
138
  export interface Loader<
164
139
  TFullSearchSchema extends AnySearchSchema = {},
@@ -201,21 +176,18 @@ export interface RouterStore<
201
176
  TState extends LocationState = LocationState,
202
177
  > {
203
178
  status: 'idle' | 'loading'
204
- latestLocation: Location<TSearchObj, TState>
179
+ latestLocation: ParsedLocation<TSearchObj, TState>
205
180
  currentMatches: RouteMatch[]
206
- currentLocation: Location<TSearchObj, TState>
181
+ currentLocation: ParsedLocation<TSearchObj, TState>
207
182
  pendingMatches?: RouteMatch[]
208
- pendingLocation?: Location<TSearchObj, TState>
183
+ pendingLocation?: ParsedLocation<TSearchObj, TState>
209
184
  lastUpdated: number
210
- actions: Record<string, Action>
211
185
  loaders: Record<string, Loader>
212
186
  isFetching: boolean
213
187
  isPreloading: boolean
214
188
  matchCache: Record<string, MatchCacheEntry>
215
189
  }
216
190
 
217
- type Listener = (router: Router<any, any, any>) => void
218
-
219
191
  export type ListenerFn = () => void
220
192
 
221
193
  export interface BuildNextOptions {
@@ -264,7 +236,7 @@ export interface DehydratedRouterState
264
236
 
265
237
  export interface DehydratedRouter<TRouterContext = unknown> {
266
238
  // location: Router['__location']
267
- store: DehydratedRouterState
239
+ state: DehydratedRouterState
268
240
  context: TRouterContext
269
241
  }
270
242
 
@@ -272,1119 +244,1153 @@ export type MatchCache = Record<string, MatchCacheEntry>
272
244
 
273
245
  interface DehydratedRouteMatch {
274
246
  matchId: string
275
- store: Pick<
247
+ state: Pick<
276
248
  RouteMatchStore<any, any>,
277
- 'status' | 'routeLoaderData' | 'isInvalid' | 'invalidAt'
249
+ 'status' | 'routeLoaderData' | 'invalid' | 'invalidAt'
278
250
  >
279
251
  }
280
252
 
281
253
  export interface RouterContext {}
282
254
 
283
- export interface Router<
255
+ export const defaultFetchServerDataFn: FetchServerDataFn = async ({
256
+ router,
257
+ routeMatch,
258
+ }) => {
259
+ const next = router.buildNext({
260
+ to: '.',
261
+ search: (d: any) => ({
262
+ ...(d ?? {}),
263
+ __data: {
264
+ matchId: routeMatch.id,
265
+ },
266
+ }),
267
+ })
268
+
269
+ const res = await fetch(next.href, {
270
+ method: 'GET',
271
+ signal: routeMatch.abortController.signal,
272
+ })
273
+
274
+ if (res.ok) {
275
+ return res.json()
276
+ }
277
+
278
+ throw new Error('Failed to fetch match data')
279
+ }
280
+
281
+ export class Router<
284
282
  TRouteConfig extends AnyRouteConfig = RouteConfig,
285
283
  TAllRouteInfo extends AnyAllRouteInfo = AllRouteInfo<TRouteConfig>,
286
284
  TRouterContext = unknown,
287
285
  > {
288
- types: {
286
+ types!: {
289
287
  // Super secret internal stuff
290
288
  RouteConfig: TRouteConfig
291
289
  AllRouteInfo: TAllRouteInfo
292
290
  }
293
291
 
294
- // Public API
295
- history: BrowserHistory | MemoryHistory | HashHistory
296
292
  options: PickAsRequired<
297
293
  RouterOptions<TRouteConfig, TRouterContext>,
298
294
  'stringifySearch' | 'parseSearch' | 'context'
299
295
  >
300
- store: RouterStore<TAllRouteInfo['fullSearchSchema']>
301
- setStore: SetStoreFunction<RouterStore<TAllRouteInfo['fullSearchSchema']>>
296
+ history: RouterHistory
302
297
  basepath: string
303
298
  // __location: Location<TAllRouteInfo['fullSearchSchema']>
304
- routeTree: Route<TAllRouteInfo, RouteInfo>
305
- routesById: RoutesById<TAllRouteInfo>
306
- reset: () => void
307
- mount: () => () => void
308
- update: <
309
- TRouteConfig extends RouteConfig = RouteConfig,
310
- TAllRouteInfo extends AnyAllRouteInfo = AllRouteInfo<TRouteConfig>,
311
- TRouterContext = unknown,
312
- >(
313
- opts?: RouterOptions<TRouteConfig, TRouterContext>,
314
- ) => Router<TRouteConfig, TAllRouteInfo, TRouterContext>
315
-
316
- buildNext: (opts: BuildNextOptions) => Location
317
- cancelMatches: () => void
318
- load: (next?: Location) => Promise<void>
319
- cleanMatchCache: () => void
320
- getRoute: <TId extends keyof TAllRouteInfo['routeInfoById']>(
321
- id: TId,
322
- ) => Route<TAllRouteInfo, TAllRouteInfo['routeInfoById'][TId]>
323
- loadRoute: (navigateOpts: BuildNextOptions) => Promise<RouteMatch[]>
324
- preloadRoute: (
325
- navigateOpts: BuildNextOptions,
326
- loaderOpts: { maxAge?: number; gcMaxAge?: number },
327
- ) => Promise<RouteMatch[]>
328
- matchRoutes: (
329
- pathname: string,
330
- opts?: { strictParseParams?: boolean },
331
- ) => RouteMatch[]
332
- loadMatches: (
333
- resolvedMatches: RouteMatch[],
334
- loaderOpts?:
335
- | { preload: true; maxAge: number; gcMaxAge: number }
336
- | { preload?: false; maxAge?: never; gcMaxAge?: never },
337
- ) => Promise<void>
338
- loadMatchData: (
339
- routeMatch: RouteMatch<any, any>,
340
- ) => Promise<Record<string, unknown>>
341
- invalidateRoute: (opts: MatchLocation) => void
342
- reload: () => Promise<void>
343
- resolvePath: (from: string, path: string) => string
344
- navigate: <
345
- TFrom extends ValidFromPath<TAllRouteInfo> = '/',
346
- TTo extends string = '.',
347
- >(
348
- opts: NavigateOptions<TAllRouteInfo, TFrom, TTo>,
349
- ) => Promise<void>
350
- matchRoute: <
351
- TFrom extends ValidFromPath<TAllRouteInfo> = '/',
352
- TTo extends string = '.',
353
- >(
354
- matchLocation: ToOptions<TAllRouteInfo, TFrom, TTo>,
355
- opts?: MatchRouteOptions,
356
- ) =>
357
- | false
358
- | TAllRouteInfo['routeInfoById'][ResolveRelativePath<
359
- TFrom,
360
- NoInfer<TTo>
361
- >]['allParams']
362
- buildLink: <
363
- TFrom extends ValidFromPath<TAllRouteInfo> = '/',
364
- TTo extends string = '.',
365
- >(
366
- opts: LinkOptions<TAllRouteInfo, TFrom, TTo>,
367
- ) => LinkInfo
368
- dehydrate: () => DehydratedRouter<TRouterContext>
369
- hydrate: (dehydratedRouter: DehydratedRouter<TRouterContext>) => void
370
- }
299
+ routeTree!: Route<TAllRouteInfo, RouteInfo>
300
+ routesById!: RoutesById<TAllRouteInfo>
301
+ navigateTimeout: undefined | Timeout
302
+ nextAction: undefined | 'push' | 'replace'
303
+ navigationPromise: undefined | Promise<void>
304
+
305
+ store: Store<RouterStore<TAllRouteInfo['fullSearchSchema']>>
306
+ startedLoadingAt = Date.now()
307
+ resolveNavigation = () => {}
308
+
309
+ constructor(options?: RouterOptions<TRouteConfig, TRouterContext>) {
310
+ this.options = {
311
+ defaultLoaderGcMaxAge: 5 * 60 * 1000,
312
+ defaultLoaderMaxAge: 0,
313
+ defaultPreloadMaxAge: 2000,
314
+ defaultPreloadDelay: 50,
315
+ context: undefined!,
316
+ ...options,
317
+ stringifySearch: options?.stringifySearch ?? defaultStringifySearch,
318
+ parseSearch: options?.parseSearch ?? defaultParseSearch,
319
+ fetchServerDataFn: options?.fetchServerDataFn ?? defaultFetchServerDataFn,
320
+ }
371
321
 
372
- // Detect if we're in the DOM
373
- const isServer =
374
- typeof window === 'undefined' || !window.document?.createElement
322
+ this.history =
323
+ this.options?.history ?? isServer
324
+ ? createMemoryHistory()
325
+ : createBrowserHistory()
326
+ this.store = createStore(getInitialRouterState())
327
+ this.basepath = ''
375
328
 
376
- // This is the default history object if none is defined
377
- const createDefaultHistory = () =>
378
- isServer ? createMemoryHistory() : createBrowserHistory()
329
+ this.update(options)
379
330
 
380
- function getInitialRouterState(): RouterStore {
381
- return {
382
- status: 'idle',
383
- latestLocation: null!,
384
- currentLocation: null!,
385
- currentMatches: [],
386
- actions: {},
387
- loaders: {},
388
- lastUpdated: Date.now(),
389
- matchCache: {},
390
- get isFetching() {
391
- return (
392
- this.status === 'loading' ||
393
- this.currentMatches.some((d) => d.store.isFetching)
394
- )
395
- },
396
- get isPreloading() {
397
- return Object.values(this.matchCache).some(
398
- (d) =>
399
- d.match.store.isFetching &&
400
- !this.currentMatches.find((dd) => dd.matchId === d.match.matchId),
401
- )
402
- },
331
+ // Allow frameworks to hook into the router creation
332
+ this.options.createRouter?.(this)
403
333
  }
404
- }
405
334
 
406
- export function createRouter<
407
- TRouteConfig extends AnyRouteConfig = RouteConfig,
408
- TAllRouteInfo extends AnyAllRouteInfo = AllRouteInfo<TRouteConfig>,
409
- TRouterContext = unknown,
410
- >(
411
- userOptions?: RouterOptions<TRouteConfig, TRouterContext>,
412
- ): Router<TRouteConfig, TAllRouteInfo, TRouterContext> {
413
- const originalOptions = {
414
- defaultLoaderGcMaxAge: 5 * 60 * 1000,
415
- defaultLoaderMaxAge: 0,
416
- defaultPreloadMaxAge: 2000,
417
- defaultPreloadDelay: 50,
418
- context: undefined!,
419
- ...userOptions,
420
- stringifySearch: userOptions?.stringifySearch ?? defaultStringifySearch,
421
- parseSearch: userOptions?.parseSearch ?? defaultParseSearch,
335
+ reset = () => {
336
+ this.store.setState((s) => Object.assign(s, getInitialRouterState()))
422
337
  }
423
338
 
424
- const [store, setStore] = createStore<RouterStore>(getInitialRouterState())
339
+ mount = () => {
340
+ // Mount only does anything on the client
341
+ if (!isServer) {
342
+ // If the router matches are empty, load the matches
343
+ if (!this.store.state.currentMatches.length) {
344
+ this.load()
345
+ }
425
346
 
426
- let navigateTimeout: undefined | Timeout
427
- let nextAction: undefined | 'push' | 'replace'
428
- let navigationPromise: undefined | Promise<void>
347
+ const unsubHistory = this.history.listen(() => {
348
+ this.load(this.#parseLocation(this.store.state.latestLocation))
349
+ })
429
350
 
430
- let startedLoadingAt = Date.now()
431
- let resolveNavigation = () => {}
351
+ const visibilityChangeEvent = 'visibilitychange'
352
+ const focusEvent = 'focus'
432
353
 
433
- function onFocus() {
434
- router.load()
435
- }
354
+ // addEventListener does not exist in React Native, but window does
355
+ // In the future, we might need to invert control here for more adapters
356
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
357
+ if (window.addEventListener) {
358
+ // Listen to visibilitychange and focus
359
+ window.addEventListener(visibilityChangeEvent, this.#onFocus, false)
360
+ window.addEventListener(focusEvent, this.#onFocus, false)
361
+ }
436
362
 
437
- function buildRouteTree(rootRouteConfig: RouteConfig) {
438
- const recurseRoutes = (
439
- routeConfigs: RouteConfig[],
440
- parent?: Route<TAllRouteInfo, any, any>,
441
- ): Route<TAllRouteInfo, any, any>[] => {
442
- return routeConfigs.map((routeConfig, i) => {
443
- const routeOptions = routeConfig.options
444
- const route = createRoute(routeConfig, routeOptions, i, parent, router)
445
- const existingRoute = (router.routesById as any)[route.routeId]
363
+ return () => {
364
+ unsubHistory()
365
+ if (window.removeEventListener) {
366
+ // Be sure to unsubscribe if a new handler is set
446
367
 
447
- if (existingRoute) {
448
- if (process.env.NODE_ENV !== 'production') {
449
- console.warn(
450
- `Duplicate routes found with id: ${String(route.routeId)}`,
451
- router.routesById,
452
- route,
453
- )
454
- }
455
- throw new Error()
368
+ window.removeEventListener(visibilityChangeEvent, this.#onFocus)
369
+ window.removeEventListener(focusEvent, this.#onFocus)
456
370
  }
371
+ }
372
+ }
457
373
 
458
- ;(router.routesById as any)[route.routeId] = route
459
-
460
- const children = routeConfig.children as RouteConfig[]
461
-
462
- route.childRoutes = children?.length
463
- ? recurseRoutes(children, route)
464
- : undefined
374
+ return () => {}
375
+ }
465
376
 
466
- return route
377
+ update = <
378
+ TRouteConfig extends RouteConfig = RouteConfig,
379
+ TAllRouteInfo extends AnyAllRouteInfo = AllRouteInfo<TRouteConfig>,
380
+ TRouterContext = unknown,
381
+ >(
382
+ opts?: RouterOptions<TRouteConfig, TRouterContext>,
383
+ ): Router<TRouteConfig, TAllRouteInfo, TRouterContext> => {
384
+ if (!this.store.state.latestLocation) {
385
+ this.store.setState((s) => {
386
+ s.latestLocation = this.#parseLocation()
387
+ s.currentLocation = s.latestLocation
467
388
  })
468
389
  }
469
390
 
470
- const routes = recurseRoutes([rootRouteConfig])
391
+ Object.assign(this.options, opts)
471
392
 
472
- return routes[0]!
473
- }
393
+ const { basepath, routeConfig } = this.options
474
394
 
475
- function parseLocation(
476
- location: History['location'],
477
- previousLocation?: Location,
478
- ): Location {
479
- const parsedSearch = router.options.parseSearch(location.search)
395
+ this.basepath = `/${trimPath(basepath ?? '') ?? ''}`
480
396
 
481
- return {
482
- pathname: location.pathname,
483
- searchStr: location.search,
484
- search: sharedClone(previousLocation?.search, parsedSearch),
485
- hash: location.hash.split('#').reverse()[0] ?? '',
486
- href: `${location.pathname}${location.search}${location.hash}`,
487
- state: location.state as LocationState,
488
- key: location.key,
397
+ if (routeConfig) {
398
+ this.routesById = {} as any
399
+ this.routeTree = this.#buildRouteTree(routeConfig)
489
400
  }
490
- }
491
401
 
492
- function navigate(location: BuildNextOptions & { replace?: boolean }) {
493
- const next = router.buildNext(location)
494
- return commitLocation(next, location.replace)
402
+ return this as any
495
403
  }
496
404
 
497
- function buildLocation(dest: BuildNextOptions = {}): Location {
498
- const fromPathname = dest.fromCurrent
499
- ? store.latestLocation.pathname
500
- : dest.from ?? store.latestLocation.pathname
405
+ buildNext = (opts: BuildNextOptions) => {
406
+ const next = this.#buildLocation(opts)
501
407
 
502
- let pathname = resolvePath(
503
- router.basepath ?? '/',
504
- fromPathname,
505
- `${dest.to ?? '.'}`,
506
- )
408
+ const matches = this.matchRoutes(next.pathname)
507
409
 
508
- const fromMatches = router.matchRoutes(store.latestLocation.pathname, {
509
- strictParseParams: true,
510
- })
410
+ const __preSearchFilters = matches
411
+ .map((match) => match.route.options.preSearchFilters ?? [])
412
+ .flat()
413
+ .filter(Boolean)
511
414
 
512
- const toMatches = router.matchRoutes(pathname)
415
+ const __postSearchFilters = matches
416
+ .map((match) => match.route.options.postSearchFilters ?? [])
417
+ .flat()
418
+ .filter(Boolean)
513
419
 
514
- const prevParams = { ...last(fromMatches)?.params }
420
+ return this.#buildLocation({
421
+ ...opts,
422
+ __preSearchFilters,
423
+ __postSearchFilters,
424
+ })
425
+ }
515
426
 
516
- let nextParams =
517
- (dest.params ?? true) === true
518
- ? prevParams
519
- : functionalUpdate(dest.params!, prevParams)
427
+ cancelMatches = () => {
428
+ ;[
429
+ ...this.store.state.currentMatches,
430
+ ...(this.store.state.pendingMatches || []),
431
+ ].forEach((match) => {
432
+ match.cancel()
433
+ })
434
+ }
520
435
 
521
- if (nextParams) {
522
- toMatches
523
- .map((d) => d.options.stringifyParams)
524
- .filter(Boolean)
525
- .forEach((fn) => {
526
- Object.assign({}, nextParams!, fn!(nextParams!))
527
- })
528
- }
436
+ load = async (next?: ParsedLocation) => {
437
+ let now = Date.now()
438
+ const startedAt = now
439
+ this.startedLoadingAt = startedAt
529
440
 
530
- pathname = interpolatePath(pathname, nextParams ?? {})
441
+ // Cancel any pending matches
442
+ this.cancelMatches()
531
443
 
532
- // Pre filters first
533
- const preFilteredSearch = dest.__preSearchFilters?.length
534
- ? dest.__preSearchFilters.reduce(
535
- (prev, next) => next(prev),
536
- store.latestLocation.search,
537
- )
538
- : store.latestLocation.search
444
+ let matches!: RouteMatch<any, any>[]
539
445
 
540
- // Then the link/navigate function
541
- const destSearch =
542
- dest.search === true
543
- ? preFilteredSearch // Preserve resolvedFrom true
544
- : dest.search
545
- ? functionalUpdate(dest.search, preFilteredSearch) ?? {} // Updater
546
- : dest.__preSearchFilters?.length
547
- ? preFilteredSearch // Preserve resolvedFrom filters
548
- : {}
446
+ batch(() => {
447
+ if (next) {
448
+ // Ingest the new location
449
+ this.store.setState((s) => {
450
+ s.latestLocation = next
451
+ })
452
+ }
549
453
 
550
- // Then post filters
551
- const postFilteredSearch = dest.__postSearchFilters?.length
552
- ? dest.__postSearchFilters.reduce((prev, next) => next(prev), destSearch)
553
- : destSearch
454
+ // Match the routes
455
+ matches = this.matchRoutes(this.store.state.latestLocation.pathname, {
456
+ strictParseParams: true,
457
+ })
554
458
 
555
- const search = sharedClone(store.latestLocation.search, postFilteredSearch)
459
+ this.store.setState((s) => {
460
+ s.status = 'loading'
461
+ s.pendingMatches = matches
462
+ s.pendingLocation = this.store.state.latestLocation
463
+ })
464
+ })
556
465
 
557
- const searchStr = router.options.stringifySearch(search)
558
- let hash =
559
- dest.hash === true
560
- ? store.latestLocation.hash
561
- : functionalUpdate(dest.hash!, store.latestLocation.hash)
562
- hash = hash ? `#${hash}` : ''
466
+ // Load the matches
467
+ try {
468
+ await this.loadMatches(matches)
469
+ } catch (err: any) {
470
+ console.warn(err)
471
+ invariant(
472
+ false,
473
+ 'Matches failed to load due to error above ☝️. Navigation cancelled!',
474
+ )
475
+ }
563
476
 
564
- return {
565
- pathname,
566
- search,
567
- searchStr,
568
- state: store.latestLocation.state,
569
- hash,
570
- href: `${pathname}${searchStr}${hash}`,
571
- key: dest.key,
477
+ if (this.startedLoadingAt !== startedAt) {
478
+ // Ignore side-effects of outdated side-effects
479
+ return this.navigationPromise
572
480
  }
573
- }
574
481
 
575
- function commitLocation(next: Location, replace?: boolean): Promise<void> {
576
- const id = '' + Date.now() + Math.random()
482
+ const previousMatches = this.store.state.currentMatches
577
483
 
578
- if (navigateTimeout) clearTimeout(navigateTimeout)
484
+ const exiting: RouteMatch[] = [],
485
+ staying: RouteMatch[] = []
579
486
 
580
- let nextAction: 'push' | 'replace' = 'replace'
487
+ previousMatches.forEach((d) => {
488
+ if (matches.find((dd) => dd.id === d.id)) {
489
+ staying.push(d)
490
+ } else {
491
+ exiting.push(d)
492
+ }
493
+ })
581
494
 
582
- if (!replace) {
583
- nextAction = 'push'
584
- }
495
+ const entering = matches.filter((d) => {
496
+ return !previousMatches.find((dd) => dd.id === d.id)
497
+ })
585
498
 
586
- const isSameUrl = parseLocation(router.history.location).href === next.href
499
+ now = Date.now()
587
500
 
588
- if (isSameUrl && !next.key) {
589
- nextAction = 'replace'
590
- }
501
+ exiting.forEach((d) => {
502
+ d.__onExit?.({
503
+ params: d.params,
504
+ search: d.store.state.routeSearch,
505
+ })
591
506
 
592
- router.history[nextAction](
593
- {
594
- pathname: next.pathname,
595
- hash: next.hash,
596
- search: next.searchStr,
597
- },
598
- {
599
- id,
600
- ...next.state,
601
- },
602
- )
507
+ // Clear non-loading error states when match leaves
508
+ if (d.store.state.status === 'error' && !d.store.state.isFetching) {
509
+ d.store.setState((s) => {
510
+ s.status = 'idle'
511
+ s.error = undefined
512
+ })
513
+ }
603
514
 
604
- return (navigationPromise = new Promise((resolve) => {
605
- const previousNavigationResolve = resolveNavigation
515
+ const gc = Math.max(
516
+ d.route.options.loaderGcMaxAge ??
517
+ this.options.defaultLoaderGcMaxAge ??
518
+ 0,
519
+ d.route.options.loaderMaxAge ?? this.options.defaultLoaderMaxAge ?? 0,
520
+ )
606
521
 
607
- resolveNavigation = () => {
608
- previousNavigationResolve()
609
- resolve()
522
+ if (gc > 0) {
523
+ this.store.setState((s) => {
524
+ s.matchCache[d.id] = {
525
+ gc: gc == Infinity ? Number.MAX_SAFE_INTEGER : now + gc,
526
+ match: d,
527
+ }
528
+ })
610
529
  }
611
- }))
612
- }
530
+ })
613
531
 
614
- const router: Router<TRouteConfig, TAllRouteInfo, TRouterContext> = {
615
- types: undefined!,
532
+ staying.forEach((d) => {
533
+ d.route.options.onTransition?.({
534
+ params: d.params,
535
+ search: d.store.state.routeSearch,
536
+ })
537
+ })
616
538
 
617
- // public api
618
- history: userOptions?.history || createDefaultHistory(),
619
- store,
620
- setStore,
621
- options: originalOptions,
622
- basepath: '',
623
- routeTree: undefined!,
624
- routesById: {} as any,
539
+ entering.forEach((d) => {
540
+ d.__onExit = d.route.options.onLoaded?.({
541
+ params: d.params,
542
+ search: d.store.state.search,
543
+ })
544
+ delete this.store.state.matchCache[d.id]
545
+ })
625
546
 
626
- reset: () => {
627
- setStore((s) => Object.assign(s, getInitialRouterState()))
628
- },
547
+ this.store.setState((s) => {
548
+ Object.assign(s, {
549
+ status: 'idle',
550
+ currentLocation: this.store.state.latestLocation,
551
+ currentMatches: matches,
552
+ pendingLocation: undefined,
553
+ pendingMatches: undefined,
554
+ })
555
+ })
629
556
 
630
- getRoute: (id) => {
631
- return router.routesById[id]
632
- },
557
+ this.options.onRouteChange?.()
633
558
 
634
- dehydrate: () => {
635
- return {
636
- store: {
637
- ...pick(store, [
638
- 'latestLocation',
639
- 'currentLocation',
640
- 'status',
641
- 'lastUpdated',
642
- ]),
643
- currentMatches: store.currentMatches.map((match) => ({
644
- matchId: match.matchId,
645
- store: pick(match.store, [
646
- 'status',
647
- 'routeLoaderData',
648
- 'isInvalid',
649
- 'invalidAt',
650
- ]),
651
- })),
652
- },
653
- context: router.options.context as TRouterContext,
654
- }
655
- },
559
+ this.resolveNavigation()
560
+ }
656
561
 
657
- hydrate: (dehydratedRouter) => {
658
- setStore((s) => {
659
- // Update the context TODO: make this part of state?
660
- router.options.context = dehydratedRouter.context
562
+ cleanMatchCache = () => {
563
+ const now = Date.now()
661
564
 
662
- // Match the routes
663
- const currentMatches = router.matchRoutes(
664
- dehydratedRouter.store.latestLocation.pathname,
665
- {
666
- strictParseParams: true,
667
- },
668
- )
565
+ this.store.setState((s) => {
566
+ Object.keys(s.matchCache).forEach((matchId) => {
567
+ const entry = s.matchCache[matchId]!
669
568
 
670
- currentMatches.forEach((match, index) => {
671
- const dehydratedMatch = dehydratedRouter.store.currentMatches[index]
672
- invariant(
673
- dehydratedMatch && dehydratedMatch.matchId === match.matchId,
674
- 'Oh no! There was a hydration mismatch when attempting to restore the state of the router! 😬',
675
- )
676
- Object.assign(match, dehydratedMatch)
677
- })
569
+ // Don't remove loading matches
570
+ if (entry.match.store.state.status === 'loading') {
571
+ return
572
+ }
678
573
 
679
- currentMatches.forEach((match) => match.__.validate())
574
+ // Do not remove successful matches that are still valid
575
+ if (entry.gc > 0 && entry.gc > now) {
576
+ return
577
+ }
680
578
 
681
- Object.assign(s, { ...dehydratedRouter.store, currentMatches })
579
+ // Everything else gets removed
580
+ delete s.matchCache[matchId]
682
581
  })
683
- },
582
+ })
583
+ }
684
584
 
685
- mount: () => {
686
- // Mount only does anything on the client
687
- if (!isServer) {
688
- // If the router matches are empty, load the matches
689
- if (!store.currentMatches.length) {
690
- router.load()
691
- }
585
+ getRoute = <TId extends keyof TAllRouteInfo['routeInfoById']>(
586
+ id: TId,
587
+ ): Route<TAllRouteInfo, TAllRouteInfo['routeInfoById'][TId]> => {
588
+ const route = this.routesById[id]
692
589
 
693
- const unsub = router.history.listen((event) => {
694
- router.load(parseLocation(event.location, store.latestLocation))
695
- })
590
+ invariant(route, `Route with id "${id as string}" not found`)
696
591
 
697
- // addEventListener does not exist in React Native, but window does
698
- // In the future, we might need to invert control here for more adapters
699
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
700
- if (window.addEventListener) {
701
- // Listen to visibilitychange and focus
702
- window.addEventListener('visibilitychange', onFocus, false)
703
- window.addEventListener('focus', onFocus, false)
704
- }
592
+ return route
593
+ }
705
594
 
706
- return () => {
707
- unsub()
708
- if (window.removeEventListener) {
709
- // Be sure to unsubscribe if a new handler is set
710
- window.removeEventListener('visibilitychange', onFocus)
711
- window.removeEventListener('focus', onFocus)
712
- }
713
- }
714
- }
595
+ loadRoute = async (
596
+ navigateOpts: BuildNextOptions = this.store.state.latestLocation,
597
+ ): Promise<RouteMatch[]> => {
598
+ const next = this.buildNext(navigateOpts)
599
+ const matches = this.matchRoutes(next.pathname, {
600
+ strictParseParams: true,
601
+ })
602
+ await this.loadMatches(matches)
603
+ return matches
604
+ }
715
605
 
716
- return () => {}
717
- },
606
+ preloadRoute = async (
607
+ navigateOpts: BuildNextOptions = this.store.state.latestLocation,
608
+ loaderOpts: { maxAge?: number; gcMaxAge?: number },
609
+ ) => {
610
+ const next = this.buildNext(navigateOpts)
611
+ const matches = this.matchRoutes(next.pathname, {
612
+ strictParseParams: true,
613
+ })
718
614
 
719
- update: (opts) => {
720
- const newHistory = opts?.history !== router.history
721
- if (!store.latestLocation || newHistory) {
722
- if (opts?.history) {
723
- router.history = opts.history
724
- }
725
- setStore((s) => {
726
- s.latestLocation = parseLocation(router.history.location)
727
- s.currentLocation = s.latestLocation
728
- })
729
- }
615
+ await this.loadMatches(matches, {
616
+ preload: true,
617
+ maxAge:
618
+ loaderOpts.maxAge ??
619
+ this.options.defaultPreloadMaxAge ??
620
+ this.options.defaultLoaderMaxAge ??
621
+ 0,
622
+ gcMaxAge:
623
+ loaderOpts.gcMaxAge ??
624
+ this.options.defaultPreloadGcMaxAge ??
625
+ this.options.defaultLoaderGcMaxAge ??
626
+ 0,
627
+ })
628
+ return matches
629
+ }
730
630
 
731
- Object.assign(router.options, opts)
631
+ matchRoutes = (pathname: string, opts?: { strictParseParams?: boolean }) => {
632
+ const matches: RouteMatch[] = []
732
633
 
733
- const { basepath, routeConfig } = router.options
634
+ if (!this.routeTree) {
635
+ return matches
636
+ }
734
637
 
735
- router.basepath = `/${trimPath(basepath ?? '') ?? ''}`
638
+ const existingMatches = [
639
+ ...this.store.state.currentMatches,
640
+ ...(this.store.state.pendingMatches ?? []),
641
+ ]
736
642
 
737
- if (routeConfig) {
738
- router.routesById = {} as any
739
- router.routeTree = buildRouteTree(routeConfig)
740
- }
643
+ const recurse = async (routes: Route<any, any>[]): Promise<void> => {
644
+ const parentMatch = last(matches)
645
+ let params = parentMatch?.params ?? {}
741
646
 
742
- return router as any
743
- },
647
+ const filteredRoutes = this.options.filterRoutes?.(routes) ?? routes
744
648
 
745
- cancelMatches: () => {
746
- ;[...store.currentMatches, ...(store.pendingMatches || [])].forEach(
747
- (match) => {
748
- match.cancel()
749
- },
750
- )
751
- },
649
+ let foundRoutes: Route[] = []
752
650
 
753
- load: async (next?: Location) => {
754
- let now = Date.now()
755
- const startedAt = now
756
- startedLoadingAt = startedAt
757
-
758
- // Cancel any pending matches
759
- router.cancelMatches()
651
+ const findMatchInRoutes = (parentRoutes: Route[], routes: Route[]) => {
652
+ routes.some((route) => {
653
+ if (!route.path && route.childRoutes?.length) {
654
+ return findMatchInRoutes([...foundRoutes, route], route.childRoutes)
655
+ }
760
656
 
761
- let matches!: RouteMatch<any, any>[]
657
+ const fuzzy = !!(route.path !== '/' || route.childRoutes?.length)
762
658
 
763
- batch(() => {
764
- if (next) {
765
- // Ingest the new location
766
- setStore((s) => {
767
- s.latestLocation = next
659
+ const matchParams = matchPathname(this.basepath, pathname, {
660
+ to: route.fullPath,
661
+ fuzzy,
662
+ caseSensitive:
663
+ route.options.caseSensitive ?? this.options.caseSensitive,
768
664
  })
769
- }
770
665
 
771
- // Match the routes
772
- matches = router.matchRoutes(store.latestLocation.pathname, {
773
- strictParseParams: true,
774
- })
666
+ if (matchParams) {
667
+ let parsedParams
668
+
669
+ try {
670
+ parsedParams =
671
+ route.options.parseParams?.(matchParams!) ?? matchParams
672
+ } catch (err) {
673
+ if (opts?.strictParseParams) {
674
+ throw err
675
+ }
676
+ }
677
+
678
+ params = {
679
+ ...params,
680
+ ...parsedParams,
681
+ }
682
+ }
775
683
 
776
- console.log('set loading', matches)
777
- setStore((s) => {
778
- s.status = 'loading'
779
- s.pendingMatches = matches
780
- s.pendingLocation = store.latestLocation
684
+ if (!!matchParams) {
685
+ foundRoutes = [...parentRoutes, route]
686
+ }
687
+
688
+ return !!foundRoutes.length
781
689
  })
782
- })
783
690
 
784
- // Load the matches
785
- try {
786
- await router.loadMatches(matches)
787
- } catch (err: any) {
788
- console.log(err)
789
- invariant(
790
- false,
791
- 'Matches failed to load due to error above ☝️. Navigation cancelled!',
792
- )
691
+ return !!foundRoutes.length
793
692
  }
794
693
 
795
- if (startedLoadingAt !== startedAt) {
796
- // Ignore side-effects of outdated side-effects
797
- return navigationPromise
798
- }
694
+ findMatchInRoutes([], filteredRoutes)
799
695
 
800
- const previousMatches = store.currentMatches
696
+ if (!foundRoutes.length) {
697
+ return
698
+ }
801
699
 
802
- const exiting: RouteMatch[] = [],
803
- staying: RouteMatch[] = []
700
+ foundRoutes.forEach((foundRoute) => {
701
+ const interpolatedPath = interpolatePath(foundRoute.path, params)
702
+ const matchId = interpolatePath(foundRoute.id, params, true)
703
+
704
+ const match =
705
+ existingMatches.find((d) => d.id === matchId) ||
706
+ this.store.state.matchCache[matchId]?.match ||
707
+ new RouteMatch(this, foundRoute, {
708
+ matchId,
709
+ params,
710
+ pathname: joinPaths([this.basepath, interpolatedPath]),
711
+ })
804
712
 
805
- previousMatches.forEach((d) => {
806
- if (matches.find((dd) => dd.matchId === d.matchId)) {
807
- staying.push(d)
808
- } else {
809
- exiting.push(d)
810
- }
713
+ matches.push(match)
811
714
  })
812
715
 
813
- const entering = matches.filter((d) => {
814
- return !previousMatches.find((dd) => dd.matchId === d.matchId)
815
- })
716
+ const foundRoute = last(foundRoutes)!
816
717
 
817
- now = Date.now()
718
+ if (foundRoute.childRoutes?.length) {
719
+ recurse(foundRoute.childRoutes)
720
+ }
721
+ }
818
722
 
819
- exiting.forEach((d) => {
820
- d.__.onExit?.({
821
- params: d.params,
822
- search: d.store.routeSearch,
823
- })
723
+ recurse([this.routeTree])
824
724
 
825
- // Clear idle error states when match leaves
826
- if (d.store.status === 'error' && !d.store.isFetching) {
827
- d.store.status = 'idle'
828
- d.store.error = undefined
829
- }
725
+ linkMatches(matches)
830
726
 
831
- const gc = Math.max(
832
- d.options.loaderGcMaxAge ?? router.options.defaultLoaderGcMaxAge ?? 0,
833
- d.options.loaderMaxAge ?? router.options.defaultLoaderMaxAge ?? 0,
834
- )
727
+ return matches
728
+ }
835
729
 
836
- if (gc > 0) {
837
- store.matchCache[d.matchId] = {
838
- gc: gc == Infinity ? Number.MAX_SAFE_INTEGER : now + gc,
839
- match: d,
730
+ loadMatches = async (
731
+ resolvedMatches: RouteMatch[],
732
+ loaderOpts?:
733
+ | { preload: true; maxAge: number; gcMaxAge: number }
734
+ | { preload?: false; maxAge?: never; gcMaxAge?: never },
735
+ ) => {
736
+ this.cleanMatchCache()
737
+ resolvedMatches.forEach(async (match) => {
738
+ // Validate the match (loads search params etc)
739
+ match.__validate()
740
+ })
741
+
742
+ // Check each match middleware to see if the route can be accessed
743
+ await Promise.all(
744
+ resolvedMatches.map(async (match) => {
745
+ try {
746
+ await match.route.options.beforeLoad?.({
747
+ router: this as any,
748
+ match,
749
+ })
750
+ } catch (err) {
751
+ if (!loaderOpts?.preload) {
752
+ match.route.options.onLoadError?.(err)
840
753
  }
754
+
755
+ throw err
841
756
  }
842
- })
757
+ }),
758
+ )
843
759
 
844
- staying.forEach((d) => {
845
- d.options.onTransition?.({
846
- params: d.params,
847
- search: d.store.routeSearch,
848
- })
849
- })
760
+ const matchPromises = resolvedMatches.map(async (match, index) => {
761
+ const prevMatch = resolvedMatches[(index = 1)]
762
+ const search = match.store.state.search as { __data?: any }
850
763
 
851
- entering.forEach((d) => {
852
- d.__.onExit = d.options.onLoaded?.({
853
- params: d.params,
854
- search: d.store.search,
855
- })
856
- delete store.matchCache[d.matchId]
857
- })
858
-
859
- if (startedLoadingAt !== startedAt) {
860
- // Ignore side-effects of match loading
764
+ if (search.__data?.matchId && search.__data.matchId !== match.id) {
861
765
  return
862
766
  }
863
767
 
864
- matches.forEach((match) => {
865
- // Clear actions
866
- if (match.action) {
867
- // TODO: Check reactivity here
868
- match.action.current = undefined
869
- match.action.submissions = []
870
- }
871
- })
768
+ match.load(loaderOpts)
872
769
 
873
- setStore((s) => {
874
- console.log('set', matches)
875
- Object.assign(s, {
876
- status: 'idle',
877
- currentLocation: store.latestLocation,
878
- currentMatches: matches,
879
- pendingLocation: undefined,
880
- pendingMatches: undefined,
881
- })
882
- })
883
-
884
- resolveNavigation()
885
- },
886
-
887
- cleanMatchCache: () => {
888
- const now = Date.now()
770
+ if (match.store.state.status !== 'success' && match.__loadPromise) {
771
+ // Wait for the first sign of activity from the match
772
+ await match.__loadPromise
773
+ }
889
774
 
890
- setStore((s) => {
891
- Object.keys(s.matchCache).forEach((matchId) => {
892
- const entry = s.matchCache[matchId]!
775
+ if (prevMatch) {
776
+ await prevMatch.__loadPromise
777
+ }
778
+ })
893
779
 
894
- // Don't remove loading matches
895
- if (entry.match.store.status === 'loading') {
896
- return
897
- }
780
+ await Promise.all(matchPromises)
781
+ }
898
782
 
899
- // Do not remove successful matches that are still valid
900
- if (entry.gc > 0 && entry.gc > now) {
901
- return
902
- }
783
+ loadMatchData = async (
784
+ routeMatch: RouteMatch<any, any>,
785
+ ): Promise<Record<string, unknown>> => {
786
+ if (isServer || !this.options.useServerData) {
787
+ return (
788
+ (await routeMatch.route.options.loader?.({
789
+ // parentLoaderPromise: routeMatch.parentMatch.dataPromise,
790
+ params: routeMatch.params,
791
+ search: routeMatch.store.state.routeSearch,
792
+ signal: routeMatch.abortController.signal,
793
+ })) || {}
794
+ )
795
+ } else {
796
+ // Refresh:
797
+ // '/dashboard'
798
+ // '/dashboard/invoices/'
799
+ // '/dashboard/invoices/123'
903
800
 
904
- // Everything else gets removed
905
- delete s.matchCache[matchId]
906
- })
907
- })
908
- },
801
+ // New:
802
+ // '/dashboard/invoices/456'
909
803
 
910
- loadRoute: async (navigateOpts = store.latestLocation) => {
911
- const next = router.buildNext(navigateOpts)
912
- const matches = router.matchRoutes(next.pathname, {
913
- strictParseParams: true,
914
- })
915
- await router.loadMatches(matches)
916
- return matches
917
- },
804
+ // TODO: batch requests when possible
918
805
 
919
- preloadRoute: async (navigateOpts = store.latestLocation, loaderOpts) => {
920
- const next = router.buildNext(navigateOpts)
921
- const matches = router.matchRoutes(next.pathname, {
922
- strictParseParams: true,
923
- })
806
+ return this.options.fetchServerDataFn!({ router: this, routeMatch })
807
+ }
808
+ }
924
809
 
925
- await router.loadMatches(matches, {
926
- preload: true,
927
- maxAge:
928
- loaderOpts.maxAge ??
929
- router.options.defaultPreloadMaxAge ??
930
- router.options.defaultLoaderMaxAge ??
931
- 0,
932
- gcMaxAge:
933
- loaderOpts.gcMaxAge ??
934
- router.options.defaultPreloadGcMaxAge ??
935
- router.options.defaultLoaderGcMaxAge ??
936
- 0,
937
- })
938
- return matches
939
- },
810
+ invalidateRoute = async (opts: MatchLocation) => {
811
+ const next = this.buildNext(opts)
812
+ const unloadedMatchIds = this.matchRoutes(next.pathname).map((d) => d.id)
813
+
814
+ await Promise.allSettled(
815
+ [
816
+ ...this.store.state.currentMatches,
817
+ ...(this.store.state.pendingMatches ?? []),
818
+ ].map(async (match) => {
819
+ if (unloadedMatchIds.includes(match.id)) {
820
+ return match.invalidate()
821
+ }
822
+ }),
823
+ )
824
+ }
940
825
 
941
- matchRoutes: (pathname, opts) => {
942
- router.cleanMatchCache()
826
+ reload = () => {
827
+ this.navigate({
828
+ fromCurrent: true,
829
+ replace: true,
830
+ search: true,
831
+ } as any)
832
+ }
943
833
 
944
- const matches: RouteMatch[] = []
834
+ resolvePath = (from: string, path: string) => {
835
+ return resolvePath(this.basepath!, from, cleanPath(path))
836
+ }
945
837
 
946
- if (!router.routeTree) {
947
- return matches
948
- }
838
+ navigate = async <
839
+ TFrom extends ValidFromPath<TAllRouteInfo> = '/',
840
+ TTo extends string = '.',
841
+ >({
842
+ from,
843
+ to = '.' as any,
844
+ search,
845
+ hash,
846
+ replace,
847
+ params,
848
+ }: NavigateOptions<TAllRouteInfo, TFrom, TTo>) => {
849
+ // If this link simply reloads the current route,
850
+ // make sure it has a new key so it will trigger a data refresh
851
+
852
+ // If this `to` is a valid external URL, return
853
+ // null for LinkUtils
854
+ const toString = String(to)
855
+ const fromString = String(from)
856
+
857
+ let isExternal
858
+
859
+ try {
860
+ new URL(`${toString}`)
861
+ isExternal = true
862
+ } catch (e) {}
863
+
864
+ invariant(
865
+ !isExternal,
866
+ 'Attempting to navigate to external url with this.navigate!',
867
+ )
949
868
 
950
- const existingMatches = [
951
- ...store.currentMatches,
952
- ...(store.pendingMatches ?? []),
953
- ]
869
+ return this.#commitLocation({
870
+ from: fromString,
871
+ to: toString,
872
+ search,
873
+ hash,
874
+ replace,
875
+ params,
876
+ })
877
+ }
954
878
 
955
- const recurse = async (routes: Route<any, any>[]): Promise<void> => {
956
- const parentMatch = last(matches)
957
- let params = parentMatch?.params ?? {}
879
+ matchRoute = <
880
+ TFrom extends ValidFromPath<TAllRouteInfo> = '/',
881
+ TTo extends string = '.',
882
+ >(
883
+ location: ToOptions<TAllRouteInfo, TFrom, TTo>,
884
+ opts?: MatchRouteOptions,
885
+ ):
886
+ | false
887
+ | TAllRouteInfo['routeInfoById'][ResolveRelativePath<
888
+ TFrom,
889
+ NoInfer<TTo>
890
+ >]['allParams'] => {
891
+ location = {
892
+ ...location,
893
+ to: location.to
894
+ ? this.resolvePath(location.from ?? '', location.to)
895
+ : undefined,
896
+ }
958
897
 
959
- const filteredRoutes = router.options.filterRoutes?.(routes) ?? routes
898
+ const next = this.buildNext(location)
960
899
 
961
- let foundRoutes: Route[] = []
900
+ if (opts?.pending) {
901
+ if (!this.store.state.pendingLocation) {
902
+ return false
903
+ }
962
904
 
963
- const findMatchInRoutes = (parentRoutes: Route[], routes: Route[]) => {
964
- routes.some((route) => {
965
- if (!route.routePath && route.childRoutes?.length) {
966
- return findMatchInRoutes(
967
- [...foundRoutes, route],
968
- route.childRoutes,
969
- )
970
- }
905
+ return matchPathname(
906
+ this.basepath,
907
+ this.store.state.pendingLocation!.pathname,
908
+ {
909
+ ...opts,
910
+ to: next.pathname,
911
+ },
912
+ ) as any
913
+ }
971
914
 
972
- const fuzzy = !!(
973
- route.routePath !== '/' || route.childRoutes?.length
974
- )
915
+ return matchPathname(
916
+ this.basepath,
917
+ this.store.state.currentLocation.pathname,
918
+ {
919
+ ...opts,
920
+ to: next.pathname,
921
+ },
922
+ ) as any
923
+ }
975
924
 
976
- const matchParams = matchPathname(router.basepath, pathname, {
977
- to: route.fullPath,
978
- fuzzy,
979
- caseSensitive:
980
- route.options.caseSensitive ?? router.options.caseSensitive,
981
- })
925
+ buildLink = <
926
+ TFrom extends ValidFromPath<TAllRouteInfo> = '/',
927
+ TTo extends string = '.',
928
+ >({
929
+ from,
930
+ to = '.' as any,
931
+ search,
932
+ params,
933
+ hash,
934
+ target,
935
+ replace,
936
+ activeOptions,
937
+ preload,
938
+ preloadMaxAge: userPreloadMaxAge,
939
+ preloadGcMaxAge: userPreloadGcMaxAge,
940
+ preloadDelay: userPreloadDelay,
941
+ disabled,
942
+ }: LinkOptions<TAllRouteInfo, TFrom, TTo>): LinkInfo => {
943
+ // If this link simply reloads the current route,
944
+ // make sure it has a new key so it will trigger a data refresh
945
+
946
+ // If this `to` is a valid external URL, return
947
+ // null for LinkUtils
948
+
949
+ try {
950
+ new URL(`${to}`)
951
+ return {
952
+ type: 'external',
953
+ href: to,
954
+ }
955
+ } catch (e) {}
982
956
 
983
- if (matchParams) {
984
- let parsedParams
957
+ const nextOpts = {
958
+ from,
959
+ to,
960
+ search,
961
+ params,
962
+ hash,
963
+ replace,
964
+ }
985
965
 
986
- try {
987
- parsedParams =
988
- route.options.parseParams?.(matchParams!) ?? matchParams
989
- } catch (err) {
990
- if (opts?.strictParseParams) {
991
- throw err
992
- }
993
- }
966
+ const next = this.buildNext(nextOpts)
994
967
 
995
- params = {
996
- ...params,
997
- ...parsedParams,
998
- }
999
- }
968
+ preload = preload ?? this.options.defaultPreload
969
+ const preloadDelay =
970
+ userPreloadDelay ?? this.options.defaultPreloadDelay ?? 0
1000
971
 
1001
- if (!!matchParams) {
1002
- foundRoutes = [...parentRoutes, route]
1003
- }
972
+ // Compare path/hash for matches
973
+ const pathIsEqual =
974
+ this.store.state.currentLocation.pathname === next.pathname
975
+ const currentPathSplit =
976
+ this.store.state.currentLocation.pathname.split('/')
977
+ const nextPathSplit = next.pathname.split('/')
978
+ const pathIsFuzzyEqual = nextPathSplit.every(
979
+ (d, i) => d === currentPathSplit[i],
980
+ )
981
+ const hashIsEqual = this.store.state.currentLocation.hash === next.hash
982
+ // Combine the matches based on user options
983
+ const pathTest = activeOptions?.exact ? pathIsEqual : pathIsFuzzyEqual
984
+ const hashTest = activeOptions?.includeHash ? hashIsEqual : true
985
+
986
+ // The final "active" test
987
+ const isActive = pathTest && hashTest
988
+
989
+ // The click handler
990
+ const handleClick = (e: MouseEvent) => {
991
+ if (
992
+ !disabled &&
993
+ !isCtrlEvent(e) &&
994
+ !e.defaultPrevented &&
995
+ (!target || target === '_self') &&
996
+ e.button === 0
997
+ ) {
998
+ e.preventDefault()
999
+ if (pathIsEqual && !search && !hash) {
1000
+ this.invalidateRoute(nextOpts)
1001
+ }
1004
1002
 
1005
- return !!foundRoutes.length
1006
- })
1003
+ // All is well? Navigate!
1004
+ this.#commitLocation(nextOpts as any)
1005
+ }
1006
+ }
1007
1007
 
1008
- return !!foundRoutes.length
1009
- }
1008
+ // The click handler
1009
+ const handleFocus = (e: MouseEvent) => {
1010
+ if (preload) {
1011
+ this.preloadRoute(nextOpts, {
1012
+ maxAge: userPreloadMaxAge,
1013
+ gcMaxAge: userPreloadGcMaxAge,
1014
+ }).catch((err) => {
1015
+ console.warn(err)
1016
+ console.warn('Error preloading route! ☝️')
1017
+ })
1018
+ }
1019
+ }
1010
1020
 
1011
- findMatchInRoutes([], filteredRoutes)
1021
+ const handleEnter = (e: MouseEvent) => {
1022
+ const target = (e.target || {}) as LinkCurrentTargetElement
1012
1023
 
1013
- if (!foundRoutes.length) {
1024
+ if (preload) {
1025
+ if (target.preloadTimeout) {
1014
1026
  return
1015
1027
  }
1016
1028
 
1017
- foundRoutes.forEach((foundRoute) => {
1018
- const interpolatedPath = interpolatePath(foundRoute.routePath, params)
1019
- const matchId = interpolatePath(foundRoute.routeId, params, true)
1020
-
1021
- const match =
1022
- existingMatches.find((d) => d.matchId === matchId) ||
1023
- store.matchCache[matchId]?.match ||
1024
- createRouteMatch(router, foundRoute, {
1025
- parentMatch,
1026
- matchId,
1027
- params,
1028
- pathname: joinPaths([router.basepath, interpolatedPath]),
1029
- })
1030
-
1031
- matches.push(match)
1032
- })
1029
+ target.preloadTimeout = setTimeout(() => {
1030
+ target.preloadTimeout = null
1031
+ this.preloadRoute(nextOpts, {
1032
+ maxAge: userPreloadMaxAge,
1033
+ gcMaxAge: userPreloadGcMaxAge,
1034
+ }).catch((err) => {
1035
+ console.warn(err)
1036
+ console.warn('Error preloading route! ☝️')
1037
+ })
1038
+ }, preloadDelay)
1039
+ }
1040
+ }
1033
1041
 
1034
- const foundRoute = last(foundRoutes)!
1042
+ const handleLeave = (e: MouseEvent) => {
1043
+ const target = (e.target || {}) as LinkCurrentTargetElement
1035
1044
 
1036
- if (foundRoute.childRoutes?.length) {
1037
- recurse(foundRoute.childRoutes)
1038
- }
1045
+ if (target.preloadTimeout) {
1046
+ clearTimeout(target.preloadTimeout)
1047
+ target.preloadTimeout = null
1039
1048
  }
1049
+ }
1040
1050
 
1041
- recurse([router.routeTree])
1051
+ return {
1052
+ type: 'internal',
1053
+ next,
1054
+ handleFocus,
1055
+ handleClick,
1056
+ handleEnter,
1057
+ handleLeave,
1058
+ isActive,
1059
+ disabled,
1060
+ }
1061
+ }
1062
+
1063
+ dehydrate = (): DehydratedRouter<TRouterContext> => {
1064
+ return {
1065
+ state: {
1066
+ ...pick(this.store.state, [
1067
+ 'latestLocation',
1068
+ 'currentLocation',
1069
+ 'status',
1070
+ 'lastUpdated',
1071
+ ]),
1072
+ currentMatches: this.store.state.currentMatches.map((match) => ({
1073
+ matchId: match.id,
1074
+ state: {
1075
+ ...pick(match.store.state, [
1076
+ 'status',
1077
+ 'routeLoaderData',
1078
+ 'invalidAt',
1079
+ 'invalid',
1080
+ ]),
1081
+ },
1082
+ })),
1083
+ },
1084
+ context: this.options.context as TRouterContext,
1085
+ }
1086
+ }
1042
1087
 
1043
- linkMatches(matches)
1088
+ hydrate = (dehydratedRouter: DehydratedRouter<TRouterContext>) => {
1089
+ this.store.setState((s) => {
1090
+ // Update the context TODO: make this part of state?
1091
+ this.options.context = dehydratedRouter.context
1044
1092
 
1045
- return matches
1046
- },
1093
+ // Match the routes
1094
+ const currentMatches = this.matchRoutes(
1095
+ dehydratedRouter.state.latestLocation.pathname,
1096
+ {
1097
+ strictParseParams: true,
1098
+ },
1099
+ )
1047
1100
 
1048
- loadMatches: async (resolvedMatches, loaderOpts) => {
1049
- resolvedMatches.forEach(async (match) => {
1050
- // Validate the match (loads search params etc)
1051
- match.__.validate()
1101
+ currentMatches.forEach((match, index) => {
1102
+ const dehydratedMatch = dehydratedRouter.state.currentMatches[index]
1103
+ invariant(
1104
+ dehydratedMatch && dehydratedMatch.matchId === match.id,
1105
+ 'Oh no! There was a hydration mismatch when attempting to rethis.store the state of the router! 😬',
1106
+ )
1107
+ Object.assign(match, dehydratedMatch)
1052
1108
  })
1053
1109
 
1054
- // Check each match middleware to see if the route can be accessed
1055
- await Promise.all(
1056
- resolvedMatches.map(async (match) => {
1057
- try {
1058
- await match.options.beforeLoad?.({
1059
- router: router as any,
1060
- match,
1061
- })
1062
- } catch (err) {
1063
- if (!loaderOpts?.preload) {
1064
- match.options.onLoadError?.(err)
1065
- }
1110
+ currentMatches.forEach((match) => match.__validate())
1066
1111
 
1067
- throw err
1068
- }
1069
- }),
1070
- )
1112
+ Object.assign(s, { ...dehydratedRouter.state, currentMatches })
1113
+ })
1114
+ }
1071
1115
 
1072
- const matchPromises = resolvedMatches.map(async (match) => {
1073
- const search = match.store.search as { __data?: any }
1116
+ getLoader = <TFrom extends keyof TAllRouteInfo['routeInfoById'] = '/'>(opts: {
1117
+ from: TFrom
1118
+ }): unknown extends TAllRouteInfo['routeInfoById'][TFrom]['routeLoaderData']
1119
+ ?
1120
+ | Loader<
1121
+ LoaderContext<
1122
+ TAllRouteInfo['routeInfoById'][TFrom]['fullSearchSchema'],
1123
+ TAllRouteInfo['routeInfoById'][TFrom]['allParams']
1124
+ >,
1125
+ TAllRouteInfo['routeInfoById'][TFrom]['routeLoaderData']
1126
+ >
1127
+ | undefined
1128
+ : Loader<
1129
+ TAllRouteInfo['routeInfoById'][TFrom]['fullSearchSchema'],
1130
+ TAllRouteInfo['routeInfoById'][TFrom]['allParams'],
1131
+ TAllRouteInfo['routeInfoById'][TFrom]['routeLoaderData']
1132
+ > => {
1133
+ const id = opts.from || ('/' as any)
1134
+
1135
+ const route = this.getRoute(id)
1136
+
1137
+ if (!route) return undefined as any
1138
+
1139
+ let loader =
1140
+ this.store.state.loaders[id] ||
1141
+ (() => {
1142
+ this.store.setState((s) => {
1143
+ s.loaders[id] = {
1144
+ pending: [],
1145
+ fetch: (async (loaderContext: LoaderContext<any, any>) => {
1146
+ if (!route) {
1147
+ return
1148
+ }
1149
+ const loaderState: LoaderState<any, any> = {
1150
+ loadedAt: Date.now(),
1151
+ loaderContext,
1152
+ }
1153
+ this.store.setState((s) => {
1154
+ s.loaders[id]!.current = loaderState
1155
+ s.loaders[id]!.latest = loaderState
1156
+ s.loaders[id]!.pending.push(loaderState)
1157
+ })
1158
+ try {
1159
+ return await route.options.loader?.(loaderContext)
1160
+ } finally {
1161
+ this.store.setState((s) => {
1162
+ s.loaders[id]!.pending = s.loaders[id]!.pending.filter(
1163
+ (d) => d !== loaderState,
1164
+ )
1165
+ })
1166
+ }
1167
+ }) as any,
1168
+ }
1169
+ })
1170
+ return this.store.state.loaders[id]!
1171
+ })()
1074
1172
 
1075
- if (search.__data?.matchId && search.__data.matchId !== match.matchId) {
1076
- return
1077
- }
1173
+ return loader as any
1174
+ }
1078
1175
 
1079
- match.load(loaderOpts)
1176
+ #buildRouteTree = (rootRouteConfig: RouteConfig) => {
1177
+ const recurseRoutes = (
1178
+ routeConfigs: RouteConfig[],
1179
+ parent?: Route<TAllRouteInfo, any, any>,
1180
+ ): Route<TAllRouteInfo, any, any>[] => {
1181
+ return routeConfigs.map((routeConfig, i) => {
1182
+ const routeOptions = routeConfig.options
1183
+ const route = new Route(routeConfig, routeOptions, i, parent, this)
1184
+ const existingRoute = (this.routesById as any)[route.id]
1080
1185
 
1081
- if (match.store.status !== 'success' && match.__.loadPromise) {
1082
- // Wait for the first sign of activity from the match
1083
- await match.__.loadPromise
1186
+ if (existingRoute) {
1187
+ if (process.env.NODE_ENV !== 'production') {
1188
+ console.warn(
1189
+ `Duplicate routes found with id: ${String(route.id)}`,
1190
+ this.routesById,
1191
+ route,
1192
+ )
1193
+ }
1194
+ throw new Error()
1084
1195
  }
1085
- })
1086
1196
 
1087
- await Promise.all(matchPromises)
1088
- },
1197
+ ;(this.routesById as any)[route.id] = route
1089
1198
 
1090
- loadMatchData: async (routeMatch) => {
1091
- if (isServer || !router.options.useServerData) {
1092
- return (
1093
- (await routeMatch.options.loader?.({
1094
- // parentLoaderPromise: routeMatch.parentMatch?.__.dataPromise,
1095
- params: routeMatch.params,
1096
- search: routeMatch.store.routeSearch,
1097
- signal: routeMatch.__.abortController.signal,
1098
- })) || {}
1099
- )
1100
- } else {
1101
- const next = router.buildNext({
1102
- to: '.',
1103
- search: (d: any) => ({
1104
- ...(d ?? {}),
1105
- __data: {
1106
- matchId: routeMatch.matchId,
1107
- },
1108
- }),
1109
- })
1199
+ const children = routeConfig.children as RouteConfig[]
1110
1200
 
1111
- // Refresh:
1112
- // '/dashboard'
1113
- // '/dashboard/invoices/'
1114
- // '/dashboard/invoices/123'
1201
+ route.childRoutes = children.length
1202
+ ? recurseRoutes(children, route)
1203
+ : undefined
1115
1204
 
1116
- // New:
1117
- // '/dashboard/invoices/456'
1205
+ return route
1206
+ })
1207
+ }
1118
1208
 
1119
- // TODO: batch requests when possible
1209
+ const routes = recurseRoutes([rootRouteConfig])
1120
1210
 
1121
- const res = await fetch(next.href, {
1122
- method: 'GET',
1123
- // signal: routeMatch.__.abortController.signal,
1124
- })
1211
+ return routes[0]!
1212
+ }
1125
1213
 
1126
- if (res.ok) {
1127
- return res.json()
1128
- }
1214
+ #parseLocation = (previousLocation?: ParsedLocation): ParsedLocation => {
1215
+ let { pathname, search, hash, state } = this.history.location
1129
1216
 
1130
- throw new Error('Failed to fetch match data')
1131
- }
1132
- },
1217
+ const parsedSearch = this.options.parseSearch(search)
1133
1218
 
1134
- invalidateRoute: (opts: MatchLocation) => {
1135
- const next = router.buildNext(opts)
1136
- const unloadedMatchIds = router
1137
- .matchRoutes(next.pathname)
1138
- .map((d) => d.matchId)
1139
- ;[...store.currentMatches, ...(store.pendingMatches ?? [])].forEach(
1140
- (match) => {
1141
- if (unloadedMatchIds.includes(match.matchId)) {
1142
- match.invalidate()
1143
- }
1144
- },
1145
- )
1146
- },
1219
+ console.log({
1220
+ pathname: pathname,
1221
+ searchStr: search,
1222
+ search: replaceEqualDeep(previousLocation?.search, parsedSearch),
1223
+ hash: hash.split('#').reverse()[0] ?? '',
1224
+ href: `${pathname}${search}${hash}`,
1225
+ state: state as LocationState,
1226
+ key: state?.key || '__init__',
1227
+ })
1147
1228
 
1148
- reload: () =>
1149
- navigate({
1150
- fromCurrent: true,
1151
- replace: true,
1152
- search: true,
1153
- }),
1229
+ return {
1230
+ pathname: pathname,
1231
+ searchStr: search,
1232
+ search: replaceEqualDeep(previousLocation?.search, parsedSearch),
1233
+ hash: hash.split('#').reverse()[0] ?? '',
1234
+ href: `${pathname}${search}${hash}`,
1235
+ state: state as LocationState,
1236
+ key: state?.key || '__init__',
1237
+ }
1238
+ }
1154
1239
 
1155
- resolvePath: (from: string, path: string) => {
1156
- return resolvePath(router.basepath!, from, cleanPath(path))
1157
- },
1240
+ #onFocus = () => {
1241
+ this.load()
1242
+ }
1158
1243
 
1159
- matchRoute: (location, opts) => {
1160
- // const location = router.buildNext(opts)
1244
+ #buildLocation = (dest: BuildNextOptions = {}): ParsedLocation => {
1245
+ const fromPathname = dest.fromCurrent
1246
+ ? this.store.state.latestLocation.pathname
1247
+ : dest.from ?? this.store.state.latestLocation.pathname
1161
1248
 
1162
- location = {
1163
- ...location,
1164
- to: location.to
1165
- ? router.resolvePath(location.from ?? '', location.to)
1166
- : undefined,
1167
- }
1249
+ let pathname = resolvePath(
1250
+ this.basepath ?? '/',
1251
+ fromPathname,
1252
+ `${dest.to ?? '.'}`,
1253
+ )
1168
1254
 
1169
- const next = router.buildNext(location)
1255
+ const fromMatches = this.matchRoutes(
1256
+ this.store.state.latestLocation.pathname,
1257
+ {
1258
+ strictParseParams: true,
1259
+ },
1260
+ )
1170
1261
 
1171
- if (opts?.pending) {
1172
- if (!store.pendingLocation) {
1173
- return false
1174
- }
1262
+ const toMatches = this.matchRoutes(pathname)
1175
1263
 
1176
- return !!matchPathname(
1177
- router.basepath,
1178
- store.pendingLocation.pathname,
1179
- {
1180
- ...opts,
1181
- to: next.pathname,
1182
- },
1183
- )
1184
- }
1264
+ const prevParams = { ...last(fromMatches)?.params }
1185
1265
 
1186
- return matchPathname(router.basepath, store.currentLocation.pathname, {
1187
- ...opts,
1188
- to: next.pathname,
1189
- }) as any
1190
- },
1266
+ let nextParams =
1267
+ (dest.params ?? true) === true
1268
+ ? prevParams
1269
+ : functionalUpdate(dest.params!, prevParams)
1191
1270
 
1192
- navigate: async ({ from, to = '.', search, hash, replace, params }) => {
1193
- // If this link simply reloads the current route,
1194
- // make sure it has a new key so it will trigger a data refresh
1271
+ if (nextParams) {
1272
+ toMatches
1273
+ .map((d) => d.route.options.stringifyParams)
1274
+ .filter(Boolean)
1275
+ .forEach((fn) => {
1276
+ Object.assign({}, nextParams!, fn!(nextParams!))
1277
+ })
1278
+ }
1195
1279
 
1196
- // If this `to` is a valid external URL, return
1197
- // null for LinkUtils
1198
- const toString = String(to)
1199
- const fromString = String(from)
1280
+ pathname = interpolatePath(pathname, nextParams ?? {})
1200
1281
 
1201
- let isExternal
1282
+ // Pre filters first
1283
+ const preFilteredSearch = dest.__preSearchFilters?.length
1284
+ ? dest.__preSearchFilters?.reduce(
1285
+ (prev, next) => next(prev),
1286
+ this.store.state.latestLocation.search,
1287
+ )
1288
+ : this.store.state.latestLocation.search
1202
1289
 
1203
- try {
1204
- new URL(`${toString}`)
1205
- isExternal = true
1206
- } catch (e) {}
1290
+ // Then the link/navigate function
1291
+ const destSearch =
1292
+ dest.search === true
1293
+ ? preFilteredSearch // Preserve resolvedFrom true
1294
+ : dest.search
1295
+ ? functionalUpdate(dest.search, preFilteredSearch) ?? {} // Updater
1296
+ : dest.__preSearchFilters?.length
1297
+ ? preFilteredSearch // Preserve resolvedFrom filters
1298
+ : {}
1207
1299
 
1208
- invariant(
1209
- !isExternal,
1210
- 'Attempting to navigate to external url with router.navigate!',
1211
- )
1300
+ // Then post filters
1301
+ const postFilteredSearch = dest.__postSearchFilters?.length
1302
+ ? dest.__postSearchFilters.reduce((prev, next) => next(prev), destSearch)
1303
+ : destSearch
1212
1304
 
1213
- return navigate({
1214
- from: fromString,
1215
- to: toString,
1216
- search,
1217
- hash,
1218
- replace,
1219
- params,
1220
- })
1221
- },
1305
+ const search = replaceEqualDeep(
1306
+ this.store.state.latestLocation.search,
1307
+ postFilteredSearch,
1308
+ )
1222
1309
 
1223
- buildLink: ({
1224
- from,
1225
- to = '.',
1310
+ const searchStr = this.options.stringifySearch(search)
1311
+ let hash =
1312
+ dest.hash === true
1313
+ ? this.store.state.latestLocation.hash
1314
+ : functionalUpdate(dest.hash!, this.store.state.latestLocation.hash)
1315
+ hash = hash ? `#${hash}` : ''
1316
+
1317
+ return {
1318
+ pathname,
1226
1319
  search,
1227
- params,
1320
+ searchStr,
1321
+ state: this.store.state.latestLocation.state,
1228
1322
  hash,
1229
- target,
1230
- replace,
1231
- activeOptions,
1232
- preload,
1233
- preloadMaxAge: userPreloadMaxAge,
1234
- preloadGcMaxAge: userPreloadGcMaxAge,
1235
- preloadDelay: userPreloadDelay,
1236
- disabled,
1237
- }) => {
1238
- // If this link simply reloads the current route,
1239
- // make sure it has a new key so it will trigger a data refresh
1240
-
1241
- // If this `to` is a valid external URL, return
1242
- // null for LinkUtils
1243
-
1244
- try {
1245
- new URL(`${to}`)
1246
- return {
1247
- type: 'external',
1248
- href: to,
1249
- }
1250
- } catch (e) {}
1251
-
1252
- const nextOpts = {
1253
- from,
1254
- to,
1255
- search,
1256
- params,
1257
- hash,
1258
- replace,
1259
- }
1323
+ href: `${pathname}${searchStr}${hash}`,
1324
+ key: dest.key,
1325
+ }
1326
+ }
1260
1327
 
1261
- const next = router.buildNext(nextOpts)
1328
+ #commitLocation = (location: BuildNextOptions & { replace?: boolean }) => {
1329
+ const next = this.buildNext(location)
1330
+ const id = '' + Date.now() + Math.random()
1262
1331
 
1263
- preload = preload ?? router.options.defaultPreload
1264
- const preloadDelay =
1265
- userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0
1332
+ if (this.navigateTimeout) clearTimeout(this.navigateTimeout)
1266
1333
 
1267
- // Compare path/hash for matches
1268
- const pathIsEqual = store.currentLocation.pathname === next.pathname
1269
- const currentPathSplit = store.currentLocation.pathname.split('/')
1270
- const nextPathSplit = next.pathname.split('/')
1271
- const pathIsFuzzyEqual = nextPathSplit.every(
1272
- (d, i) => d === currentPathSplit[i],
1273
- )
1274
- const hashIsEqual = store.currentLocation.hash === next.hash
1275
- // Combine the matches based on user options
1276
- const pathTest = activeOptions?.exact ? pathIsEqual : pathIsFuzzyEqual
1277
- const hashTest = activeOptions?.includeHash ? hashIsEqual : true
1278
-
1279
- // The final "active" test
1280
- const isActive = pathTest && hashTest
1281
-
1282
- // The click handler
1283
- const handleClick = (e: MouseEvent) => {
1284
- if (
1285
- !disabled &&
1286
- !isCtrlEvent(e) &&
1287
- !e.defaultPrevented &&
1288
- (!target || target === '_self') &&
1289
- e.button === 0
1290
- ) {
1291
- e.preventDefault()
1292
- if (pathIsEqual && !search && !hash) {
1293
- router.invalidateRoute(nextOpts)
1294
- }
1334
+ let nextAction: 'push' | 'replace' = 'replace'
1295
1335
 
1296
- // All is well? Navigate!
1297
- navigate(nextOpts)
1298
- }
1299
- }
1336
+ if (!location.replace) {
1337
+ nextAction = 'push'
1338
+ }
1300
1339
 
1301
- // The click handler
1302
- const handleFocus = (e: MouseEvent) => {
1303
- if (preload) {
1304
- router
1305
- .preloadRoute(nextOpts, {
1306
- maxAge: userPreloadMaxAge,
1307
- gcMaxAge: userPreloadGcMaxAge,
1308
- })
1309
- .catch((err) => {
1310
- console.log(err)
1311
- console.warn('Error preloading route! ☝️')
1312
- })
1313
- }
1314
- }
1340
+ const isSameUrl = this.store.state.latestLocation.href === next.href
1315
1341
 
1316
- const handleEnter = (e: MouseEvent) => {
1317
- const target = (e.target || {}) as LinkCurrentTargetElement
1342
+ if (isSameUrl && !next.key) {
1343
+ nextAction = 'replace'
1344
+ }
1318
1345
 
1319
- if (preload) {
1320
- if (target.preloadTimeout) {
1321
- return
1322
- }
1346
+ const href = `${next.pathname}${next.searchStr}${
1347
+ next.hash ? `#${next.hash}` : ''
1348
+ }`
1323
1349
 
1324
- target.preloadTimeout = setTimeout(() => {
1325
- target.preloadTimeout = null
1326
- router
1327
- .preloadRoute(nextOpts, {
1328
- maxAge: userPreloadMaxAge,
1329
- gcMaxAge: userPreloadGcMaxAge,
1330
- })
1331
- .catch((err) => {
1332
- console.log(err)
1333
- console.warn('Error preloading route! ☝️')
1334
- })
1335
- }, preloadDelay)
1336
- }
1337
- }
1350
+ this.history[nextAction === 'push' ? 'push' : 'replace'](href, {
1351
+ id,
1352
+ ...next.state,
1353
+ })
1338
1354
 
1339
- const handleLeave = (e: MouseEvent) => {
1340
- const target = (e.target || {}) as LinkCurrentTargetElement
1355
+ this.load(this.#parseLocation(this.store.state.latestLocation))
1341
1356
 
1342
- if (target.preloadTimeout) {
1343
- clearTimeout(target.preloadTimeout)
1344
- target.preloadTimeout = null
1345
- }
1346
- }
1357
+ return (this.navigationPromise = new Promise((resolve) => {
1358
+ const previousNavigationResolve = this.resolveNavigation
1347
1359
 
1348
- return {
1349
- type: 'internal',
1350
- next,
1351
- handleFocus,
1352
- handleClick,
1353
- handleEnter,
1354
- handleLeave,
1355
- isActive,
1356
- disabled,
1360
+ this.resolveNavigation = () => {
1361
+ previousNavigationResolve()
1362
+ resolve()
1357
1363
  }
1358
- },
1359
- buildNext: (opts: BuildNextOptions) => {
1360
- const next = buildLocation(opts)
1361
-
1362
- const matches = router.matchRoutes(next.pathname)
1363
-
1364
- const __preSearchFilters = matches
1365
- .map((match) => match.options.preSearchFilters ?? [])
1366
- .flat()
1367
- .filter(Boolean)
1364
+ }))
1365
+ }
1366
+ }
1368
1367
 
1369
- const __postSearchFilters = matches
1370
- .map((match) => match.options.postSearchFilters ?? [])
1371
- .flat()
1372
- .filter(Boolean)
1368
+ // Detect if we're in the DOM
1369
+ const isServer = typeof window === 'undefined' || !window.document.createElement
1373
1370
 
1374
- return buildLocation({
1375
- ...opts,
1376
- __preSearchFilters,
1377
- __postSearchFilters,
1378
- })
1371
+ function getInitialRouterState(): RouterStore {
1372
+ return {
1373
+ status: 'idle',
1374
+ latestLocation: null!,
1375
+ currentLocation: null!,
1376
+ currentMatches: [],
1377
+ loaders: {},
1378
+ lastUpdated: Date.now(),
1379
+ matchCache: {},
1380
+ get isFetching() {
1381
+ return (
1382
+ this.status === 'loading' ||
1383
+ this.currentMatches.some((d) => d.store.state.isFetching)
1384
+ )
1385
+ },
1386
+ get isPreloading() {
1387
+ return Object.values(this.matchCache).some(
1388
+ (d) =>
1389
+ d.match.store.state.isFetching &&
1390
+ !this.currentMatches.find((dd) => dd.id === d.match.id),
1391
+ )
1379
1392
  },
1380
1393
  }
1381
-
1382
- router.update(userOptions)
1383
-
1384
- // Allow frameworks to hook into the router creation
1385
- router.options.createRouter?.(router)
1386
-
1387
- return router
1388
1394
  }
1389
1395
 
1390
1396
  function isCtrlEvent(e: MouseEvent) {
@@ -1396,9 +1402,7 @@ function linkMatches(matches: RouteMatch<any, any>[]) {
1396
1402
  const parent = matches[index - 1]
1397
1403
 
1398
1404
  if (parent) {
1399
- match.__.setParentMatch(parent)
1400
- } else {
1401
- match.__.setParentMatch(undefined)
1405
+ match.__setParentMatch(parent)
1402
1406
  }
1403
1407
  })
1404
1408
  }