@tanstack/router-core 1.124.2 → 1.125.1

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.
Files changed (45) hide show
  1. package/dist/cjs/Matches.cjs.map +1 -1
  2. package/dist/cjs/Matches.d.cts +29 -0
  3. package/dist/cjs/index.cjs +1 -0
  4. package/dist/cjs/index.cjs.map +1 -1
  5. package/dist/cjs/index.d.cts +1 -1
  6. package/dist/cjs/route.cjs +0 -5
  7. package/dist/cjs/route.cjs.map +1 -1
  8. package/dist/cjs/route.d.cts +20 -6
  9. package/dist/cjs/router.cjs +208 -79
  10. package/dist/cjs/router.cjs.map +1 -1
  11. package/dist/cjs/router.d.cts +6 -1
  12. package/dist/cjs/ssr/ssr-client.cjs +38 -2
  13. package/dist/cjs/ssr/ssr-client.cjs.map +1 -1
  14. package/dist/cjs/ssr/ssr-client.d.cts +1 -0
  15. package/dist/cjs/ssr/ssr-server.cjs +2 -1
  16. package/dist/cjs/ssr/ssr-server.cjs.map +1 -1
  17. package/dist/cjs/utils.cjs +5 -0
  18. package/dist/cjs/utils.cjs.map +1 -1
  19. package/dist/cjs/utils.d.cts +1 -0
  20. package/dist/esm/Matches.d.ts +29 -0
  21. package/dist/esm/Matches.js.map +1 -1
  22. package/dist/esm/index.d.ts +1 -1
  23. package/dist/esm/index.js +2 -1
  24. package/dist/esm/route.d.ts +20 -6
  25. package/dist/esm/route.js +0 -5
  26. package/dist/esm/route.js.map +1 -1
  27. package/dist/esm/router.d.ts +6 -1
  28. package/dist/esm/router.js +208 -79
  29. package/dist/esm/router.js.map +1 -1
  30. package/dist/esm/ssr/ssr-client.d.ts +1 -0
  31. package/dist/esm/ssr/ssr-client.js +38 -2
  32. package/dist/esm/ssr/ssr-client.js.map +1 -1
  33. package/dist/esm/ssr/ssr-server.js +2 -1
  34. package/dist/esm/ssr/ssr-server.js.map +1 -1
  35. package/dist/esm/utils.d.ts +1 -0
  36. package/dist/esm/utils.js +5 -0
  37. package/dist/esm/utils.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/Matches.ts +38 -0
  40. package/src/index.ts +1 -0
  41. package/src/route.ts +32 -10
  42. package/src/router.ts +259 -96
  43. package/src/ssr/ssr-client.ts +49 -3
  44. package/src/ssr/ssr-server.ts +1 -0
  45. package/src/utils.ts +12 -0
package/src/router.ts CHANGED
@@ -57,6 +57,7 @@ import type {
57
57
  RouteContextOptions,
58
58
  RouteMask,
59
59
  SearchMiddleware,
60
+ SsrContextOptions,
60
61
  } from './route'
