@tanstack/router-core 1.124.2 → 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 +176 -55
  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 +176 -55
  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 +216 -63
  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
  }
@@ -1787,7 +1791,11 @@ export class RouterCore<
1787
1791
  }
1788
1792
  }
1789
1793
  // Match the routes
1790
- 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
+ }
1791
1799
 
1792
1800
  // Ingest the new matches
1793
1801
  this.__store.setState((s) => ({
@@ -1806,7 +1814,6 @@ export class RouterCore<
1806
1814
  load: LoadFn = async (opts?: { sync?: boolean }): Promise<void> => {
1807
1815
  let redirect: AnyRedirect | undefined
1808
1816
  let notFound: NotFoundError | undefined
1809
-
1810
1817
  let loadPromise: Promise<void>
1811
1818
 
1812
1819
  // eslint-disable-next-line prefer-const
@@ -2061,6 +2068,40 @@ export class RouterCore<
2061
2068
  const triggerOnReady = async () => {
2062
2069
  if (!rendered) {
2063
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
+
2064
2105
  await onReady?.()
2065
2106
  }
2066
2107
  }
@@ -2069,6 +2110,12 @@ export class RouterCore<
2069
2110
  return !!(allPreload && !this.state.matches.find((d) => d.id === matchId))
2070
2111
  }
2071
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
+
2072
2119
  const handleRedirectAndNotFound = (match: AnyRouteMatch, err: any) => {
2073
2120
  if (isRedirect(err) || isNotFound(err)) {
2074
2121
  if (isRedirect(err)) {
@@ -2120,6 +2167,21 @@ export class RouterCore<
2120
2167
  }
2121
2168
  }
2122
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
+
2123
2185
  try {
2124
2186
  await new Promise<void>((resolveAll, rejectAll) => {
2125
2187
  ;(async () => {
@@ -2169,12 +2231,73 @@ export class RouterCore<
2169
2231
  for (const [index, { id: matchId, routeId }] of matches.entries()) {
2170
2232
  const existingMatch = this.getMatch(matchId)!
2171
2233
  const parentMatchId = matches[index - 1]?.id
2234
+ const parentMatch = parentMatchId
2235
+ ? this.getMatch(parentMatchId)!
2236
+ : undefined
2172
2237
 
2173
2238
  const route = this.looseRoutesById[routeId]!
2174
2239
 
2175
2240
  const pendingMs =
2176
2241
  route.options.pendingMs ?? this.options.defaultPendingMs
2177
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
+
2178
2301
  const shouldPending = !!(
2179
2302
  onReady &&
2180
2303
  !this.isServer &&
@@ -2257,10 +2380,8 @@ export class RouterCore<
2257
2380
  handleSerialError(index, searchError, 'VALIDATE_SEARCH')
2258
2381
  }
2259
2382
 
2260
- const getParentMatchContext = () =>
2261
- parentMatchId
2262
- ? this.getMatch(parentMatchId)!.context
2263
- : (this.options.context ?? {})
2383
+ const parentMatchContext =
2384
+ parentMatch?.context ?? this.options.context ?? {}
2264
2385
 
2265
2386
  updateMatch(matchId, (prev) => ({
2266
2387
  ...prev,
@@ -2269,7 +2390,7 @@ export class RouterCore<
2269
2390
  abortController,
2270
2391
  pendingTimeout,
2271
2392
  context: {
2272
- ...getParentMatchContext(),
2393
+ ...parentMatchContext,
2273
2394
  ...prev.__routeContext,
2274
2395
  },
2275
2396
  }))
@@ -2315,7 +2436,7 @@ export class RouterCore<
2315
2436
  ...prev,
2316
2437
  __beforeLoadContext: beforeLoadContext,
2317
2438
  context: {
2318
- ...getParentMatchContext(),
2439
+ ...parentMatchContext,
2319
2440
  ...prev.__routeContext,
2320
2441
  ...beforeLoadContext,
2321
2442
  },
@@ -2346,10 +2467,65 @@ export class RouterCore<
2346
2467
  (async () => {
2347
2468
  let loaderShouldRunAsync = false
2348
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
+ }
2349
2509
 
2350
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
+ }
2351
2527
  // there is a loaderPromise, so we are in the middle of a load
2352
- if (prevMatch.loaderPromise) {
2528
+ else if (prevMatch.loaderPromise) {
2353
2529
  // do not block if we already have stale data we can show
2354
2530
  // but only if the ongoing load is not a preload since error handling is different for preloads
2355
2531
  // and we don't want to swallow errors
@@ -2367,7 +2543,6 @@ export class RouterCore<
2367
2543
  }
2368
2544
  } else {
2369
2545
  const parentMatchPromise = matchPromises[index - 1] as any
2370
- const route = this.looseRoutesById[routeId]!
2371
2546
 
2372
2547
  const getLoaderContext = (): LoaderFnContext => {
2373
2548
  const {
@@ -2426,39 +2601,6 @@ export class RouterCore<
2426
2601
  !this.state.matches.find((d) => d.id === matchId),
2427
2602
  }))
2428
2603
 
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
2604
  const runLoader = async () => {
2463
2605
  try {
2464
2606
  // If the Matches component rendered
@@ -2466,17 +2608,16 @@ export class RouterCore<
2466
2608
  // a minimum duration, we''ll wait for it to resolve
2467
2609
  // before committing to the match and resolving
2468
2610
  // the loadPromise
2469
- const potentialPendingMinPromise = async () => {
2470
- const latestMatch = this.getMatch(matchId)!
2471
-
2472
- if (latestMatch.minPendingPromise) {
2473
- await latestMatch.minPendingPromise
2474
- }
2475
- }
2476
2611
 
2477
2612
  // Actually run the loader and handle the result
2478
2613
  try {
2479
- this.loadRouteChunk(route)
2614
+ if (
2615
+ !this.isServer ||
2616
+ (this.isServer &&
2617
+ this.getMatch(matchId)!.ssr === true)
2618
+ ) {
2619
+ this.loadRouteChunk(route)
2620
+ }
2480
2621
 
2481
2622
  updateMatch(matchId, (prev) => ({
2482
2623
  ...prev,
@@ -2491,29 +2632,27 @@ export class RouterCore<
2491
2632
  this.getMatch(matchId)!,
2492
2633
  loaderData,
2493
2634
  )
2635
+ updateMatch(matchId, (prev) => ({
2636
+ ...prev,
2637
+ loaderData,
2638
+ }))
2494
2639
 
2495
2640
  // Lazy option can modify the route options,
2496
2641
  // so we need to wait for it to resolve before
2497
2642
  // we can use the options
2498
2643
  await route._lazyPromise
2499
-
2644
+ const head = await executeHead()
2500
2645
  await potentialPendingMinPromise()
2501
2646
 
2502
2647
  // Last but not least, wait for the the components
2503
2648
  // to be preloaded before we resolve the match
2504
2649
  await route._componentsPromise
2505
-
2506
2650
  updateMatch(matchId, (prev) => ({
2507
2651
  ...prev,
2508
2652
  error: undefined,
2509
2653
  status: 'success',
2510
2654
  isFetching: false,
2511
2655
  updatedAt: Date.now(),
2512
- loaderData,
2513
- }))
2514
- const head = await executeHead()
2515
- updateMatch(matchId, (prev) => ({
2516
- ...prev,
2517
2656
  ...head,
2518
2657
  }))
2519
2658
  } catch (e) {
@@ -2559,13 +2698,18 @@ export class RouterCore<
2559
2698
  }
2560
2699
 
2561
2700
  // If the route is successful and still fresh, just resolve
2562
- const { status, invalid } = this.getMatch(matchId)!
2701
+ const { status, invalid, _forcePending } =
2702
+ this.getMatch(matchId)!
2563
2703
  loaderShouldRunAsync =
2564
2704
  status === 'success' &&
2565
2705
  (invalid || (shouldReload ?? age > staleAge))
2566
2706
  if (preload && route.options.preload === false) {
2567
2707
  // Do nothing
2568
- } else if (loaderShouldRunAsync && !sync) {
2708
+ } else if (
2709
+ loaderShouldRunAsync &&
2710
+ !sync &&
2711
+ !_forcePending
2712
+ ) {
2569
2713
  loaderIsRunningAsync = true
2570
2714
  ;(async () => {
2571
2715
  try {
@@ -2590,6 +2734,9 @@ export class RouterCore<
2590
2734
  ) {
2591
2735
  await runLoader()
2592
2736
  } else {
2737
+ if (_forcePending) {
2738
+ await potentialPendingMinPromise()
2739
+ }
2593
2740
  // if the loader did not run, still update head.
2594
2741
  // reason: parent's beforeLoad may have changed the route context
2595
2742
  // and only now do we know the route context (and that the loader would not run)
@@ -2598,6 +2745,10 @@ export class RouterCore<
2598
2745
  ...prev,
2599
2746
  ...head,
2600
2747
  }))
2748
+ this.serverSsr?.onMatchSettled({
2749
+ router: this,
2750
+ match: this.getMatch(matchId)!,
2751
+ })
2601
2752
  }
2602
2753
  }
2603
2754
  if (!loaderIsRunningAsync) {
@@ -2614,6 +2765,8 @@ export class RouterCore<
2614
2765
  ? prev.loaderPromise
2615
2766
  : undefined,
2616
2767
  invalid: false,
2768
+ _dehydrated: undefined,
2769
+ _forcePending: undefined,
2617
2770
  }))
2618
2771
  return this.getMatch(matchId)!
2619
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
+ }