@tanstack/router-core 1.132.0-alpha.1 → 1.132.0-alpha.2

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,
@@ -1139,8 +1152,8 @@ export class RouterCore<
1139
1152
  const parentMatchId = parentMatch?.id
1140
1153
 
1141
1154
  const parentContext = !parentMatchId
1142
- ? ((this.options.context as any) ?? {})
1143
- : (parentMatch.context ?? this.options.context ?? {})
1155
+ ? ((this.options.context as any) ?? undefined)
1156
+ : (parentMatch.context ?? this.options.context ?? undefined)
1144
1157
 
1145
1158
  return parentContext
1146
1159
  }
@@ -1162,12 +1175,12 @@ export class RouterCore<
1162
1175
  ] = (() => {
1163
1176
  // Validate the search params and stabilize them
1164
1177
  const parentSearch = parentMatch?.search ?? next.search
1165
- const parentStrictSearch = parentMatch?._strictSearch ?? {}
1178
+ const parentStrictSearch = parentMatch?._strictSearch ?? undefined
1166
1179
 
1167
1180
  try {
1168
1181
  const strictSearch =
1169
1182
  validateSearch(route.options.validateSearch, { ...parentSearch }) ??
1170
- {}
1183
+ undefined
1171
1184
 
1172
1185
  return [
1173
1186
  {
@@ -1277,7 +1290,10 @@ export class RouterCore<
1277
1290
  isFetching: false,
1278
1291
  error: undefined,
1279
1292
  paramsError: parseErrors[index],
1280
- __routeContext: {},
1293
+ __routeContext: undefined,
1294
+ _nonReactive: {
1295
+ loadPromise: createControlledPromise(),
1296
+ },
1281
1297
  __beforeLoadContext: undefined,
1282
1298
  context: {},
1283
1299
  abortController: new AbortController(),
@@ -1293,7 +1309,6 @@ export class RouterCore<
1293
1309
  headScripts: undefined,
1294
1310
  meta: undefined,
1295
1311
  staticData: route.options.staticData || {},
1296
- loadPromise: createControlledPromise(),
1297
1312
  fullPath: route.fullPath,
1298
1313
  }
1299
1314
  }
@@ -1328,22 +1343,25 @@ export class RouterCore<
1328
1343
  const parentContext = getParentContext(parentMatch)
1329
1344
 
1330
1345
  // Update the match's context
1331
- const contextFnContext: RouteContextOptions<any, any, any, any> = {
1332
- deps: match.loaderDeps,
1333
- params: match.params,
1334
- context: parentContext,
1335
- location: next,
1336
- navigate: (opts: any) =>
1337
- this.navigate({ ...opts, _fromLocation: next }),
1338
- buildLocation: this.buildLocation,
1339
- cause: match.cause,
1340
- abortController: match.abortController,
1341
- preload: !!match.preload,
1342
- matches,
1343
- }
1344
1346
 
1345
- // Get the route context
1346
- match.__routeContext = route.options.context?.(contextFnContext) ?? {}
1347
+ if (route.options.context) {
1348
+ const contextFnContext: RouteContextOptions<any, any, any, any> = {
1349
+ deps: match.loaderDeps,
1350
+ params: match.params,
1351
+ context: parentContext ?? {},
1352
+ location: next,
1353
+ navigate: (opts: any) =>
1354
+ this.navigate({ ...opts, _fromLocation: next }),
1355
+ buildLocation: this.buildLocation,
1356
+ cause: match.cause,
1357
+ abortController: match.abortController,
1358
+ preload: !!match.preload,
1359
+ matches,
1360
+ }
1361
+ // Get the route context
1362
+ match.__routeContext =
1363
+ route.options.context(contextFnContext) ?? undefined
1364
+ }
1347
1365
 
1348
1366
  match.context = {
1349
1367
  ...parentContext,
@@ -1381,13 +1399,8 @@ export class RouterCore<
1381
1399
  if (!match) return
1382
1400
 
1383
1401
  match.abortController.abort()
1384
- this.updateMatch(id, (prev) => {
1385
- clearTimeout(prev.pendingTimeout)
1386
- return {
1387
- ...prev,
1388
- pendingTimeout: undefined,
1389
- }
1390
- })
1402
+ clearTimeout(match._nonReactive.pendingTimeout)
1403
+ match._nonReactive.pendingTimeout = undefined
1391
1404
  }
1392
1405
 
1393
1406
  cancelMatches = () => {
@@ -1413,7 +1426,7 @@ export class RouterCore<
1413
1426
 
1414
1427
  // First let's find the starting pathname
1415
1428
  // By default, start with the current location
1416
- let fromPath = lastMatch.fullPath
1429
+ let fromPath = this.resolvePathWithBase(lastMatch.fullPath, '.')
1417
1430
  const toPath = dest.to
1418
1431
  ? this.resolvePathWithBase(fromPath, `${dest.to}`)
1419
1432
  : this.resolvePathWithBase(fromPath, '.')
@@ -1454,6 +1467,8 @@ export class RouterCore<
1454
1467
  }
1455
1468
  }
1456
1469
 
1470
+ fromPath = this.resolvePathWithBase(fromPath, '.')
1471
+
1457
1472
  // From search should always use the current location
1458
1473
  const fromSearch = lastMatch.search
1459
1474
  // Same with params. It can't hurt to provide as many as possible
@@ -1482,13 +1497,9 @@ export class RouterCore<
1482
1497
  parseCache: this.parsePathnameCache,
1483
1498
  }).interpolatedPath
1484
1499
 
1485
- const destRoutes = this.matchRoutes(
1486
- interpolatedNextTo,
1487
- {},
1488
- {
1489
- _buildLocation: true,
1490
- },
1491
- ).map((d) => this.looseRoutesById[d.routeId]!)
1500
+ const destRoutes = this.matchRoutes(interpolatedNextTo, undefined, {
1501
+ _buildLocation: true,
1502
+ }).map((d) => this.looseRoutesById[d.routeId]!)
1492
1503
 
1493
1504
  // If there are any params, we need to stringify them
1494
1505
  if (Object.keys(nextParams).length > 0) {
@@ -2077,702 +2088,871 @@ export class RouterCore<
2077
2088
  )
2078
2089
  }
2079
2090
 
