@tanstack/router-core 1.131.13 → 1.131.16

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
@@ -9,6 +9,7 @@ import {
9
9
  createControlledPromise,
10
10
  deepEqual,
11
11
  functionalUpdate,
12
+ isPromise,
12
13
  last,
13
14
  pick,
14
15
  replaceEqualDeep,
@@ -762,6 +763,18 @@ export type CreateRouterFn = <
762
763
  TDehydrated
763
764
  >
764
765
 
766
+ type InnerLoadContext = {
767
+ location: ParsedLocation
768
+ firstBadMatchIndex?: number
769
+ rendered?: boolean
770
+ updateMatch: UpdateMatchFn
771
+ matches: Array<AnyRouteMatch>
772
+ preload?: boolean
773
+ onReady?: () => Promise<void>
774
+ sync?: boolean
775
+ matchPromises: Array<Promise<AnyRouteMatch>>
776
+ }
777
+
765
778
  export class RouterCore<
766
779
  in out TRouteTree extends AnyRoute,
767
780
  in out TTrailingSlashOption extends TrailingSlashOption,
@@ -2082,699 +2095,838 @@ export class RouterCore<
2082
2095
  )
2083
2096
  }
2084
2097
 
2085
- loadMatches = async ({
2086
- location,
2087
- matches,
2088
- preload: allPreload,
2089
- onReady,
2090
- updateMatch = this.updateMatch,
2091
- sync,
2092
- }: {
2093
- location: ParsedLocation
2094
- matches: Array<AnyRouteMatch>
2095
- preload?: boolean
2096
- onReady?: () => Promise<void>
2097
- updateMatch?: (
2098
- id: string,
2099
- updater: (match: AnyRouteMatch) => AnyRouteMatch,
2100
- ) => void
2101
- getMatch?: (matchId: string) => AnyRouteMatch | undefined
2102
- sync?: boolean
2103
- }): Promise<Array<MakeRouteMatch>> => {
2104
- let firstBadMatchIndex: number | undefined
2105
- let rendered = false
2098
+ private triggerOnReady = (
2099
+ innerLoadContext: InnerLoadContext,
2100
+ ): void | Promise<void> => {
2101
+ if (!innerLoadContext.rendered) {
2102
+ innerLoadContext.rendered = true
2103
+ return innerLoadContext.onReady?.()
2104
+ }
2105
+ }
2106
+
2107
+ private resolvePreload = (
2108
+ innerLoadContext: InnerLoadContext,
2109
+ matchId: string,
2110
+ ): boolean => {
2111
+ return !!(
2112
+ innerLoadContext.preload &&
2113
+ !this.state.matches.some((d) => d.id === matchId)
2114
+ )
2115
+ }
2116
+
2117
+ private handleRedirectAndNotFound = (
2118
+ innerLoadContext: InnerLoadContext,
2119
+ match: AnyRouteMatch | undefined,
2120
+ err: unknown,
2121
+ ): void => {
2122
+ if (!isRedirect(err) && !isNotFound(err)) return
2123
+
2124
+ if (isRedirect(err) && err.redirectHandled && !err.options.reloadDocument) {
2125
+ throw err
2126
+ }
2106
2127
 
2107
- const triggerOnReady = async () => {
2108
- if (!rendered) {
2109
- rendered = true
2110
- await onReady?.()
2128
+ // in case of a redirecting match during preload, the match does not exist
2129
+ if (match) {
2130
+ match._nonReactive.beforeLoadPromise?.resolve()
2131
+ match._nonReactive.loaderPromise?.resolve()
2132
+ match._nonReactive.beforeLoadPromise = undefined
2133
+ match._nonReactive.loaderPromise = undefined
2134
+
2135
+ const status = isRedirect(err) ? 'redirected' : 'notFound'
2136
+
2137
+ innerLoadContext.updateMatch(match.id, (prev) => ({
2138
+ ...prev,
2139
+ status,
2140
+ isFetching: false,
2141
+ error: err,
2142
+ }))
2143
+
2144
+ if (isNotFound(err) && !err.routeId) {
2145
+ err.routeId = match.routeId
2111
2146
  }
2147
+
2148
+ match._nonReactive.loadPromise?.resolve()
2112
2149
  }
2113
2150
 
2114
- const resolvePreload = (matchId: string) => {
2115
- return !!(allPreload && !this.state.matches.some((d) => d.id === matchId))
2151
+ if (isRedirect(err)) {
2152
+ innerLoadContext.rendered = true
2153
+ err.options._fromLocation = innerLoadContext.location
2154
+ err.redirectHandled = true
2155
+ err = this.resolveRedirect(err)
2156
+ throw err
2157
+ } else {
2158
+ this._handleNotFound(innerLoadContext, err)
2159
+ throw err
2116
2160
  }
2161
+ }
2117
2162
 
2118
- // make sure the pending component is immediately rendered when hydrating a match that is not SSRed
2119
- // the pending component was already rendered on the server and we want to keep it shown on the client until minPendingMs is reached
2120
- if (!this.isServer && this.state.matches.some((d) => d._forcePending)) {
2121
- triggerOnReady()
2163
+ private shouldSkipLoader = (matchId: string): boolean => {
2164
+ const match = this.getMatch(matchId)!
2165
+ // upon hydration, we skip the loader if the match has been dehydrated on the server
2166
+ if (!this.isServer && match._nonReactive.dehydrated) {
2167
+ return true
2122
2168
  }
2123
2169
 
2124
- const handleRedirectAndNotFound = (
2125
- match: AnyRouteMatch | undefined,
2126
- err: unknown,
2127
- ) => {
2128
- if (!isRedirect(err) && !isNotFound(err)) return
2170
+ if (this.isServer) {
2171
+ if (match.ssr === false) {
2172
+ return true
2173
+ }
2174
+ }
2175
+ return false
2176
+ }
2129
2177
 
2130
- if (
2131
- isRedirect(err) &&
2132
- err.redirectHandled &&
2133
- !err.options.reloadDocument
2178
+ private handleSerialError = (
2179
+ innerLoadContext: InnerLoadContext,
2180
+ index: number,
2181
+ err: any,
2182
+ routerCode: string,
2183
+ ): void => {
2184
+ const { id: matchId, routeId } = innerLoadContext.matches[index]!
2185
+ const route = this.looseRoutesById[routeId]!
2186
+
2187
+ // Much like suspense, we use a promise here to know if
2188
+ // we've been outdated by a new loadMatches call and
2189
+ // should abort the current async operation
2190
+ if (err instanceof Promise) {
2191
+ throw err
2192
+ }
2193
+
2194
+ err.routerCode = routerCode
2195
+ innerLoadContext.firstBadMatchIndex ??= index
2196
+ this.handleRedirectAndNotFound(
2197
+ innerLoadContext,
2198
+ this.getMatch(matchId),
2199
+ err,
2200
+ )
2201
+
2202
+ try {
2203
+ route.options.onError?.(err)
2204
+ } catch (errorHandlerErr) {
2205
+ err = errorHandlerErr
2206
+ this.handleRedirectAndNotFound(
2207
+ innerLoadContext,
2208
+ this.getMatch(matchId),
2209
+ err,
2210
+ )
2211
+ }
2212
+
2213
+ innerLoadContext.updateMatch(matchId, (prev) => {
2214
+ prev._nonReactive.beforeLoadPromise?.resolve()
2215
+ prev._nonReactive.beforeLoadPromise = undefined
2216
+ prev._nonReactive.loadPromise?.resolve()
2217
+
2218
+ return {
2219
+ ...prev,
2220
+ error: err,
2221
+ status: 'error',
2222
+ isFetching: false,
2223
+ updatedAt: Date.now(),
2224
+ abortController: new AbortController(),
2225
+ }
2226
+ })
2227
+ }
2228
+
2229
+ private isBeforeLoadSsr = (
2230
+ innerLoadContext: InnerLoadContext,
2231
+ matchId: string,
2232
+ index: number,
2233
+ route: AnyRoute,
2234
+ ): void | Promise<void> => {
2235
+ const existingMatch = this.getMatch(matchId)!
2236
+ const parentMatchId = innerLoadContext.matches[index - 1]?.id
2237
+ const parentMatch = parentMatchId
2238
+ ? this.getMatch(parentMatchId)!
2239
+ : undefined
2240
+
2241
+ // in SPA mode, only SSR the root route
2242
+ if (this.isShell()) {
2243
+ existingMatch.ssr = matchId === rootRouteId
2244
+ return
2245
+ }
2246
+
2247
+ if (parentMatch?.ssr === false) {
2248
+ existingMatch.ssr = false
2249
+ return
2250
+ }
2251
+
2252
+ const parentOverride = (tempSsr: boolean | 'data-only') => {
2253
+ if (tempSsr === true && parentMatch?.ssr === 'data-only') {
2254
+ return 'data-only'
2255
+ }
2256
+ return tempSsr
2257
+ }
2258
+
2259
+ const defaultSsr = this.options.defaultSsr ?? true
2260
+
2261
+ if (route.options.ssr === undefined) {
2262
+ existingMatch.ssr = parentOverride(defaultSsr)
2263
+ return
2264
+ }
2265
+
2266
+ if (typeof route.options.ssr !== 'function') {
2267
+ existingMatch.ssr = parentOverride(route.options.ssr)
2268
+ return
2269
+ }
2270
+ const { search, params } = this.getMatch(matchId)!
2271
+
2272
+ const ssrFnContext: SsrContextOptions<any, any, any> = {
2273
+ search: makeMaybe(search, existingMatch.searchError),
2274
+ params: makeMaybe(params, existingMatch.paramsError),
2275
+ location: innerLoadContext.location,
2276
+ matches: innerLoadContext.matches.map((match) => ({
2277
+ index: match.index,
2278
+ pathname: match.pathname,
2279
+ fullPath: match.fullPath,
2280
+ staticData: match.staticData,
2281
+ id: match.id,
2282
+ routeId: match.routeId,
2283
+ search: makeMaybe(match.search, match.searchError),
2284
+ params: makeMaybe(match.params, match.paramsError),
2285
+ ssr: match.ssr,
2286
+ })),
2287
+ }
2288
+
2289
+ const tempSsr = route.options.ssr(ssrFnContext)
2290
+ if (isPromise(tempSsr)) {
2291
+ return tempSsr.then((ssr) => {
2292
+ existingMatch.ssr = parentOverride(ssr ?? defaultSsr)
2293
+ })
2294
+ }
2295
+
2296
+ existingMatch.ssr = parentOverride(tempSsr ?? defaultSsr)
2297
+ return
2298
+ }
2299
+
2300
+ private setupPendingTimeout = (
2301
+ innerLoadContext: InnerLoadContext,
2302
+ matchId: string,
2303
+ route: AnyRoute,
2304
+ ): void => {
2305
+ const pendingMs = route.options.pendingMs ?? this.options.defaultPendingMs
2306
+ const shouldPending = !!(
2307
+ innerLoadContext.onReady &&
2308
+ !this.isServer &&
2309
+ !this.resolvePreload(innerLoadContext, matchId) &&
2310
+ (route.options.loader ||
2311
+ route.options.beforeLoad ||
2312
+ routeNeedsPreload(route)) &&
2313
+ typeof pendingMs === 'number' &&
2314
+ pendingMs !== Infinity &&
2315
+ (route.options.pendingComponent ??
2316
+ (this.options as any)?.defaultPendingComponent)
2317
+ )
2318
+ const match = this.getMatch(matchId)!
2319
+ if (shouldPending && match._nonReactive.pendingTimeout === undefined) {
2320
+ const pendingTimeout = setTimeout(() => {
2321
+ try {
2322
+ // Update the match and prematurely resolve the loadMatches promise so that
2323
+ // the pending component can start rendering
2324
+ this.triggerOnReady(innerLoadContext)
2325
+ } catch {}
2326
+ }, pendingMs)
2327
+ match._nonReactive.pendingTimeout = pendingTimeout
2328
+ }
2329
+ }
2330
+
2331
+ private shouldExecuteBeforeLoad = (
2332
+ innerLoadContext: InnerLoadContext,
2333
+ matchId: string,
2334
+ route: AnyRoute,
2335
+ ): boolean | Promise<boolean> => {
2336
+ const existingMatch = this.getMatch(matchId)!
2337
+
2338
+ // If we are in the middle of a load, either of these will be present
2339
+ // (not to be confused with `loadPromise`, which is always defined)
2340
+ if (
2341
+ !existingMatch._nonReactive.beforeLoadPromise &&
2342
+ !existingMatch._nonReactive.loaderPromise
2343
+ )
2344
+ return true
2345
+
2346
+ this.setupPendingTimeout(innerLoadContext, matchId, route)
2347
+
2348
+ const then = () => {
2349
+ let shouldExecuteBeforeLoad = true
2350
+ const match = this.getMatch(matchId)!
2351
+ if (match.status === 'error') {
2352
+ shouldExecuteBeforeLoad = true
2353
+ } else if (
2354
+ match.preload &&
2355
+ (match.status === 'redirected' || match.status === 'notFound')
2134
2356
  ) {
2135
- throw err
2357
+ this.handleRedirectAndNotFound(innerLoadContext, match, match.error)
2136
2358
  }
2359
+ return shouldExecuteBeforeLoad
2360
+ }
2137
2361
 
2138
- // in case of a redirecting match during preload, the match does not exist
2139
- if (match) {
2140
- match._nonReactive.beforeLoadPromise?.resolve()
2141
- match._nonReactive.loaderPromise?.resolve()
2142
- match._nonReactive.beforeLoadPromise = undefined
2143
- match._nonReactive.loaderPromise = undefined
2362
+ // Wait for the beforeLoad to resolve before we continue
2363
+ return existingMatch._nonReactive.beforeLoadPromise
2364
+ ? existingMatch._nonReactive.beforeLoadPromise.then(then)
2365
+ : then()
2366
+ }
2144
2367
 
2145
- const status = isRedirect(err) ? 'redirected' : 'notFound'
2368
+ private executeBeforeLoad = (
2369
+ innerLoadContext: InnerLoadContext,
2370
+ matchId: string,
2371
+ index: number,
2372
+ route: AnyRoute,
2373
+ ): void | Promise<void> => {
2374
+ const resolve = () => {
2375
+ innerLoadContext.updateMatch(matchId, (prev) => {
2376
+ prev._nonReactive.beforeLoadPromise?.resolve()
2377
+ prev._nonReactive.beforeLoadPromise = undefined
2146
2378
 
2147
- updateMatch(match.id, (prev) => ({
2379
+ return {
2148
2380
  ...prev,
2149
- status,
2150
2381
  isFetching: false,
2151
- error: err,
2152
- }))
2382
+ }
2383
+ })
2384
+ }
2385
+
2386
+ try {
2387
+ const match = this.getMatch(matchId)!
2388
+ match._nonReactive.beforeLoadPromise = createControlledPromise<void>()
2389
+ // explicitly capture the previous loadPromise
2390
+ const prevLoadPromise = match._nonReactive.loadPromise
2391
+ match._nonReactive.loadPromise = createControlledPromise<void>(() => {
2392
+ prevLoadPromise?.resolve()
2393
+ })
2394
+
2395
+ const { paramsError, searchError } = this.getMatch(matchId)!
2396
+
2397
+ if (paramsError) {
2398
+ this.handleSerialError(
2399
+ innerLoadContext,
2400
+ index,
2401
+ paramsError,
2402
+ 'PARSE_PARAMS',
2403
+ )
2404
+ }
2405
+
2406
+ if (searchError) {
2407
+ this.handleSerialError(
2408
+ innerLoadContext,
2409
+ index,
2410
+ searchError,
2411
+ 'VALIDATE_SEARCH',
2412
+ )
2413
+ }
2414
+
2415
+ this.setupPendingTimeout(innerLoadContext, matchId, route)
2416
+
2417
+ const abortController = new AbortController()
2418
+
2419
+ const parentMatchId = innerLoadContext.matches[index - 1]?.id
2420
+ const parentMatch = parentMatchId
2421
+ ? this.getMatch(parentMatchId)!
2422
+ : undefined
2423
+ const parentMatchContext =
2424
+ parentMatch?.context ?? this.options.context ?? undefined
2425
+
2426
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2427
+ ...prev,
2428
+ isFetching: 'beforeLoad',
2429
+ fetchCount: prev.fetchCount + 1,
2430
+ abortController,
2431
+ context: {
2432
+ ...parentMatchContext,
2433
+ ...prev.__routeContext,
2434
+ },
2435
+ }))
2436
+
2437
+ const { search, params, context, cause } = this.getMatch(matchId)!
2438
+
2439
+ const preload = this.resolvePreload(innerLoadContext, matchId)
2440
+
2441
+ const beforeLoadFnContext: BeforeLoadContextOptions<
2442
+ any,
2443
+ any,
2444
+ any,
2445
+ any,
2446
+ any
2447
+ > = {
2448
+ search,
2449
+ abortController,
2450
+ params,
2451
+ preload,
2452
+ context,
2453
+ location: innerLoadContext.location,
2454
+ navigate: (opts: any) =>
2455
+ this.navigate({ ...opts, _fromLocation: innerLoadContext.location }),
2456
+ buildLocation: this.buildLocation,
2457
+ cause: preload ? 'preload' : cause,
2458
+ matches: innerLoadContext.matches,
2459
+ }
2153
2460
 
2154
- if (isNotFound(err) && !err.routeId) {
2155
- err.routeId = match.routeId
2461
+ const updateContext = (beforeLoadContext: any) => {
2462
+ if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) {
2463
+ this.handleSerialError(
2464
+ innerLoadContext,
2465
+ index,
2466
+ beforeLoadContext,
2467
+ 'BEFORE_LOAD',
2468
+ )
2156
2469
  }
2157
2470
 
2158
- match._nonReactive.loadPromise?.resolve()
2471
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2472
+ ...prev,
2473
+ __beforeLoadContext: beforeLoadContext,
2474
+ context: {
2475
+ ...parentMatchContext,
2476
+ ...prev.__routeContext,
2477
+ ...beforeLoadContext,
2478
+ },
2479
+ abortController,
2480
+ }))
2159
2481
  }
2160
2482
 
2161
- if (isRedirect(err)) {
2162
- rendered = true
2163
- err.options._fromLocation = location
2164
- err.redirectHandled = true
2165
- err = this.resolveRedirect(err)
2166
- throw err
2483
+ const beforeLoadContext = route.options.beforeLoad?.(beforeLoadFnContext)
2484
+ if (isPromise(beforeLoadContext)) {
2485
+ return beforeLoadContext
2486
+ .then(updateContext)
2487
+ .catch((err) => {
2488
+ this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD')
2489
+ })
2490
+ .then(resolve)
2167
2491
  } else {
2168
- this._handleNotFound(matches, err, updateMatch)
2169
- throw err
2492
+ updateContext(beforeLoadContext)
2170
2493
  }
2494
+ } catch (err) {
2495
+ this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD')
2171
2496
  }
2172
2497
 
2173
- const shouldSkipLoader = (matchId: string) => {
2174
- const match = this.getMatch(matchId)!
2175
- // upon hydration, we skip the loader if the match has been dehydrated on the server
2176
- if (!this.isServer && match._nonReactive.dehydrated) {
2177
- return true
2178
- }
2498
+ resolve()
2499
+ return
2500
+ }
2179
2501
 
2502
+ private handleBeforeLoad = (
2503
+ innerLoadContext: InnerLoadContext,
2504
+ index: number,
2505
+ ): void | Promise<void> => {
2506
+ const { id: matchId, routeId } = innerLoadContext.matches[index]!
2507
+ const route = this.looseRoutesById[routeId]!
2508
+
2509
+ const serverSsr = () => {
2510
+ // on the server, determine whether SSR the current match or not
2180
2511
  if (this.isServer) {
2181
- if (match.ssr === false) {
2182
- return true
2183
- }
2512
+ const maybePromise = this.isBeforeLoadSsr(
2513
+ innerLoadContext,
2514
+ matchId,
2515
+ index,
2516
+ route,
2517
+ )
2518
+ if (isPromise(maybePromise)) return maybePromise.then(queueExecution)
2184
2519
  }
2185
- return false
2520
+ return queueExecution()
2186
2521
  }
2187
2522
 
2523
+ const queueExecution = () => {
2524
+ if (this.shouldSkipLoader(matchId)) return
2525
+ const shouldExecuteBeforeLoadResult = this.shouldExecuteBeforeLoad(
2526
+ innerLoadContext,
2527
+ matchId,
2528
+ route,
2529
+ )
2530
+ return isPromise(shouldExecuteBeforeLoadResult)
2531
+ ? shouldExecuteBeforeLoadResult.then(execute)
2532
+ : execute(shouldExecuteBeforeLoadResult)
2533
+ }
2534
+
2535
+ const execute = (shouldExecuteBeforeLoad: boolean) => {
2536
+ if (shouldExecuteBeforeLoad) {
2537
+ // If we are not in the middle of a load OR the previous load failed, start it
2538
+ return this.executeBeforeLoad(innerLoadContext, matchId, index, route)
2539
+ }
2540
+ return
2541
+ }
2542
+
2543
+ return serverSsr()
2544
+ }
2545
+
2546
+ private executeHead = (
2547
+ innerLoadContext: InnerLoadContext,
2548
+ matchId: string,
2549
+ route: AnyRoute,
2550
+ ): void | Promise<
2551
+ Pick<
2552
+ AnyRouteMatch,
2553
+ 'meta' | 'links' | 'headScripts' | 'headers' | 'scripts' | 'styles'
2554
+ >
2555
+ > => {
2556
+ const match = this.getMatch(matchId)
2557
+ // in case of a redirecting match during preload, the match does not exist
2558
+ if (!match) {
2559
+ return
2560
+ }
2561
+ if (
2562
+ !route.options.head &&
2563
+ !route.options.scripts &&
2564
+ !route.options.headers
2565
+ ) {
2566
+ return
2567
+ }
2568
+ const assetContext = {
2569
+ matches: innerLoadContext.matches,
2570
+ match,
2571
+ params: match.params,
2572
+ loaderData: match.loaderData,
2573
+ }
2574
+
2575
+ return Promise.all([
2576
+ route.options.head?.(assetContext),
2577
+ route.options.scripts?.(assetContext),
2578
+ route.options.headers?.(assetContext),
2579
+ ]).then(([headFnContent, scripts, headers]) => {
2580
+ const meta = headFnContent?.meta
2581
+ const links = headFnContent?.links
2582
+ const headScripts = headFnContent?.scripts
2583
+ const styles = headFnContent?.styles
2584
+
2585
+ return {
2586
+ meta,
2587
+ links,
2588
+ headScripts,
2589
+ headers,
2590
+ scripts,
2591
+ styles,
2592
+ }
2593
+ })
2594
+ }
2595
+
2596
+ private potentialPendingMinPromise = (
2597
+ matchId: string,
2598
+ ): void | ControlledPromise<void> => {
2599
+ const latestMatch = this.getMatch(matchId)!
2600
+ return latestMatch._nonReactive.minPendingPromise
2601
+ }
2602
+
2603
+ private getLoaderContext = (
2604
+ innerLoadContext: InnerLoadContext,
2605
+ matchId: string,
2606
+ index: number,
2607
+ route: AnyRoute,
2608
+ ): LoaderFnContext => {
2609
+ const parentMatchPromise = innerLoadContext.matchPromises[index - 1] as any
2610
+ const { params, loaderDeps, abortController, context, cause } =
2611
+ this.getMatch(matchId)!
2612
+
2613
+ const preload = this.resolvePreload(innerLoadContext, matchId)
2614
+
2615
+ return {
2616
+ params,
2617
+ deps: loaderDeps,
2618
+ preload: !!preload,
2619
+ parentMatchPromise,
2620
+ abortController: abortController,
2621
+ context,
2622
+ location: innerLoadContext.location,
2623
+ navigate: (opts) =>
2624
+ this.navigate({ ...opts, _fromLocation: innerLoadContext.location }),
2625
+ cause: preload ? 'preload' : cause,
2626
+ route,
2627
+ }
2628
+ }
2629
+
2630
+ private runLoader = async (
2631
+ innerLoadContext: InnerLoadContext,
2632
+ matchId: string,
2633
+ index: number,
2634
+ route: AnyRoute,
2635
+ ): Promise<void> => {
2188
2636
  try {
2189
- await new Promise<void>((resolveAll, rejectAll) => {
2190
- ;(async () => {
2191
- try {
2192
- const handleSerialError = (
2193
- index: number,
2194
- err: any,
2195
- routerCode: string,
2196
- ) => {
2197
- const { id: matchId, routeId } = matches[index]!
2198
- const route = this.looseRoutesById[routeId]!
2199
-
2200
- // Much like suspense, we use a promise here to know if
2201
- // we've been outdated by a new loadMatches call and
2202
- // should abort the current async operation
2203
- if (err instanceof Promise) {
2204
- throw err
2205
- }
2637
+ // If the Matches component rendered
2638
+ // the pending component and needs to show it for
2639
+ // a minimum duration, we''ll wait for it to resolve
2640
+ // before committing to the match and resolving
2641
+ // the loadPromise
2206
2642
 
2207
- err.routerCode = routerCode
2208
- firstBadMatchIndex = firstBadMatchIndex ?? index
2209
- handleRedirectAndNotFound(this.getMatch(matchId), err)
2643
+ // Actually run the loader and handle the result
2644
+ try {
2645
+ if (!this.isServer || this.getMatch(matchId)!.ssr === true) {
2646
+ this.loadRouteChunk(route)
2647
+ }
2210
2648
 
2211
- try {
2212
- route.options.onError?.(err)
2213
- } catch (errorHandlerErr) {
2214
- err = errorHandlerErr
2215
- handleRedirectAndNotFound(this.getMatch(matchId), err)
2216
- }
2649
+ // Kick off the loader!
2650
+ const loaderResult = route.options.loader?.(
2651
+ this.getLoaderContext(innerLoadContext, matchId, index, route),
2652
+ )
2653
+ const loaderResultIsPromise =
2654
+ route.options.loader && isPromise(loaderResult)
2655
+
2656
+ const willLoadSomething = !!(
2657
+ loaderResultIsPromise ||
2658
+ route._lazyPromise ||
2659
+ route._componentsPromise ||
2660
+ route.options.head ||
2661
+ route.options.scripts ||
2662
+ route.options.headers ||
2663
+ this.getMatch(matchId)!._nonReactive.minPendingPromise
2664
+ )
2217
2665
 
2218
- updateMatch(matchId, (prev) => {
2219
- prev._nonReactive.beforeLoadPromise?.resolve()
2220
- prev._nonReactive.beforeLoadPromise = undefined
2221
- prev._nonReactive.loadPromise?.resolve()
2222
-
2223
- return {
2224
- ...prev,
2225
- error: err,
2226
- status: 'error',
2227
- isFetching: false,
2228
- updatedAt: Date.now(),
2229
- abortController: new AbortController(),
2230
- }
2231
- })
2232
- }
2666
+ if (willLoadSomething) {
2667
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2668
+ ...prev,
2669
+ isFetching: 'loader',
2670
+ }))
2671
+ }
2233
2672
 
2234
- for (const [index, { id: matchId, routeId }] of matches.entries()) {
2235
- const existingMatch = this.getMatch(matchId)!
2236
- const parentMatchId = matches[index - 1]?.id
2237
- const parentMatch = parentMatchId
2238
- ? this.getMatch(parentMatchId)!
2239
- : undefined
2240
-
2241
- const route = this.looseRoutesById[routeId]!
2242
-
2243
- const pendingMs =
2244
- route.options.pendingMs ?? this.options.defaultPendingMs
2245
-
2246
- // on the server, determine whether SSR the current match or not
2247
- if (this.isServer) {
2248
- let ssr: boolean | 'data-only'
2249
- // in SPA mode, only SSR the root route
2250
- if (this.isShell()) {
2251
- ssr = matchId === rootRouteId
2252
- } else {
2253
- const defaultSsr = this.options.defaultSsr ?? true
2254
- if (parentMatch?.ssr === false) {
2255
- ssr = false
2256
- } else {
2257
- let tempSsr: boolean | 'data-only'
2258
- if (route.options.ssr === undefined) {
2259
- tempSsr = defaultSsr
2260
- } else if (typeof route.options.ssr === 'function') {
2261
- const { search, params } = this.getMatch(matchId)!
2262
-
2263
- function makeMaybe(value: any, error: any) {
2264
- if (error) {
2265
- return { status: 'error' as const, error }
2266
- }
2267
- return { status: 'success' as const, value }
2268
- }
2269
-
2270
- const ssrFnContext: SsrContextOptions<any, any, any> = {
2271
- search: makeMaybe(search, existingMatch.searchError),
2272
- params: makeMaybe(params, existingMatch.paramsError),
2273
- location,
2274
- matches: matches.map((match) => ({
2275
- index: match.index,
2276
- pathname: match.pathname,
2277
- fullPath: match.fullPath,
2278
- staticData: match.staticData,
2279
- id: match.id,
2280
- routeId: match.routeId,
2281
- search: makeMaybe(match.search, match.searchError),
2282
- params: makeMaybe(match.params, match.paramsError),
2283
- ssr: match.ssr,
2284
- })),
2285
- }
2286
- tempSsr =
2287
- (await route.options.ssr(ssrFnContext)) ?? defaultSsr
2288
- } else {
2289
- tempSsr = route.options.ssr
2290
- }
2673
+ if (route.options.loader) {
2674
+ const loaderData = loaderResultIsPromise
2675
+ ? await loaderResult
2676
+ : loaderResult
2291
2677
 
2292
- if (tempSsr === true && parentMatch?.ssr === 'data-only') {
2293
- ssr = 'data-only'
2294
- } else {
2295
- ssr = tempSsr
2296
- }
2297
- }
2298
- }
2299
- existingMatch.ssr = ssr
2300
- }
2678
+ this.handleRedirectAndNotFound(
2679
+ innerLoadContext,
2680
+ this.getMatch(matchId),
2681
+ loaderData,
2682
+ )
2683
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2684
+ ...prev,
2685
+ loaderData,
2686
+ }))
2687
+ }
2301
2688
 
2302
- if (shouldSkipLoader(matchId)) {
2303
- continue
2304
- }
2689
+ // Lazy option can modify the route options,
2690
+ // so we need to wait for it to resolve before
2691
+ // we can use the options
2692
+ if (route._lazyPromise) await route._lazyPromise
2693
+ const headResult = this.executeHead(innerLoadContext, matchId, route)
2694
+ const head = headResult ? await headResult : undefined
2695
+ const pendingPromise = this.potentialPendingMinPromise(matchId)
2696
+ if (pendingPromise) await pendingPromise
2697
+
2698
+ // Last but not least, wait for the the components
2699
+ // to be preloaded before we resolve the match
2700
+ if (route._componentsPromise) await route._componentsPromise
2701
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2702
+ ...prev,
2703
+ error: undefined,
2704
+ status: 'success',
2705
+ isFetching: false,
2706
+ updatedAt: Date.now(),
2707
+ ...head,
2708
+ }))
2709
+ } catch (e) {
2710
+ let error = e
2305
2711
 
2306
- const shouldPending = !!(
2307
- onReady &&
2308
- !this.isServer &&
2309
- !resolvePreload(matchId) &&
2310
- (route.options.loader ||
2311
- route.options.beforeLoad ||
2312
- routeNeedsPreload(route)) &&
2313
- typeof pendingMs === 'number' &&
2314
- pendingMs !== Infinity &&
2315
- (route.options.pendingComponent ??
2316
- (this.options as any)?.defaultPendingComponent)
2317
- )
2712
+ await this.potentialPendingMinPromise(matchId)
2318
2713
 
2319
- let executeBeforeLoad = true
2320
- const setupPendingTimeout = () => {
2321
- const match = this.getMatch(matchId)!
2322
- if (
2323
- shouldPending &&
2324
- match._nonReactive.pendingTimeout === undefined
2325
- ) {
2326
- const pendingTimeout = setTimeout(() => {
2327
- try {
2328
- // Update the match and prematurely resolve the loadMatches promise so that
2329
- // the pending component can start rendering
2330
- triggerOnReady()
2331
- } catch {}
2332
- }, pendingMs)
2333
- match._nonReactive.pendingTimeout = pendingTimeout
2334
- }
2335
- }
2336
- if (
2337
- // If we are in the middle of a load, either of these will be present
2338
- // (not to be confused with `loadPromise`, which is always defined)
2339
- existingMatch._nonReactive.beforeLoadPromise ||
2340
- existingMatch._nonReactive.loaderPromise
2341
- ) {
2342
- setupPendingTimeout()
2343
-
2344
- // Wait for the beforeLoad to resolve before we continue
2345
- await existingMatch._nonReactive.beforeLoadPromise
2346
- const match = this.getMatch(matchId)!
2347
- if (match.status === 'error') {
2348
- executeBeforeLoad = true
2349
- } else if (
2350
- match.preload &&
2351
- (match.status === 'redirected' || match.status === 'notFound')
2352
- ) {
2353
- handleRedirectAndNotFound(match, match.error)
2354
- }
2355
- }
2356
- if (executeBeforeLoad) {
2357
- // If we are not in the middle of a load OR the previous load failed, start it
2358
- try {
2359
- const match = this.getMatch(matchId)!
2360
- match._nonReactive.beforeLoadPromise =
2361
- createControlledPromise<void>()
2362
- // explicitly capture the previous loadPromise
2363
- const prevLoadPromise = match._nonReactive.loadPromise
2364
- match._nonReactive.loadPromise =
2365
- createControlledPromise<void>(() => {
2366
- prevLoadPromise?.resolve()
2367
- })
2368
-
2369
- const { paramsError, searchError } = this.getMatch(matchId)!
2370
-
2371
- if (paramsError) {
2372
- handleSerialError(index, paramsError, 'PARSE_PARAMS')
2373
- }
2374
-
2375
- if (searchError) {
2376
- handleSerialError(index, searchError, 'VALIDATE_SEARCH')
2377
- }
2378
-
2379
- setupPendingTimeout()
2380
-
2381
- const abortController = new AbortController()
2382
-
2383
- const parentMatchContext =
2384
- parentMatch?.context ?? this.options.context ?? undefined
2385
-
2386
- updateMatch(matchId, (prev) => ({
2387
- ...prev,
2388
- isFetching: 'beforeLoad',
2389
- fetchCount: prev.fetchCount + 1,
2390
- abortController,
2391
- context: {
2392
- ...parentMatchContext,
2393
- ...prev.__routeContext,
2394
- },
2395
- }))
2396
-
2397
- const { search, params, context, cause } =
2398
- this.getMatch(matchId)!
2399
-
2400
- const preload = resolvePreload(matchId)
2401
-
2402
- const beforeLoadFnContext: BeforeLoadContextOptions<
2403
- any,
2404
- any,
2405
- any,
2406
- any,
2407
- any
2408
- > = {
2409
- search,
2410
- abortController,
2411
- params,
2412
- preload,
2413
- context,
2414
- location,
2415
- navigate: (opts: any) =>
2416
- this.navigate({ ...opts, _fromLocation: location }),
2417
- buildLocation: this.buildLocation,
2418
- cause: preload ? 'preload' : cause,
2419
- matches,
2420
- }
2421
-
2422
- const beforeLoadContext =
2423
- await route.options.beforeLoad?.(beforeLoadFnContext)
2424
-
2425
- if (
2426
- isRedirect(beforeLoadContext) ||
2427
- isNotFound(beforeLoadContext)
2428
- ) {
2429
- handleSerialError(index, beforeLoadContext, 'BEFORE_LOAD')
2430
- }
2431
-
2432
- updateMatch(matchId, (prev) => {
2433
- return {
2434
- ...prev,
2435
- __beforeLoadContext: beforeLoadContext,
2436
- context: {
2437
- ...parentMatchContext,
2438
- ...prev.__routeContext,
2439
- ...beforeLoadContext,
2440
- },
2441
- abortController,
2442
- }
2443
- })
2444
- } catch (err) {
2445
- handleSerialError(index, err, 'BEFORE_LOAD')
2446
- }
2447
-
2448
- updateMatch(matchId, (prev) => {
2449
- prev._nonReactive.beforeLoadPromise?.resolve()
2450
- prev._nonReactive.beforeLoadPromise = undefined
2451
-
2452
- return {
2453
- ...prev,
2454
- isFetching: false,
2455
- }
2456
- })
2457
- }
2714
+ this.handleRedirectAndNotFound(
2715
+ innerLoadContext,
2716
+ this.getMatch(matchId),
2717
+ e,
2718
+ )
2719
+
2720
+ try {
2721
+ route.options.onError?.(e)
2722
+ } catch (onErrorError) {
2723
+ error = onErrorError
2724
+ this.handleRedirectAndNotFound(
2725
+ innerLoadContext,
2726
+ this.getMatch(matchId),
2727
+ onErrorError,
2728
+ )
2729
+ }
2730
+ const headResult = this.executeHead(innerLoadContext, matchId, route)
2731
+ const head = headResult ? await headResult : undefined
2732
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2733
+ ...prev,
2734
+ error,
2735
+ status: 'error',
2736
+ isFetching: false,
2737
+ ...head,
2738
+ }))
2739
+ }
2740
+ } catch (err) {
2741
+ const match = this.getMatch(matchId)
2742
+ // in case of a redirecting match during preload, the match does not exist
2743
+ if (match) {
2744
+ const headResult = this.executeHead(innerLoadContext, matchId, route)
2745
+ if (headResult) {
2746
+ const head = await headResult
2747
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2748
+ ...prev,
2749
+ ...head,
2750
+ }))
2751
+ }
2752
+ match._nonReactive.loaderPromise = undefined
2753
+ }
2754
+ this.handleRedirectAndNotFound(innerLoadContext, match, err)
2755
+ }
2756
+ }
2757
+
2758
+ private loadRouteMatch = async (
2759
+ innerLoadContext: InnerLoadContext,
2760
+ index: number,
2761
+ ): Promise<AnyRouteMatch> => {
2762
+ const { id: matchId, routeId } = innerLoadContext.matches[index]!
2763
+ let loaderShouldRunAsync = false
2764
+ let loaderIsRunningAsync = false
2765
+ const route = this.looseRoutesById[routeId]!
2766
+
2767
+ const prevMatch = this.getMatch(matchId)!
2768
+ if (this.shouldSkipLoader(matchId)) {
2769
+ if (this.isServer) {
2770
+ const headResult = this.executeHead(innerLoadContext, matchId, route)
2771
+ if (headResult) {
2772
+ const head = await headResult
2773
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2774
+ ...prev,
2775
+ ...head,
2776
+ }))
2777
+ }
2778
+ return this.getMatch(matchId)!
2779
+ }
2780
+ }
2781
+ // there is a loaderPromise, so we are in the middle of a load
2782
+ else if (prevMatch._nonReactive.loaderPromise) {
2783
+ // do not block if we already have stale data we can show
2784
+ // but only if the ongoing load is not a preload since error handling is different for preloads
2785
+ // and we don't want to swallow errors
2786
+ if (
2787
+ prevMatch.status === 'success' &&
2788
+ !innerLoadContext.sync &&
2789
+ !prevMatch.preload
2790
+ ) {
2791
+ return this.getMatch(matchId)!
2792
+ }
2793
+ await prevMatch._nonReactive.loaderPromise
2794
+ const match = this.getMatch(matchId)!
2795
+ if (match.error) {
2796
+ this.handleRedirectAndNotFound(innerLoadContext, match, match.error)
2797
+ }
2798
+ } else {
2799
+ // This is where all of the stale-while-revalidate magic happens
2800
+ const age = Date.now() - this.getMatch(matchId)!.updatedAt
2801
+
2802
+ const preload = this.resolvePreload(innerLoadContext, matchId)
2803
+
2804
+ const staleAge = preload
2805
+ ? (route.options.preloadStaleTime ??
2806
+ this.options.defaultPreloadStaleTime ??
2807
+ 30_000) // 30 seconds for preloads by default
2808
+ : (route.options.staleTime ?? this.options.defaultStaleTime ?? 0)
2809
+
2810
+ const shouldReloadOption = route.options.shouldReload
2811
+
2812
+ // Default to reloading the route all the time
2813
+ // Allow shouldReload to get the last say,
2814
+ // if provided.
2815
+ const shouldReload =
2816
+ typeof shouldReloadOption === 'function'
2817
+ ? shouldReloadOption(
2818
+ this.getLoaderContext(innerLoadContext, matchId, index, route),
2819
+ )
2820
+ : shouldReloadOption
2821
+
2822
+ innerLoadContext.updateMatch(matchId, (prev) => {
2823
+ prev._nonReactive.loaderPromise = createControlledPromise<void>()
2824
+ return {
2825
+ ...prev,
2826
+ preload:
2827
+ !!preload && !this.state.matches.some((d) => d.id === matchId),
2828
+ }
2829
+ })
2830
+
2831
+ // If the route is successful and still fresh, just resolve
2832
+ const { status, invalid } = this.getMatch(matchId)!
2833
+ loaderShouldRunAsync =
2834
+ status === 'success' && (invalid || (shouldReload ?? age > staleAge))
2835
+ if (preload && route.options.preload === false) {
2836
+ // Do nothing
2837
+ } else if (loaderShouldRunAsync && !innerLoadContext.sync) {
2838
+ loaderIsRunningAsync = true
2839
+ ;(async () => {
2840
+ try {
2841
+ await this.runLoader(innerLoadContext, matchId, index, route)
2842
+ const match = this.getMatch(matchId)!
2843
+ match._nonReactive.loaderPromise?.resolve()
2844
+ match._nonReactive.loadPromise?.resolve()
2845
+ match._nonReactive.loaderPromise = undefined
2846
+ } catch (err) {
2847
+ if (isRedirect(err)) {
2848
+ await this.navigate(err.options)
2458
2849
  }
2850
+ }
2851
+ })()
2852
+ } else if (
2853
+ status !== 'success' ||
2854
+ (loaderShouldRunAsync && innerLoadContext.sync)
2855
+ ) {
2856
+ await this.runLoader(innerLoadContext, matchId, index, route)
2857
+ } else {
2858
+ // if the loader did not run, still update head.
2859
+ // reason: parent's beforeLoad may have changed the route context
2860
+ // and only now do we know the route context (and that the loader would not run)
2861
+ const headResult = this.executeHead(innerLoadContext, matchId, route)
2862
+ if (headResult) {
2863
+ const head = await headResult
2864
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2865
+ ...prev,
2866
+ ...head,
2867
+ }))
2868
+ }
2869
+ }
2870
+ }
2871
+ if (!loaderIsRunningAsync) {
2872
+ const match = this.getMatch(matchId)!
2873
+ match._nonReactive.loaderPromise?.resolve()
2874
+ match._nonReactive.loadPromise?.resolve()
2875
+ }
2459
2876
 
