@tanstack/router-core 1.131.19 → 1.131.22

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.
@@ -0,0 +1,972 @@
1
+ import { batch } from '@tanstack/store'
2
+ import invariant from 'tiny-invariant'
3
+ import { createControlledPromise, isPromise } from './utils'
4
+ import { isNotFound } from './not-found'
5
+ import { rootRouteId } from './root'
6
+ import { isRedirect } from './redirect'
7
+ import type { NotFoundError } from './not-found'
8
+ import type { ParsedLocation } from './location'
9
+ import type {
10
+ AnyRoute,
11
+ BeforeLoadContextOptions,
12
+ LoaderFnContext,
13
+ SsrContextOptions,
14
+ } from './route'
15
+ import type { AnyRouteMatch, MakeRouteMatch } from './Matches'
16
+ import type { AnyRouter, UpdateMatchFn } from './router'
17
+
18
+ /**
19
+ * An object of this shape is created when calling `loadMatches`.
20
+ * It contains everything we need for all other functions in this file
21
+ * to work. (It's basically the function's argument, plus a few mutable states)
22
+ */
23
+ type InnerLoadContext = {
24
+ /** the calling router instance */
25
+ router: AnyRouter
26
+ location: ParsedLocation
27
+ /** mutable state, scoped to a `loadMatches` call */
28
+ firstBadMatchIndex?: number
29
+ /** mutable state, scoped to a `loadMatches` call */
30
+ rendered?: boolean
31
+ updateMatch: UpdateMatchFn
32
+ matches: Array<AnyRouteMatch>
33
+ preload?: boolean
34
+ onReady?: () => Promise<void>
35
+ sync?: boolean
36
+ /** mutable state, scoped to a `loadMatches` call */
37
+ matchPromises: Array<Promise<AnyRouteMatch>>
38
+ }
39
+
40
+ const triggerOnReady = (inner: InnerLoadContext): void | Promise<void> => {
41
+ if (!inner.rendered) {
42
+ inner.rendered = true
43
+ return inner.onReady?.()
44
+ }
45
+ }
46
+
47
+ const resolvePreload = (inner: InnerLoadContext, matchId: string): boolean => {
48
+ return !!(
49
+ inner.preload && !inner.router.state.matches.some((d) => d.id === matchId)
50
+ )
51
+ }
52
+
53
+ const _handleNotFound = (inner: InnerLoadContext, err: NotFoundError) => {
54
+ // Find the route that should handle the not found error
55
+ // First check if a specific route is requested to show the error
56
+ const routeCursor =
57
+ inner.router.routesById[err.routeId ?? ''] ?? inner.router.routeTree
58
+
59
+ // Ensure a NotFoundComponent exists on the route
60
+ if (
61
+ !routeCursor.options.notFoundComponent &&
62
+ (inner.router.options as any)?.defaultNotFoundComponent
63
+ ) {
64
+ routeCursor.options.notFoundComponent = (
65
+ inner.router.options as any
66
+ ).defaultNotFoundComponent
67
+ }
68
+
69
+ // Ensure we have a notFoundComponent
70
+ invariant(
71
+ routeCursor.options.notFoundComponent,
72
+ 'No notFoundComponent found. Please set a notFoundComponent on your route or provide a defaultNotFoundComponent to the router.',
73
+ )
74
+
75
+ // Find the match for this route
76
+ const matchForRoute = inner.matches.find((m) => m.routeId === routeCursor.id)
77
+
78
+ invariant(matchForRoute, 'Could not find match for route: ' + routeCursor.id)
79
+
80
+ // Assign the error to the match - using non-null assertion since we've checked with invariant
81
+ inner.updateMatch(matchForRoute.id, (prev) => ({
82
+ ...prev,
83
+ status: 'notFound',
84
+ error: err,
85
+ isFetching: false,
86
+ }))
87
+
88
+ if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) {
89
+ err.routeId = routeCursor.parentRoute.id
90
+ _handleNotFound(inner, err)
91
+ }
92
+ }
93
+
94
+ const handleRedirectAndNotFound = (
95
+ inner: InnerLoadContext,
96
+ match: AnyRouteMatch | undefined,
97
+ err: unknown,
98
+ ): void => {
99
+ if (!isRedirect(err) && !isNotFound(err)) return
100
+
101
+ if (isRedirect(err) && err.redirectHandled && !err.options.reloadDocument) {
102
+ throw err
103
+ }
104
+
105
+ // in case of a redirecting match during preload, the match does not exist
106
+ if (match) {
107
+ match._nonReactive.beforeLoadPromise?.resolve()
108
+ match._nonReactive.loaderPromise?.resolve()
109
+ match._nonReactive.beforeLoadPromise = undefined
110
+ match._nonReactive.loaderPromise = undefined
111
+
112
+ const status = isRedirect(err) ? 'redirected' : 'notFound'
113
+
114
+ inner.updateMatch(match.id, (prev) => ({
115
+ ...prev,
116
+ status,
117
+ isFetching: false,
118
+ error: err,
119
+ }))
120
+
121
+ if (isNotFound(err) && !err.routeId) {
122
+ err.routeId = match.routeId
123
+ }
124
+
125
+ match._nonReactive.loadPromise?.resolve()
126
+ }
127
+
128
+ if (isRedirect(err)) {
129
+ inner.rendered = true
130
+ err.options._fromLocation = inner.location
131
+ err.redirectHandled = true
132
+ err = inner.router.resolveRedirect(err)
133
+ throw err
134
+ } else {
135
+ _handleNotFound(inner, err)
136
+ throw err
137
+ }
138
+ }
139
+
140
+ const shouldSkipLoader = (
141
+ inner: InnerLoadContext,
142
+ matchId: string,
143
+ ): boolean => {
144
+ const match = inner.router.getMatch(matchId)!
145
+ // upon hydration, we skip the loader if the match has been dehydrated on the server
146
+ if (!inner.router.isServer && match._nonReactive.dehydrated) {
147
+ return true
148
+ }
149
+
150
+ if (inner.router.isServer) {
151
+ if (match.ssr === false) {
152
+ return true
153
+ }
154
+ }
155
+ return false
156
+ }
157
+
158
+ const handleSerialError = (
159
+ inner: InnerLoadContext,
160
+ index: number,
161
+ err: any,
162
+ routerCode: string,
163
+ ): void => {
164
+ const { id: matchId, routeId } = inner.matches[index]!
165
+ const route = inner.router.looseRoutesById[routeId]!
166
+
167
+ // Much like suspense, we use a promise here to know if
168
+ // we've been outdated by a new loadMatches call and
169
+ // should abort the current async operation
170
+ if (err instanceof Promise) {
171
+ throw err
172
+ }
173
+
174
+ err.routerCode = routerCode
175
+ inner.firstBadMatchIndex ??= index
176
+ handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), err)
177
+
178
+ try {
179
+ route.options.onError?.(err)
180
+ } catch (errorHandlerErr) {
181
+ err = errorHandlerErr
182
+ handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), err)
183
+ }
184
+
185
+ inner.updateMatch(matchId, (prev) => {
186
+ prev._nonReactive.beforeLoadPromise?.resolve()
187
+ prev._nonReactive.beforeLoadPromise = undefined
188
+ prev._nonReactive.loadPromise?.resolve()
189
+
190
+ return {
191
+ ...prev,
192
+ error: err,
193
+ status: 'error',
194
+ isFetching: false,
195
+ updatedAt: Date.now(),
196
+ abortController: new AbortController(),
197
+ }
198
+ })
199
+ }
200
+
201
+ const isBeforeLoadSsr = (
202
+ inner: InnerLoadContext,
203
+ matchId: string,
204
+ index: number,
205
+ route: AnyRoute,
206
+ ): void | Promise<void> => {
207
+ const existingMatch = inner.router.getMatch(matchId)!
208
+ const parentMatchId = inner.matches[index - 1]?.id
209
+ const parentMatch = parentMatchId
210
+ ? inner.router.getMatch(parentMatchId)!
211
+ : undefined
212
+
213
+ // in SPA mode, only SSR the root route
214
+ if (inner.router.isShell()) {
215
+ existingMatch.ssr = matchId === rootRouteId
216
+ return
217
+ }
218
+
219
+ if (parentMatch?.ssr === false) {
220
+ existingMatch.ssr = false
221
+ return
222
+ }
223
+
224
+ const parentOverride = (tempSsr: boolean | 'data-only') => {
225
+ if (tempSsr === true && parentMatch?.ssr === 'data-only') {
226
+ return 'data-only'
227
+ }
228
+ return tempSsr
229
+ }
230
+
231
+ const defaultSsr = inner.router.options.defaultSsr ?? true
232
+
233
+ if (route.options.ssr === undefined) {
234
+ existingMatch.ssr = parentOverride(defaultSsr)
235
+ return
236
+ }
237
+
238
+ if (typeof route.options.ssr !== 'function') {
239
+ existingMatch.ssr = parentOverride(route.options.ssr)
240
+ return
241
+ }
242
+ const { search, params } = existingMatch
243
+
244
+ const ssrFnContext: SsrContextOptions<any, any, any> = {
245
+ search: makeMaybe(search, existingMatch.searchError),
246
+ params: makeMaybe(params, existingMatch.paramsError),
247
+ location: inner.location,
248
+ matches: inner.matches.map((match) => ({
249
+ index: match.index,
250
+ pathname: match.pathname,
251
+ fullPath: match.fullPath,
252
+ staticData: match.staticData,
253
+ id: match.id,
254
+ routeId: match.routeId,
255
+ search: makeMaybe(match.search, match.searchError),
256
+ params: makeMaybe(match.params, match.paramsError),
257
+ ssr: match.ssr,
258
+ })),
259
+ }
260
+
261
+ const tempSsr = route.options.ssr(ssrFnContext)
262
+ if (isPromise(tempSsr)) {
263
+ return tempSsr.then((ssr) => {
264
+ existingMatch.ssr = parentOverride(ssr ?? defaultSsr)
265
+ })
266
+ }
267
+
268
+ existingMatch.ssr = parentOverride(tempSsr ?? defaultSsr)
269
+ return
270
+ }
271
+
272
+ const setupPendingTimeout = (
273
+ inner: InnerLoadContext,
274
+ matchId: string,
275
+ route: AnyRoute,
276
+ match: AnyRouteMatch,
277
+ ): void => {
278
+ if (match._nonReactive.pendingTimeout !== undefined) return
279
+
280
+ const pendingMs =
281
+ route.options.pendingMs ?? inner.router.options.defaultPendingMs
282
+ const shouldPending = !!(
283
+ inner.onReady &&
284
+ !inner.router.isServer &&
285
+ !resolvePreload(inner, matchId) &&
286
+ (route.options.loader ||
287
+ route.options.beforeLoad ||
288
+ routeNeedsPreload(route)) &&
289
+ typeof pendingMs === 'number' &&
290
+ pendingMs !== Infinity &&
291
+ (route.options.pendingComponent ??
292
+ (inner.router.options as any)?.defaultPendingComponent)
293
+ )
294
+
295
+ if (shouldPending) {
296
+ const pendingTimeout = setTimeout(() => {
297
+ // Update the match and prematurely resolve the loadMatches promise so that
298
+ // the pending component can start rendering
299
+ triggerOnReady(inner)
300
+ }, pendingMs)
301
+ match._nonReactive.pendingTimeout = pendingTimeout
302
+ }
303
+ }
304
+
305
+ const shouldExecuteBeforeLoad = (
306
+ inner: InnerLoadContext,
307
+ matchId: string,
308
+ route: AnyRoute,
309
+ ): boolean | Promise<boolean> => {
310
+ const existingMatch = inner.router.getMatch(matchId)!
311
+
312
+ // If we are in the middle of a load, either of these will be present
313
+ // (not to be confused with `loadPromise`, which is always defined)
314
+ if (
315
+ !existingMatch._nonReactive.beforeLoadPromise &&
316
+ !existingMatch._nonReactive.loaderPromise
317
+ )
318
+ return true
319
+
320
+ setupPendingTimeout(inner, matchId, route, existingMatch)
321
+
322
+ const then = () => {
323
+ let result = true
324
+ const match = inner.router.getMatch(matchId)!
325
+ if (match.status === 'error') {
326
+ result = true
327
+ } else if (
328
+ match.preload &&
329
+ (match.status === 'redirected' || match.status === 'notFound')
330
+ ) {
331
+ handleRedirectAndNotFound(inner, match, match.error)
332
+ }
333
+ return result
334
+ }
335
+
336
+ // Wait for the beforeLoad to resolve before we continue
337
+ return existingMatch._nonReactive.beforeLoadPromise
338
+ ? existingMatch._nonReactive.beforeLoadPromise.then(then)
339
+ : then()
340
+ }
341
+
342
+ const executeBeforeLoad = (
343
+ inner: InnerLoadContext,
344
+ matchId: string,
345
+ index: number,
346
+ route: AnyRoute,
347
+ ): void | Promise<void> => {
348
+ const match = inner.router.getMatch(matchId)!
349
+
350
+ // explicitly capture the previous loadPromise
351
+ const prevLoadPromise = match._nonReactive.loadPromise
352
+ match._nonReactive.loadPromise = createControlledPromise<void>(() => {
353
+ prevLoadPromise?.resolve()
354
+ })
355
+
356
+ const { paramsError, searchError } = match
357
+
358
+ if (paramsError) {
359
+ handleSerialError(inner, index, paramsError, 'PARSE_PARAMS')
360
+ }
361
+
362
+ if (searchError) {
363
+ handleSerialError(inner, index, searchError, 'VALIDATE_SEARCH')
364
+ }
365
+
366
+ setupPendingTimeout(inner, matchId, route, match)
367
+
368
+ const abortController = new AbortController()
369
+
370
+ const parentMatchId = inner.matches[index - 1]?.id
371
+ const parentMatch = parentMatchId
372
+ ? inner.router.getMatch(parentMatchId)!
373
+ : undefined
374
+ const parentMatchContext =
375
+ parentMatch?.context ?? inner.router.options.context ?? undefined
376
+
377
+ const context = { ...parentMatchContext, ...match.__routeContext }
378
+
379
+ let isPending = false
380
+ const pending = () => {
381
+ if (isPending) return
382
+ isPending = true
383
+ inner.updateMatch(matchId, (prev) => ({
384
+ ...prev,
385
+ isFetching: 'beforeLoad',
386
+ fetchCount: prev.fetchCount + 1,
387
+ abortController,
388
+ context,
389
+ }))
390
+ }
391
+
392
+ const resolve = () => {
393
+ match._nonReactive.beforeLoadPromise?.resolve()
394
+ match._nonReactive.beforeLoadPromise = undefined
395
+ inner.updateMatch(matchId, (prev) => ({
396
+ ...prev,
397
+ isFetching: false,
398
+ }))
399
+ }
400
+
401
+ // if there is no `beforeLoad` option, skip everything, batch update the store, return early
402
+ if (!route.options.beforeLoad) {
403
+ batch(() => {
404
+ pending()
405
+ resolve()
406
+ })
407
+ return
408
+ }
409
+
410
+ match._nonReactive.beforeLoadPromise = createControlledPromise<void>()
411
+
412
+ const { search, params, cause } = match
413
+ const preload = resolvePreload(inner, matchId)
414
+ const beforeLoadFnContext: BeforeLoadContextOptions<any, any, any, any, any> =
415
+ {
416
+ search,
417
+ abortController,
418
+ params,
419
+ preload,
420
+ context,
421
+ location: inner.location,
422
+ navigate: (opts: any) =>
423
+ inner.router.navigate({
424
+ ...opts,
425
+ _fromLocation: inner.location,
426
+ }),
427
+ buildLocation: inner.router.buildLocation,
428
+ cause: preload ? 'preload' : cause,
429
+ matches: inner.matches,
430
+ }
431
+
432
+ const updateContext = (beforeLoadContext: any) => {
433
+ if (beforeLoadContext === undefined) {
434
+ batch(() => {
435
+ pending()
436
+ resolve()
437
+ })
438
+ return
439
+ }
440
+ if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) {
441
+ pending()
442
+ handleSerialError(inner, index, beforeLoadContext, 'BEFORE_LOAD')
443
+ }
444
+
445
+ batch(() => {
446
+ pending()
447
+ inner.updateMatch(matchId, (prev) => ({
448
+ ...prev,
449
+ __beforeLoadContext: beforeLoadContext,
450
+ context: {
451
+ ...prev.context,
452
+ ...beforeLoadContext,
453
+ },
454
+ }))
455
+ resolve()
456
+ })
457
+ }
458
+
459
+ let beforeLoadContext
460
+ try {
461
+ beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext)
462
+ if (isPromise(beforeLoadContext)) {
463
+ pending()
464
+ return beforeLoadContext
465
+ .catch((err) => {
466
+ handleSerialError(inner, index, err, 'BEFORE_LOAD')
467
+ })
468
+ .then(updateContext)
469
+ }
470
+ } catch (err) {
471
+ pending()
472
+ handleSerialError(inner, index, err, 'BEFORE_LOAD')
473
+ }
474
+
475
+ updateContext(beforeLoadContext)
476
+ return
477
+ }
478
+
479
+ const handleBeforeLoad = (
480
+ inner: InnerLoadContext,
481
+ index: number,
482
+ ): void | Promise<void> => {
483
+ const { id: matchId, routeId } = inner.matches[index]!
484
+ const route = inner.router.looseRoutesById[routeId]!
485
+
486
+ const serverSsr = () => {
487
+ // on the server, determine whether SSR the current match or not
488
+ if (inner.router.isServer) {
489
+ const maybePromise = isBeforeLoadSsr(inner, matchId, index, route)
490
+ if (isPromise(maybePromise)) return maybePromise.then(queueExecution)
491
+ }
492
+ return queueExecution()
493
+ }
494
+
495
+ const queueExecution = () => {
496
+ if (shouldSkipLoader(inner, matchId)) return
497
+ const shouldExecuteBeforeLoadResult = shouldExecuteBeforeLoad(
498
+ inner,
499
+ matchId,
500
+ route,
501
+ )
502
+ return isPromise(shouldExecuteBeforeLoadResult)
503
+ ? shouldExecuteBeforeLoadResult.then(execute)
504
+ : execute(shouldExecuteBeforeLoadResult)
505
+ }
506
+
507
+ const execute = (shouldExec: boolean) => {
508
+ if (shouldExec) {
509
+ // If we are not in the middle of a load OR the previous load failed, start it
510
+ return executeBeforeLoad(inner, matchId, index, route)
511
+ }
512
+ return
513
+ }
514
+
515
+ return serverSsr()
516
+ }
517
+
518
+ const executeHead = (
519
+ inner: InnerLoadContext,
520
+ matchId: string,
521
+ route: AnyRoute,
522
+ ): void | Promise<
523
+ Pick<
524
+ AnyRouteMatch,
525
+ 'meta' | 'links' | 'headScripts' | 'headers' | 'scripts' | 'styles'
526
+ >
527
+ > => {
528
+ const match = inner.router.getMatch(matchId)
529
+ // in case of a redirecting match during preload, the match does not exist
530
+ if (!match) {
531
+ return
532
+ }
533
+ if (!route.options.head && !route.options.scripts && !route.options.headers) {
534
+ return
535
+ }
536
+ const assetContext = {
537
+ matches: inner.matches,
538
+ match,
539
+ params: match.params,
540
+ loaderData: match.loaderData,
541
+ }
542
+
543
+ return Promise.all([
544
+ route.options.head?.(assetContext),
545
+ route.options.scripts?.(assetContext),
546
+ route.options.headers?.(assetContext),
547
+ ]).then(([headFnContent, scripts, headers]) => {
548
+ const meta = headFnContent?.meta
549
+ const links = headFnContent?.links
550
+ const headScripts = headFnContent?.scripts
551
+ const styles = headFnContent?.styles
552
+
553
+ return {
554
+ meta,
555
+ links,
556
+ headScripts,
557
+ headers,
558
+ scripts,
559
+ styles,
560
+ }
561
+ })
562
+ }
563
+
564
+ const getLoaderContext = (
565
+ inner: InnerLoadContext,
566
+ matchId: string,
567
+ index: number,
568
+ route: AnyRoute,
569
+ ): LoaderFnContext => {
570
+ const parentMatchPromise = inner.matchPromises[index - 1] as any
571
+ const { params, loaderDeps, abortController, context, cause } =
572
+ inner.router.getMatch(matchId)!
573
+
574
+ const preload = resolvePreload(inner, matchId)
575
+
576
+ return {
577
+ params,
578
+ deps: loaderDeps,
579
+ preload: !!preload,
580
+ parentMatchPromise,
581
+ abortController,
582
+ context,
583
+ location: inner.location,
584
+ navigate: (opts) =>
585
+ inner.router.navigate({
586
+ ...opts,
587
+ _fromLocation: inner.location,
588
+ }),
589
+ cause: preload ? 'preload' : cause,
590
+ route,
591
+ }
592
+ }
593
+
594
+ const runLoader = async (
595
+ inner: InnerLoadContext,
596
+ matchId: string,
597
+ index: number,
598
+ route: AnyRoute,
599
+ ): Promise<void> => {
600
+ try {
601
+ // If the Matches component rendered
602
+ // the pending component and needs to show it for
603
+ // a minimum duration, we''ll wait for it to resolve
604
+ // before committing to the match and resolving
605
+ // the loadPromise
606
+
607
+ const match = inner.router.getMatch(matchId)!
608
+
609
+ // Actually run the loader and handle the result
610
+ try {
611
+ if (!inner.router.isServer || match.ssr === true) {
612
+ loadRouteChunk(route)
613
+ }
614
+
615
+ // Kick off the loader!
616
+ const loaderResult = route.options.loader?.(
617
+ getLoaderContext(inner, matchId, index, route),
618
+ )
619
+ const loaderResultIsPromise =
620
+ route.options.loader && isPromise(loaderResult)
621
+
622
+ const willLoadSomething = !!(
623
+ loaderResultIsPromise ||
624
+ route._lazyPromise ||
625
+ route._componentsPromise ||
626
+ route.options.head ||
627
+ route.options.scripts ||
628
+ route.options.headers ||
629
+ match._nonReactive.minPendingPromise
630
+ )
631
+
632
+ if (willLoadSomething) {
633
+ inner.updateMatch(matchId, (prev) => ({
634
+ ...prev,
635
+ isFetching: 'loader',
636
+ }))
637
+ }
638
+
639
+ if (route.options.loader) {
640
+ const loaderData = loaderResultIsPromise
641
+ ? await loaderResult
642
+ : loaderResult
643
+
644
+ handleRedirectAndNotFound(
645
+ inner,
646
+ inner.router.getMatch(matchId),
647
+ loaderData,
648
+ )
649
+ if (loaderData !== undefined) {
650
+ inner.updateMatch(matchId, (prev) => ({
651
+ ...prev,
652
+ loaderData,
653
+ }))
654
+ }
655
+ }
656
+
657
+ // Lazy option can modify the route options,
658
+ // so we need to wait for it to resolve before
659
+ // we can use the options
660
+ if (route._lazyPromise) await route._lazyPromise
661
+ const headResult = executeHead(inner, matchId, route)
662
+ const head = headResult ? await headResult : undefined
663
+ const pendingPromise = match._nonReactive.minPendingPromise
664
+ if (pendingPromise) await pendingPromise
665
+
666
+ // Last but not least, wait for the the components
667
+ // to be preloaded before we resolve the match
668
+ if (route._componentsPromise) await route._componentsPromise
669
+ inner.updateMatch(matchId, (prev) => ({
670
+ ...prev,
671
+ error: undefined,
672
+ status: 'success',
673
+ isFetching: false,
674
+ updatedAt: Date.now(),
675
+ ...head,
676
+ }))
677
+ } catch (e) {
678
+ let error = e
679
+
680
+ const pendingPromise = match._nonReactive.minPendingPromise
681
+ if (pendingPromise) await pendingPromise
682
+
683
+ handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), e)
684
+
685
+ try {
686
+ route.options.onError?.(e)
687
+ } catch (onErrorError) {
688
+ error = onErrorError
689
+ handleRedirectAndNotFound(
690
+ inner,
691
+ inner.router.getMatch(matchId),
692
+ onErrorError,
693
+ )
694
+ }
695
+ const headResult = executeHead(inner, matchId, route)
696
+ const head = headResult ? await headResult : undefined
697
+ inner.updateMatch(matchId, (prev) => ({
698
+ ...prev,
699
+ error,
700
+ status: 'error',
701
+ isFetching: false,
702
+ ...head,
703
+ }))
704
+ }
705
+ } catch (err) {
706
+ const match = inner.router.getMatch(matchId)
707
+ // in case of a redirecting match during preload, the match does not exist
708
+ if (match) {
709
+ const headResult = executeHead(inner, matchId, route)
710
+ if (headResult) {
711
+ const head = await headResult
712
+ inner.updateMatch(matchId, (prev) => ({
713
+ ...prev,
714
+ ...head,
715
+ }))
716
+ }
717
+ match._nonReactive.loaderPromise = undefined
718
+ }
719
+ handleRedirectAndNotFound(inner, match, err)
720
+ }
721
+ }
722
+
723
+ const loadRouteMatch = async (
724
+ inner: InnerLoadContext,
725
+ index: number,
726
+ ): Promise<AnyRouteMatch> => {
727
+ const { id: matchId, routeId } = inner.matches[index]!
728
+ let loaderShouldRunAsync = false
729
+ let loaderIsRunningAsync = false
730
+ const route = inner.router.looseRoutesById[routeId]!
731
+
732
+ if (shouldSkipLoader(inner, matchId)) {
733
+ if (inner.router.isServer) {
734
+ const headResult = executeHead(inner, matchId, route)
735
+ if (headResult) {
736
+ const head = await headResult
737
+ inner.updateMatch(matchId, (prev) => ({
738
+ ...prev,
739
+ ...head,
740
+ }))
741
+ }
742
+ return inner.router.getMatch(matchId)!
743
+ }
744
+ } else {
745
+ const prevMatch = inner.router.getMatch(matchId)!
746
+ // there is a loaderPromise, so we are in the middle of a load
747
+ if (prevMatch._nonReactive.loaderPromise) {
748
+ // do not block if we already have stale data we can show
749
+ // but only if the ongoing load is not a preload since error handling is different for preloads
750
+ // and we don't want to swallow errors
751
+ if (prevMatch.status === 'success' && !inner.sync && !prevMatch.preload) {
752
+ return prevMatch
753
+ }
754
+ await prevMatch._nonReactive.loaderPromise
755
+ const match = inner.router.getMatch(matchId)!
756
+ if (match.error) {
757
+ handleRedirectAndNotFound(inner, match, match.error)
758
+ }
759
+ } else {
760
+ // This is where all of the stale-while-revalidate magic happens
761
+ const age = Date.now() - prevMatch.updatedAt
762
+
763
+ const preload = resolvePreload(inner, matchId)
764
+
765
+ const staleAge = preload
766
+ ? (route.options.preloadStaleTime ??
767
+ inner.router.options.defaultPreloadStaleTime ??
768
+ 30_000) // 30 seconds for preloads by default
769
+ : (route.options.staleTime ??
770
+ inner.router.options.defaultStaleTime ??
771
+ 0)
772
+
773
+ const shouldReloadOption = route.options.shouldReload
774
+
775
+ // Default to reloading the route all the time
776
+ // Allow shouldReload to get the last say,
777
+ // if provided.
778
+ const shouldReload =
779
+ typeof shouldReloadOption === 'function'
780
+ ? shouldReloadOption(getLoaderContext(inner, matchId, index, route))
781
+ : shouldReloadOption
782
+
783
+ const nextPreload =
784
+ !!preload && !inner.router.state.matches.some((d) => d.id === matchId)
785
+ const match = inner.router.getMatch(matchId)!
786
+ match._nonReactive.loaderPromise = createControlledPromise<void>()
787
+ if (nextPreload !== match.preload) {
788
+ inner.updateMatch(matchId, (prev) => ({
789
+ ...prev,
790
+ preload: nextPreload,
791
+ }))
792
+ }
793
+
794
+ // If the route is successful and still fresh, just resolve
795
+ const { status, invalid } = match
796
+ loaderShouldRunAsync =
797
+ status === 'success' && (invalid || (shouldReload ?? age > staleAge))
798
+ if (preload && route.options.preload === false) {
799
+ // Do nothing
800
+ } else if (loaderShouldRunAsync && !inner.sync) {
801
+ loaderIsRunningAsync = true
802
+ ;(async () => {
803
+ try {
804
+ await runLoader(inner, matchId, index, route)
805
+ const match = inner.router.getMatch(matchId)!
806
+ match._nonReactive.loaderPromise?.resolve()
807
+ match._nonReactive.loadPromise?.resolve()
808
+ match._nonReactive.loaderPromise = undefined
809
+ } catch (err) {
810
+ if (isRedirect(err)) {
811
+ await inner.router.navigate(err.options)
812
+ }
813
+ }
814
+ })()
815
+ } else if (status !== 'success' || (loaderShouldRunAsync && inner.sync)) {
816
+ await runLoader(inner, matchId, index, route)
817
+ } else {
818
+ // if the loader did not run, still update head.
819
+ // reason: parent's beforeLoad may have changed the route context
820
+ // and only now do we know the route context (and that the loader would not run)
821
+ const headResult = executeHead(inner, matchId, route)
822
+ if (headResult) {
823
+ const head = await headResult
824
+ inner.updateMatch(matchId, (prev) => ({
825
+ ...prev,
826
+ ...head,
827
+ }))
828
+ }
829
+ }
830
+ }
831
+ }
832
+ const match = inner.router.getMatch(matchId)!
833
+ if (!loaderIsRunningAsync) {
834
+ match._nonReactive.loaderPromise?.resolve()
835
+ match._nonReactive.loadPromise?.resolve()
836
+ }
837
+
838
+ clearTimeout(match._nonReactive.pendingTimeout)
839
+ match._nonReactive.pendingTimeout = undefined
840
+ if (!loaderIsRunningAsync) match._nonReactive.loaderPromise = undefined
841
+ match._nonReactive.dehydrated = undefined
842
+ const nextIsFetching = loaderIsRunningAsync ? match.isFetching : false
843
+ if (nextIsFetching !== match.isFetching || match.invalid !== false) {
844
+ inner.updateMatch(matchId, (prev) => ({
845
+ ...prev,
846
+ isFetching: nextIsFetching,
847
+ invalid: false,
848
+ }))
849
+ return inner.router.getMatch(matchId)!
850
+ } else {
851
+ return match
852
+ }
853
+ }
854
+
855
+ export async function loadMatches(arg: {
856
+ router: AnyRouter
857
+ location: ParsedLocation
858
+ matches: Array<AnyRouteMatch>
859
+ preload?: boolean
860
+ onReady?: () => Promise<void>
861
+ updateMatch: UpdateMatchFn
862
+ sync?: boolean
863
+ }): Promise<Array<MakeRouteMatch>> {
864
+ const inner: InnerLoadContext = Object.assign(arg, {
865
+ matchPromises: [],
866
+ })
867
+
868
+ // make sure the pending component is immediately rendered when hydrating a match that is not SSRed
869
+ // the pending component was already rendered on the server and we want to keep it shown on the client until minPendingMs is reached
870
+ if (
871
+ !inner.router.isServer &&
872
+ inner.router.state.matches.some((d) => d._forcePending)
873
+ ) {
874
+ triggerOnReady(inner)
875
+ }
876
+
877
+ try {
878
+ // Execute all beforeLoads one by one
879
+ for (let i = 0; i < inner.matches.length; i++) {
880
+ const beforeLoad = handleBeforeLoad(inner, i)
881
+ if (isPromise(beforeLoad)) await beforeLoad
882
+ }
883
+
884
+ // Execute all loaders in parallel
885
+ const max = inner.firstBadMatchIndex ?? inner.matches.length
886
+ for (let i = 0; i < max; i++) {
887
+ inner.matchPromises.push(loadRouteMatch(inner, i))
888
+ }
889
+ await Promise.all(inner.matchPromises)
890
+
891
+ const readyPromise = triggerOnReady(inner)
892
+ if (isPromise(readyPromise)) await readyPromise
893
+ } catch (err) {
894
+ if (isNotFound(err) && !inner.preload) {
895
+ const readyPromise = triggerOnReady(inner)
896
+ if (isPromise(readyPromise)) await readyPromise
897
+ throw err
898
+ }
899
+ if (isRedirect(err)) {
900
+ throw err
901
+ }
902
+ }
903
+
904
+ return inner.matches
905
+ }
906
+
907
+ export async function loadRouteChunk(route: AnyRoute) {
908
+ if (!route._lazyLoaded && route._lazyPromise === undefined) {
909
+ if (route.lazyFn) {
910
+ route._lazyPromise = route.lazyFn().then((lazyRoute) => {
911
+ // explicitly don't copy over the lazy route's id
912
+ const { id: _id, ...options } = lazyRoute.options
913
+ Object.assign(route.options, options)
914
+ route._lazyLoaded = true
915
+ route._lazyPromise = undefined // gc promise, we won't need it anymore
916
+ })
917
+ } else {
918
+ route._lazyLoaded = true
919
+ }
920
+ }
921
+
922
+ // If for some reason lazy resolves more lazy components...
923
+ // We'll wait for that before we attempt to preload the
924
+ // components themselves.
925
+ if (!route._componentsLoaded && route._componentsPromise === undefined) {
926
+ const loadComponents = () => {
927
+ const preloads = []
928
+ for (const type of componentTypes) {
929
+ const preload = (route.options[type] as any)?.preload
930
+ if (preload) preloads.push(preload())
931
+ }
932
+ if (preloads.length)
933
+ return Promise.all(preloads).then(() => {
934
+ route._componentsLoaded = true
935
+ route._componentsPromise = undefined // gc promise, we won't need it anymore
936
+ })
937
+ route._componentsLoaded = true
938
+ route._componentsPromise = undefined // gc promise, we won't need it anymore
939
+ return
940
+ }
941
+ route._componentsPromise = route._lazyPromise
942
+ ? route._lazyPromise.then(loadComponents)
943
+ : loadComponents()
944
+ }
945
+ return route._componentsPromise
946
+ }
947
+
948
+ function makeMaybe<TValue, TError>(
949
+ value: TValue,
950
+ error: TError,
951
+ ): { status: 'success'; value: TValue } | { status: 'error'; error: TError } {
952
+ if (error) {
953
+ return { status: 'error' as const, error }
954
+ }
955
+ return { status: 'success' as const, value }
956
+ }
957
+
958
+ export function routeNeedsPreload(route: AnyRoute) {
959
+ for (const componentType of componentTypes) {
960
+ if ((route.options[componentType] as any)?.preload) {
961
+ return true
962
+ }
963
+ }
964
+ return false
965
+ }
966
+
967
+ export const componentTypes = [
968
+ 'component',
969
+ 'errorComponent',
970
+ 'pendingComponent',
971
+ 'notFoundComponent',
972
+ ] as const