2080
- loadMatches = async ({
2081
- location,
2082
- matches,
2083
- preload: allPreload,
2084
- onReady,
2085
- updateMatch = this.updateMatch,
2086
- sync,
2087
- }: {
2088
- location: ParsedLocation
2089
- matches: Array<AnyRouteMatch>
2090
- preload?: boolean
2091
- onReady?: () => Promise<void>
2092
- updateMatch?: (
2093
- id: string,
2094
- updater: (match: AnyRouteMatch) => AnyRouteMatch,
2095
- ) => void
2096
- getMatch?: (matchId: string) => AnyRouteMatch | undefined
2097
- sync?: boolean
2098
- }): Promise<Array<MakeRouteMatch>> => {
2099
- let firstBadMatchIndex: number | undefined
2100
- let rendered = false
2091
+ private triggerOnReady = (
2092
+ innerLoadContext: InnerLoadContext,
2093
+ ): void | Promise<void> => {
2094
+ if (!innerLoadContext.rendered) {
2095
+ innerLoadContext.rendered = true
2096
+ return innerLoadContext.onReady?.()
2097
+ }
2098
+ }
2099
+
2100
+ private resolvePreload = (
2101
+ innerLoadContext: InnerLoadContext,
2102
+ matchId: string,
2103
+ ): boolean => {
2104
+ return !!(
2105
+ innerLoadContext.preload &&
2106
+ !this.state.matches.some((d) => d.id === matchId)
2107
+ )
2108
+ }
2109
+
2110
+ private handleRedirectAndNotFound = (
2111
+ innerLoadContext: InnerLoadContext,
2112
+ match: AnyRouteMatch | undefined,
2113
+ err: unknown,
2114
+ ): void => {
2115
+ if (!isRedirect(err) && !isNotFound(err)) return
2101
2116
 
2102
- const triggerOnReady = async () => {
2103
- if (!rendered) {
2104
- rendered = true
2105
- await onReady?.()
2117
+ if (isRedirect(err) && err.redirectHandled && !err.options.reloadDocument) {
2118
+ throw err
2119
+ }
2120
+
2121
+ // in case of a redirecting match during preload, the match does not exist
2122
+ if (match) {
2123
+ match._nonReactive.beforeLoadPromise?.resolve()
2124
+ match._nonReactive.loaderPromise?.resolve()
2125
+ match._nonReactive.beforeLoadPromise = undefined
2126
+ match._nonReactive.loaderPromise = undefined
2127
+
2128
+ const status = isRedirect(err) ? 'redirected' : 'notFound'
2129
+
2130
+ innerLoadContext.updateMatch(match.id, (prev) => ({
2131
+ ...prev,
2132
+ status,
2133
+ isFetching: false,
2134
+ error: err,
2135
+ }))
2136
+
2137
+ if (isNotFound(err) && !err.routeId) {
2138
+ err.routeId = match.routeId
2106
2139
  }
2140
+
2141
+ match._nonReactive.loadPromise?.resolve()
2107
2142
  }
2108
2143
 
2109
- const resolvePreload = (matchId: string) => {
2110
- return !!(allPreload && !this.state.matches.some((d) => d.id === matchId))
2144
+ if (isRedirect(err)) {
2145
+ innerLoadContext.rendered = true
2146
+ err.options._fromLocation = innerLoadContext.location
2147
+ err.redirectHandled = true
2148
+ err = this.resolveRedirect(err)
2149
+ throw err
2150
+ } else {
2151
+ this._handleNotFound(innerLoadContext, err)
2152
+ throw err
2111
2153
  }
2154
+ }
2112
2155
 
2113
- // make sure the pending component is immediately rendered when hydrating a match that is not SSRed
2114
- // the pending component was already rendered on the server and we want to keep it shown on the client until minPendingMs is reached
2115
- if (!this.isServer && this.state.matches.some((d) => d._forcePending)) {
2116
- triggerOnReady()
2156
+ private shouldSkipLoader = (matchId: string): boolean => {
2157
+ const match = this.getMatch(matchId)!
2158
+ // upon hydration, we skip the loader if the match has been dehydrated on the server
2159
+ if (!this.isServer && match._nonReactive.dehydrated) {
2160
+ return true
2117
2161
  }
2118
2162
 
2119
- const handleRedirectAndNotFound = (match: AnyRouteMatch, err: any) => {
2120
- if (isRedirect(err) || isNotFound(err)) {
2121
- if (isRedirect(err)) {
2122
- if (err.redirectHandled) {
2123
- if (!err.options.reloadDocument) {
2124
- throw err
2125
- }
2126
- }
2127
- }
2163
+ if (this.isServer) {
2164
+ if (match.ssr === false) {
2165
+ return true
2166
+ }
2167
+ }
2168
+ return false
2169
+ }
2128
2170
 
2129
- match.beforeLoadPromise?.resolve()
2130
- match.loaderPromise?.resolve()
2171
+ private handleSerialError = (
2172
+ innerLoadContext: InnerLoadContext,
2173
+ index: number,
2174
+ err: any,
2175
+ routerCode: string,
2176
+ ): void => {
2177
+ const { id: matchId, routeId } = innerLoadContext.matches[index]!
2178
+ const route = this.looseRoutesById[routeId]!
2179
+
2180
+ // Much like suspense, we use a promise here to know if
2181
+ // we've been outdated by a new loadMatches call and
2182
+ // should abort the current async operation
2183
+ if (err instanceof Promise) {
2184
+ throw err
2185
+ }
2131
2186
 
2132
- updateMatch(match.id, (prev) => ({
2133
- ...prev,
2134
- status: isRedirect(err)
2135
- ? 'redirected'
2136
- : isNotFound(err)
2137
- ? 'notFound'
2138
- : 'error',
2139
- isFetching: false,
2140
- error: err,
2141
- beforeLoadPromise: undefined,
2142
- loaderPromise: undefined,
2143
- }))
2187
+ err.routerCode = routerCode
2188
+ innerLoadContext.firstBadMatchIndex ??= index
2189
+ this.handleRedirectAndNotFound(
2190
+ innerLoadContext,
2191
+ this.getMatch(matchId),
2192
+ err,
2193
+ )
2144
2194
 
2145
- if (!(err as any).routeId) {
2146
- ;(err as any).routeId = match.routeId
2147
- }
2195
+ try {
2196
+ route.options.onError?.(err)
2197
+ } catch (errorHandlerErr) {
2198
+ err = errorHandlerErr
2199
+ this.handleRedirectAndNotFound(
2200
+ innerLoadContext,
2201
+ this.getMatch(matchId),
2202
+ err,
2203
+ )
2204
+ }
2148
2205
 
2149
- match.loadPromise?.resolve()
2150
-
2151
- if (isRedirect(err)) {
2152
- rendered = true
2153
- err.options._fromLocation = location
2154
- err.redirectHandled = true
2155
- err = this.resolveRedirect(err)
2156
- throw err
2157
- } else if (isNotFound(err)) {
2158
- this._handleNotFound(matches, err, {
2159
- updateMatch,
2160
- })
2161
- throw err
2162
- }
2206
+ innerLoadContext.updateMatch(matchId, (prev) => {
2207
+ prev._nonReactive.beforeLoadPromise?.resolve()
2208
+ prev._nonReactive.beforeLoadPromise = undefined
2209
+ prev._nonReactive.loadPromise?.resolve()
2210
+
2211
+ return {
2212
+ ...prev,
2213
+ error: err,
2214
+ status: 'error',
2215
+ isFetching: false,
2216
+ updatedAt: Date.now(),
2217
+ abortController: new AbortController(),
2218
+ }
2219
+ })
2220
+ }
2221
+
2222
+ private isBeforeLoadSsr = (
2223
+ innerLoadContext: InnerLoadContext,
2224
+ matchId: string,
2225
+ index: number,
2226
+ route: AnyRoute,
2227
+ ): void | Promise<void> => {
2228
+ const existingMatch = this.getMatch(matchId)!
2229
+ const parentMatchId = innerLoadContext.matches[index - 1]?.id
2230
+ const parentMatch = parentMatchId
2231
+ ? this.getMatch(parentMatchId)!
2232
+ : undefined
2233
+
2234
+ // in SPA mode, only SSR the root route
2235
+ if (this.isShell()) {
2236
+ existingMatch.ssr = matchId === rootRouteId
2237
+ return
2238
+ }
2239
+
2240
+ if (parentMatch?.ssr === false) {
2241
+ existingMatch.ssr = false
2242
+ return
2243
+ }
2244
+
2245
+ const parentOverride = (tempSsr: boolean | 'data-only') => {
2246
+ if (tempSsr === true && parentMatch?.ssr === 'data-only') {
2247
+ return 'data-only'
2163
2248
  }
2249
+ return tempSsr
2250
+ }
2251
+
2252
+ const defaultSsr = this.options.defaultSsr ?? true
2253
+
2254
+ if (route.options.ssr === undefined) {
2255
+ existingMatch.ssr = parentOverride(defaultSsr)
2256
+ return
2257
+ }
2258
+
2259
+ if (typeof route.options.ssr !== 'function') {
2260
+ existingMatch.ssr = parentOverride(route.options.ssr)
2261
+ return
2262
+ }
2263
+ const { search, params } = this.getMatch(matchId)!
2264
+
2265
+ const ssrFnContext: SsrContextOptions<any, any, any> = {
2266
+ search: makeMaybe(search, existingMatch.searchError),
2267
+ params: makeMaybe(params, existingMatch.paramsError),
2268
+ location: innerLoadContext.location,
2269
+ matches: innerLoadContext.matches.map((match) => ({
2270
+ index: match.index,
2271
+ pathname: match.pathname,
2272
+ fullPath: match.fullPath,
2273
+ staticData: match.staticData,
2274
+ id: match.id,
2275
+ routeId: match.routeId,
2276
+ search: makeMaybe(match.search, match.searchError),
2277
+ params: makeMaybe(match.params, match.paramsError),
2278
+ ssr: match.ssr,
2279
+ })),
2164
2280
  }
2165
2281
 
2166
- const shouldSkipLoader = (matchId: string) => {
2282
+ const tempSsr = route.options.ssr(ssrFnContext)
2283
+ if (isPromise(tempSsr)) {
2284
+ return tempSsr.then((ssr) => {
2285
+ existingMatch.ssr = parentOverride(ssr ?? defaultSsr)
2286
+ })
2287
+ }
2288
+
2289
+ existingMatch.ssr = parentOverride(tempSsr ?? defaultSsr)
2290
+ return
2291
+ }
2292
+
2293
+ private setupPendingTimeout = (
2294
+ innerLoadContext: InnerLoadContext,
2295
+ matchId: string,
2296
+ route: AnyRoute,
2297
+ ): void => {
2298
+ const pendingMs = route.options.pendingMs ?? this.options.defaultPendingMs
2299
+ const shouldPending = !!(
2300
+ innerLoadContext.onReady &&
2301
+ !this.isServer &&
2302
+ !this.resolvePreload(innerLoadContext, matchId) &&
2303
+ (route.options.loader ||
2304
+ route.options.beforeLoad ||
2305
+ routeNeedsPreload(route)) &&
2306
+ typeof pendingMs === 'number' &&
2307
+ pendingMs !== Infinity &&
2308
+ (route.options.pendingComponent ??
2309
+ (this.options as any)?.defaultPendingComponent)
2310
+ )
2311
+ const match = this.getMatch(matchId)!
2312
+ if (shouldPending && match._nonReactive.pendingTimeout === undefined) {
2313
+ const pendingTimeout = setTimeout(() => {
2314
+ // Update the match and prematurely resolve the loadMatches promise so that
2315
+ // the pending component can start rendering
2316
+ this.triggerOnReady(innerLoadContext)
2317
+ }, pendingMs)
2318
+ match._nonReactive.pendingTimeout = pendingTimeout
2319
+ }
2320
+ }
2321
+
2322
+ private shouldExecuteBeforeLoad = (
2323
+ innerLoadContext: InnerLoadContext,
2324
+ matchId: string,
2325
+ route: AnyRoute,
2326
+ ): boolean | Promise<boolean> => {
2327
+ const existingMatch = this.getMatch(matchId)!
2328
+
2329
+ // If we are in the middle of a load, either of these will be present
2330
+ // (not to be confused with `loadPromise`, which is always defined)
2331
+ if (
2332
+ !existingMatch._nonReactive.beforeLoadPromise &&
2333
+ !existingMatch._nonReactive.loaderPromise
2334
+ )
2335
+ return true
2336
+
2337
+ this.setupPendingTimeout(innerLoadContext, matchId, route)
2338
+
2339
+ const then = () => {
2340
+ let shouldExecuteBeforeLoad = true
2167
2341
  const match = this.getMatch(matchId)!
2168
- // upon hydration, we skip the loader if the match has been dehydrated on the server
2169
- if (!this.isServer && match._dehydrated) {
2170
- return true
2342
+ if (match.status === 'error') {
2343
+ shouldExecuteBeforeLoad = true
2344
+ } else if (
2345
+ match.preload &&
2346
+ (match.status === 'redirected' || match.status === 'notFound')
2347
+ ) {
2348
+ this.handleRedirectAndNotFound(innerLoadContext, match, match.error)
2171
2349
  }
2350
+ return shouldExecuteBeforeLoad
2351
+ }
2172
2352
 
2173
- if (this.isServer) {
2174
- if (match.ssr === false) {
2175
- return true
2176
- }
2353
+ // Wait for the beforeLoad to resolve before we continue
2354
+ return existingMatch._nonReactive.beforeLoadPromise
2355
+ ? existingMatch._nonReactive.beforeLoadPromise.then(then)
2356
+ : then()
2357
+ }
2358
+
2359
+ private executeBeforeLoad = (
2360
+ innerLoadContext: InnerLoadContext,
2361
+ matchId: string,
2362
+ index: number,
2363
+ route: AnyRoute,
2364
+ ): void | Promise<void> => {
2365
+ const match = this.getMatch(matchId)!
2366
+
2367
+ match._nonReactive.beforeLoadPromise = createControlledPromise<void>()
2368
+ // explicitly capture the previous loadPromise
2369
+ const prevLoadPromise = match._nonReactive.loadPromise
2370
+ match._nonReactive.loadPromise = createControlledPromise<void>(() => {
2371
+ prevLoadPromise?.resolve()
2372
+ })
2373
+
2374
+ const { paramsError, searchError } = match
2375
+
2376
+ if (paramsError) {
2377
+ this.handleSerialError(
2378
+ innerLoadContext,
2379
+ index,
2380
+ paramsError,
2381
+ 'PARSE_PARAMS',
2382
+ )
2383
+ }
2384
+
2385
+ if (searchError) {
2386
+ this.handleSerialError(
2387
+ innerLoadContext,
2388
+ index,
2389
+ searchError,
2390
+ 'VALIDATE_SEARCH',
2391
+ )
2392
+ }
2393
+
2394
+ this.setupPendingTimeout(innerLoadContext, matchId, route)
2395
+
2396
+ const abortController = new AbortController()
2397
+
2398
+ const parentMatchId = innerLoadContext.matches[index - 1]?.id
2399
+ const parentMatch = parentMatchId
2400
+ ? this.getMatch(parentMatchId)!
2401
+ : undefined
2402
+ const parentMatchContext =
2403
+ parentMatch?.context ?? this.options.context ?? undefined
2404
+
2405
+ const context = { ...parentMatchContext, ...match.__routeContext }
2406
+
2407
+ let isPending = false
2408
+ const pending = () => {
2409
+ if (isPending) return
2410
+ isPending = true
2411
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2412
+ ...prev,
2413
+ isFetching: 'beforeLoad',
2414
+ fetchCount: prev.fetchCount + 1,
2415
+ abortController,
2416
+ context,
2417
+ }))
2418
+ }
2419
+
2420
+ const resolve = () => {
2421
+ match._nonReactive.beforeLoadPromise?.resolve()
2422
+ match._nonReactive.beforeLoadPromise = undefined
2423
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2424
+ ...prev,
2425
+ isFetching: false,
2426
+ }))
2427
+ }
2428
+
2429
+ // if there is no `beforeLoad` option, skip everything, batch update the store, return early
2430
+ if (!route.options.beforeLoad) {
2431
+ batch(() => {
2432
+ pending()
2433
+ resolve()
2434
+ })
2435
+ return
2436
+ }
2437
+
2438
+ const { search, params, cause } = match
2439
+ const preload = this.resolvePreload(innerLoadContext, matchId)
2440
+ const beforeLoadFnContext: BeforeLoadContextOptions<
2441
+ any,
2442
+ any,
2443
+ any,
2444
+ any,
2445
+ any
2446
+ > = {
2447
+ search,
2448
+ abortController,
2449
+ params,
2450
+ preload,
2451
+ context,
2452
+ location: innerLoadContext.location,
2453
+ navigate: (opts: any) =>
2454
+ this.navigate({ ...opts, _fromLocation: innerLoadContext.location }),
2455
+ buildLocation: this.buildLocation,
2456
+ cause: preload ? 'preload' : cause,
2457
+ matches: innerLoadContext.matches,
2458
+ }
2459
+
2460
+ const updateContext = (beforeLoadContext: any) => {
2461
+ if (beforeLoadContext === undefined) {
2462
+ batch(() => {
2463
+ pending()
2464
+ resolve()
2465
+ })
2466
+ return
2177
2467
  }
2178
- return false
2468
+ if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) {
2469
+ pending()
2470
+ this.handleSerialError(
2471
+ innerLoadContext,
2472
+ index,
2473
+ beforeLoadContext,
2474
+ 'BEFORE_LOAD',
2475
+ )
2476
+ }
2477
+
2478
+ batch(() => {
2479
+ pending()
2480
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2481
+ ...prev,
2482
+ __beforeLoadContext: beforeLoadContext,
2483
+ context: {
2484
+ ...prev.context,
2485
+ ...beforeLoadContext,
2486
+ },
2487
+ }))
2488
+ resolve()
2489
+ })
2179
2490
  }
2180
2491
 
2492
+ let beforeLoadContext
2181
2493
  try {
2182
- await new Promise<void>((resolveAll, rejectAll) => {
2183
- ;(async () => {
2184
- try {
2185
- const handleSerialError = (
2186
- index: number,
2187
- err: any,
2188
- routerCode: string,
2189
- ) => {
2190
- const { id: matchId, routeId } = matches[index]!
2191
- const route = this.looseRoutesById[routeId]!
2192
-
2193
- // Much like suspense, we use a promise here to know if
2194
- // we've been outdated by a new loadMatches call and
2195
- // should abort the current async operation
2196
- if (err instanceof Promise) {
2197
- throw err
2198
- }
2494
+ beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext)
2495
+ if (isPromise(beforeLoadContext)) {
2496
+ pending()
2497
+ return beforeLoadContext
2498
+ .catch((err) => {
2499
+ this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD')
2500
+ })
2501
+ .then(updateContext)
2502
+ }
2503
+ } catch (err) {
2504
+ pending()
2505
+ this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD')
2506
+ }
2199
2507
 
2200
- err.routerCode = routerCode
2201
- firstBadMatchIndex = firstBadMatchIndex ?? index
2202
- handleRedirectAndNotFound(this.getMatch(matchId)!, err)
2508
+ updateContext(beforeLoadContext)
2509
+ return
2510
+ }
2203
2511
 
2204
- try {
2205
- route.options.onError?.(err)
2206
- } catch (errorHandlerErr) {
2207
- err = errorHandlerErr
2208
- handleRedirectAndNotFound(this.getMatch(matchId)!, err)
2209
- }
2512
+ private handleBeforeLoad = (
2513
+ innerLoadContext: InnerLoadContext,
2514
+ index: number,
2515
+ ): void | Promise<void> => {
2516
+ const { id: matchId, routeId } = innerLoadContext.matches[index]!
2517
+ const route = this.looseRoutesById[routeId]!
2210
2518
 
2211
- updateMatch(matchId, (prev) => {
2212
- prev.beforeLoadPromise?.resolve()
2213
- prev.loadPromise?.resolve()
2214
-
2215
- return {
2216
- ...prev,
2217
- error: err,
2218
- status: 'error',
2219
- isFetching: false,
2220
- updatedAt: Date.now(),
2221
- abortController: new AbortController(),
2222
- beforeLoadPromise: undefined,
2223
- }
2224
- })
2225
- }
2519
+ const serverSsr = () => {
2520
+ // on the server, determine whether SSR the current match or not
2521
+ if (this.isServer) {
2522
+ const maybePromise = this.isBeforeLoadSsr(
2523
+ innerLoadContext,
2524
+ matchId,
2525
+ index,
2526
+ route,
2527
+ )
2528
+ if (isPromise(maybePromise)) return maybePromise.then(queueExecution)
2529
+ }
2530
+ return queueExecution()
2531
+ }
2226
2532
 
2227
- for (const [index, { id: matchId, routeId }] of matches.entries()) {
2228
- const existingMatch = this.getMatch(matchId)!
2229
- const parentMatchId = matches[index - 1]?.id
2230
- const parentMatch = parentMatchId
2231
- ? this.getMatch(parentMatchId)!
2232
- : undefined
2233
-
2234
- const route = this.looseRoutesById[routeId]!
2235
-
2236
- const pendingMs =
2237
- route.options.pendingMs ?? this.options.defaultPendingMs
2238
-
2239
- // on the server, determine whether SSR the current match or not
2240
- if (this.isServer) {
2241
- let ssr: boolean | 'data-only'
2242
- // in SPA mode, only SSR the root route
2243
- if (this.isShell()) {
2244
- ssr = matchId === rootRouteId
2245
- } else {
2246
- const defaultSsr = this.options.defaultSsr ?? true
2247
- if (parentMatch?.ssr === false) {
2248
- ssr = false
2249
- } else {
2250
- let tempSsr: boolean | 'data-only'
2251
- if (route.options.ssr === undefined) {
2252
- tempSsr = defaultSsr
2253
- } else if (typeof route.options.ssr === 'function') {
2254
- const { search, params } = this.getMatch(matchId)!
2255
-
2256
- function makeMaybe(value: any, error: any) {
2257
- if (error) {
2258
- return { status: 'error' as const, error }
2259
- }
2260
- return { status: 'success' as const, value }
2261
- }
2262
-
2263
- const ssrFnContext: SsrContextOptions<any, any, any> = {
2264
- search: makeMaybe(search, existingMatch.searchError),
2265
- params: makeMaybe(params, existingMatch.paramsError),
2266
- location,
2267
- matches: matches.map((match) => ({
2268
- index: match.index,
2269
- pathname: match.pathname,
2270
- fullPath: match.fullPath,
2271
- staticData: match.staticData,
2272
- id: match.id,
2273
- routeId: match.routeId,
2274
- search: makeMaybe(match.search, match.searchError),
2275
- params: makeMaybe(match.params, match.paramsError),
2276
- ssr: match.ssr,
2277
- })),
2278
- }
2279
- tempSsr =
2280
- (await route.options.ssr(ssrFnContext)) ?? defaultSsr
2281
- } else {
2282
- tempSsr = route.options.ssr
2283
- }
2533
+ const queueExecution = () => {
2534
+ if (this.shouldSkipLoader(matchId)) return
2535
+ const shouldExecuteBeforeLoadResult = this.shouldExecuteBeforeLoad(
2536
+ innerLoadContext,
2537
+ matchId,
2538
+ route,
2539
+ )
2540
+ return isPromise(shouldExecuteBeforeLoadResult)
2541
+ ? shouldExecuteBeforeLoadResult.then(execute)
2542
+ : execute(shouldExecuteBeforeLoadResult)
2543
+ }
2284
2544
 
2285
- if (tempSsr === true && parentMatch?.ssr === 'data-only') {
2286
- ssr = 'data-only'
2287
- } else {
2288
- ssr = tempSsr
2289
- }
2290
- }
2291
- }
2292
- updateMatch(matchId, (prev) => ({
2293
- ...prev,
2294
- ssr,
2295
- }))
2296
- }
2545
+ const execute = (shouldExecuteBeforeLoad: boolean) => {
2546
+ if (shouldExecuteBeforeLoad) {
2547
+ // If we are not in the middle of a load OR the previous load failed, start it
2548
+ return this.executeBeforeLoad(innerLoadContext, matchId, index, route)
2549
+ }
2550
+ return
2551
+ }
2297
2552
 
2298
- if (shouldSkipLoader(matchId)) {
2299
- continue
2300
- }
2553
+ return serverSsr()
2554
+ }
2301
2555
 
2302
- const shouldPending = !!(
2303
- onReady &&
2304
- !this.isServer &&
2305
- !resolvePreload(matchId) &&
2306
- (route.options.loader ||
2307
- route.options.beforeLoad ||
2308
- routeNeedsPreload(route)) &&
2309
- typeof pendingMs === 'number' &&
2310
- pendingMs !== Infinity &&
2311
- (route.options.pendingComponent ??
2312
- (this.options as any)?.defaultPendingComponent)
2313
- )
2556
+ private executeHead = (
2557
+ innerLoadContext: InnerLoadContext,
2558
+ matchId: string,
2559
+ route: AnyRoute,
2560
+ ): void | Promise<
2561
+ Pick<
2562
+ AnyRouteMatch,
2563
+ 'meta' | 'links' | 'headScripts' | 'headers' | 'scripts' | 'styles'
2564
+ >
2565
+ > => {
2566
+ const match = this.getMatch(matchId)
2567
+ // in case of a redirecting match during preload, the match does not exist
2568
+ if (!match) {
2569
+ return
2570
+ }
2571
+ if (
2572
+ !route.options.head &&
2573
+ !route.options.scripts &&
2574
+ !route.options.headers
2575
+ ) {
2576
+ return
2577
+ }
2578
+ const assetContext = {
2579
+ matches: innerLoadContext.matches,
2580
+ match,
2581
+ params: match.params,
2582
+ loaderData: match.loaderData,
2583
+ }
2314
2584
 
2315
- let executeBeforeLoad = true
2316
- const setupPendingTimeout = () => {
2317
- if (
2318
- shouldPending &&
2319
- this.getMatch(matchId)!.pendingTimeout === undefined
2320
- ) {
2321
- const pendingTimeout = setTimeout(() => {
2322
- try {
2323
- // Update the match and prematurely resolve the loadMatches promise so that
2324
- // the pending component can start rendering
2325
- triggerOnReady()
2326
- } catch {}
2327
- }, pendingMs)
2328
- updateMatch(matchId, (prev) => ({
2329
- ...prev,
2330
- pendingTimeout,
2331
- }))
2332
- }
2333
- }
2334
- if (
2335
- // If we are in the middle of a load, either of these will be present
2336
- // (not to be confused with `loadPromise`, which is always defined)
2337
- existingMatch.beforeLoadPromise ||
2338
- existingMatch.loaderPromise
2339
- ) {
2340
- setupPendingTimeout()
2341
-
2342
- // Wait for the beforeLoad to resolve before we continue
2343
- await existingMatch.beforeLoadPromise
2344
- const match = this.getMatch(matchId)!
2345
- if (match.status === 'error') {
2346
- executeBeforeLoad = true
2347
- } else if (
2348
- match.preload &&
2349
- (match.status === 'redirected' || match.status === 'notFound')
2350
- ) {
2351
- handleRedirectAndNotFound(match, match.error)
2352
- }
2353
- }
2354
- if (executeBeforeLoad) {
2355
- // If we are not in the middle of a load OR the previous load failed, start it
2356
- try {
2357
- updateMatch(matchId, (prev) => {
2358
- // explicitly capture the previous loadPromise
2359
- const prevLoadPromise = prev.loadPromise
2360
- return {
2361
- ...prev,
2362
- loadPromise: createControlledPromise<void>(() => {
2363
- prevLoadPromise?.resolve()
2364
- }),
2365
- beforeLoadPromise: createControlledPromise<void>(),
2366
- }
2367
- })
2585
+ return Promise.all([
2586
+ route.options.head?.(assetContext),
2587
+ route.options.scripts?.(assetContext),
2588
+ route.options.headers?.(assetContext),
2589
+ ]).then(([headFnContent, scripts, headers]) => {
2590
+ const meta = headFnContent?.meta
2591
+ const links = headFnContent?.links
2592
+ const headScripts = headFnContent?.scripts
2593
+ const styles = headFnContent?.styles
2368
2594
 
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 ?? {}
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.beforeLoadPromise?.resolve()
2450
-
2451
- return {
2452
- ...prev,
2453
- beforeLoadPromise: undefined,
2454
- isFetching: false,
2455
- }
2456
- })
2457
- }
2458
- }
2595
+ return {
2596
+ meta,
2597
+ links,
2598
+ headScripts,
2599
+ headers,
2600
+ scripts,
2601
+ styles,
2602
+ }
2603
+ })
2604
+ }
2459
2605
 
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 = async () => {
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
- const assetContext = {
2477
- matches,
2478
- match,
2479
- params: match.params,
2480
- loaderData: match.loaderData,
2481
- }
2482
- const headFnContent =
2483
- await route.options.head?.(assetContext)
2484
- const meta = headFnContent?.meta
2485
- const links = headFnContent?.links
2486
- const headScripts = headFnContent?.scripts
2487
- const styles = headFnContent?.styles
2488
-
2489
- const scripts = await route.options.scripts?.(assetContext)
2490
- const headers = await route.options.headers?.(assetContext)
2491
- return {
2492
- meta,
2493
- links,
2494
- headScripts,
2495
- headers,
2496
- scripts,
2497
- styles,
2498
- }
2499
- }
2606
+ private potentialPendingMinPromise = (
2607
+ matchId: string,
2608
+ ): void | ControlledPromise<void> => {
2609
+ const latestMatch = this.getMatch(matchId)!
2610
+ return latestMatch._nonReactive.minPendingPromise
2611
+ }
2500
2612
 
2501
- const potentialPendingMinPromise = async () => {
2502
- const latestMatch = this.getMatch(matchId)!
2503
- if (latestMatch.minPendingPromise) {
2504
- await latestMatch.minPendingPromise
2505
- }
2506
- }
2507
-
2508
- const prevMatch = this.getMatch(matchId)!
2509
- if (shouldSkipLoader(matchId)) {
2510
- if (this.isServer) {
2511
- const head = await executeHead()
2512
- updateMatch(matchId, (prev) => ({
2513
- ...prev,
2514
- ...head,
2515
- }))
2516
- return this.getMatch(matchId)!
2517
- }
2518
- }
2519
- // there is a loaderPromise, so we are in the middle of a load
2520
- else if (prevMatch.loaderPromise) {
2521
- // do not block if we already have stale data we can show
2522
- // but only if the ongoing load is not a preload since error handling is different for preloads
2523
- // and we don't want to swallow errors
2524
- if (
2525
- prevMatch.status === 'success' &&
2526
- !sync &&
2527
- !prevMatch.preload
2528
- ) {
2529
- return this.getMatch(matchId)!
2530
- }
2531
- await prevMatch.loaderPromise
2532
- const match = this.getMatch(matchId)!
2533
- if (match.error) {
2534
- handleRedirectAndNotFound(match, match.error)
2535
- }
2536
- } else {
2537
- const parentMatchPromise = matchPromises[index - 1] as any
2538
-
2539
- const getLoaderContext = (): LoaderFnContext => {
2540
- const {
2541
- params,
2542
- loaderDeps,
2543
- abortController,
2544
- context,
2545
- cause,
2546
- } = this.getMatch(matchId)!
2547
-
2548
- const preload = resolvePreload(matchId)
2549
-
2550
- return {
2551
- params,
2552
- deps: loaderDeps,
2553
- preload: !!preload,
2554
- parentMatchPromise,
2555
- abortController: abortController,
2556
- context,
2557
- location,
2558
- navigate: (opts) =>
2559
- this.navigate({ ...opts, _fromLocation: location }),
2560
- cause: preload ? 'preload' : cause,
2561
- route,
2562
- }
2563
- }
2613
+ private getLoaderContext = (
2614
+ innerLoadContext: InnerLoadContext,
2615
+ matchId: string,
2616
+ index: number,
2617
+ route: AnyRoute,
2618
+ ): LoaderFnContext => {
2619
+ const parentMatchPromise = innerLoadContext.matchPromises[index - 1] as any
2620
+ const { params, loaderDeps, abortController, context, cause } =
2621
+ this.getMatch(matchId)!
2622
+
2623
+ const preload = this.resolvePreload(innerLoadContext, matchId)
2624
+
2625
+ return {
2626
+ params,
2627
+ deps: loaderDeps,
2628
+ preload: !!preload,
2629
+ parentMatchPromise,
2630
+ abortController: abortController,
2631
+ context,
2632
+ location: innerLoadContext.location,
2633
+ navigate: (opts) =>
2634
+ this.navigate({ ...opts, _fromLocation: innerLoadContext.location }),
2635
+ cause: preload ? 'preload' : cause,
2636
+ route,
2637
+ }
2638
+ }
2564
2639
 
2565
- // This is where all of the stale-while-revalidate magic happens
2566
- const age = Date.now() - this.getMatch(matchId)!.updatedAt
2567
-
2568
- const preload = resolvePreload(matchId)
2569
-
2570
- const staleAge = preload
2571
- ? (route.options.preloadStaleTime ??
2572
- this.options.defaultPreloadStaleTime ??
2573
- 30_000) // 30 seconds for preloads by default
2574
- : (route.options.staleTime ??
2575
- this.options.defaultStaleTime ??
2576
- 0)
2577
-
2578
- const shouldReloadOption = route.options.shouldReload
2579
-
2580
- // Default to reloading the route all the time
2581
- // Allow shouldReload to get the last say,
2582
- // if provided.
2583
- const shouldReload =
2584
- typeof shouldReloadOption === 'function'
2585
- ? shouldReloadOption(getLoaderContext())
2586
- : shouldReloadOption
2587
-
2588
- updateMatch(matchId, (prev) => ({
2589
- ...prev,
2590
- loaderPromise: createControlledPromise<void>(),
2591
- preload:
2592
- !!preload &&
2593
- !this.state.matches.some((d) => d.id === matchId),
2594
- }))
2595
-
2596
- const runLoader = async () => {
2597
- try {
2598
- // If the Matches component rendered
2599
- // the pending component and needs to show it for
2600
- // a minimum duration, we''ll wait for it to resolve
2601
- // before committing to the match and resolving
2602
- // the loadPromise
2603
-
2604
- // Actually run the loader and handle the result
2605
- try {
2606
- if (
2607
- !this.isServer ||
2608
- (this.isServer &&
2609
- this.getMatch(matchId)!.ssr === true)
2610
- ) {
2611
- this.loadRouteChunk(route)
2612
- }
2613
-
2614
- updateMatch(matchId, (prev) => ({
2615
- ...prev,
2616
- isFetching: 'loader',
2617
- }))
2618
-
2619
- // Kick off the loader!
2620
- const loaderData =
2621
- await route.options.loader?.(getLoaderContext())
2622
-
2623
- handleRedirectAndNotFound(
2624
- this.getMatch(matchId)!,
2625
- loaderData,
2626
- )
2627
- updateMatch(matchId, (prev) => ({
2628
- ...prev,
2629
- loaderData,
2630
- }))
2631
-
2632
- // Lazy option can modify the route options,
2633
- // so we need to wait for it to resolve before
2634
- // we can use the options
2635
- await route._lazyPromise
2636
- const head = await executeHead()
2637
- await potentialPendingMinPromise()
2638
-
2639
- // Last but not least, wait for the the components
2640
- // to be preloaded before we resolve the match
2641
- await route._componentsPromise
2642
- updateMatch(matchId, (prev) => ({
2643
- ...prev,
2644
- error: undefined,
2645
- status: 'success',
2646
- isFetching: false,
2647
- updatedAt: Date.now(),
2648
- ...head,
2649
- }))
2650
- } catch (e) {
2651
- let error = e
2652
-
2653
- await potentialPendingMinPromise()
2654
-
2655
- handleRedirectAndNotFound(this.getMatch(matchId)!, e)
2656
-
2657
- try {
2658
- route.options.onError?.(e)
2659
- } catch (onErrorError) {
2660
- error = onErrorError
2661
- handleRedirectAndNotFound(
2662
- this.getMatch(matchId)!,
2663
- onErrorError,
2664
- )
2665
- }
2666
- const head = await executeHead()
2667
- updateMatch(matchId, (prev) => ({
2668
- ...prev,
2669
- error,
2670
- status: 'error',
2671
- isFetching: false,
2672
- ...head,
2673
- }))
2674
- }
2675
- } catch (err) {
2676
- const head = await executeHead()
2677
-
2678
- updateMatch(matchId, (prev) => ({
2679
- ...prev,
2680
- loaderPromise: undefined,
2681
- ...head,
2682
- }))
2683
- handleRedirectAndNotFound(this.getMatch(matchId)!, err)
2684
- }
2685
- }
2640
+ private runLoader = async (
2641
+ innerLoadContext: InnerLoadContext,
2642
+ matchId: string,
2643
+ index: number,
2644
+ route: AnyRoute,
2645
+ ): Promise<void> => {
2646
+ try {
2647
+ // If the Matches component rendered
2648
+ // the pending component and needs to show it for
2649
+ // a minimum duration, we''ll wait for it to resolve
2650
+ // before committing to the match and resolving
2651
+ // the loadPromise
2686
2652
 
2687
- // If the route is successful and still fresh, just resolve
2688
- const { status, invalid } = this.getMatch(matchId)!
2689
- loaderShouldRunAsync =
2690
- status === 'success' &&
2691
- (invalid || (shouldReload ?? age > staleAge))
2692
- if (preload && route.options.preload === false) {
2693
- // Do nothing
2694
- } else if (loaderShouldRunAsync && !sync) {
2695
- loaderIsRunningAsync = true
2696
- ;(async () => {
2697
- try {
2698
- await runLoader()
2699
- const { loaderPromise, loadPromise } =
2700
- this.getMatch(matchId)!
2701
- loaderPromise?.resolve()
2702
- loadPromise?.resolve()
2703
- updateMatch(matchId, (prev) => ({
2704
- ...prev,
2705
- loaderPromise: undefined,
2706
- }))
2707
- } catch (err) {
2708
- if (isRedirect(err)) {
2709
- await this.navigate(err.options)
2710
- }
2711
- }
2712
- })()
2713
- } else if (
2714
- status !== 'success' ||
2715
- (loaderShouldRunAsync && sync)
2716
- ) {
2717
- await runLoader()
2718
- } else {
2719
- // if the loader did not run, still update head.
2720
- // reason: parent's beforeLoad may have changed the route context
2721
- // and only now do we know the route context (and that the loader would not run)
2722
- const head = await executeHead()
2723
- updateMatch(matchId, (prev) => ({
2724
- ...prev,
2725
- ...head,
2726
- }))
2727
- }
2728
- }
2729
- if (!loaderIsRunningAsync) {
2730
- const { loaderPromise, loadPromise } =
2731
- this.getMatch(matchId)!
2732
- loaderPromise?.resolve()
2733
- loadPromise?.resolve()
2734
- }
2735
-
2736
- updateMatch(matchId, (prev) => {
2737
- clearTimeout(prev.pendingTimeout)
2738
- return {
2739
- ...prev,
2740
- isFetching: loaderIsRunningAsync
2741
- ? prev.isFetching
2742
- : false,
2743
- loaderPromise: loaderIsRunningAsync
2744
- ? prev.loaderPromise
2745
- : undefined,
2746
- invalid: false,
2747
- pendingTimeout: undefined,
2748
- _dehydrated: undefined,
2749
- }
2750
- })
2751
- return this.getMatch(matchId)!
2752
- })(),
2753
- )
2754
- })
2653
+ // Actually run the loader and handle the result
2654
+ try {
2655
+ if (!this.isServer || this.getMatch(matchId)!.ssr === true) {
2656
+ this.loadRouteChunk(route)
2657
+ }
2755
2658
 
2756
- await Promise.all(matchPromises)
2659
+ // Kick off the loader!
2660
+ const loaderResult = route.options.loader?.(
2661
+ this.getLoaderContext(innerLoadContext, matchId, index, route),
2662
+ )
2663
+ const loaderResultIsPromise =
2664
+ route.options.loader && isPromise(loaderResult)
2665
+
2666
+ const willLoadSomething = !!(
2667
+ loaderResultIsPromise ||
2668
+ route._lazyPromise ||
2669
+ route._componentsPromise ||
2670
+ route.options.head ||
2671
+ route.options.scripts ||
2672
+ route.options.headers ||
2673
+ this.getMatch(matchId)!._nonReactive.minPendingPromise
2674
+ )
2675
+
2676
+ if (willLoadSomething) {
2677
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2678
+ ...prev,
2679
+ isFetching: 'loader',
2680
+ }))
2681
+ }
2682
+
2683
+ if (route.options.loader) {
2684
+ const loaderData = loaderResultIsPromise
2685
+ ? await loaderResult
2686
+ : loaderResult
2687
+
2688
+ this.handleRedirectAndNotFound(
2689
+ innerLoadContext,
2690
+ this.getMatch(matchId),
2691
+ loaderData,
2692
+ )
2693
+ if (loaderData !== undefined) {
2694
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2695
+ ...prev,
2696
+ loaderData,
2697
+ }))
2698
+ }
2699
+ }
2757
2700
 
