@tanstack/router-core 1.124.0 → 1.125.0

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 +178 -56
  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 +178 -56
  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 +225 -66
  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
  }
@@ -1415,11 +1419,15 @@ export class RouterCore<
1415
1419
  // By default, start with the current location
1416
1420
  let fromPath = lastMatch.fullPath
1417
1421
 
1418
- // If there is a to, it means we are changing the path in some way
1419
- // So we need to find the relative fromPath
1422
+ const routeIsChanging =
1423
+ !!dest.to &&
1424
+ dest.to !== fromPath &&
1425
+ this.resolvePathWithBase(fromPath, `${dest.to}`) !== fromPath
1426
+
1427
+ // If the route is changing we need to find the relative fromPath
1420
1428
  if (dest.unsafeRelative === 'path') {
1421
1429
  fromPath = currentLocation.pathname
1422
- } else if (dest.to && dest.from) {
1430
+ } else if (routeIsChanging && dest.from) {
1423
1431
  fromPath = dest.from
1424
1432
  const existingFrom = [...allFromMatches].reverse().find((d) => {
1425
1433
  return (
@@ -1708,6 +1716,7 @@ export class RouterCore<
1708
1716
  }: BuildNextOptions & CommitLocationOptions = {}) => {
1709
1717
  if (href) {
1710
1718
  const currentIndex = this.history.location.state.__TSR_index
1719
+
1711
1720
  const parsed = parseHref(href, {
1712
1721
  __TSR_index: replace ? currentIndex : currentIndex + 1,
1713
1722
  })
@@ -1721,6 +1730,7 @@ export class RouterCore<
1721
1730
  ...(rest as any),
1722
1731
  _includeValidateSearch: true,
1723
1732
  })
1733
+
1724
1734
  return this.commitLocation({
1725
1735
  ...location,
1726
1736
  viewTransition,
@@ -1781,7 +1791,11 @@ export class RouterCore<
1781
1791
  }
1782
1792
  }
1783
1793
  // Match the routes
1784
- const pendingMatches = this.matchRoutes(this.latestLocation)
1794
+ let pendingMatches = this.matchRoutes(this.latestLocation)
1795
+ // in SPA mode we only want to load the root route
1796
+ if (this.isShell) {
1797
+ pendingMatches = pendingMatches.slice(0, 1)
1798
+ }
1785
1799
 
1786
1800
  // Ingest the new matches
1787
1801
  this.__store.setState((s) => ({
@@ -1800,7 +1814,6 @@ export class RouterCore<
1800
1814
  load: LoadFn = async (opts?: { sync?: boolean }): Promise<void> => {
1801
1815
  let redirect: AnyRedirect | undefined
1802
1816
  let notFound: NotFoundError | undefined
1803
-
1804
1817
  let loadPromise: Promise<void>
1805
1818
 
1806
1819
  // eslint-disable-next-line prefer-const
@@ -2055,6 +2068,40 @@ export class RouterCore<
2055
2068
  const triggerOnReady = async () => {
2056
2069
  if (!rendered) {
2057
2070
  rendered = true
2071
+
2072
+ // create a minPendingPromise for matches that have forcePending set to true
2073
+ // usually the minPendingPromise is created in the Match component if a pending match is rendered
2074
+ // however, this might be too late if the match synchronously resolves
2075
+ if (!allPreload && !this.isServer) {
2076
+ matches.forEach((match) => {
2077
+ const {
2078
+ id: matchId,
2079
+ routeId,
2080
+ _forcePending,
2081
+ minPendingPromise,
2082
+ } = match
2083
+ const route = this.looseRoutesById[routeId]!
2084
+ const pendingMinMs =
2085
+ route.options.pendingMinMs ?? this.options.defaultPendingMinMs
2086
+ if (_forcePending && pendingMinMs && !minPendingPromise) {
2087
+ const minPendingPromise = createControlledPromise<void>()
2088
+ updateMatch(matchId, (prev) => ({
2089
+ ...prev,
2090
+ minPendingPromise,
2091
+ }))
2092
+
2093
+ setTimeout(() => {
2094
+ minPendingPromise.resolve()
2095
+ // We've handled the minPendingPromise, so we can delete it
2096
+ updateMatch(matchId, (prev) => ({
2097
+ ...prev,
2098
+ minPendingPromise: undefined,
2099
+ }))
2100
+ }, pendingMinMs)
2101
+ }
2102
+ })
2103
+ }
2104
+
2058
2105
  await onReady?.()
2059
2106
  }
2060
2107
  }
@@ -2063,6 +2110,12 @@ export class RouterCore<
2063
2110
  return !!(allPreload && !this.state.matches.find((d) => d.id === matchId))
2064
2111
  }
2065
2112
 
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.find((d) => d._forcePending)) {
2116
+ triggerOnReady()
2117
+ }
2118
+
2066
2119
  const handleRedirectAndNotFound = (match: AnyRouteMatch, err: any) => {
2067
2120
  if (isRedirect(err) || isNotFound(err)) {
2068
2121
  if (isRedirect(err)) {
@@ -2114,6 +2167,21 @@ export class RouterCore<
2114
2167
  }
2115
2168
  }
2116
2169
 
2170
+ const shouldSkipLoader = (matchId: string) => {
2171
+ const match = this.getMatch(matchId)!
2172
+ // upon hydration, we skip the loader if the match has been dehydrated on the server
2173
+ if (!this.isServer && match._dehydrated) {
2174
+ return true
2175
+ }
2176
+
2177
+ if (this.isServer) {
2178
+ if (match.ssr === false) {
2179
+ return true
2180
+ }
2181
+ }
2182
+ return false
2183
+ }
2184
+
2117
2185
  try {
2118
2186
  await new Promise<void>((resolveAll, rejectAll) => {
2119
2187
  ;(async () => {
@@ -2163,12 +2231,73 @@ export class RouterCore<
2163
2231
  for (const [index, { id: matchId, routeId }] of matches.entries()) {
2164
2232
  const existingMatch = this.getMatch(matchId)!
2165
2233
  const parentMatchId = matches[index - 1]?.id
2234
+ const parentMatch = parentMatchId
2235
+ ? this.getMatch(parentMatchId)!
2236
+ : undefined
2166
2237
 
2167
2238
  const route = this.looseRoutesById[routeId]!
2168
2239
 
2169
2240
  const pendingMs =
2170
2241
  route.options.pendingMs ?? this.options.defaultPendingMs
2171
2242
 
2243
+ // on the server, determine whether SSR the current match or not
2244
+ if (this.isServer) {
2245
+ const defaultSsr = this.options.defaultSsr ?? true
2246
+ let ssr: boolean | 'data-only'
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
+ }
2284
+
2285
+ if (tempSsr === true && parentMatch?.ssr === 'data-only') {
2286
+ ssr = 'data-only'
2287
+ } else {
2288
+ ssr = tempSsr
2289
+ }
2290
+ }
2291
+ updateMatch(matchId, (prev) => ({
2292
+ ...prev,
2293
+ ssr,
2294
+ }))
2295
+ }
2296
+
2297
+ if (shouldSkipLoader(matchId)) {
2298
+ continue
2299
+ }
2300
+
2172
2301
  const shouldPending = !!(
2173
2302
  onReady &&
2174
2303
  !this.isServer &&
@@ -2251,10 +2380,8 @@ export class RouterCore<
2251
2380
  handleSerialError(index, searchError, 'VALIDATE_SEARCH')
2252
2381
  }
2253
2382
 
2254
- const getParentMatchContext = () =>
2255
- parentMatchId
2256
- ? this.getMatch(parentMatchId)!.context
2257
- : (this.options.context ?? {})
2383
+ const parentMatchContext =
2384
+ parentMatch?.context ?? this.options.context ?? {}
2258
2385
 
2259
2386
  updateMatch(matchId, (prev) => ({
2260
2387
  ...prev,
@@ -2263,7 +2390,7 @@ export class RouterCore<
2263
2390
  abortController,
2264
2391
  pendingTimeout,
2265
2392
  context: {
2266
- ...getParentMatchContext(),
2393
+ ...parentMatchContext,
2267
2394
  ...prev.__routeContext,
2268
2395
  },
2269
2396
  }))
@@ -2309,7 +2436,7 @@ export class RouterCore<
2309
2436
  ...prev,
2310
2437
  __beforeLoadContext: beforeLoadContext,
2311
2438
  context: {
2312
- ...getParentMatchContext(),
2439
+ ...parentMatchContext,
2313
2440
  ...prev.__routeContext,
2314
2441
  ...beforeLoadContext,
2315
2442
  },
@@ -2340,10 +2467,65 @@ export class RouterCore<
2340
2467
  (async () => {
2341
2468
  let loaderShouldRunAsync = false
2342
2469
  let loaderIsRunningAsync = false
2470
+ const route = this.looseRoutesById[routeId]!
2471
+
2472
+ const executeHead = async () => {
2473
+ const match = this.getMatch(matchId)
2474
+ // in case of a redirecting match during preload, the match does not exist
2475
+ if (!match) {
2476
+ return
2477
+ }
2478
+ const assetContext = {
2479
+ matches,
2480
+ match,
2481
+ params: match.params,
2482
+ loaderData: match.loaderData,
2483
+ }
2484
+ const headFnContent =
2485
+ await route.options.head?.(assetContext)
2486
+ const meta = headFnContent?.meta
2487
+ const links = headFnContent?.links
2488
+ const headScripts = headFnContent?.scripts
2489
+ const styles = headFnContent?.styles
2490
+
2491
+ const scripts = await route.options.scripts?.(assetContext)
2492
+ const headers = await route.options.headers?.(assetContext)
2493
+ return {
2494
+ meta,
2495
+ links,
2496
+ headScripts,
2497
+ headers,
2498
+ scripts,
2499
+ styles,
2500
+ }
2501
+ }
2502
+
2503
+ const potentialPendingMinPromise = async () => {
2504
+ const latestMatch = this.getMatch(matchId)!
2505
+ if (latestMatch.minPendingPromise) {
2506
+ await latestMatch.minPendingPromise
2507
+ }
2508
+ }
2343
2509
 
2344
2510
  const prevMatch = this.getMatch(matchId)!
2511
+ if (shouldSkipLoader(matchId)) {
2512
+ if (this.isServer) {
2513
+ const head = await executeHead()
2514
+ updateMatch(matchId, (prev) => ({
2515
+ ...prev,
2516
+ ...head,
2517
+ }))
2518
+ this.serverSsr?.onMatchSettled({
2519
+ router: this,
2520
+ match: this.getMatch(matchId)!,
2521
+ })
2522
+ return this.getMatch(matchId)!
2523
+ } else {
2524
+ await potentialPendingMinPromise()
2525
+ }
2526
+ }
2345
2527
  // there is a loaderPromise, so we are in the middle of a load
2346
- if (prevMatch.loaderPromise) {
2528
+ else if (prevMatch.loaderPromise) {
2347
2529
  // do not block if we already have stale data we can show
2348
2530
  // but only if the ongoing load is not a preload since error handling is different for preloads
2349
2531
  // and we don't want to swallow errors
@@ -2361,7 +2543,6 @@ export class RouterCore<
2361
2543
  }
2362
2544
  } else {
2363
2545
  const parentMatchPromise = matchPromises[index - 1] as any
2364
- const route = this.looseRoutesById[routeId]!
2365
2546
 
2366
2547
  const getLoaderContext = (): LoaderFnContext => {
2367
2548
  const {
@@ -2420,39 +2601,6 @@ export class RouterCore<
2420
2601
  !this.state.matches.find((d) => d.id === matchId),
2421
2602
  }))
2422
2603
 
2423
- const executeHead = async () => {
2424
- const match = this.getMatch(matchId)
2425
- // in case of a redirecting match during preload, the match does not exist
2426
- if (!match) {
2427
- return
2428
- }
2429
- const assetContext = {
2430
- matches,
2431
- match,
2432
- params: match.params,
2433
- loaderData: match.loaderData,
2434
- }
2435
- const headFnContent =
2436
- await route.options.head?.(assetContext)
2437
- const meta = headFnContent?.meta
2438
- const links = headFnContent?.links
2439
- const headScripts = headFnContent?.scripts
2440
- const styles = headFnContent?.styles
2441
-
2442
- const scripts =
2443
- await route.options.scripts?.(assetContext)
2444
- const headers =
2445
- await route.options.headers?.(assetContext)
2446
- return {
2447
- meta,
2448
- links,
2449
- headScripts,
2450
- headers,
2451
- scripts,
2452
- styles,
2453
- }
2454
- }
2455
-
2456
2604
  const runLoader = async () => {
2457
2605
  try {
2458
2606
  // If the Matches component rendered
@@ -2460,17 +2608,16 @@ export class RouterCore<
2460
2608
  // a minimum duration, we''ll wait for it to resolve
2461
2609
  // before committing to the match and resolving
2462
2610
  // the loadPromise
2463
- const potentialPendingMinPromise = async () => {
2464
- const latestMatch = this.getMatch(matchId)!
2465
-
2466
- if (latestMatch.minPendingPromise) {
2467
- await latestMatch.minPendingPromise
2468
- }
2469
- }
2470
2611
 
2471
2612
  // Actually run the loader and handle the result
2472
2613
  try {
2473
- this.loadRouteChunk(route)
2614
+ if (
2615
+ !this.isServer ||
2616
+ (this.isServer &&
2617
+ this.getMatch(matchId)!.ssr === true)
2618
+ ) {
2619
+ this.loadRouteChunk(route)
2620
+ }
2474
2621
 
2475
2622
  updateMatch(matchId, (prev) => ({
2476
2623
  ...prev,
@@ -2485,29 +2632,27 @@ export class RouterCore<
2485
2632
  this.getMatch(matchId)!,
2486
2633
  loaderData,
2487
2634
  )
2635
+ updateMatch(matchId, (prev) => ({
2636
+ ...prev,
2637
+ loaderData,
2638
+ }))
2488
2639
 
2489
2640
  // Lazy option can modify the route options,
2490
2641
  // so we need to wait for it to resolve before
2491
2642
  // we can use the options
2492
2643
  await route._lazyPromise
2493
-
2644
+ const head = await executeHead()
2494
2645
  await potentialPendingMinPromise()
2495
2646
 
2496
2647
  // Last but not least, wait for the the components
2497
2648
  // to be preloaded before we resolve the match
2498
2649
  await route._componentsPromise
2499
-
2500
2650
  updateMatch(matchId, (prev) => ({
2501
2651
  ...prev,
2502
2652
  error: undefined,
2503
2653
  status: 'success',
2504
2654
  isFetching: false,
2505
2655
  updatedAt: Date.now(),
2506
- loaderData,
2507
- }))
2508
- const head = await executeHead()
2509
- updateMatch(matchId, (prev) => ({
2510
- ...prev,
2511
2656
  ...head,
2512
2657
  }))
2513
2658
  } catch (e) {
@@ -2553,13 +2698,18 @@ export class RouterCore<
2553
2698
  }
2554
2699
 
2555
2700
  // If the route is successful and still fresh, just resolve
2556
- const { status, invalid } = this.getMatch(matchId)!
2701
+ const { status, invalid, _forcePending } =
2702
+ this.getMatch(matchId)!
2557
2703
  loaderShouldRunAsync =
2558
2704
  status === 'success' &&
2559
2705
  (invalid || (shouldReload ?? age > staleAge))
2560
2706
  if (preload && route.options.preload === false) {
2561
2707
  // Do nothing
2562
- } else if (loaderShouldRunAsync && !sync) {
2708
+ } else if (
2709
+ loaderShouldRunAsync &&
2710
+ !sync &&
2711
+ !_forcePending
2712
+ ) {
2563
2713
  loaderIsRunningAsync = true
2564
2714
  ;(async () => {
2565
2715
  try {
@@ -2584,6 +2734,9 @@ export class RouterCore<
2584
2734
  ) {
2585
2735
  await runLoader()
2586
2736
  } else {
2737
+ if (_forcePending) {
2738
+ await potentialPendingMinPromise()
2739
+ }
2587
2740
  // if the loader did not run, still update head.
2588
2741
  // reason: parent's beforeLoad may have changed the route context
2589
2742
  // and only now do we know the route context (and that the loader would not run)
@@ -2592,6 +2745,10 @@ export class RouterCore<
2592
2745
  ...prev,
2593
2746
  ...head,
2594
2747
  }))
2748
+ this.serverSsr?.onMatchSettled({
2749
+ router: this,
2750
+ match: this.getMatch(matchId)!,
2751
+ })
2595
2752
  }
2596
2753
  }
2597
2754
  if (!loaderIsRunningAsync) {
@@ -2608,6 +2765,8 @@ export class RouterCore<
2608
2765
  ? prev.loaderPromise
2609
2766
  : undefined,
2610
2767
  invalid: false,
2768
+ _dehydrated: undefined,
2769
+ _forcePending: undefined,
2611
2770
  }))
2612
2771
  return this.getMatch(matchId)!
2613
2772
  })(),
@@ -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
+ }