@rangojs/router 0.0.0-experimental.124 → 0.0.0-experimental.126

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 (235) hide show
  1. package/README.md +6 -4
  2. package/dist/bin/rango.js +3 -4
  3. package/dist/vite/index.js +315 -68
  4. package/package.json +19 -18
  5. package/skills/breadcrumbs/SKILL.md +60 -0
  6. package/skills/hooks/SKILL.md +2 -2
  7. package/skills/route/SKILL.md +6 -0
  8. package/skills/server-actions/SKILL.md +25 -1
  9. package/skills/testing/SKILL.md +17 -17
  10. package/skills/testing/cache-prerender.md +29 -3
  11. package/skills/testing/flight.md +13 -10
  12. package/skills/testing/render-handler.md +3 -0
  13. package/skills/testing/server-tree.md +1 -1
  14. package/skills/testing/setup.md +1 -1
  15. package/src/__internal.ts +0 -65
  16. package/src/browser/action-coordinator.ts +1 -1
  17. package/src/browser/action-fence.ts +10 -0
  18. package/src/browser/event-controller.ts +1 -83
  19. package/src/browser/navigation-store-handle.ts +3 -4
  20. package/src/browser/navigation-store.ts +0 -39
  21. package/src/browser/navigation-transaction.ts +0 -32
  22. package/src/browser/partial-update.ts +23 -84
  23. package/src/browser/prefetch/cache.ts +6 -45
  24. package/src/browser/prefetch/queue.ts +6 -3
  25. package/src/browser/rango-state.ts +2 -23
  26. package/src/browser/react/Link.tsx +0 -2
  27. package/src/browser/react/NavigationProvider.tsx +2 -1
  28. package/src/browser/react/ScrollRestoration.tsx +10 -6
  29. package/src/browser/react/filter-segment-order.ts +0 -2
  30. package/src/browser/react/index.ts +0 -45
  31. package/src/browser/react/location-state-shared.ts +0 -13
  32. package/src/browser/react/location-state.ts +0 -1
  33. package/src/browser/react/use-action.ts +6 -15
  34. package/src/browser/react/use-handle.ts +0 -5
  35. package/src/browser/react/use-link-status.ts +0 -4
  36. package/src/browser/react/use-navigation.ts +0 -3
  37. package/src/browser/react/use-params.ts +0 -2
  38. package/src/browser/react/use-router.ts +2 -1
  39. package/src/browser/react/use-search-params.ts +0 -5
  40. package/src/browser/react/use-segments.ts +0 -13
  41. package/src/browser/rsc-router.tsx +10 -3
  42. package/src/browser/server-action-bridge.ts +51 -3
  43. package/src/browser/types.ts +23 -5
  44. package/src/browser/validate-redirect-origin.ts +43 -16
  45. package/src/build/index.ts +8 -9
  46. package/src/build/route-trie.ts +46 -11
  47. package/src/build/route-types/param-extraction.ts +6 -3
  48. package/src/build/route-types/router-processing.ts +0 -8
  49. package/src/cache/cache-policy.ts +0 -54
  50. package/src/cache/cache-runtime.ts +48 -24
  51. package/src/cache/cache-scope.ts +0 -27
  52. package/src/cache/cache-tag.ts +0 -37
  53. package/src/cache/cf/cf-cache-store.ts +72 -45
  54. package/src/cache/cf/index.ts +0 -24
  55. package/src/cache/document-cache.ts +10 -36
  56. package/src/cache/handle-snapshot.ts +0 -40
  57. package/src/cache/index.ts +0 -27
  58. package/src/cache/memory-segment-store.ts +0 -52
  59. package/src/cache/profile-registry.ts +6 -30
  60. package/src/cache/read-through-swr.ts +41 -11
  61. package/src/cache/segment-codec.ts +0 -16
  62. package/src/cache/types.ts +0 -98
  63. package/src/client.rsc.tsx +4 -22
  64. package/src/client.tsx +19 -32
  65. package/src/context-var.ts +12 -0
  66. package/src/defer.ts +196 -0
  67. package/src/deps/ssr.ts +0 -1
  68. package/src/handle.ts +2 -12
  69. package/src/handles/MetaTags.tsx +0 -14
  70. package/src/handles/breadcrumbs.ts +16 -5
  71. package/src/handles/meta.ts +0 -39
  72. package/src/host/cookie-handler.ts +0 -36
  73. package/src/host/errors.ts +0 -24
  74. package/src/host/index.ts +6 -0
  75. package/src/host/pattern-matcher.ts +7 -50
  76. package/src/host/router.ts +1 -65
  77. package/src/host/testing.ts +0 -16
  78. package/src/host/types.ts +6 -2
  79. package/src/href-client.ts +0 -4
  80. package/src/index.rsc.ts +27 -2
  81. package/src/index.ts +7 -0
  82. package/src/internal-debug.ts +2 -4
  83. package/src/loader.rsc.ts +4 -15
  84. package/src/loader.ts +3 -9
  85. package/src/network-error-thrower.tsx +1 -6
  86. package/src/outlet-provider.tsx +1 -5
  87. package/src/prerender/param-hash.ts +10 -11
  88. package/src/prerender/store.ts +23 -30
  89. package/src/prerender.ts +34 -0
  90. package/src/redirect-origin.ts +100 -0
  91. package/src/root-error-boundary.tsx +1 -19
  92. package/src/route-content-wrapper.tsx +1 -44
  93. package/src/route-definition/dsl-helpers.ts +7 -19
  94. package/src/route-definition/helpers-types.ts +3 -3
  95. package/src/route-definition/redirect.ts +43 -9
  96. package/src/route-definition/resolve-handler-use.ts +6 -0
  97. package/src/route-map-builder.ts +0 -16
  98. package/src/router/content-negotiation.ts +0 -13
  99. package/src/router/error-handling.ts +12 -16
  100. package/src/router/find-match.ts +4 -31
  101. package/src/router/intercept-resolution.ts +10 -1
  102. package/src/router/lazy-includes.ts +1 -57
  103. package/src/router/loader-resolution.ts +25 -23
  104. package/src/router/logging.ts +0 -6
  105. package/src/router/manifest.ts +1 -25
  106. package/src/router/match-api.ts +0 -20
  107. package/src/router/match-context.ts +0 -22
  108. package/src/router/match-handlers.ts +0 -43
  109. package/src/router/match-middleware/background-revalidation.ts +0 -7
  110. package/src/router/match-middleware/cache-lookup.ts +96 -179
  111. package/src/router/match-middleware/cache-store.ts +0 -31
  112. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  113. package/src/router/match-middleware/segment-resolution.ts +0 -22
  114. package/src/router/match-pipelines.ts +1 -42
  115. package/src/router/match-result.ts +1 -52
  116. package/src/router/metrics.ts +0 -34
  117. package/src/router/middleware-types.ts +0 -116
  118. package/src/router/middleware.ts +77 -60
  119. package/src/router/navigation-snapshot.ts +0 -51
  120. package/src/router/params-util.ts +23 -0
  121. package/src/router/pattern-matching.ts +5 -56
  122. package/src/router/prerender-match.ts +56 -51
  123. package/src/router/request-classification.ts +1 -38
  124. package/src/router/revalidation.ts +14 -62
  125. package/src/router/route-snapshot.ts +0 -1
  126. package/src/router/router-context.ts +0 -27
  127. package/src/router/router-interfaces.ts +10 -0
  128. package/src/router/segment-resolution/fresh.ts +25 -57
  129. package/src/router/segment-resolution/helpers.ts +34 -0
  130. package/src/router/segment-resolution/loader-cache.ts +35 -23
  131. package/src/router/segment-resolution/revalidation.ts +188 -283
  132. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  133. package/src/router/segment-resolution.ts +4 -1
  134. package/src/router/segment-wrappers.ts +0 -3
  135. package/src/router/telemetry-otel.ts +0 -20
  136. package/src/router/telemetry.ts +0 -22
  137. package/src/router/timeout.ts +0 -20
  138. package/src/router/trie-matching.ts +66 -45
  139. package/src/router/types.ts +1 -63
  140. package/src/router/url-params.ts +0 -5
  141. package/src/router.ts +8 -11
  142. package/src/rsc/handler-context.ts +1 -0
  143. package/src/rsc/handler.ts +20 -4
  144. package/src/rsc/helpers.ts +71 -3
  145. package/src/rsc/json-route-result.ts +38 -0
  146. package/src/rsc/origin-guard.ts +9 -15
  147. package/src/rsc/progressive-enhancement.ts +10 -1
  148. package/src/rsc/redirect-guard.ts +99 -0
  149. package/src/rsc/response-route-handler.ts +23 -18
  150. package/src/rsc/rsc-rendering.ts +2 -7
  151. package/src/rsc/runtime-warnings.ts +14 -0
  152. package/src/rsc/server-action.ts +34 -29
  153. package/src/rsc/types.ts +6 -3
  154. package/src/search-params.ts +0 -16
  155. package/src/segment-loader-promise.ts +14 -2
  156. package/src/segment-system.tsx +79 -88
  157. package/src/server/handle-store.ts +7 -24
  158. package/src/server/loader-registry.ts +5 -24
  159. package/src/server/request-context.ts +29 -92
  160. package/src/ssr/index.tsx +14 -14
  161. package/src/static-handler.ts +2 -27
  162. package/src/testing/cache-status.ts +44 -48
  163. package/src/testing/collect-handle.ts +1 -24
  164. package/src/testing/dispatch.ts +43 -6
  165. package/src/testing/e2e/index.ts +1 -22
  166. package/src/testing/e2e/matchers.ts +0 -16
  167. package/src/testing/flight-matchers.ts +0 -13
  168. package/src/testing/flight-normalize.ts +3 -30
  169. package/src/testing/flight.ts +46 -48
  170. package/src/testing/generated-routes.ts +1 -41
  171. package/src/testing/index.ts +1 -21
  172. package/src/testing/internal/context.ts +3 -45
  173. package/src/testing/internal/seed-vars.ts +0 -26
  174. package/src/testing/render-handler.ts +31 -61
  175. package/src/testing/render-route.tsx +75 -103
  176. package/src/testing/run-loader.ts +0 -96
  177. package/src/testing/run-middleware.ts +0 -26
  178. package/src/theme/ThemeProvider.tsx +0 -52
  179. package/src/theme/ThemeScript.tsx +0 -6
  180. package/src/theme/constants.ts +0 -12
  181. package/src/theme/index.ts +0 -7
  182. package/src/theme/theme-context.ts +1 -5
  183. package/src/theme/theme-script.ts +0 -14
  184. package/src/theme/use-theme.ts +0 -3
  185. package/src/types/boundaries.ts +0 -35
  186. package/src/types/error-types.ts +25 -89
  187. package/src/types/global-namespace.ts +4 -14
  188. package/src/types/handler-context.ts +28 -9
  189. package/src/types/index.ts +0 -10
  190. package/src/types/request-scope.ts +0 -19
  191. package/src/types/route-config.ts +6 -50
  192. package/src/types/route-entry.ts +0 -6
  193. package/src/types/segments.ts +0 -13
  194. package/src/urls/include-helper.ts +0 -4
  195. package/src/urls/index.ts +0 -6
  196. package/src/urls/path-helper-types.ts +2 -2
  197. package/src/urls/path-helper.ts +0 -54
  198. package/src/urls/urls-function.ts +0 -13
  199. package/src/use-loader.tsx +0 -186
  200. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  201. package/src/vite/discovery/discover-routers.ts +28 -18
  202. package/src/vite/discovery/prerender-collection.ts +2 -4
  203. package/src/vite/discovery/state.ts +5 -0
  204. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  205. package/src/vite/plugin-types.ts +35 -9
  206. package/src/vite/plugins/cjs-to-esm.ts +0 -11
  207. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  208. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  209. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  210. package/src/vite/plugins/expose-action-id.ts +2 -73
  211. package/src/vite/plugins/expose-id-utils.ts +0 -55
  212. package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
  213. package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
  214. package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
  215. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  216. package/src/vite/plugins/expose-internal-ids.ts +10 -0
  217. package/src/vite/plugins/performance-tracks.ts +0 -3
  218. package/src/vite/plugins/refresh-cmd.ts +1 -1
  219. package/src/vite/plugins/use-cache-transform.ts +21 -46
  220. package/src/vite/plugins/version-injector.ts +0 -20
  221. package/src/vite/plugins/version-plugin.ts +1 -49
  222. package/src/vite/plugins/virtual-entries.ts +0 -15
  223. package/src/vite/rango.ts +2 -108
  224. package/src/vite/router-discovery.ts +9 -1
  225. package/src/vite/utils/ast-handler-extract.ts +0 -16
  226. package/src/vite/utils/bundle-analysis.ts +6 -13
  227. package/src/vite/utils/client-chunks.ts +0 -6
  228. package/src/vite/utils/forward-user-plugins.ts +0 -22
  229. package/src/vite/utils/manifest-utils.ts +0 -4
  230. package/src/vite/utils/package-resolution.ts +1 -73
  231. package/src/vite/utils/prerender-utils.ts +0 -35
  232. package/src/vite/utils/shared-utils.ts +3 -35
  233. package/src/browser/shallow.ts +0 -40
  234. package/src/handles/index.ts +0 -7
  235. package/src/router/middleware-cookies.ts +0 -55