2758
- resolveAll()
2701
+ // Lazy option can modify the route options,
2702
+ // so we need to wait for it to resolve before
2703
+ // we can use the options
2704
+ if (route._lazyPromise) await route._lazyPromise
2705
+ const headResult = this.executeHead(innerLoadContext, matchId, route)
2706
+ const head = headResult ? await headResult : undefined
2707
+ const pendingPromise = this.potentialPendingMinPromise(matchId)
2708
+ if (pendingPromise) await pendingPromise
2709
+
2710
+ // Last but not least, wait for the the components
2711
+ // to be preloaded before we resolve the match
2712
+ if (route._componentsPromise) await route._componentsPromise
2713
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2714
+ ...prev,
2715
+ error: undefined,
2716
+ status: 'success',
2717
+ isFetching: false,
2718
+ updatedAt: Date.now(),
2719
+ ...head,
2720
+ }))
2721
+ } catch (e) {
2722
+ let error = e
2723
+
2724
+ const pendingPromise = this.potentialPendingMinPromise(matchId)
2725
+ if (pendingPromise) await pendingPromise
2726
+
2727
+ this.handleRedirectAndNotFound(
2728
+ innerLoadContext,
2729
+ this.getMatch(matchId),
2730
+ e,
2731
+ )
2732
+
2733
+ try {
2734
+ route.options.onError?.(e)
2735
+ } catch (onErrorError) {
2736
+ error = onErrorError
2737
+ this.handleRedirectAndNotFound(
2738
+ innerLoadContext,
2739
+ this.getMatch(matchId),
2740
+ onErrorError,
2741
+ )
2742
+ }
2743
+ const headResult = this.executeHead(innerLoadContext, matchId, route)
2744
+ const head = headResult ? await headResult : undefined
2745
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2746
+ ...prev,
2747
+ error,
2748
+ status: 'error',
2749
+ isFetching: false,
2750
+ ...head,
2751
+ }))
2752
+ }
2753
+ } catch (err) {
2754
+ const match = this.getMatch(matchId)
2755
+ // in case of a redirecting match during preload, the match does not exist
2756
+ if (match) {
2757
+ const headResult = this.executeHead(innerLoadContext, matchId, route)
2758
+ if (headResult) {
2759
+ const head = await headResult
2760
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2761
+ ...prev,
2762
+ ...head,
2763
+ }))
2764
+ }
2765
+ match._nonReactive.loaderPromise = undefined
2766
+ }
2767
+ this.handleRedirectAndNotFound(innerLoadContext, match, err)
2768
+ }
2769
+ }
2770
+
2771
+ private loadRouteMatch = async (
2772
+ innerLoadContext: InnerLoadContext,
2773
+ index: number,
2774
+ ): Promise<AnyRouteMatch> => {
2775
+ const { id: matchId, routeId } = innerLoadContext.matches[index]!
2776
+ let loaderShouldRunAsync = false
2777
+ let loaderIsRunningAsync = false
2778
+ const route = this.looseRoutesById[routeId]!
2779
+
2780
+ const prevMatch = this.getMatch(matchId)!
2781
+ if (this.shouldSkipLoader(matchId)) {
2782
+ if (this.isServer) {
2783
+ const headResult = this.executeHead(innerLoadContext, matchId, route)
2784
+ if (headResult) {
2785
+ const head = await headResult
2786
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2787
+ ...prev,
2788
+ ...head,
2789
+ }))
2790
+ }
2791
+ return this.getMatch(matchId)!
2792
+ }
2793
+ }
2794
+ // there is a loaderPromise, so we are in the middle of a load
2795
+ else if (prevMatch._nonReactive.loaderPromise) {
2796
+ // do not block if we already have stale data we can show
2797
+ // but only if the ongoing load is not a preload since error handling is different for preloads
2798
+ // and we don't want to swallow errors
2799
+ if (
2800
+ prevMatch.status === 'success' &&
2801
+ !innerLoadContext.sync &&
2802
+ !prevMatch.preload
2803
+ ) {
2804
+ return this.getMatch(matchId)!
2805
+ }
2806
+ await prevMatch._nonReactive.loaderPromise
2807
+ const match = this.getMatch(matchId)!
2808
+ if (match.error) {
2809
+ this.handleRedirectAndNotFound(innerLoadContext, match, match.error)
2810
+ }
2811
+ } else {
2812
+ // This is where all of the stale-while-revalidate magic happens
2813
+ const age = Date.now() - this.getMatch(matchId)!.updatedAt
2814
+
2815
+ const preload = this.resolvePreload(innerLoadContext, matchId)
2816
+
2817
+ const staleAge = preload
2818
+ ? (route.options.preloadStaleTime ??
2819
+ this.options.defaultPreloadStaleTime ??
2820
+ 30_000) // 30 seconds for preloads by default
2821
+ : (route.options.staleTime ?? this.options.defaultStaleTime ?? 0)
2822
+
2823
+ const shouldReloadOption = route.options.shouldReload
2824
+
2825
+ // Default to reloading the route all the time
2826
+ // Allow shouldReload to get the last say,
2827
+ // if provided.
2828
+ const shouldReload =
2829
+ typeof shouldReloadOption === 'function'
2830
+ ? shouldReloadOption(
2831
+ this.getLoaderContext(innerLoadContext, matchId, index, route),
2832
+ )
2833
+ : shouldReloadOption
2834
+
2835
+ const nextPreload =
2836
+ !!preload && !this.state.matches.some((d) => d.id === matchId)
2837
+ const match = this.getMatch(matchId)!
2838
+ match._nonReactive.loaderPromise = createControlledPromise<void>()
2839
+ if (nextPreload !== match.preload) {
2840
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2841
+ ...prev,
2842
+ preload: nextPreload,
2843
+ }))
2844
+ }
2845
+
2846
+ // If the route is successful and still fresh, just resolve
2847
+ const { status, invalid } = this.getMatch(matchId)!
2848
+ loaderShouldRunAsync =
2849
+ status === 'success' && (invalid || (shouldReload ?? age > staleAge))
2850
+ if (preload && route.options.preload === false) {
2851
+ // Do nothing
2852
+ } else if (loaderShouldRunAsync && !innerLoadContext.sync) {
2853
+ loaderIsRunningAsync = true
2854
+ ;(async () => {
2855
+ try {
2856
+ await this.runLoader(innerLoadContext, matchId, index, route)
2857
+ const match = this.getMatch(matchId)!
2858
+ match._nonReactive.loaderPromise?.resolve()
2859
+ match._nonReactive.loadPromise?.resolve()
2860
+ match._nonReactive.loaderPromise = undefined
2759
2861
  } catch (err) {
2760
- rejectAll(err)
2862
+ if (isRedirect(err)) {
2863
+ await this.navigate(err.options)
2864
+ }
2761
2865
  }
2762
2866
  })()
2763
- })
2764
- await triggerOnReady()
2765
- } catch (err) {
2766
- if (isRedirect(err) || isNotFound(err)) {
2767
- if (isNotFound(err) && !allPreload) {
2768
- await triggerOnReady()
2867
+ } else if (
2868
+ status !== 'success' ||
2869
+ (loaderShouldRunAsync && innerLoadContext.sync)
2870
+ ) {
2871
+ await this.runLoader(innerLoadContext, matchId, index, route)
2872
+ } else {
2873
+ // if the loader did not run, still update head.
2874
+ // reason: parent's beforeLoad may have changed the route context
2875
+ // and only now do we know the route context (and that the loader would not run)
2876
+ const headResult = this.executeHead(innerLoadContext, matchId, route)
2877
+ if (headResult) {
2878
+ const head = await headResult
2879
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2880
+ ...prev,
2881
+ ...head,
2882
+ }))
2769
2883
  }
