@tanstack/router-core 0.0.1-beta.2 → 0.0.1-beta.21

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
@@ -23,7 +23,7 @@ import {
23
23
  matchPathname,
24
24
  resolvePath,
25
25
  } from './path'
26
- import { AnyRoute, cascadeLoaderData, createRoute, Route } from './route'
26
+ import { AnyRoute, createRoute, Route } from './route'
27
27
  import {
28
28
  AnyLoaderData,
29
29
  AnyPathParams,
@@ -45,6 +45,7 @@ import { defaultParseSearch, defaultStringifySearch } from './searchParams'
45
45
  import {
46
46
  functionalUpdate,
47
47
  last,
48
+ pick,
48
49
  PickAsRequired,
49
50
  PickRequired,
50
51
  replaceEqualDeep,
@@ -89,23 +90,23 @@ export interface RouterOptions<TRouteConfig extends AnyRouteConfig> {
89
90
  defaultPreloadMaxAge?: number
90
91
  defaultPreloadGcMaxAge?: number
91
92
  defaultPreloadDelay?: number
92
- useErrorBoundary?: boolean
93
- defaultElement?: GetFrameworkGeneric<'Element'>
94
- defaultErrorElement?: GetFrameworkGeneric<'Element'>
95
- defaultCatchElement?: GetFrameworkGeneric<'Element'>
96
- defaultPendingElement?: GetFrameworkGeneric<'Element'>
97
- defaultPendingMs?: number
98
- defaultPendingMinMs?: number
93
+ defaultComponent?: GetFrameworkGeneric<'Component'>
94
+ defaultErrorComponent?: GetFrameworkGeneric<'Component'>
95
+ defaultPendingComponent?: GetFrameworkGeneric<'Component'>
99
96
  defaultLoaderMaxAge?: number
100
97
  defaultLoaderGcMaxAge?: number
101
98
  caseSensitive?: boolean
102
99
  routeConfig?: TRouteConfig
103
100
  basepath?: string
101
+ useServerData?: boolean
104
102
  createRouter?: (router: Router<any, any>) => void
105
103
  createRoute?: (opts: { route: AnyRoute; router: Router<any, any> }) => void
106
- createElement?: (
107
- element: GetFrameworkGeneric<'SyncOrAsyncElement'>,
108
- ) => Promise<GetFrameworkGeneric<'Element'>>
104
+ loadComponent?: (
105
+ component: GetFrameworkGeneric<'Component'>,
106
+ ) => Promise<GetFrameworkGeneric<'Component'>>
107
+ // renderComponent?: (
108
+ // component: GetFrameworkGeneric<'Component'>,
109
+ // ) => GetFrameworkGeneric<'Element'>
109
110
  }
110
111
 
111
112
  export interface Action<
@@ -113,10 +114,13 @@ export interface Action<
113
114
  TResponse = unknown,
114
115
  // TError = unknown,
115
116
  > {
116
- submit: (submission?: TPayload) => Promise<TResponse>
117
+ submit: (
118
+ submission?: TPayload,
119
+ actionOpts?: { invalidate?: boolean; multi?: boolean },
120
+ ) => Promise<TResponse>
117
121
  current?: ActionState<TPayload, TResponse>
118
122
  latest?: ActionState<TPayload, TResponse>
119
- pending: ActionState<TPayload, TResponse>[]
123
+ submissions: ActionState<TPayload, TResponse>[]
120
124
  }
121
125
 
122
126
  export interface ActionState<
@@ -127,6 +131,7 @@ export interface ActionState<
127
131
  submittedAt: number
128
132
  status: 'idle' | 'pending' | 'success' | 'error'
129
133
  submission: TPayload
134
+ isMulti: boolean
130
135
  data?: TResponse
131
136
  error?: unknown
132
137
  }
@@ -160,8 +165,8 @@ export interface Loader<
160
165
  }
161
166
 
162
167
  export interface LoaderState<
