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