@@ -9,6 +9,7 @@ import type { NonceProvider } from "../rsc/types.js";
9
9
  import type { ExecutionContext } from "../server/request-context.js";
10
10
  import type { SerializedSegmentData } from "../cache/types.js";
11
11
  import type { MiddlewareEntry, MiddlewareFn } from "./middleware.js";
12
+ import type { ExtractParams } from "../types/route-config.js";
12
13
  import { RSC_ROUTER_BRAND } from "./router-registry.js";
13
14
  import type { RangoOptions, RootLayoutProps } from "./router-options.js";
14
15
  import type { DefaultVars } from "../types/global-namespace.js";
@@ -105,9 +106,14 @@ export interface Rango<
105
106
  * createRouter({ document: RootLayout })
106
107
  * .use(loggerMiddleware) // All routes
107
108
  * .use("/api/*", rateLimiter) // Pattern match
109
+ * .use("/users/:id", (ctx) => {}) // ctx.params.id is typed
108
110
  * .routes(urlpatterns)
109
111
  * ```
110
112
  */
113
+ use<Pattern extends string>(
114
+ pattern: Pattern,
115
+ middleware: MiddlewareFn<TEnv, ExtractParams<Pattern>>,
116
+ ): Rango<TEnv, TRoutes>;
111
117
  use(
112
118
  patternOrMiddleware: string | MiddlewareFn<TEnv>,
113
119
  middleware?: MiddlewareFn<TEnv>,
@@ -225,6 +231,10 @@ export interface RangoInternal<
225
231
  /**
226
232
  * Add global middleware that runs on all routes
227
233
  */
234
+ use<Pattern extends string>(
235
+ pattern: Pattern,
236
+ middleware: MiddlewareFn<TEnv, ExtractParams<Pattern>>,
237
+ ): Rango<TEnv, TRoutes>;
228
238
  use(
229
239
  patternOrMiddleware: string | MiddlewareFn<TEnv>,
230
240
  middleware?: MiddlewareFn<TEnv>,
@@ -27,59 +27,17 @@ import {
27
27
  tryStaticSlot,
28
28
  resolveLayoutComponent,
29
29
  resolveWithErrorBoundary,
30
+ warnOnStreamedResponse,
30
31
  } from "./helpers.js";
31
32
  import { applyViewTransitionDefault } from "./view-transition-default.js";
32
33
  import { getRouterContext } from "../router-context.js";
33
- import { resolveSink, safeEmit } from "../telemetry.js";
34
+ import { observeStreamedHandler } from "./streamed-handler-telemetry.js";
34
35
  import {
35
36
  track,
36
37
  RangoContext,
37
38
  runInsideLoaderScope,
38
39
  } from "../../server/context.js";
39
40
 
40
- // ---------------------------------------------------------------------------
41
- // Streamed handler telemetry
42
- // ---------------------------------------------------------------------------
43
-
44
- /**
45
- * Attach a fire-and-forget rejection observer to a streamed handler promise.
46
- * React catches the actual error via its error boundary; this only emits
47
- * the handler.error telemetry event.
48
- */
49
- function observeStreamedHandler(
50
- promise: Promise<ReactNode>,
51
- segmentId: string,
52
- segmentType: string,
53
- pathname?: string,
54
- routeKey?: string,
55
- params?: Record<string, string>,
56
- ): void {
57
- let routerCtx;
58
- try {
59
- routerCtx = getRouterContext();
60
- } catch {
61
- return;
62
- }
63
- if (!routerCtx?.telemetry) return;
64
- const sink = resolveSink(routerCtx.telemetry);
65
- const reqId = routerCtx.requestId;
66
- promise.catch((err: unknown) => {
67
- const errorObj = err instanceof Error ? err : new Error(String(err));
68
- safeEmit(sink, {
69
- type: "handler.error",
70
- timestamp: performance.now(),
71
- requestId: reqId,
72
- segmentId,
73
- segmentType,
74
- error: errorObj,
75
- handledByBoundary: true,
76
- pathname,
77
- routeKey,
78
- params,
79
- });
80
- });
81
- }
82
-
83
41
  // ---------------------------------------------------------------------------
84
42
  // Fresh path (full match, no revalidation)
85
43
  // ---------------------------------------------------------------------------
@@ -133,18 +91,32 @@ export async function resolveLoaders<TEnv>(
133
91
 
134
92
  // Loading disabled: still start all loaders in parallel, but only emit
135
93
  // settled promises so handlers don't stream loading placeholders.
136
- const pendingLoaderData = loaderEntries.map((loaderEntry) => {
94
+ //
95
+ // Wrap each loader promise with wrapLoaderPromise BEFORE awaiting. The wrapped
96
+ // promise resolves to a LoaderDataResult and never rejects, routing a failed
97
+ // loader to its own per-loader error boundary. Awaiting the RAW promises here
98
+ // instead would (1) propagate a rejection to the segment-level boundary,
99
+ // collapsing the whole entry and discarding successful sibling data, and
100
+ // (2) leave the other in-flight raw promises without a .catch, producing
101
+ // unhandled rejections. Mirrors the loading path and intercept-resolution.
102
+ const pendingLoaderData = loaderEntries.map((loaderEntry, i) => {
103
+ const { loader } = loaderEntry;
104
+ const segmentId = `${shortCode}D${i}.${loader.$$id}`;
137
105
  const start = performance.now();
138
- const promise = runInsideLoaderScope(() =>
139
- resolveLoaderData(loaderEntry, ctx, ctx.pathname),
106
+ const wrapped = deps.wrapLoaderPromise(
107
+ runInsideLoaderScope(() =>
108
+ resolveLoaderData(loaderEntry, ctx, ctx.pathname),
109
+ ),
110
+ entry,
111
+ segmentId,
112
+ ctx.pathname,
140
113
  );
141
- return { promise, start, loaderId: loaderEntry.loader.$$id };
114
+ return { wrapped, start, segmentId, loaderId: loader.$$id };
142
115
  });
143
- await Promise.all(pendingLoaderData.map((p) => p.promise));
116
+ await Promise.all(pendingLoaderData.map((p) => p.wrapped));
144
117
 
145
118
  return loaderEntries.map((loaderEntry, i) => {
146
119
  const { loader } = loaderEntry;
147
- const segmentId = `${shortCode}D${i}.${loader.$$id}`;
148
120
  const pending = pendingLoaderData[i]!;
149
121
  if (ms && !ms.metrics.some((m) => m.label === `loader:${loader.$$id}`)) {
150
122
  // All loaders ran in parallel via Promise.all — each span covers
@@ -160,19 +132,14 @@ export async function resolveLoaders<TEnv>(
160
132
  );
161
133
  }
162
134
  return {
163
- id: segmentId,
135
+ id: pending.segmentId,
164
136
  namespace: entry.id,
165
137
  type: "loader" as const,
166
138
  index: i,
167
139
  component: null,
168
140
  params: ctx.params,
169
141
  loaderId: loader.$$id,
170
- loaderData: deps.wrapLoaderPromise(
171
- pending.promise,
172
- entry,
173
- segmentId,
174
- ctx.pathname,
175
- ),
142
+ loaderData: pending.wrapped,
176
143
  belongsToRoute,
177
144
  };
178
145
  });
@@ -297,6 +264,7 @@ export async function resolveSegment<TEnv>(
297
264
  if (entry.loading) {
298
265
  const result = handleHandlerResult(handler(context));
299
266
  if (result instanceof Promise) {
267
+ warnOnStreamedResponse(result, entry.id);
300
268
  result.finally(doneRouteHandler).catch(() => {});
301
269
  const tracked = deps.trackHandler(result, {
302
270
  segmentId: entry.shortCode,
@@ -52,6 +52,40 @@ export function handleHandlerResult(
52
52
  return result;
53
53
  }
54
54
 
55
+ /**
56
+ * Dev-only: warn when a handler on a route that declares loading() resolves or
57
+ * rejects with a Response (e.g. redirect()).
58
+ *
59
+ * On a non-loading route a returned/thrown Response short-circuits to an HTTP
60
+ * redirect. But when the route declares loading(), the handler result is
61
+ * streamed (not awaited at the resolution boundary), so the Response surfaces
62
+ * only during RSC serialization and is rendered into the stream instead of
63
+ * becoming a 302/308 — a silent failure mode. Issue redirects from middleware,
64
+ * a loader, or a synchronous handler return instead. Compiled out in production.
65
+ */
66
+ export function warnOnStreamedResponse(
67
+ result: Promise<unknown>,
68
+ entryId: string,
69
+ ): void {
70
+ if (process.env.NODE_ENV === "production") return;
71
+ // A Response can surface either as a rejection (handleHandlerResult rethrows a
72
+ // resolved Response) or as a resolved value (the raw parallel-slot handler is
73
+ // not run through handleHandlerResult). Check both so every streamed path is
74
+ // covered. Each handler is an independent observer; it does not consume the
75
+ // rejection for the trackHandler/observeStreamedHandler chains.
76
+ const check = (value: unknown) => {
77
+ if (value instanceof Response) {
78
+ console.warn(
79
+ `[rango] Handler for "${entryId}" returned a Response (e.g. ` +
80
+ `redirect()), but it declares loading(): the Response is rendered ` +
81
+ `into the RSC stream, NOT sent as an HTTP redirect. Issue redirects ` +
82
+ `from middleware, a loader, or a synchronous handler return.`,
83
+ );
84
+ }
85
+ };
86
+ result.then(check, check);
87
+ }
88
+
55
89
  // ---------------------------------------------------------------------------
56
90
  // Static handler interception
57
91
  // ---------------------------------------------------------------------------
@@ -8,7 +8,7 @@
8
8
  * Cache key resolution (3-tier, matching CacheScope.resolveKey):
9
9
  * 1. options.key(requestCtx) — full override
10
10
  * 2. store.keyGenerator(requestCtx, defaultKey) — store-level modification
11
- * 3. loader:{loaderId}:{pathname}:{sortedParams} — default
11
+ * 3. loader:{loaderId}:{host}{pathname}:{sortedParams} — default
12
12
  *
13
13
  * Values are serialized via RSC Flight (serializeResult/deserializeResult),
14
14
  * supporting ReactNode, Promises, null, and all RSC-serializable types.
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import type { LoaderEntry } from "../../server/context.js";
22
- import type { HandlerContext } from "../../types.js";
22
+ import type { HandlerContext, InternalHandlerContext } from "../../types.js";
23
23
  import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
24
24
  import { getRequestContext } from "../../server/request-context.js";
25
25
  import { sortedRouteParams } from "../../cache/cache-key-utils.js";
@@ -57,12 +57,13 @@ function debugLoaderCacheLog(message: string): void {
57
57
 
58
58
  function getDefaultLoaderCacheKey(
59
59
  loaderId: string,
60
+ host: string,
60
61
  pathname: string,
61
62
  params: Record<string, string>,
62
63
  ): string {
63
64
  const paramStr = sortedRouteParams(params);
64
65
  const base = paramStr ? `${pathname}:${paramStr}` : pathname;
65
- return `loader:${loaderId}:${base}`;
66
+ return `loader:${loaderId}:${host}${base}`;
66
67
  }
67
68
 
68
69
  /**
@@ -76,7 +77,13 @@ async function resolveLoaderKey(
76
77
  params: Record<string, string>,
77
78
  ): Promise<string> {
78
79
  const options = loaderEntry.cache!.options;
79
- const defaultKey = getDefaultLoaderCacheKey(loaderId, pathname, params);
80
+ // The host is part of the loader cache identity, matching the route-level
81
+ // cache (cache-scope getCacheKeyBase: `${host}${pathname}`) and "use cache"
82
+ // (cache-runtime pushes ctx.url.host). Without it, a multi-tenant host router
83
+ // serving the same pathname for different hosts would leak one host's cached
84
+ // loader data to another.
85
+ const host = getRequestContext()?.url?.host ?? "localhost";
86
+ const defaultKey = getDefaultLoaderCacheKey(loaderId, host, pathname, params);
80
87
  if (options === false) return defaultKey;
81
88
  return resolveCacheKey(options.key, store, defaultKey, "LoaderCache");
82
89
  }
@@ -139,15 +146,30 @@ export function resolveLoaderData<TEnv>(
139
146
  const swrWindow = resolveSwrWindow(options.swr, store.defaults);
140
147
  const swr = swrWindow || undefined;
141
148
  const tags = resolveTags(loaderEntry);
142
- // Loader tags are config-derived, so they are the complete set whether this is
143
- // a cache hit or miss; record them every time so a document built from this
144
- // loader is tagged for invalidation.
145
149
  recordRequestTags(tags);
146
150
 
147
- // Wrap ctx.use() so cache HIT primes the handler's memoization map.
148
- // ctx.use() closes over the match context's loaderPromises (not request context's).
149
- // By intercepting ctx.use(), we inject cached data into the correct map.
150
- const originalUse = ctx.use;
151
+ // A handler that later awaits this same loader via ctx.use(loader) must get
152
+ // THIS memoized promise, not a fresh execution. Rather than rebind ctx.use
153
+ // once per cached loader (O(N) chained wrappers + a synchronous
154
+ // capture-before-overwrite invariant), install a single stable interceptor on
155
+ // the first cached loader that consults a per-ctx override table, then just
156
+ // prime the table for each subsequent cached loader. The captured pre-
157
+ // interceptor `originalUse` (whatever setup mode installed it) runs the
158
+ // cache-miss execute, so a loader never awaits its own in-flight promise.
159
+ const internal = ctx as InternalHandlerContext<any, TEnv>;
160
+ let overrides = internal._loaderCacheOverrides;
161
+ if (!overrides) {
162
+ overrides = internal._loaderCacheOverrides = new Map();
163
+ const originalUse = ctx.use;
164
+ internal._loaderCacheOriginalUse = originalUse;
165
+ ctx.use = ((item: any) => {
166
+ const cached = overrides!.get(item?.$$id);
167
+ if (cached) return cached;
168
+ return originalUse(item);
169
+ }) as typeof ctx.use;
170
+ }
171
+ const runMiss = internal._loaderCacheOriginalUse!;
172
+
151
173
  const dataPromise = (async () => {
152
174
  const codec = await getCodec();
153
175
  const key = await resolveLoaderKey(
@@ -162,7 +184,7 @@ export function resolveLoaderData<TEnv>(
162
184
  getItem: (k) => store.getItem!(k),
163
185
  setItem: (k, v, o) => store.setItem!(k, v, o),
164
186
  key,
165
- execute: () => originalUse(loaderEntry.loader),
187
+ execute: () => runMiss(loaderEntry.loader),
166
188
  serialize: (d) => codec.serializeResult(d),
167
189
  deserialize: (v) => codec.deserializeResult(v),
168
190
  storeOptions: { ttl, swr, tags },
@@ -174,17 +196,7 @@ export function resolveLoaderData<TEnv>(
174
196
  });
175
197
  })();
176
198
 
177
- // Temporarily replace ctx.use() so the handler's call returns cached data.
178
- // This is needed because ctx.use() closes over the match context's loaderPromises
179
- // map which is separate from the request context. By wrapping use(), we intercept
180
- // the handler's call and return the shared dataPromise.
181
- const wrappedUse = ((item: any) => {
182
- if (item === loaderEntry.loader || item?.$$id === loaderId) {
183
- return dataPromise;
184
- }
185
- return originalUse(item);
186
- }) as typeof ctx.use;
187
- ctx.use = wrappedUse;
199
+ overrides.set(loaderId, dataPromise);
188
200
 
189
201
  return dataPromise;
190
202
  }