2460
- const validResolvedMatches = matches.slice(0, firstBadMatchIndex)
2461
- const matchPromises: Array<Promise<AnyRouteMatch>> = []
2462
-
2463
- validResolvedMatches.forEach(({ id: matchId, routeId }, index) => {
2464
- matchPromises.push(
2465
- (async () => {
2466
- let loaderShouldRunAsync = false
2467
- let loaderIsRunningAsync = false
2468
- const route = this.looseRoutesById[routeId]!
2469
-
2470
- const executeHead = () => {
2471
- const match = this.getMatch(matchId)
2472
- // in case of a redirecting match during preload, the match does not exist
2473
- if (!match) {
2474
- return
2475
- }
2476
- if (
2477
- !route.options.head &&
2478
- !route.options.scripts &&
2479
- !route.options.headers
2480
- ) {
2481
- return
2482
- }
2483
- const assetContext = {
2484
- matches,
2485
- match,
2486
- params: match.params,
2487
- loaderData: match.loaderData,
2488
- }
2877
+ innerLoadContext.updateMatch(matchId, (prev) => {
2878
+ clearTimeout(prev._nonReactive.pendingTimeout)
2879
+ prev._nonReactive.pendingTimeout = undefined
2880
+ if (!loaderIsRunningAsync) prev._nonReactive.loaderPromise = undefined
2881
+ prev._nonReactive.dehydrated = undefined
2882
+ return {
2883
+ ...prev,
2884
+ isFetching: loaderIsRunningAsync ? prev.isFetching : false,
2885
+ invalid: false,
2886
+ }
2887
+ })
2888
+ return this.getMatch(matchId)!
2889
+ }
2489
2890
 
2490
- return Promise.all([
2491
- route.options.head?.(assetContext),
2492
- route.options.scripts?.(assetContext),
2493
- route.options.headers?.(assetContext),
2494
- ]).then(([headFnContent, scripts, headers]) => {
2495
- const meta = headFnContent?.meta
2496
- const links = headFnContent?.links
2497
- const headScripts = headFnContent?.scripts
2498
- const styles = headFnContent?.styles
2499
-
2500
- return {
2501
- meta,
2502
- links,
2503
- headScripts,
2504
- headers,
2505
- scripts,
2506
- styles,
2507
- }
2508
- })
2509
- }
2510
-
2511
- const potentialPendingMinPromise = async () => {
2512
- const latestMatch = this.getMatch(matchId)!
2513
- if (latestMatch._nonReactive.minPendingPromise) {
2514
- await latestMatch._nonReactive.minPendingPromise
2515
- }
2516
- }
2517
-
2518
- const prevMatch = this.getMatch(matchId)!
2519
- if (shouldSkipLoader(matchId)) {
2520
- if (this.isServer) {
2521
- const headResult = executeHead()
2522
- if (headResult) {
2523
- const head = await headResult
2524
- updateMatch(matchId, (prev) => ({
2525
- ...prev,
2526
- ...head,
2527
- }))
2528
- }
2529
- return this.getMatch(matchId)!
2530
- }
2531
- }
2532
- // there is a loaderPromise, so we are in the middle of a load
2533
- else if (prevMatch._nonReactive.loaderPromise) {
2534
- // do not block if we already have stale data we can show
2535
- // but only if the ongoing load is not a preload since error handling is different for preloads
2536
- // and we don't want to swallow errors
2537
- if (
2538
- prevMatch.status === 'success' &&
2539
- !sync &&
2540
- !prevMatch.preload
2541
- ) {
2542
- return this.getMatch(matchId)!
2543
- }
2544
- await prevMatch._nonReactive.loaderPromise
2545
- const match = this.getMatch(matchId)!
2546
- if (match.error) {
2547
- handleRedirectAndNotFound(match, match.error)
2548
- }
2549
- } else {
2550
- const parentMatchPromise = matchPromises[index - 1] as any
2551
-
2552
- const getLoaderContext = (): LoaderFnContext => {
2553
- const {
2554
- params,
2555
- loaderDeps,
2556
- abortController,
2557
- context,
2558
- cause,
2559
- } = this.getMatch(matchId)!
2560
-
2561
- const preload = resolvePreload(matchId)
2562
-
2563
- return {
2564
- params,
2565
- deps: loaderDeps,
2566
- preload: !!preload,
2567
- parentMatchPromise,
2568
- abortController: abortController,
2569
- context,
2570
- location,
2571
- navigate: (opts) =>
2572
- this.navigate({ ...opts, _fromLocation: location }),
2573
- cause: preload ? 'preload' : cause,
2574
- route,
2575
- }
2576
- }
2891
+ loadMatches = async (baseContext: {
2892
+ location: ParsedLocation
2893
+ matches: Array<AnyRouteMatch>
2894
+ preload?: boolean
2895
+ onReady?: () => Promise<void>
2896
+ updateMatch?: UpdateMatchFn
2897
+ sync?: boolean
2898
+ }): Promise<Array<MakeRouteMatch>> => {
2899
+ const innerLoadContext = baseContext as InnerLoadContext
2900
+ innerLoadContext.updateMatch ??= this.updateMatch
2901
+ innerLoadContext.matchPromises = []
2577
2902
 
2578
- // This is where all of the stale-while-revalidate magic happens
2579
- const age = Date.now() - this.getMatch(matchId)!.updatedAt
2580
-
2581
- const preload = resolvePreload(matchId)
2582
-
2583
- const staleAge = preload
2584
- ? (route.options.preloadStaleTime ??
2585
- this.options.defaultPreloadStaleTime ??
2586
- 30_000) // 30 seconds for preloads by default
2587
- : (route.options.staleTime ??
2588
- this.options.defaultStaleTime ??
2589
- 0)
2590
-
2591
- const shouldReloadOption = route.options.shouldReload
2592
-
2593
- // Default to reloading the route all the time
2594
- // Allow shouldReload to get the last say,
2595
- // if provided.
2596
- const shouldReload =
2597
- typeof shouldReloadOption === 'function'
2598
- ? shouldReloadOption(getLoaderContext())
2599
- : shouldReloadOption
2600
-
2601
- updateMatch(matchId, (prev) => {
2602
- prev._nonReactive.loaderPromise =
2603
- createControlledPromise<void>()
2604
- return {
2605
- ...prev,
2606
- preload:
2607
- !!preload &&
2608
- !this.state.matches.some((d) => d.id === matchId),
2609
- }
2610
- })
2611
-
2612
- const runLoader = async () => {
2613
- try {
2614
- // If the Matches component rendered
2615
- // the pending component and needs to show it for
2616
- // a minimum duration, we''ll wait for it to resolve
2617
- // before committing to the match and resolving
2618
- // the loadPromise
2619
-
2620
- // Actually run the loader and handle the result
2621
- try {
2622
- if (
2623
- !this.isServer ||
2624
- (this.isServer &&
2625
- this.getMatch(matchId)!.ssr === true)
2626
- ) {
2627
- this.loadRouteChunk(route)
2628
- }
2629
-
2630
- updateMatch(matchId, (prev) => ({
2631
- ...prev,
2632
- isFetching: 'loader',
2633
- }))
2634
-
2635
- // Kick off the loader!
2636
- const loaderData =
2637
- await route.options.loader?.(getLoaderContext())
2638
-
2639
- handleRedirectAndNotFound(
2640
- this.getMatch(matchId),
2641
- loaderData,
2642
- )
2643
- updateMatch(matchId, (prev) => ({
2644
- ...prev,
2645
- loaderData,
2646
- }))
2647
-
2648
- // Lazy option can modify the route options,
2649
- // so we need to wait for it to resolve before
2650
- // we can use the options
2651
- await route._lazyPromise
2652
- const headResult = executeHead()
2653
- const head = headResult ? await headResult : undefined
2654
- await potentialPendingMinPromise()
2655
-
2656
- // Last but not least, wait for the the components
2657
- // to be preloaded before we resolve the match
2658
- await route._componentsPromise
2659
- updateMatch(matchId, (prev) => ({
2660
- ...prev,
2661
- error: undefined,
2662
- status: 'success',
2663
- isFetching: false,
2664
- updatedAt: Date.now(),
2665
- ...head,
2666
- }))
2667
- } catch (e) {
2668
- let error = e
2669
-
2670
- await potentialPendingMinPromise()
2671
-
2672
- handleRedirectAndNotFound(this.getMatch(matchId), e)
2673
-
2674
- try {
2675
- route.options.onError?.(e)
2676
- } catch (onErrorError) {
2677
- error = onErrorError
2678
- handleRedirectAndNotFound(
2679
- this.getMatch(matchId),
2680
- onErrorError,
2681
- )
2682
- }
2683
- const headResult = executeHead()
2684
- const head = headResult ? await headResult : undefined
2685
- updateMatch(matchId, (prev) => ({
2686
- ...prev,
2687
- error,
2688
- status: 'error',
2689
- isFetching: false,
2690
- ...head,
2691
- }))
2692
- }
2693
- } catch (err) {
2694
- const match = this.getMatch(matchId)
2695
- // in case of a redirecting match during preload, the match does not exist
2696
- if (match) {
2697
- const headResult = executeHead()
2698
- if (headResult) {
2699
- const head = await headResult
2700
- updateMatch(matchId, (prev) => ({
2701
- ...prev,
2702
- ...head,
2703
- }))
2704
- }
2705
- match._nonReactive.loaderPromise = undefined
2706
- }
2707
- handleRedirectAndNotFound(match, err)
2708
- }
2709
- }
2903
+ // make sure the pending component is immediately rendered when hydrating a match that is not SSRed
2904
+ // the pending component was already rendered on the server and we want to keep it shown on the client until minPendingMs is reached
2905
+ if (!this.isServer && this.state.matches.some((d) => d._forcePending)) {
2906
+ this.triggerOnReady(innerLoadContext)
2907
+ }
2710
2908
 
2711
- // If the route is successful and still fresh, just resolve
2712
- const { status, invalid } = this.getMatch(matchId)!
2713
- loaderShouldRunAsync =
2714
- status === 'success' &&
2715
- (invalid || (shouldReload ?? age > staleAge))
2716
- if (preload && route.options.preload === false) {
2717
- // Do nothing
2718
- } else if (loaderShouldRunAsync && !sync) {
2719
- loaderIsRunningAsync = true
2720
- ;(async () => {
2721
- try {
2722
- await runLoader()
2723
- const match = this.getMatch(matchId)!
2724
- match._nonReactive.loaderPromise?.resolve()
2725
- match._nonReactive.loadPromise?.resolve()
2726
- match._nonReactive.loaderPromise = undefined
2727
- } catch (err) {
2728
- if (isRedirect(err)) {
2729
- await this.navigate(err.options)
2730
- }
2731
- }
2732
- })()
2733
- } else if (
2734
- status !== 'success' ||
2735
- (loaderShouldRunAsync && sync)
2736
- ) {
2737
- await runLoader()
2738
- } else {
2739
- // if the loader did not run, still update head.
2740
- // reason: parent's beforeLoad may have changed the route context
2741
- // and only now do we know the route context (and that the loader would not run)
2742
- const headResult = executeHead()
2743
- if (headResult) {
2744
- const head = await headResult
2745
- updateMatch(matchId, (prev) => ({
2746
- ...prev,
2747
- ...head,
2748
- }))
2749
- }
2750
- }
2751
- }
2752
- if (!loaderIsRunningAsync) {
2753
- const match = this.getMatch(matchId)!
2754
- match._nonReactive.loaderPromise?.resolve()
2755
- match._nonReactive.loadPromise?.resolve()
2756
- }
2757
-
2758
- updateMatch(matchId, (prev) => {
2759
- clearTimeout(prev._nonReactive.pendingTimeout)
2760
- prev._nonReactive.pendingTimeout = undefined
2761
- if (!loaderIsRunningAsync)
2762
- prev._nonReactive.loaderPromise = undefined
2763
- prev._nonReactive.dehydrated = undefined
2764
- return {
2765
- ...prev,
2766
- isFetching: loaderIsRunningAsync
2767
- ? prev.isFetching
2768
- : false,
2769
- invalid: false,
2770
- }
2771
- })
2772
- return this.getMatch(matchId)!
2773
- })(),
2909
+ try {
2910
+ await new Promise<void>((resolveAll, rejectAll) => {
2911
+ ;(async () => {
2912
+ try {
2913
+ // Execute all beforeLoads one by one
2914
+ for (let i = 0; i < innerLoadContext.matches.length; i++) {
2915
+ const beforeLoad = this.handleBeforeLoad(innerLoadContext, i)
2916
+ if (isPromise(beforeLoad)) await beforeLoad
2917
+ }
2918
+
2919
+ // Execute all loaders in parallel
2920
+ const max =
2921
+ innerLoadContext.firstBadMatchIndex ??
2922
+ innerLoadContext.matches.length
2923
+ for (let i = 0; i < max; i++) {
2924
+ innerLoadContext.matchPromises.push(
2925
+ this.loadRouteMatch(innerLoadContext, i),
2774
2926
  )
2775
- })
2927
+ }
2776
2928
 
2777
- await Promise.all(matchPromises)
2929
+ await Promise.all(innerLoadContext.matchPromises)
2778
2930
 
2779
2931
  resolveAll()
2780
2932
  } catch (err) {
@@ -2782,18 +2934,20 @@ export class RouterCore<
2782
2934
  }
2783
2935
  })()
2784
2936
  })
2785
- await triggerOnReady()
2937
+ const readyPromise = this.triggerOnReady(innerLoadContext)
2938
+ if (isPromise(readyPromise)) await readyPromise
2786
2939
  } catch (err) {
2787
- if (isRedirect(err) || isNotFound(err)) {
2788
- if (isNotFound(err) && !allPreload) {
2789
- await triggerOnReady()
2790
- }
2791
-
2940
+ if (isNotFound(err) && !innerLoadContext.preload) {
2941
+ const readyPromise = this.triggerOnReady(innerLoadContext)
2942
+ if (isPromise(readyPromise)) await readyPromise
2943
+ throw err
2944
+ }
2945
+ if (isRedirect(err)) {
2792
2946
  throw err
2793
2947
  }
2794
2948
  }
2795
2949
 
2796
- return matches
2950
+ return innerLoadContext.matches
2797
2951
  }
2798
2952
 
2799
2953
  invalidate: InvalidateFn<
@@ -2890,33 +3044,44 @@ export class RouterCore<
2890
3044
  }