61
62
  import type {
62
63
  FullSearchSchema,
@@ -342,7 +343,12 @@ export interface RouterOptions<
342
343
  */
343
344
  isServer?: boolean
344
345
 
345
- defaultSsr?: boolean
346
+ /**
347
+ * The default `ssr` a route should use if no `ssr` is provided.
348
+ *
349
+ * @default true
350
+ */
351
+ defaultSsr?: boolean | 'data-only'
346
352
 
347
353
  search?: {
348
354
  /**
@@ -946,7 +952,6 @@ export class RouterCore<
946
952
  initRoute: (route, i) => {
947
953
  route.init({
948
954
  originalIndex: i,
949
- defaultSsr: this.options.defaultSsr,
950
955
  })
951
956
  },
952
957
  })
@@ -960,7 +965,6 @@ export class RouterCore<
960
965
  if (notFoundRoute) {
961
966
  notFoundRoute.init({
962
967
  originalIndex: 99999999999,
963
- defaultSsr: this.options.defaultSsr,
964
968
  })
965
969
  this.routesById[notFoundRoute.id] = notFoundRoute
966
970
  }
@@ -1387,7 +1391,13 @@ export class RouterCore<
1387
1391
  if (!match) return
1388
1392
 
1389
1393
  match.abortController.abort()
1390
- clearTimeout(match.pendingTimeout)
1394
+ this.updateMatch(id, (prev) => {
1395
+ clearTimeout(prev.pendingTimeout)
1396
+ return {
1397
+ ...prev,
1398
+ pendingTimeout: undefined,
1399
+ }
1400
+ })
1391
1401
  }
1392
1402
 
1393
1403
  cancelMatches = () => {
@@ -1787,7 +1797,11 @@ export class RouterCore<
1787
1797
  }
1788
1798
  }
1789
1799
  // Match the routes
1790
- const pendingMatches = this.matchRoutes(this.latestLocation)
1800
+ let pendingMatches = this.matchRoutes(this.latestLocation)
1801
+ // in SPA mode we only want to load the root route
1802
+ if (this.isShell) {
1803
+ pendingMatches = pendingMatches.slice(0, 1)
1804
+ }
1791
1805
 
1792
1806
  // Ingest the new matches
1793
1807
  this.__store.setState((s) => ({
@@ -1806,7 +1820,6 @@ export class RouterCore<
1806
1820
  load: LoadFn = async (opts?: { sync?: boolean }): Promise<void> => {
1807
1821
  let redirect: AnyRedirect | undefined
1808
1822
  let notFound: NotFoundError | undefined
1809
-
1810
1823
  let loadPromise: Promise<void>
1811
1824
 
1812
1825
  // eslint-disable-next-line prefer-const
@@ -2061,6 +2074,40 @@ export class RouterCore<
2061
2074
  const triggerOnReady = async () => {
2062
2075
  if (!rendered) {
2063
2076
  rendered = true
2077
+
2078
+ // create a minPendingPromise for matches that have forcePending set to true
2079
+ // usually the minPendingPromise is created in the Match component if a pending match is rendered
2080
+ // however, this might be too late if the match synchronously resolves
2081
+ if (!allPreload && !this.isServer) {
2082
+ matches.forEach((match) => {
2083
+ const {
2084
+ id: matchId,
2085
+ routeId,
2086
+ _forcePending,
2087
+ minPendingPromise,
2088
+ } = match
2089
+ const route = this.looseRoutesById[routeId]!
2090
+ const pendingMinMs =
2091
+ route.options.pendingMinMs ?? this.options.defaultPendingMinMs
2092
+ if (_forcePending && pendingMinMs && !minPendingPromise) {
2093
+ const minPendingPromise = createControlledPromise<void>()
2094
+ updateMatch(matchId, (prev) => ({
2095
+ ...prev,
2096
+ minPendingPromise,
2097
+ }))
2098
+
2099
+ setTimeout(() => {
2100
+ minPendingPromise.resolve()
2101
+ // We've handled the minPendingPromise, so we can delete it
2102
+ updateMatch(matchId, (prev) => ({
2103
+ ...prev,
2104
+ minPendingPromise: undefined,
2105
+ }))
2106
+ }, pendingMinMs)
2107
+ }
2108
+ })
2109
+ }
2110
+
2064
2111
  await onReady?.()
2065
2112
  }
2066
2113
  }
@@ -2069,6 +2116,12 @@ export class RouterCore<
2069
2116
  return !!(allPreload && !this.state.matches.find((d) => d.id === matchId))
2070
2117
  }
2071
2118
 
2119
+ // make sure the pending component is immediately rendered when hydrating a match that is not SSRed
2120
+ // the pending component was already rendered on the server and we want to keep it shown on the client until minPendingMs is reached
2121
+ if (!this.isServer && this.state.matches.find((d) => d._forcePending)) {
2122
+ triggerOnReady()
2123
+ }
2124
+
2072
2125
  const handleRedirectAndNotFound = (match: AnyRouteMatch, err: any) => {
2073
2126
  if (isRedirect(err) || isNotFound(err)) {
2074
2127
  if (isRedirect(err)) {
@@ -2120,6 +2173,21 @@ export class RouterCore<
2120
2173
  }
2121
2174
  }
2122
2175
 
2176
+ const shouldSkipLoader = (matchId: string) => {
2177
+ const match = this.getMatch(matchId)!
2178
+ // upon hydration, we skip the loader if the match has been dehydrated on the server
2179
+ if (!this.isServer && match._dehydrated) {
2180
+ return true
2181
+ }
2182
+
2183
+ if (this.isServer) {
2184
+ if (match.ssr === false) {
2185
+ return true
2186
+ }
2187
+ }
2188
+ return false
2189
+ }
2190
+
2123
2191
  try {
2124
2192
  await new Promise<void>((resolveAll, rejectAll) => {
2125
2193
  ;(async () => {
@@ -2169,12 +2237,73 @@ export class RouterCore<
2169
2237
  for (const [index, { id: matchId, routeId }] of matches.entries()) {
2170
2238
  const existingMatch = this.getMatch(matchId)!
2171
2239
  const parentMatchId = matches[index - 1]?.id
2240
+ const parentMatch = parentMatchId
2241
+ ? this.getMatch(parentMatchId)!
2242
+ : undefined
2172
2243
 
2173
2244
  const route = this.looseRoutesById[routeId]!
2174
2245
 
2175
2246
  const pendingMs =
2176
2247
  route.options.pendingMs ?? this.options.defaultPendingMs
2177
2248
 
2249
+ // on the server, determine whether SSR the current match or not
2250
+ if (this.isServer) {
2251
+ const defaultSsr = this.options.defaultSsr ?? true
2252
+ let ssr: boolean | 'data-only'
2253
+ if (parentMatch?.ssr === false) {
2254
+ ssr = false
2255
+ } else {
2256
+ let tempSsr: boolean | 'data-only'
2257
+ if (route.options.ssr === undefined) {
2258
+ tempSsr = defaultSsr
2259
+ } else if (typeof route.options.ssr === 'function') {
2260
+ const { search, params } = this.getMatch(matchId)!
2261
+
2262
+ function makeMaybe(value: any, error: any) {
2263
+ if (error) {
2264
+ return { status: 'error' as const, error }
2265
+ }
2266
+ return { status: 'success' as const, value }
2267
+ }
2268
+
2269
+ const ssrFnContext: SsrContextOptions<any, any, any> = {
2270
+ search: makeMaybe(search, existingMatch.searchError),
2271
+ params: makeMaybe(params, existingMatch.paramsError),
2272
+ location,
2273
+ matches: matches.map((match) => ({
2274
+ index: match.index,
2275
+ pathname: match.pathname,
2276
+ fullPath: match.fullPath,
2277
+ staticData: match.staticData,
2278
+ id: match.id,
2279
+ routeId: match.routeId,
2280
+ search: makeMaybe(match.search, match.searchError),
2281
+ params: makeMaybe(match.params, match.paramsError),
2282
+ ssr: match.ssr,
2283
+ })),
2284
+ }
2285
+ tempSsr =
2286
+ (await route.options.ssr(ssrFnContext)) ?? defaultSsr
2287
+ } else {
2288
+ tempSsr = route.options.ssr
2289
+ }
2290
+
2291
+ if (tempSsr === true && parentMatch?.ssr === 'data-only') {
2292
+ ssr = 'data-only'
2293
+ } else {
2294
+ ssr = tempSsr
2295
+ }
2296
+ }
2297
+ updateMatch(matchId, (prev) => ({
2298
+ ...prev,
2299
+ ssr,
2300
+ }))
2301
+ }
2302
+
2303
+ if (shouldSkipLoader(matchId)) {
2304
+ continue
2305
+ }
2306
+
2178
2307
  const shouldPending = !!(
2179
2308
  onReady &&
2180
2309
  !this.isServer &&
@@ -2189,21 +2318,31 @@ export class RouterCore<
2189
2318
  )
2190
2319
 
2191
2320
  let executeBeforeLoad = true
2192
- if (
2193
- // If we are in the middle of a load, either of these will be present
2194
- // (not to be confused with `loadPromise`, which is always defined)
2195
- existingMatch.beforeLoadPromise ||
2196
- existingMatch.loaderPromise
2197
- ) {
2198
- if (shouldPending) {
2199
- setTimeout(() => {
2321
+ const setupPendingTimeout = () => {
2322
+ if (
2323
+ shouldPending &&
2324
+ this.getMatch(matchId)!.pendingTimeout === undefined
2325
+ ) {
2326
+ const pendingTimeout = setTimeout(() => {
2200
2327
  try {
2201
2328
  // Update the match and prematurely resolve the loadMatches promise so that
2202
2329
  // the pending component can start rendering
2203
2330
  triggerOnReady()
2204
2331
  } catch {}
2205
2332
  }, pendingMs)
2333
+ updateMatch(matchId, (prev) => ({
2334
+ ...prev,
2335
+ pendingTimeout,
2336
+ }))
2206
2337
  }
2338
+ }
2339
+ if (
2340
+ // If we are in the middle of a load, either of these will be present
2341
+ // (not to be confused with `loadPromise`, which is always defined)
2342
+ existingMatch.beforeLoadPromise ||
2343
+ existingMatch.loaderPromise
2344
+ ) {
2345
+ setupPendingTimeout()
2207
2346
 
2208
2347
  // Wait for the beforeLoad to resolve before we continue
2209
2348
  await existingMatch.beforeLoadPromise
@@ -2231,21 +2370,6 @@ export class RouterCore<
2231
2370
  beforeLoadPromise: createControlledPromise<void>(),
2232
2371
  }
2233
2372
  })
2234
- const abortController = new AbortController()
2235
-
2236
- let pendingTimeout: ReturnType<typeof setTimeout>
2237
-
2238
- if (shouldPending) {
2239
- // If we might show a pending component, we need to wait for the
2240
- // pending promise to resolve before we start showing that state
2241
- pendingTimeout = setTimeout(() => {
2242
- try {
2243
- // Update the match and prematurely resolve the loadMatches promise so that
2244
- // the pending component can start rendering
2245
- triggerOnReady()
2246
- } catch {}
2247
- }, pendingMs)
2248
- }
2249
2373
 
2250
2374
  const { paramsError, searchError } = this.getMatch(matchId)!
2251
2375
 
@@ -2257,19 +2381,20 @@ export class RouterCore<
2257
2381
  handleSerialError(index, searchError, 'VALIDATE_SEARCH')
2258
2382
  }
2259
2383
 
2260
- const getParentMatchContext = () =>
2261
- parentMatchId
2262
- ? this.getMatch(parentMatchId)!.context
2263
- : (this.options.context ?? {})
2384
+ setupPendingTimeout()
2385
+
2386
+ const abortController = new AbortController()
2387
+
2388
+ const parentMatchContext =
2389
+ parentMatch?.context ?? this.options.context ?? {}
2264
2390
 
2265
2391
  updateMatch(matchId, (prev) => ({
2266
2392
  ...prev,
2267
2393
  isFetching: 'beforeLoad',
2268
2394
  fetchCount: prev.fetchCount + 1,
2269
2395
  abortController,
2270
- pendingTimeout,
2271
2396
  context: {
2272
- ...getParentMatchContext(),
2397
+ ...parentMatchContext,
2273
2398
  ...prev.__routeContext,
2274
2399
  },
2275
2400
  }))
@@ -2315,7 +2440,7 @@ export class RouterCore<
2315
2440
  ...prev,
2316
2441
  __beforeLoadContext: beforeLoadContext,
2317
2442
  context: {
2318
- ...getParentMatchContext(),
2443
+ ...parentMatchContext,
2319
2444
  ...prev.__routeContext,
2320
2445
  ...beforeLoadContext,
2321
2446
  },
@@ -2346,10 +2471,65 @@ export class RouterCore<
2346
2471
  (async () => {
2347
2472
  let loaderShouldRunAsync = false
2348
2473
  let loaderIsRunningAsync = false
2474
+ const route = this.looseRoutesById[routeId]!
2475
+
2476
+ const executeHead = async () => {
2477
+ const match = this.getMatch(matchId)
2478
+ // in case of a redirecting match during preload, the match does not exist
2479
+ if (!match) {
2480
+ return
2481
+ }
2482
+ const assetContext = {
2483
+ matches,
2484
+ match,
2485
+ params: match.params,
2486
+ loaderData: match.loaderData,
2487
+ }
2488
+ const headFnContent =
2489
+ await route.options.head?.(assetContext)
2490
+ const meta = headFnContent?.meta
2491
+ const links = headFnContent?.links
2492
+ const headScripts = headFnContent?.scripts
2493
+ const styles = headFnContent?.styles
2494
+
2495
+ const scripts = await route.options.scripts?.(assetContext)
2496
+ const headers = await route.options.headers?.(assetContext)
2497
+ return {
2498
+ meta,
2499
+ links,
2500
+ headScripts,
2501
+ headers,
2502
+ scripts,
2503
+ styles,
2504
+ }
2505
+ }
2506
+
2507
+ const potentialPendingMinPromise = async () => {
2508
+ const latestMatch = this.getMatch(matchId)!
2509
+ if (latestMatch.minPendingPromise) {
2510
+ await latestMatch.minPendingPromise
2511
+ }
2512
+ }
2349
2513
 
2350
2514
  const prevMatch = this.getMatch(matchId)!
2515
+ if (shouldSkipLoader(matchId)) {
2516
+ if (this.isServer) {
2517
+ const head = await executeHead()
2518
+ updateMatch(matchId, (prev) => ({
2519
+ ...prev,
2520
+ ...head,
2521
+ }))
2522
+ this.serverSsr?.onMatchSettled({
2523
+ router: this,
2524
+ match: this.getMatch(matchId)!,
2525
+ })
2526
+ return this.getMatch(matchId)!
2527
+ } else {
2528
+ await potentialPendingMinPromise()
2529
+ }
2530
+ }
2351
2531
  // there is a loaderPromise, so we are in the middle of a load
2352
- if (prevMatch.loaderPromise) {
2532
+ else if (prevMatch.loaderPromise) {
2353
2533
  // do not block if we already have stale data we can show
2354
2534
  // but only if the ongoing load is not a preload since error handling is different for preloads
2355
2535
  // and we don't want to swallow errors
@@ -2367,7 +2547,6 @@ export class RouterCore<
2367
2547
  }
2368
2548
  } else {
2369
2549
  const parentMatchPromise = matchPromises[index - 1] as any
2370
- const route = this.looseRoutesById[routeId]!
2371
2550
 
2372
2551
  const getLoaderContext = (): LoaderFnContext => {
2373
2552
  const {
@@ -2426,39 +2605,6 @@ export class RouterCore<
2426
2605
  !this.state.matches.find((d) => d.id === matchId),
2427
2606
  }))
2428
2607
 
2429
- const executeHead = async () => {
2430
- const match = this.getMatch(matchId)
2431
- // in case of a redirecting match during preload, the match does not exist
2432
- if (!match) {
2433
- return
2434
- }
2435
- const assetContext = {
2436
- matches,
2437
- match,
2438
- params: match.params,
2439
- loaderData: match.loaderData,
2440
- }
2441
- const headFnContent =
2442
- await route.options.head?.(assetContext)
2443
- const meta = headFnContent?.meta
2444
- const links = headFnContent?.links
2445
- const headScripts = headFnContent?.scripts
2446
- const styles = headFnContent?.styles
2447
-
2448
- const scripts =
2449
- await route.options.scripts?.(assetContext)
2450
- const headers =
2451
- await route.options.headers?.(assetContext)
2452
- return {
2453
- meta,
2454
- links,
2455
- headScripts,
2456
- headers,
2457
- scripts,
2458
- styles,
2459
- }
2460
- }
2461
-
2462
2608
  const runLoader = async () => {
2463
2609
  try {
2464
2610
  // If the Matches component rendered
@@ -2466,17 +2612,16 @@ export class RouterCore<
2466
2612
  // a minimum duration, we''ll wait for it to resolve
2467
2613
  // before committing to the match and resolving
2468
2614
  // the loadPromise
2469
- const potentialPendingMinPromise = async () => {
2470
- const latestMatch = this.getMatch(matchId)!
2471
-
2472
- if (latestMatch.minPendingPromise) {
2473
- await latestMatch.minPendingPromise
2474
- }
2475
- }
2476
2615
 
2477
2616
  // Actually run the loader and handle the result
2478
2617
  try {
2479
- this.loadRouteChunk(route)
2618
+ if (
2619
+ !this.isServer ||
2620
+ (this.isServer &&
2621
+ this.getMatch(matchId)!.ssr === true)
2622
+ ) {
2623
+ this.loadRouteChunk(route)
2624
+ }
2480
2625
 
2481
2626
  updateMatch(matchId, (prev) => ({
2482
2627
  ...prev,
@@ -2491,29 +2636,27 @@ export class RouterCore<
2491
2636
  this.getMatch(matchId)!,
2492
2637
  loaderData,
2493
2638
  )
2639
+ updateMatch(matchId, (prev) => ({
2640
+ ...prev,
2641
+ loaderData,
2642
+ }))
2494
2643
 
2495
2644
  // Lazy option can modify the route options,
2496
2645
  // so we need to wait for it to resolve before
2497
2646
  // we can use the options
2498
2647
  await route._lazyPromise
2499
-
2648
+ const head = await executeHead()
2500
2649
  await potentialPendingMinPromise()
2501
2650
 
2502
2651
  // Last but not least, wait for the the components
2503
2652
  // to be preloaded before we resolve the match
2504
2653
  await route._componentsPromise
2505
-
2506
2654
  updateMatch(matchId, (prev) => ({
2507
2655
  ...prev,
2508
2656
  error: undefined,
2509
2657
  status: 'success',
2510
2658
  isFetching: false,
2511
2659
  updatedAt: Date.now(),
2512
- loaderData,
2513
- }))
2514
- const head = await executeHead()
2515
- updateMatch(matchId, (prev) => ({
2516
- ...prev,
2517
2660
  ...head,
2518
2661
  }))
2519
2662
  } catch (e) {
@@ -2559,13 +2702,18 @@ export class RouterCore<
2559
2702
  }
2560
2703
 
2561
2704
  // If the route is successful and still fresh, just resolve
2562
- const { status, invalid } = this.getMatch(matchId)!
2705
+ const { status, invalid, _forcePending } =
2706
+ this.getMatch(matchId)!
2563
2707
  loaderShouldRunAsync =
2564
2708
  status === 'success' &&
2565
2709
  (invalid || (shouldReload ?? age > staleAge))
2566
2710
  if (preload && route.options.preload === false) {
2567
2711
  // Do nothing
2568
- } else if (loaderShouldRunAsync && !sync) {
2712
+ } else if (
2713
+ loaderShouldRunAsync &&
2714
+ !sync &&
2715
+ !_forcePending
2716
+ ) {
2569
2717
  loaderIsRunningAsync = true
2570
2718
  ;(async () => {
2571
2719
  try {
@@ -2590,6 +2738,9 @@ export class RouterCore<
2590
2738
  ) {
2591
2739
  await runLoader()
2592
2740
  } else {
2741
+ if (_forcePending) {
2742
+ await potentialPendingMinPromise()
2743
+ }
2593
2744
  // if the loader did not run, still update head.
2594
2745
  // reason: parent's beforeLoad may have changed the route context
2595
2746
  // and only now do we know the route context (and that the loader would not run)
@@ -2598,6 +2749,10 @@ export class RouterCore<
2598
2749
  ...prev,
2599
2750
  ...head,
2600
2751
  }))
2752
+ this.serverSsr?.onMatchSettled({
2753
+ router: this,
2754
+ match: this.getMatch(matchId)!,
2755
+ })
2601
2756
  }
2602
2757
  }
2603
2758
  if (!loaderIsRunningAsync) {
@@ -2607,14 +2762,22 @@ export class RouterCore<
2607
2762
  loadPromise?.resolve()
2608
2763
  }
2609
2764
 
2610
- updateMatch(matchId, (prev) => ({
2611
- ...prev,
2612
- isFetching: loaderIsRunningAsync ? prev.isFetching : false,
2613
- loaderPromise: loaderIsRunningAsync
2614
- ? prev.loaderPromise
2615
- : undefined,
2616
- invalid: false,
2617
- }))
2765
+ updateMatch(matchId, (prev) => {
2766
+ clearTimeout(prev.pendingTimeout)
2767
+ return {
2768
+ ...prev,
2769
+ isFetching: loaderIsRunningAsync
2770
+ ? prev.isFetching
2771
+ : false,
2772
+ loaderPromise: loaderIsRunningAsync
2773
+ ? prev.loaderPromise
2774
+ : undefined,
2775
+ invalid: false,
2776
+ pendingTimeout: undefined,
2777
+ _dehydrated: undefined,
2778
+ _forcePending: undefined,
2779
+ }
2780
+ })
2618
2781
  return this.getMatch(matchId)!
2619
2782
  })(),
2620
2783
  )
@@ -42,6 +42,7 @@ export interface SsrMatch {
42
42
  extracted?: Array<ClientExtractedEntry>
43
43
  updatedAt: MakeRouteMatch['updatedAt']
44
44
  status: MakeRouteMatch['status']
45
+ ssr?: boolean | 'data-only'
45
46
  }
46
47
 
47
48
  export type ClientExtractedEntry =
@@ -123,17 +124,36 @@ export async function hydrate(router: AnyRouter): Promise<any> {
123
124
 
124
125
  // Right after hydration and before the first render, we need to rehydrate each match
125
126
  // First step is to reyhdrate loaderData and __beforeLoadContext
127
+ let firstNonSsrMatchIndex: number | undefined = undefined
126
128
  matches.forEach((match) => {
127
129
  const dehydratedMatch = window.__TSR_SSR__!.matches.find(
128
130
  (d) => d.id === match.id,
129
131
  )
130
132
 
131
133
  if (!dehydratedMatch) {
134
+ Object.assign(match, { dehydrated: false, ssr: false })
132
135
  return
133
136
  }
134
137
 
135
138
  Object.assign(match, dehydratedMatch)
136
139
 
140
+ if (match.ssr === false) {
141
+ match._dehydrated = false
142
+ } else {
143
+ match._dehydrated = true
144
+ }
145
+
146
+ if (match.ssr === 'data-only' || match.ssr === false) {
147
+ if (firstNonSsrMatchIndex === undefined) {
148
+ firstNonSsrMatchIndex = match.index
149
+ match._forcePending = true
150
+ }
151
+ }
152
+
153
+ if (match.ssr === false) {
154
+ return
155
+ }
156
+
137
157
  // Handle beforeLoadContext
138
158
  if (dehydratedMatch.__beforeLoadContext) {
139
159
  match.__beforeLoadContext = router.ssr!.serializer.parse(
@@ -157,8 +177,6 @@ export async function hydrate(router: AnyRouter): Promise<any> {
157
177
  ;(match as unknown as SsrMatch).extracted?.forEach((ex) => {
158
178
  deepMutableSetByPath(match, ['loaderData', ...ex.path], ex.value)
159
179
  })
160
-
161
- return match
162
180
  })
163
181
 
164
182
  router.__store.setState((s) => {
@@ -222,8 +240,36 @@ export async function hydrate(router: AnyRouter): Promise<any> {
222
240
  }),
223
241
  )
224
242
 
243
+ // schedule router.load() to run after the next tick so we can store the promise in the match before loading starts
244
+ const loadPromise = Promise.resolve()
245
+ .then(() => router.load())
246
+ .catch((err) => {
247
+ console.error('Error during router hydration:', err)
248
+ })
249
+
250
+ // in SPA mode we need to keep the outermost match pending until router.load() is finished
251
+ // this will prevent that other pending components are rendered but hydration is not blocked
225
252
  if (matches[matches.length - 1]!.id !== lastMatchId) {
226
- return await Promise.all([routeChunkPromise, router.load()])
253
+ const matchId = matches[0]!.id
254
+ router.updateMatch(matchId, (prev) => {
255
+ return {
256
+ ...prev,
257
+ _displayPending: true,
258
+ displayPendingPromise: loadPromise,
259
+ // make sure that the pending component is displayed for at least pendingMinMs
260
+ _forcePending: true,
261
+ }
262
+ })
263
+ // hide the pending component once the load is finished
264
+ loadPromise.then(() => {
265
+ router.updateMatch(matchId, (prev) => {
266
+ return {
267
+ ...prev,
268
+ _displayPending: undefined,
269
+ displayPendingPromise: undefined,
270
+ }
271
+ })
272
+ })
227
273
  }
228
274
 
229
275
  return routeChunkPromise
@@ -190,6 +190,7 @@ export function onMatchSettled(opts: {
190
190
  extracted: extracted?.map((entry) => pick(entry, ['type', 'path'])),
191
191
  updatedAt: match.updatedAt,
192
192
  status: match.status,
193
+ ssr: match.ssr,
193
194
  } satisfies SsrMatch,
194
195
  {
195
196
  isScriptContext: true,
package/src/utils.ts CHANGED
@@ -461,3 +461,15 @@ export function shallow<T>(objA: T, objB: T) {
461
461
  }
462
462
  return true
463
463
  }
464
+
465
+ export function isModuleNotFoundError(error: any): boolean {
466
+ // chrome: "Failed to fetch dynamically imported module: http://localhost:5173/src/routes/posts.index.tsx?tsr-split"
467
+ // firefox: "error loading dynamically imported module: http://localhost:5173/src/routes/posts.index.tsx?tsr-split"
468
+ // safari: "Importing a module script failed."
469
+ if (typeof error?.message !== 'string') return false
470
+ return (
471
+ error.message.startsWith('Failed to fetch dynamically imported module') ||
472
+ error.message.startsWith('error loading dynamically imported module') ||
473
+ error.message.startsWith('Importing a module script failed')
474
+ )
475
+ }