163
- TFullSearchSchema = unknown,
164
- TAllParams = unknown,
168
+ TFullSearchSchema extends AnySearchSchema = {},
169
+ TAllParams extends AnyPathParams = {},
165
170
  > {
166
171
  loadedAt: number
167
172
  loaderContext: LoaderContext<TFullSearchSchema, TAllParams>
@@ -172,9 +177,6 @@ export interface RouterState {
172
177
  location: Location
173
178
  matches: RouteMatch[]
174
179
  lastUpdated: number
175
- loaderData: unknown
176
- currentAction?: ActionState
177
- latestAction?: ActionState
178
180
  actions: Record<string, Action>
179
181
  loaders: Record<string, Loader>
180
182
  pending?: PendingState
@@ -196,6 +198,7 @@ export interface BuildNextOptions {
196
198
  params?: true | Updater<Record<string, any>>
197
199
  search?: true | Updater<unknown>
198
200
  hash?: true | Updater<string>
201
+ state?: LocationState
199
202
  key?: string
200
203
  from?: string
201
204
  fromCurrent?: boolean
@@ -225,10 +228,33 @@ type LinkCurrentTargetElement = {
225
228
  preloadTimeout?: null | ReturnType<typeof setTimeout>
226
229
  }
227
230
 
231
+ interface DehydratedRouterState
232
+ extends Pick<RouterState, 'status' | 'location' | 'lastUpdated'> {
233
+ matches: DehydratedRouteMatch[]
234
+ }
235
+
236
+ interface DehydratedRouteMatch
237
+ extends Pick<
238
+ RouteMatch<any, any>,
239
+ | 'matchId'
240
+ | 'status'
241
+ | 'routeLoaderData'
242
+ | 'loaderData'
243
+ | 'isInvalid'
244
+ | 'invalidAt'
245
+ > {}
246
+
228
247
  export interface Router<
229
248
  TRouteConfig extends AnyRouteConfig = RouteConfig,
230
249
  TAllRouteInfo extends AnyAllRouteInfo = AllRouteInfo<TRouteConfig>,
231
250
  > {
251
+ types: {
252
+ // Super secret internal stuff
253
+ RouteConfig: TRouteConfig
254
+ AllRouteInfo: TAllRouteInfo
255
+ }
256
+
257
+ // Public API
232
258
  history: BrowserHistory | MemoryHistory | HashHistory
233
259
  options: PickAsRequired<
234
260
  RouterOptions<TRouteConfig>,
@@ -237,7 +263,6 @@ export interface Router<
237
263
  // Computed in this.update()
238
264
  basepath: string
239
265
  // Internal:
240
- allRouteInfo: TAllRouteInfo
241
266
  listeners: Listener[]
242
267
  location: Location
243
268
  navigateTimeout?: Timeout
@@ -246,10 +271,10 @@ export interface Router<
246
271
  routeTree: Route<TAllRouteInfo, RouteInfo>
247
272
  routesById: RoutesById<TAllRouteInfo>
248
273
  navigationPromise: Promise<void>
249
- removeActionQueue: { action: Action; actionState: ActionState }[]
250
274
  startedLoadingAt: number
251
275
  resolveNavigation: () => void
252
276
  subscribe: (listener: Listener) => () => void
277
+ reset: () => void
253
278
  notify: () => void
254
279
  mount: () => () => void
255
280
  onFocus: () => void
@@ -259,7 +284,7 @@ export interface Router<
259
284
 
260
285
  buildNext: (opts: BuildNextOptions) => Location
261
286
  cancelMatches: () => void
262
- loadLocation: (next?: Location) => Promise<void>
287
+ load: (next?: Location) => Promise<void>
263
288
  matchCache: Record<string, MatchCacheEntry>
264
289
  cleanMatchCache: () => void
265
290
  getRoute: <TId extends keyof TAllRouteInfo['routeInfoById']>(
@@ -276,11 +301,13 @@ export interface Router<
276
301
  ) => RouteMatch[]
277
302
  loadMatches: (
278
303
  resolvedMatches: RouteMatch[],
279
- loaderOpts?: { withPending?: boolean } & (
304
+ loaderOpts?:
280
305
  | { preload: true; maxAge: number; gcMaxAge: number }
281
- | { preload?: false; maxAge?: never; gcMaxAge?: never }
282
- ),
306
+ | { preload?: false; maxAge?: never; gcMaxAge?: never },
283
307
  ) => Promise<void>
308
+ loadMatchData: (
309
+ routeMatch: RouteMatch<any, any>,
310
+ ) => Promise<Record<string, unknown>>
284
311
  invalidateRoute: (opts: MatchLocation) => void
285
312
  reload: () => Promise<void>
286
313
  resolvePath: (from: string, path: string) => string
@@ -303,6 +330,8 @@ export interface Router<
303
330
  >(
304
331
  opts: LinkOptions<TAllRouteInfo, TFrom, TTo>,
305
332
  ) => LinkInfo
333
+ dehydrateState: () => DehydratedRouterState
334
+ hydrateState: (state: DehydratedRouterState) => void
306
335
  __: {
307
336
  buildRouteTree: (
308
337
  routeConfig: RouteConfig,
@@ -320,13 +349,25 @@ export interface Router<
320
349
  }
321
350
 
322
351
  // Detect if we're in the DOM
323
- const isServer = Boolean(
324
- typeof window === 'undefined' || !window.document?.createElement,
325
- )
352
+ const isServer =
353
+ typeof window === 'undefined' || !window.document?.createElement
326
354
 
327
355
  // This is the default history object if none is defined
328
356
  const createDefaultHistory = () =>
329
- !isServer ? createBrowserHistory() : createMemoryHistory()
357
+ isServer ? createMemoryHistory() : createBrowserHistory()
358
+
359
+ function getInitialRouterState(): RouterState {
360
+ return {
361
+ status: 'idle',
362
+ location: null!,
363
+ matches: [],
364
+ actions: {},
365
+ loaders: {},
366
+ lastUpdated: Date.now(),
367
+ isFetching: false,
368
+ isPreloading: false,
369
+ }
370
+ }
330
371
 
331
372
  export function createRouter<
332
373
  TRouteConfig extends AnyRouteConfig = RouteConfig,
@@ -347,30 +388,25 @@ export function createRouter<
347
388
  }
348
389
 
349
390
  let router: Router<TRouteConfig, TAllRouteInfo> = {
391
+ types: undefined!,
392
+
393
+ // public api
350
394
  history,
351
395
  options: originalOptions,
352
396
  listeners: [],
353
- removeActionQueue: [],
354
397
  // Resolved after construction
355
398
  basepath: '',
356
399
  routeTree: undefined!,
357
400
  routesById: {} as any,
358
401
  location: undefined!,
359
- allRouteInfo: undefined!,
360
402
  //
361
403
  navigationPromise: Promise.resolve(),
362
404
  resolveNavigation: () => {},
363
405
  matchCache: {},
364
- state: {
365
- status: 'idle',
366
- location: null!,
367
- matches: [],
368
- actions: {},
369
- loaders: {},
370
- loaderData: {} as any,
371
- lastUpdated: Date.now(),
372
- isFetching: false,
373
- isPreloading: false,
406
+ state: getInitialRouterState(),
407
+ reset: () => {
408
+ router.state = getInitialRouterState()
409
+ router.notify()
374
410
  },
375
411
  startedLoadingAt: Date.now(),
376
412
  subscribe: (listener: Listener): (() => void) => {
@@ -383,22 +419,71 @@ export function createRouter<
383
419
  return router.routesById[id]
384
420
  },
385
421
  notify: (): void => {
386
- router.state = {
387
- ...router.state,
388
- isFetching:
389
- router.state.status === 'loading' ||
390
- router.state.matches.some((d) => d.isFetching),
391
- isPreloading: Object.values(router.matchCache).some(
392
- (d) =>
393
- d.match.isFetching &&
394
- !router.state.matches.find((dd) => dd.matchId === d.match.matchId),
395
- ),
422
+ const isFetching =
423
+ router.state.status === 'loading' ||
424
+ router.state.matches.some((d) => d.isFetching)
425
+
426
+ const isPreloading = Object.values(router.matchCache).some(
427
+ (d) =>
428
+ d.match.isFetching &&
429
+ !router.state.matches.find((dd) => dd.matchId === d.match.matchId),
430
+ )
431
+
432
+ if (
433
+ router.state.isFetching !== isFetching ||
434
+ router.state.isPreloading !== isPreloading
435
+ ) {
436
+ router.state = {
437
+ ...router.state,
438
+ isFetching,
439
+ isPreloading,
440
+ }
396
441
  }
397
442
 
398
443
  cascadeLoaderData(router.state.matches)
399
444
  router.listeners.forEach((listener) => listener(router))
400
445
  },
401
446
 
447
+ dehydrateState: () => {
448
+ return {
449
+ ...pick(router.state, ['status', 'location', 'lastUpdated']),
450
+ matches: router.state.matches.map((match) =>
451
+ pick(match, [
452
+ 'matchId',
453
+ 'status',
454
+ 'routeLoaderData',
455
+ 'loaderData',
456
+ 'isInvalid',
457
+ 'invalidAt',
458
+ ]),
459
+ ),
460
+ }
461
+ },
462
+
463
+ hydrateState: (dehydratedState) => {
464
+ // Match the routes
465
+ const matches = router.matchRoutes(router.location.pathname, {
466
+ strictParseParams: true,
467
+ })
468
+
469
+ matches.forEach((match, index) => {
470
+ const dehydratedMatch = dehydratedState.matches[index]
471
+ invariant(
472
+ dehydratedMatch,
473
+ 'Oh no! Dehydrated route matches did not match the active state of the router 😬',
474
+ )
475
+ Object.assign(match, dehydratedMatch)
476
+ })
477
+
478
+ matches.forEach((match) => match.__.validate())
479
+
480
+ router.state = {
481
+ ...router.state,
482
+ ...dehydratedState,
483
+ matches,
484
+ }
485
+ },
486
+
402
487
  mount: () => {
403
488
  const next = router.__.buildLocation({
404
489
  to: '.',
@@ -410,14 +495,14 @@ export function createRouter<
410
495
  // to the current location. Otherwise, load the current location.
411
496
  if (next.href !== router.location.href) {
412
497
  router.__.commitLocation(next, true)
413
- } else {
414
- router.loadLocation()
415
498
  }
416
499
 
417
- const unsub = history.listen((event) => {
418
- router.loadLocation(
419
- router.__.parseLocation(event.location, router.location),
420
- )
500
+ if (!router.state.matches.length) {
501
+ router.load()
502
+ }
503
+
504
+ const unsub = router.history.listen((event) => {
505
+ router.load(router.__.parseLocation(event.location, router.location))
421
506
  })
422
507
 
423
508
  // addEventListener does not exist in React Native, but window does
@@ -430,17 +515,28 @@ export function createRouter<
430
515
 
431
516
  return () => {
432
517
  unsub()
433
- // Be sure to unsubscribe if a new handler is set
434
- window.removeEventListener('visibilitychange', router.onFocus)
435
- window.removeEventListener('focus', router.onFocus)
518
+ if (!isServer && window.removeEventListener) {
519
+ // Be sure to unsubscribe if a new handler is set
520
+ window.removeEventListener('visibilitychange', router.onFocus)
521
+ window.removeEventListener('focus', router.onFocus)
522
+ }
436
523
  }
437
524
  },
438
525
 
439
526
  onFocus: () => {
440
- router.loadLocation()
527
+ router.load()
441
528
  },
442
529
 
443
530
  update: (opts) => {
531
+ const newHistory = opts?.history !== router.history
532
+ if (!router.location || newHistory) {
533
+ if (opts?.history) {
534
+ router.history = opts.history
535
+ }
536
+ router.location = router.__.parseLocation(router.history.location)
537
+ router.state.location = router.location
538
+ }
539
+
444
540
  Object.assign(router.options, opts)
445
541
 
446
542
  const { basepath, routeConfig } = router.options
@@ -464,7 +560,7 @@ export function createRouter<
464
560
  })
465
561
  },
466
562
 
467
- loadLocation: async (next?: Location) => {
563
+ load: async (next?: Location) => {
468
564
  const id = Math.random()
469
565
  router.startedLoadingAt = id
470
566
 
@@ -473,40 +569,36 @@ export function createRouter<
473
569
  router.location = next
474
570
  }
475
571
 
476
- // Clear out old actions
477
- router.removeActionQueue.forEach(({ action, actionState }) => {
478
- if (router.state.currentAction === actionState) {
479
- router.state.currentAction = undefined
480
- }
481
- if (action.current === actionState) {
482
- action.current = undefined
483
- }
484
- })
485
- router.removeActionQueue = []
486
-
487
572
  // Cancel any pending matches
488
573
  router.cancelMatches()
489
574
 
490
575
  // Match the routes
491
- const matches = router.matchRoutes(location.pathname, {
576
+ const matches = router.matchRoutes(router.location.pathname, {
492
577
  strictParseParams: true,
493
578
  })
494
579
 
495
- router.state = {
496
- ...router.state,
497
- pending: {
580
+ if (typeof document !== 'undefined') {
581
+ router.state = {
582
+ ...router.state,
583
+ pending: {
584
+ matches: matches,
585
+ location: router.location,
586
+ },
587
+ status: 'loading',
588
+ }
589
+ } else {
590
+ router.state = {
591
+ ...router.state,
498
592
  matches: matches,
499
593
  location: router.location,
500
- },
501
- status: 'loading',
594
+ status: 'loading',
595
+ }
502
596
  }
503
597
 
504
598
  router.notify()
505
599
 
506
600
  // Load the matches
507
- await router.loadMatches(matches, {
508
- withPending: true,
509
- })
601
+ await router.loadMatches(matches)
510
602
 
511
603
  if (router.startedLoadingAt !== id) {
512
604
  // Ignore side-effects of match loading
@@ -526,6 +618,10 @@ export function createRouter<
526
618
  }
527
619
  })
528
620
 
621
+ const entering = matches.filter((d) => {
622
+ return !previousMatches.find((dd) => dd.matchId === d.matchId)
623
+ })
624
+
529
625
  const now = Date.now()
530
626
 
531
627
  exiting.forEach((d) => {
@@ -533,6 +629,7 @@ export function createRouter<
533
629
  params: d.params,
534
630
  search: d.routeSearch,
535
631
  })
632
+
536
633
  // Clear idle error states when match leaves
537
634
  if (d.status === 'error' && !d.isFetching) {
538
635
  d.status = 'idle'
@@ -557,10 +654,6 @@ export function createRouter<
557
654
  })
558
655
  })
559
656
 
560
- const entering = matches.filter((d) => {
561
- return !previousMatches.find((dd) => dd.matchId === d.matchId)
562
- })
563
-
564
657
  entering.forEach((d) => {
565
658
  d.__.onExit = d.options.onMatch?.({
566
659
  params: d.params,
@@ -569,17 +662,19 @@ export function createRouter<
569
662
  delete router.matchCache[d.matchId]
570
663
  })
571
664
 
572
- if (matches.some((d) => d.status === 'loading')) {
573
- router.notify()
574
- await Promise.all(
575
- matches.map((d) => d.__.loaderPromise || Promise.resolve()),
576
- )
577
- }
578
665
  if (router.startedLoadingAt !== id) {
579
666
  // Ignore side-effects of match loading
580
667
  return
581
668
  }
582
669
 
670
+ matches.forEach((match) => {
671
+ // Clear actions
672
+ if (match.action) {
673
+ match.action.current = undefined
674
+ match.action.submissions = []
675
+ }
676
+ })
677
+
583
678
  router.state = {
584
679
  ...router.state,
585
680
  location: router.location,
@@ -727,6 +822,7 @@ export function createRouter<
727
822
  existingMatches.find((d) => d.matchId === matchId) ||
728
823
  router.matchCache[matchId]?.match ||
729
824
  createRouteMatch(router, foundRoute, {
825
+ parentMatch,
730
826
  matchId,
731
827
  params,
732
828
  pathname: joinPaths([pathname, interpolatedPath]),
@@ -755,12 +851,14 @@ export function createRouter<
755
851
  match.__.validate()
756
852
  match.load(loaderOpts)
757
853
 
758
- if (match.status === 'loading') {
759
- // If requested, start the pending timers
760
- if (loaderOpts?.withPending) match.__.startPending()
854
+ const search = match.search as { __data?: any }
855
+
856
+ if (search.__data && search.__data.matchId !== match.matchId) {
857
+ return
858
+ }
761
859
 
860
+ if (match.__.loadPromise) {
762
861
  // Wait for the first sign of activity from the match
763
- // This might be completion, error, or a pending state
764
862
  await match.__.loadPromise
765
863
  }
766
864
  })
@@ -770,6 +868,40 @@ export function createRouter<
770
868
  await Promise.all(matchPromises)
771
869
  },
772
870
 
871
+ loadMatchData: async (routeMatch) => {
872
+ if (isServer || !router.options.useServerData) {
873
+ return (
874
+ (await routeMatch.options.loader?.({
875
+ // parentLoaderPromise: routeMatch.parentMatch?.__.dataPromise,
876
+ params: routeMatch.params,
877
+ search: routeMatch.routeSearch,
878
+ signal: routeMatch.__.abortController.signal,
879
+ })) ?? {}
880
+ )
881
+ } else {
882
+ const next = router.buildNext({
883
+ to: '.',
884
+ search: (d: any) => ({
885
+ ...(d ?? {}),
886
+ __data: {
887
+ matchId: routeMatch.matchId,
888
+ },
889
+ }),
890
+ })
891
+
892
+ const res = await fetch(next.href, {
893
+ method: 'GET',
894
+ // signal: routeMatch.__.abortController.signal,
895
+ })
896
+
897
+ if (res.ok) {
898
+ return res.json()
899
+ }
900
+
901
+ throw new Error('Failed to fetch match data')
902
+ }
903
+ },
904
+
773
905
  invalidateRoute: (opts: MatchLocation) => {
774
906
  const next = router.buildNext(opts)
775
907
  const unloadedMatchIds = router
@@ -1012,12 +1144,6 @@ export function createRouter<
1012
1144
  return routeConfigs.map((routeConfig) => {
1013
1145
  const routeOptions = routeConfig.options
1014
1146
  const route = createRoute(routeConfig, routeOptions, parent, router)
1015
-
1016
- // {
1017
- // pendingMs: routeOptions.pendingMs ?? router.defaultPendingMs,
1018
- // pendingMinMs: routeOptions.pendingMinMs ?? router.defaultPendingMinMs,
1019
- // }
1020
-
1021
1147
  const existingRoute = (router.routesById as any)[route.routeId]
1022
1148
 
1023
1149
  if (existingRoute) {
@@ -1183,6 +1309,7 @@ export function createRouter<
1183
1309
  },
1184
1310
  {
1185
1311
  id,
1312
+ ...next.state,
1186
1313
  },
1187
1314
  )
1188
1315
  } else {
@@ -1212,9 +1339,6 @@ export function createRouter<
1212
1339
  },
1213
1340
  }
1214
1341
 
1215
- router.location = router.__.parseLocation(history.location)
1216
- router.state.location = router.location
1217
-
1218
1342
  router.update(userOptions)
1219
1343
 
1220
1344
  // Allow frameworks to hook into the router creation
@@ -1226,3 +1350,16 @@ export function createRouter<
1226
1350
  function isCtrlEvent(e: MouseEvent) {
1227
1351
  return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
1228
1352
  }
1353
+
1354
+ function cascadeLoaderData(matches: RouteMatch<any, any>[]) {
1355
+ matches.forEach((match, index) => {
1356
+ const parent = matches[index - 1]
1357
+
1358
+ if (parent) {
1359
+ match.loaderData = replaceEqualDeep(match.loaderData, {
1360
+ ...parent.loaderData,
1361
+ ...match.routeLoaderData,
1362
+ })
1363
+ }
1364
+ })
1365
+ }
package/src/utils.ts CHANGED
@@ -155,3 +155,10 @@ export function functionalUpdate<TResult>(
155
155
 
156
156
  return updater
157
157
  }
158
+
159
+ export function pick<T, K extends keyof T>(parent: T, keys: K[]): Pick<T, K> {
160
+ return keys.reduce((obj: any, key: K) => {
161
+ obj[key] = parent[key]
162
+ return obj
163
+ }, {} as any)
164
+ }