2891
3045
 
2892
3046
  loadRouteChunk = (route: AnyRoute) => {
2893
- if (route._lazyPromise === undefined) {
3047
+ if (!route._lazyLoaded && route._lazyPromise === undefined) {
2894
3048
  if (route.lazyFn) {
2895
3049
  route._lazyPromise = route.lazyFn().then((lazyRoute) => {
2896
3050
  // explicitly don't copy over the lazy route's id
2897
3051
  const { id: _id, ...options } = lazyRoute.options
2898
3052
  Object.assign(route.options, options)
3053
+ route._lazyLoaded = true
3054
+ route._lazyPromise = undefined // gc promise, we won't need it anymore
2899
3055
  })
2900
3056
  } else {
2901
- route._lazyPromise = Promise.resolve()
3057
+ route._lazyLoaded = true
2902
3058
  }
2903
3059
  }
2904
3060
 
2905
3061
  // If for some reason lazy resolves more lazy components...
2906
- // We'll wait for that before pre attempt to preload any
3062
+ // We'll wait for that before we attempt to preload the
2907
3063
  // components themselves.
2908
- if (route._componentsPromise === undefined) {
2909
- route._componentsPromise = route._lazyPromise.then(() =>
2910
- Promise.all(
2911
- componentTypes.map(async (type) => {
2912
- const component = route.options[type]
2913
- if ((component as any)?.preload) {
2914
- await (component as any).preload()
2915
- }
2916
- }),
2917
- ),
2918
- )
3064
+ if (!route._componentsLoaded && route._componentsPromise === undefined) {
3065
+ const loadComponents = () => {
3066
+ const preloads = []
3067
+ for (const type of componentTypes) {
3068
+ const preload = (route.options[type] as any)?.preload
3069
+ if (preload) preloads.push(preload())
3070
+ }
3071
+ if (preloads.length)
3072
+ return Promise.all(preloads).then(() => {
3073
+ route._componentsLoaded = true
3074
+ route._componentsPromise = undefined // gc promise, we won't need it anymore
3075
+ })
3076
+ route._componentsLoaded = true
3077
+ route._componentsPromise = undefined // gc promise, we won't need it anymore
3078
+ return
3079
+ }
3080
+ route._componentsPromise = route._lazyPromise
3081
+ ? route._lazyPromise.then(loadComponents)
3082
+ : loadComponents()
2919
3083
  }
3084
+
2920
3085
  return route._componentsPromise
2921
3086
  }
2922
3087
 
@@ -3056,13 +3221,9 @@ export class RouterCore<
3056
3221
 
3057
3222
  serverSsr?: ServerSsr
3058
3223
 
3059
- _handleNotFound = (
3060
- matches: Array<AnyRouteMatch>,
3224
+ private _handleNotFound = (
3225
+ innerLoadContext: InnerLoadContext,
3061
3226
  err: NotFoundError,
3062
- updateMatch: (
3063
- id: string,
3064
- updater: (match: AnyRouteMatch) => AnyRouteMatch,
3065
- ) => void = this.updateMatch,
3066
3227
  ) => {
3067
3228
  // Find the route that should handle the not found error
3068
3229
  // First check if a specific route is requested to show the error
@@ -3070,7 +3231,7 @@ export class RouterCore<
3070
3231
  const matchesByRouteId: Record<string, AnyRouteMatch> = {}
3071
3232
 
3072
3233
  // Setup routesByRouteId object for quick access
3073
- for (const match of matches) {
3234
+ for (const match of innerLoadContext.matches) {
3074
3235
  matchesByRouteId[match.routeId] = match
3075
3236
  }
3076
3237
 
@@ -3099,7 +3260,7 @@ export class RouterCore<
3099
3260
  )
3100
3261
 
3101
3262
  // Assign the error to the match - using non-null assertion since we've checked with invariant
3102
- updateMatch(matchForRoute.id, (prev) => ({
3263
+ innerLoadContext.updateMatch(matchForRoute.id, (prev) => ({
3103
3264
  ...prev,
3104
3265
  status: 'notFound',
3105
3266
  error: err,
@@ -3108,7 +3269,7 @@ export class RouterCore<
3108
3269
 
3109
3270
  if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) {
3110
3271
  err.routeId = routeCursor.parentRoute.id
3111
- this._handleNotFound(matches, err, updateMatch)
3272
+ this._handleNotFound(innerLoadContext, err)
3112
3273
  }
3113
3274
  }
3114
3275
 
@@ -3123,6 +3284,16 @@ export class SearchParamError extends Error {}
3123
3284
 
3124
3285
  export class PathParamError extends Error {}
3125
3286
 
3287
+ function makeMaybe<TValue, TError>(
3288
+ value: TValue,
3289
+ error: TError,
3290
+ ): { status: 'success'; value: TValue } | { status: 'error'; error: TError } {
3291
+ if (error) {
3292
+ return { status: 'error' as const, error }
3293
+ }
3294
+ return { status: 'success' as const, value }
3295
+ }
3296
+
3126
3297
  const normalize = (str: string) =>
3127
3298
  str.endsWith('/') && str.length > 1 ? str.slice(0, -1) : str
3128
3299
  function comparePaths(a: string, b: string) {