2884
+ }
2885
+ }
2886
+ const match = this.getMatch(matchId)!
2887
+ if (!loaderIsRunningAsync) {
2888
+ match._nonReactive.loaderPromise?.resolve()
2889
+ match._nonReactive.loadPromise?.resolve()
2890
+ }
2891
+
2892
+ clearTimeout(match._nonReactive.pendingTimeout)
2893
+ match._nonReactive.pendingTimeout = undefined
2894
+ if (!loaderIsRunningAsync) match._nonReactive.loaderPromise = undefined
2895
+ match._nonReactive.dehydrated = undefined
2896
+ const nextIsFetching = loaderIsRunningAsync ? match.isFetching : false
2897
+ if (nextIsFetching !== match.isFetching || match.invalid !== false) {
2898
+ innerLoadContext.updateMatch(matchId, (prev) => ({
2899
+ ...prev,
2900
+ isFetching: nextIsFetching,
2901
+ invalid: false,
2902
+ }))
2903
+ }
2904
+ return this.getMatch(matchId)!
2905
+ }
2906
+
2907
+ loadMatches = async (baseContext: {
2908
+ location: ParsedLocation
2909
+ matches: Array<AnyRouteMatch>
2910
+ preload?: boolean
2911
+ onReady?: () => Promise<void>
2912
+ updateMatch?: UpdateMatchFn
2913
+ sync?: boolean
2914
+ }): Promise<Array<MakeRouteMatch>> => {
2915
+ const innerLoadContext = baseContext as InnerLoadContext
2916
+ innerLoadContext.updateMatch ??= this.updateMatch
2917
+ innerLoadContext.matchPromises = []
2918
+
2919
+ // make sure the pending component is immediately rendered when hydrating a match that is not SSRed
2920
+ // the pending component was already rendered on the server and we want to keep it shown on the client until minPendingMs is reached
2921
+ if (!this.isServer && this.state.matches.some((d) => d._forcePending)) {
2922
+ this.triggerOnReady(innerLoadContext)
2923
+ }
2924
+
2925
+ try {
2926
+ // Execute all beforeLoads one by one
2927
+ for (let i = 0; i < innerLoadContext.matches.length; i++) {
2928
+ const beforeLoad = this.handleBeforeLoad(innerLoadContext, i)
2929
+ if (isPromise(beforeLoad)) await beforeLoad
2930
+ }
2931
+
2932
+ // Execute all loaders in parallel
2933
+ const max =
2934
+ innerLoadContext.firstBadMatchIndex ?? innerLoadContext.matches.length
2935
+ for (let i = 0; i < max; i++) {
2936
+ innerLoadContext.matchPromises.push(
2937
+ this.loadRouteMatch(innerLoadContext, i),
2938
+ )
2939
+ }
2940
+ await Promise.all(innerLoadContext.matchPromises)
2770
2941
 
2942
+ const readyPromise = this.triggerOnReady(innerLoadContext)
2943
+ if (isPromise(readyPromise)) await readyPromise
2944
+ } catch (err) {
2945
+ if (isNotFound(err) && !innerLoadContext.preload) {
2946
+ const readyPromise = this.triggerOnReady(innerLoadContext)
2947
+ if (isPromise(readyPromise)) await readyPromise
2948
+ throw err
2949
+ }
2950
+ if (isRedirect(err)) {
2771
2951
  throw err
2772
2952
  }
2773
2953
  }
2774
2954
 
2775
- return matches
2955
+ return innerLoadContext.matches
2776
2956
  }
2777
2957
 
2778
2958
  invalidate: InvalidateFn<
@@ -2791,7 +2971,7 @@ export class RouterCore<
2791
2971
  invalid: true,
2792
2972
  ...(opts?.forcePending || d.status === 'error'
2793
2973
  ? ({ status: 'pending', error: undefined } as const)
2794
- : {}),
2974
+ : undefined),
2795
2975
  }
2796
2976
  }
2797
2977
  return d
@@ -2869,33 +3049,44 @@ export class RouterCore<
2869
3049
  }
2870
3050
 
2871
3051
  loadRouteChunk = (route: AnyRoute) => {
2872
- if (route._lazyPromise === undefined) {
3052
+ if (!route._lazyLoaded && route._lazyPromise === undefined) {
2873
3053
  if (route.lazyFn) {
2874
3054
  route._lazyPromise = route.lazyFn().then((lazyRoute) => {
2875
3055
  // explicitly don't copy over the lazy route's id
2876
3056
  const { id: _id, ...options } = lazyRoute.options
2877
3057
  Object.assign(route.options, options)
3058
+ route._lazyLoaded = true
3059
+ route._lazyPromise = undefined // gc promise, we won't need it anymore
2878
3060
  })
2879
3061
  } else {
2880
- route._lazyPromise = Promise.resolve()
3062
+ route._lazyLoaded = true
2881
3063
  }
2882
3064
  }
2883
3065
 
2884
3066
  // If for some reason lazy resolves more lazy components...
2885
- // We'll wait for that before pre attempt to preload any
3067
+ // We'll wait for that before we attempt to preload the
2886
3068
  // components themselves.
2887
- if (route._componentsPromise === undefined) {
2888
- route._componentsPromise = route._lazyPromise.then(() =>
2889
- Promise.all(
2890
- componentTypes.map(async (type) => {
2891
- const component = route.options[type]
2892
- if ((component as any)?.preload) {
2893
- await (component as any).preload()
2894
- }
2895
- }),
2896
- ),
2897
- )
3069
+ if (!route._componentsLoaded && route._componentsPromise === undefined) {
3070
+ const loadComponents = () => {
3071
+ const preloads = []
3072
+ for (const type of componentTypes) {
3073
+ const preload = (route.options[type] as any)?.preload
3074
+ if (preload) preloads.push(preload())
3075
+ }
3076
+ if (preloads.length)
3077
+ return Promise.all(preloads).then(() => {
3078
+ route._componentsLoaded = true
3079
+ route._componentsPromise = undefined // gc promise, we won't need it anymore
3080
+ })
3081
+ route._componentsLoaded = true
3082
+ route._componentsPromise = undefined // gc promise, we won't need it anymore
3083
+ return
3084
+ }
3085
+ route._componentsPromise = route._lazyPromise
3086
+ ? route._lazyPromise.then(loadComponents)
3087
+ : loadComponents()
2898
3088
  }
3089
+
2899
3090
  return route._componentsPromise
2900
3091
  }
2901
3092
 
@@ -3035,17 +3226,9 @@ export class RouterCore<
3035
3226
 
3036
3227
  serverSsr?: ServerSsr
3037
3228
 
3038
- _handleNotFound = (
3039
- matches: Array<AnyRouteMatch>,
3229
+ private _handleNotFound = (
3230
+ innerLoadContext: InnerLoadContext,
3040
3231
  err: NotFoundError,
3041
- {
3042
- updateMatch = this.updateMatch,
3043
- }: {
3044
- updateMatch?: (
3045
- id: string,
3046
- updater: (match: AnyRouteMatch) => AnyRouteMatch,
3047
- ) => void
3048
- } = {},
3049
3232
  ) => {
3050
3233
  // Find the route that should handle the not found error
3051
3234
  // First check if a specific route is requested to show the error
@@ -3053,7 +3236,7 @@ export class RouterCore<
3053
3236
  const matchesByRouteId: Record<string, AnyRouteMatch> = {}
3054
3237
 
3055
3238
  // Setup routesByRouteId object for quick access
3056
- for (const match of matches) {
3239
+ for (const match of innerLoadContext.matches) {
3057
3240
  matchesByRouteId[match.routeId] = match
3058
3241
  }
3059
3242
 
@@ -3082,7 +3265,7 @@ export class RouterCore<
3082
3265
  )
3083
3266
 
3084
3267
  // Assign the error to the match - using non-null assertion since we've checked with invariant
3085
- updateMatch(matchForRoute.id, (prev) => ({
3268
+ innerLoadContext.updateMatch(matchForRoute.id, (prev) => ({
3086
3269
  ...prev,
3087
3270
  status: 'notFound',
3088
3271
  error: err,
@@ -3091,9 +3274,7 @@ export class RouterCore<
3091
3274
 
3092
3275
  if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) {
3093
3276
  err.routeId = routeCursor.parentRoute.id
3094
- this._handleNotFound(matches, err, {
3095
- updateMatch,
3096
- })
3277
+ this._handleNotFound(innerLoadContext, err)
3097
3278
  }
3098
3279
  }
3099
3280
 
@@ -3108,6 +3289,16 @@ export class SearchParamError extends Error {}
3108
3289
 
3109
3290
  export class PathParamError extends Error {}
3110
3291
 
3292
+ function makeMaybe<TValue, TError>(
3293
+ value: TValue,
3294
+ error: TError,
3295
+ ): { status: 'success'; value: TValue } | { status: 'error'; error: TError } {
3296
+ if (error) {
3297
+ return { status: 'error' as const, error }
3298
+ }
3299
+ return { status: 'success' as const, value }
3300
+ }
3301
+
3111
3302
  const normalize = (str: string) =>
3112
3303
  str.endsWith('/') && str.length > 1 ? str.slice(0, -1) : str
3113
3304
  function comparePaths(a: string, b: string) {
@@ -3562,7 +3753,8 @@ function applySearchMiddleware({
3562
3753
  try {
3563
3754
  const validatedSearch = {
3564
3755
  ...result,
3565
- ...(validateSearch(route.options.validateSearch, result) ?? {}),
3756
+ ...(validateSearch(route.options.validateSearch, result) ??
3757
+ undefined),
3566
3758
  }
3567
3759
  return validatedSearch
3568
3760
  } catch {