@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.dc2bd2b4

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 (253) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +48 -0
  4. package/dist/vite/index.js +2151 -846
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/document-cache/SKILL.md +78 -55
  13. package/skills/handler-use/SKILL.md +364 -0
  14. package/skills/hooks/SKILL.md +229 -20
  15. package/skills/host-router/SKILL.md +45 -20
  16. package/skills/i18n/SKILL.md +276 -0
  17. package/skills/intercept/SKILL.md +46 -4
  18. package/skills/layout/SKILL.md +28 -7
  19. package/skills/links/SKILL.md +247 -17
  20. package/skills/loader/SKILL.md +219 -9
  21. package/skills/middleware/SKILL.md +47 -12
  22. package/skills/migrate-nextjs/SKILL.md +562 -0
  23. package/skills/migrate-react-router/SKILL.md +769 -0
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +71 -6
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +242 -22
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +66 -9
  31. package/skills/route/SKILL.md +57 -4
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +751 -0
  34. package/skills/streams-and-websockets/SKILL.md +283 -0
  35. package/skills/testing/SKILL.md +647 -0
  36. package/skills/typesafety/SKILL.md +319 -27
  37. package/skills/use-cache/SKILL.md +34 -5
  38. package/skills/view-transitions/SKILL.md +294 -0
  39. package/src/__augment-tests__/augment.ts +81 -0
  40. package/src/__augment-tests__/augmented.check.ts +117 -0
  41. package/src/browser/action-coordinator.ts +53 -36
  42. package/src/browser/app-shell.ts +52 -0
  43. package/src/browser/event-controller.ts +86 -70
  44. package/src/browser/history-state.ts +21 -0
  45. package/src/browser/index.ts +3 -3
  46. package/src/browser/navigation-bridge.ts +84 -11
  47. package/src/browser/navigation-client.ts +76 -28
  48. package/src/browser/navigation-store.ts +32 -9
  49. package/src/browser/navigation-transaction.ts +10 -28
  50. package/src/browser/partial-update.ts +64 -26
  51. package/src/browser/prefetch/cache.ts +129 -21
  52. package/src/browser/prefetch/fetch.ts +148 -16
  53. package/src/browser/prefetch/queue.ts +36 -5
  54. package/src/browser/rango-state.ts +53 -13
  55. package/src/browser/react/Link.tsx +30 -2
  56. package/src/browser/react/NavigationProvider.tsx +72 -31
  57. package/src/browser/react/filter-segment-order.ts +51 -7
  58. package/src/browser/react/index.ts +3 -0
  59. package/src/browser/react/location-state-shared.ts +175 -4
  60. package/src/browser/react/location-state.ts +39 -13
  61. package/src/browser/react/use-handle.ts +17 -9
  62. package/src/browser/react/use-navigation.ts +22 -2
  63. package/src/browser/react/use-params.ts +20 -8
  64. package/src/browser/react/use-reverse.ts +106 -0
  65. package/src/browser/react/use-router.ts +22 -2
  66. package/src/browser/react/use-segments.ts +11 -8
  67. package/src/browser/response-adapter.ts +25 -0
  68. package/src/browser/rsc-router.tsx +64 -22
  69. package/src/browser/scroll-restoration.ts +22 -14
  70. package/src/browser/segment-reconciler.ts +36 -14
  71. package/src/browser/segment-structure-assert.ts +2 -2
  72. package/src/browser/server-action-bridge.ts +23 -30
  73. package/src/browser/types.ts +21 -0
  74. package/src/build/collect-fallback-refs.ts +107 -0
  75. package/src/build/generate-manifest.ts +60 -35
  76. package/src/build/generate-route-types.ts +2 -0
  77. package/src/build/index.ts +2 -0
  78. package/src/build/route-trie.ts +52 -25
  79. package/src/build/route-types/codegen.ts +4 -4
  80. package/src/build/route-types/include-resolution.ts +1 -1
  81. package/src/build/route-types/per-module-writer.ts +7 -4
  82. package/src/build/route-types/router-processing.ts +55 -14
  83. package/src/build/route-types/scan-filter.ts +1 -1
  84. package/src/build/route-types/source-scan.ts +118 -0
  85. package/src/build/runtime-discovery.ts +9 -20
  86. package/src/cache/cache-scope.ts +28 -42
  87. package/src/cache/cf/cf-cache-store.ts +54 -13
  88. package/src/client.rsc.tsx +3 -0
  89. package/src/client.tsx +92 -182
  90. package/src/context-var.ts +5 -5
  91. package/src/decode-loader-results.ts +36 -0
  92. package/src/errors.ts +30 -1
  93. package/src/handle.ts +26 -13
  94. package/src/host/index.ts +2 -2
  95. package/src/host/router.ts +129 -57
  96. package/src/host/types.ts +31 -2
  97. package/src/host/utils.ts +1 -1
  98. package/src/href-client.ts +140 -20
  99. package/src/index.rsc.ts +9 -4
  100. package/src/index.ts +53 -15
  101. package/src/loader-store.ts +500 -0
  102. package/src/loader.rsc.ts +2 -5
  103. package/src/loader.ts +3 -10
  104. package/src/missing-id-error.ts +68 -0
  105. package/src/outlet-context.ts +1 -1
  106. package/src/prerender.ts +4 -4
  107. package/src/response-utils.ts +37 -0
  108. package/src/reverse.ts +65 -36
  109. package/src/route-content-wrapper.tsx +6 -28
  110. package/src/route-definition/dsl-helpers.ts +384 -257
  111. package/src/route-definition/helper-factories.ts +29 -139
  112. package/src/route-definition/helpers-types.ts +100 -28
  113. package/src/route-definition/resolve-handler-use.ts +6 -0
  114. package/src/route-definition/use-item-types.ts +32 -0
  115. package/src/route-types.ts +26 -41
  116. package/src/router/basename.ts +14 -0
  117. package/src/router/content-negotiation.ts +15 -2
  118. package/src/router/error-handling.ts +1 -1
  119. package/src/router/handler-context.ts +21 -38
  120. package/src/router/intercept-resolution.ts +4 -18
  121. package/src/router/lazy-includes.ts +8 -8
  122. package/src/router/loader-resolution.ts +19 -2
  123. package/src/router/manifest.ts +22 -13
  124. package/src/router/match-api.ts +4 -3
  125. package/src/router/match-handlers.ts +63 -20
  126. package/src/router/match-middleware/cache-lookup.ts +44 -91
  127. package/src/router/match-middleware/cache-store.ts +3 -2
  128. package/src/router/match-result.ts +53 -32
  129. package/src/router/metrics.ts +1 -1
  130. package/src/router/middleware-types.ts +15 -26
  131. package/src/router/middleware.ts +99 -84
  132. package/src/router/pattern-matching.ts +101 -17
  133. package/src/router/prerender-match.ts +1 -1
  134. package/src/router/preview-match.ts +3 -1
  135. package/src/router/request-classification.ts +4 -28
  136. package/src/router/revalidation.ts +58 -2
  137. package/src/router/router-interfaces.ts +45 -28
  138. package/src/router/router-options.ts +40 -1
  139. package/src/router/router-registry.ts +2 -5
  140. package/src/router/segment-resolution/fresh.ts +27 -6
  141. package/src/router/segment-resolution/revalidation.ts +147 -106
  142. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  143. package/src/router/substitute-pattern-params.ts +56 -0
  144. package/src/router/telemetry.ts +99 -0
  145. package/src/router/trie-matching.ts +18 -13
  146. package/src/router/types.ts +8 -0
  147. package/src/router/url-params.ts +49 -0
  148. package/src/router.ts +38 -23
  149. package/src/rsc/handler-context.ts +2 -2
  150. package/src/rsc/handler.ts +28 -69
  151. package/src/rsc/helpers.ts +91 -43
  152. package/src/rsc/index.ts +1 -1
  153. package/src/rsc/origin-guard.ts +28 -10
  154. package/src/rsc/progressive-enhancement.ts +4 -0
  155. package/src/rsc/response-route-handler.ts +46 -53
  156. package/src/rsc/rsc-rendering.ts +35 -51
  157. package/src/rsc/runtime-warnings.ts +9 -10
  158. package/src/rsc/server-action.ts +17 -37
  159. package/src/rsc/ssr-setup.ts +16 -0
  160. package/src/rsc/types.ts +8 -2
  161. package/src/search-params.ts +4 -4
  162. package/src/segment-content-promise.ts +67 -0
  163. package/src/segment-loader-promise.ts +122 -0
  164. package/src/segment-system.tsx +132 -116
  165. package/src/serialize.ts +243 -0
  166. package/src/server/context.ts +143 -53
  167. package/src/server/cookie-store.ts +28 -4
  168. package/src/server/request-context.ts +20 -42
  169. package/src/ssr/index.tsx +5 -1
  170. package/src/static-handler.ts +1 -1
  171. package/src/testing/cache-status.ts +166 -0
  172. package/src/testing/collect-handle.ts +63 -0
  173. package/src/testing/dispatch.ts +440 -0
  174. package/src/testing/dom.entry.ts +22 -0
  175. package/src/testing/e2e/fixture.ts +154 -0
  176. package/src/testing/e2e/index.ts +149 -0
  177. package/src/testing/e2e/matchers.ts +51 -0
  178. package/src/testing/e2e/page-helpers.ts +272 -0
  179. package/src/testing/e2e/parity.ts +306 -0
  180. package/src/testing/e2e/server.ts +183 -0
  181. package/src/testing/flight-matchers.ts +104 -0
  182. package/src/testing/flight-runtime.d.ts +21 -0
  183. package/src/testing/flight.entry.ts +22 -0
  184. package/src/testing/flight.ts +182 -0
  185. package/src/testing/generated-routes.ts +223 -0
  186. package/src/testing/index.ts +105 -0
  187. package/src/testing/internal/context.ts +193 -0
  188. package/src/testing/render-route.tsx +536 -0
  189. package/src/testing/run-loader.ts +296 -0
  190. package/src/testing/run-middleware.ts +170 -0
  191. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  192. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  193. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  194. package/src/testing/vitest-stubs/version.ts +5 -0
  195. package/src/testing/vitest.ts +183 -0
  196. package/src/types/global-namespace.ts +39 -26
  197. package/src/types/handler-context.ts +68 -50
  198. package/src/types/index.ts +1 -0
  199. package/src/types/loader-types.ts +5 -6
  200. package/src/types/request-scope.ts +126 -0
  201. package/src/types/route-entry.ts +11 -0
  202. package/src/types/segments.ts +35 -2
  203. package/src/urls/include-helper.ts +34 -67
  204. package/src/urls/index.ts +0 -3
  205. package/src/urls/path-helper-types.ts +41 -7
  206. package/src/urls/path-helper.ts +17 -52
  207. package/src/urls/pattern-types.ts +36 -19
  208. package/src/urls/response-types.ts +22 -29
  209. package/src/urls/type-extraction.ts +26 -116
  210. package/src/urls/urls-function.ts +1 -5
  211. package/src/use-loader.tsx +413 -42
  212. package/src/vite/debug.ts +185 -0
  213. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  214. package/src/vite/discovery/discover-routers.ts +101 -51
  215. package/src/vite/discovery/discovery-errors.ts +194 -0
  216. package/src/vite/discovery/gate-state.ts +171 -0
  217. package/src/vite/discovery/prerender-collection.ts +67 -26
  218. package/src/vite/discovery/route-types-writer.ts +40 -84
  219. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  220. package/src/vite/discovery/state.ts +33 -0
  221. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  222. package/src/vite/index.ts +2 -0
  223. package/src/vite/plugin-types.ts +67 -0
  224. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  225. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  226. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  227. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  228. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  229. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  230. package/src/vite/plugins/expose-action-id.ts +54 -30
  231. package/src/vite/plugins/expose-id-utils.ts +12 -8
  232. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  233. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  234. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  235. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  236. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  237. package/src/vite/plugins/performance-tracks.ts +29 -25
  238. package/src/vite/plugins/use-cache-transform.ts +65 -50
  239. package/src/vite/plugins/version-injector.ts +39 -23
  240. package/src/vite/plugins/version-plugin.ts +59 -2
  241. package/src/vite/plugins/virtual-entries.ts +2 -2
  242. package/src/vite/rango.ts +116 -29
  243. package/src/vite/router-discovery.ts +750 -100
  244. package/src/vite/utils/ast-handler-extract.ts +15 -15
  245. package/src/vite/utils/banner.ts +1 -1
  246. package/src/vite/utils/bundle-analysis.ts +4 -2
  247. package/src/vite/utils/client-chunks.ts +190 -0
  248. package/src/vite/utils/forward-user-plugins.ts +193 -0
  249. package/src/vite/utils/manifest-utils.ts +21 -5
  250. package/src/vite/utils/package-resolution.ts +41 -1
  251. package/src/vite/utils/prerender-utils.ts +21 -6
  252. package/src/vite/utils/shared-utils.ts +107 -26
  253. package/src/browser/action-response-classifier.ts +0 -99
@@ -27,7 +27,7 @@ import {
27
27
  hasBodyContent,
28
28
  createResponseWithMergedHeaders,
29
29
  createSimpleRedirectResponse,
30
- carryOverRedirectHeaders,
30
+ interceptRedirectForPartial,
31
31
  } from "./helpers.js";
32
32
  import type { HandlerContext } from "./handler-context.js";
33
33
 
@@ -111,49 +111,25 @@ export async function executeServerAction<TEnv>(
111
111
  loadedAction = await ctx.loadServerAction(actionId);
112
112
  const data = await loadedAction!.apply(null, args);
113
113
 
114
- // Intercept redirect responses from actions. Without this, the redirect
115
- // Response would be serialized as the action returnValue (which fails)
116
- // and the revalidation step would run unnecessarily.
114
+ // Intercept redirect Responses: serializing one as the action returnValue
115
+ // would fail, and revalidation would run needlessly.
117
116
  if (data instanceof Response) {
118
- const redirectUrl = data.headers.get("Location");
119
- const isRedirect = data.status >= 300 && data.status < 400 && redirectUrl;
120
- if (isRedirect) {
121
- const locationState = getLocationState();
122
- let redirect: Response;
123
- if (locationState) {
124
- redirect = ctx.createRedirectFlightResponse(
125
- redirectUrl,
126
- resolveLocationStateEntries(locationState),
127
- );
128
- } else {
129
- redirect = createSimpleRedirectResponse(redirectUrl);
130
- }
131
- carryOverRedirectHeaders(data, redirect);
132
- return redirect;
133
- }
117
+ const intercepted = interceptRedirectForPartial(
118
+ data,
119
+ ctx.createRedirectFlightResponse,
120
+ );
121
+ if (intercepted) return intercepted;
134
122
  }
135
123
 
136
124
  returnValue = { ok: true, data };
137
125
  } catch (error) {
138
126
  // Handle thrown redirect (e.g., throw redirect('/path'))
139
127
  if (error instanceof Response) {
140
- const redirectUrl = error.headers.get("Location");
141
- const isRedirect =
142
- error.status >= 300 && error.status < 400 && redirectUrl;
143
- if (isRedirect) {
144
- const locationState = getLocationState();
145
- let redirect: Response;
146
- if (locationState) {
147
- redirect = ctx.createRedirectFlightResponse(
148
- redirectUrl,
149
- resolveLocationStateEntries(locationState),
150
- );
151
- } else {
152
- redirect = createSimpleRedirectResponse(redirectUrl);
153
- }
154
- carryOverRedirectHeaders(error, redirect);
155
- return redirect;
156
- }
128
+ const intercepted = interceptRedirectForPartial(
129
+ error,
130
+ ctx.createRedirectFlightResponse,
131
+ );
132
+ if (intercepted) return intercepted;
157
133
 
158
134
  // Non-redirect Response thrown from action — this will be treated
159
135
  // as a regular error and routed to the error boundary. Warn in dev
@@ -213,6 +189,8 @@ export async function executeServerAction<TEnv>(
213
189
  isPartial: true,
214
190
  matched: errorResult.matched,
215
191
  diff: errorResult.diff,
192
+ resolvedIds: errorResult.resolvedIds,
193
+ params: errorResult.params,
216
194
  isError: true,
217
195
  handles: handleStore.stream(),
218
196
  version: ctx.version,
@@ -323,6 +301,8 @@ export async function revalidateAfterAction<TEnv>(
323
301
  isPartial: true,
324
302
  matched: matchResult.matched,
325
303
  diff: matchResult.diff,
304
+ resolvedIds: matchResult.resolvedIds,
305
+ params: matchResult.params,
326
306
  slots: matchResult.slots,
327
307
  handles: handleStore.stream(),
328
308
  version: ctx.version,
@@ -126,3 +126,19 @@ export function mayNeedSSR(request: Request, url: URL): boolean {
126
126
 
127
127
  return true;
128
128
  }
129
+
130
+ // Final render-time decision: is the response an RSC stream (vs HTML)? Distinct
131
+ // from mayNeedSSR, which is a conservative pre-classifier (it treats a missing
132
+ // Accept header as needing SSR; this treats it as RSC).
133
+ export function isRscRequest(
134
+ request: Request,
135
+ url: URL,
136
+ isPartial: boolean,
137
+ ): boolean {
138
+ return (
139
+ isPartial ||
140
+ (!request.headers.get("accept")?.includes("text/html") &&
141
+ !url.searchParams.has("__html")) ||
142
+ url.searchParams.has("__rsc")
143
+ );
144
+ }
package/src/rsc/types.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { ResolvedSegment, SlotState } from "../types.js";
9
9
  import type { HandleData } from "../server/handle-store.js";
10
- import type { RSCRouterInternal } from "../router/router-interfaces.js";
10
+ import type { RangoInternal } from "../router/router-interfaces.js";
11
11
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
12
12
 
13
13
  /**
@@ -26,6 +26,12 @@ export interface RscPayload {
26
26
  isError?: boolean;
27
27
  matched?: string[];
28
28
  diff?: string[];
29
+ /**
30
+ * All segment ids re-resolved on the server, including null-component
31
+ * ones excluded from `segments`/`diff`. Drives client-side handle-bucket
32
+ * cleanup. Superset of `diff`. See MatchResult.resolvedIds.
33
+ */
34
+ resolvedIds?: string[];
29
35
  /** Merged route params from the matched route */
30
36
  params?: Record<string, string>;
31
37
  slots?: Record<string, SlotState>;
@@ -179,7 +185,7 @@ export interface CreateRSCHandlerOptions<
179
185
  /**
180
186
  * The RSC router instance
181
187
  */
182
- router: RSCRouterInternal<TEnv, TRoutes>;
188
+ router: RangoInternal<TEnv, TRoutes>;
183
189
 
184
190
  /**
185
191
  * RSC dependencies from @vitejs/plugin-rsc/rsc.
@@ -81,11 +81,11 @@ export type ResolveSearchSchema<T extends SearchSchema> = Simplify<
81
81
  // ============================================================================
82
82
 
83
83
  /** Resolve the global route map from RegisteredRoutes or GeneratedRouteMap. */
84
- type GlobalRouteMap = keyof RSCRouter.RegisteredRoutes extends never
85
- ? keyof RSCRouter.GeneratedRouteMap extends never
84
+ type GlobalRouteMap = keyof Rango.RegisteredRoutes extends never
85
+ ? keyof Rango.GeneratedRouteMap extends never
86
86
  ? Record<string, string>
87
- : RSCRouter.GeneratedRouteMap
88
- : RSCRouter.RegisteredRoutes;
87
+ : Rango.GeneratedRouteMap
88
+ : Rango.RegisteredRoutes;
89
89
 
90
90
  /**
91
91
  * Extract the resolved search params type for a named route.
@@ -0,0 +1,67 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * Stable Promise wrappers keyed on the component itself. Objects (React
5
+ * elements, functions, lazy payloads) land in a WeakMap so entries GC when
6
+ * the underlying component is released; primitives (string, number, boolean,
7
+ * null) land in a Map so memoization still applies to text-/null-backed
8
+ * segments like those in partial-update flows. Keeping this cache outside
9
+ * the segment eliminates preservation fields on ResolvedSegment — it survives
10
+ * reconciliation naturally because the component ref is what's stable.
11
+ *
12
+ * Browser-only. On the server each SSR render needs a fresh pending promise
13
+ * so Suspense can emit the loading fallback HTML before content streams. A
14
+ * shared already-resolved promise has `.status === "fulfilled"` attached by
15
+ * React on its first observation — subsequent `use()` calls return
16
+ * synchronously without suspending, so the Suspense fallback never makes it
17
+ * into the initial HTML. Route-definition components share refs across
18
+ * requests, so a global cache would leak tracked state between renders.
19
+ */
20
+ const IS_BROWSER = typeof window !== "undefined";
21
+ const objectContentCache = IS_BROWSER
22
+ ? new WeakMap<object, Promise<ReactNode>>()
23
+ : null;
24
+ const primitiveContentCache = IS_BROWSER
25
+ ? new Map<unknown, Promise<ReactNode>>()
26
+ : null;
27
+
28
+ /**
29
+ * Return a stable Promise wrapping `component`, memoized on the component ref.
30
+ *
31
+ * A fresh `Promise.resolve(component)` each render would suspend for one
32
+ * microtask and briefly commit the loading fallback inside Suspender — the
33
+ * intercept / parallel-slot flicker this indirection prevents. Reusing the
34
+ * same Promise ref keeps React's `use()` in "known fulfilled" state after
35
+ * the first observation.
36
+ *
37
+ * @internal
38
+ */
39
+ export function getMemoizedContentPromise(
40
+ component: ReactNode,
41
+ ): Promise<ReactNode> {
42
+ if (component instanceof Promise) {
43
+ return component as Promise<ReactNode>;
44
+ }
45
+
46
+ if (!objectContentCache || !primitiveContentCache) {
47
+ return Promise.resolve(component);
48
+ }
49
+
50
+ if (component !== null && typeof component === "object") {
51
+ const cached = objectContentCache.get(component);
52
+ if (cached) {
53
+ return cached;
54
+ }
55
+ const promise = Promise.resolve(component);
56
+ objectContentCache.set(component, promise);
57
+ return promise;
58
+ }
59
+
60
+ const cached = primitiveContentCache.get(component);
61
+ if (cached) {
62
+ return cached;
63
+ }
64
+ const promise = Promise.resolve(component);
65
+ primitiveContentCache.set(component, promise);
66
+ return promise;
67
+ }
@@ -0,0 +1,122 @@
1
+ import type { ResolvedSegment } from "./types.js";
2
+
3
+ /**
4
+ * Cache of aggregate Promise.all results keyed on the first loader's
5
+ * `loaderData` reference. Each entry holds the source refs it was built from
6
+ * plus the resulting Promise/array; lookup scans entries for the matching
7
+ * source array (typically a single entry, since distinct loader groups rarely
8
+ * share a first source). Object first-refs live in a WeakMap (auto-GC);
9
+ * primitive first-refs (strings/numbers/booleans/null) live in a Map so
10
+ * loaders that resolve to primitive data are memoized too — bounded in
11
+ * practice by the application's loader set.
12
+ *
13
+ * Keying externally means reconciliation's fresh segment objects no longer
14
+ * drop memoization — the cache survives as long as the underlying loader
15
+ * segments do, and GC collects entries when those loaders are released
16
+ * (object keys only).
17
+ *
18
+ * Browser-only. On the server each SSR render needs a fresh Promise so
19
+ * Suspense can actually suspend and emit the loading fallback HTML before
20
+ * content streams. A shared already-resolved promise has `.status` attached
21
+ * by React on first `use()`; subsequent observations return synchronously
22
+ * and skip the fallback. The zero-loader case is especially prone because
23
+ * every empty-loader site would otherwise share one promise across requests.
24
+ */
25
+ const IS_BROWSER = typeof window !== "undefined";
26
+
27
+ interface LoaderCacheEntry {
28
+ sources: any[];
29
+ promise: Promise<any[]> | any[];
30
+ }
31
+
32
+ const objectLoaderCache = IS_BROWSER
33
+ ? new WeakMap<object, LoaderCacheEntry[]>()
34
+ : null;
35
+ const primitiveLoaderCache = IS_BROWSER
36
+ ? new Map<unknown, LoaderCacheEntry[]>()
37
+ : null;
38
+
39
+ // In the browser, a single shared empty aggregate is safe (and desirable) —
40
+ // reusing the same resolved promise keeps React's `use()` in a known-fulfilled
41
+ // state across renders. On the server it would leak `.status = "fulfilled"`
42
+ // across requests and skip the Suspense fallback, so we rebuild on each call.
43
+ const SHARED_EMPTY_LOADER_PROMISE: Promise<any[]> | null = IS_BROWSER
44
+ ? Promise.resolve([])
45
+ : null;
46
+
47
+ function hasSameReferences(a: any[], b: any[]): boolean {
48
+ if (a.length !== b.length) {
49
+ return false;
50
+ }
51
+ for (let i = 0; i < a.length; i++) {
52
+ if (a[i] !== b[i]) {
53
+ return false;
54
+ }
55
+ }
56
+ return true;
57
+ }
58
+
59
+ function buildLoaderPromise(loaders: ResolvedSegment[]): Promise<any[]> {
60
+ if (loaders.length === 0) {
61
+ return Promise.resolve([]);
62
+ }
63
+ return Promise.all(
64
+ loaders.map((loader) =>
65
+ loader.loaderData instanceof Promise
66
+ ? loader.loaderData
67
+ : Promise.resolve(loader.loaderData),
68
+ ),
69
+ );
70
+ }
71
+
72
+ function isObjectLike(value: unknown): value is object {
73
+ return (
74
+ value !== null && (typeof value === "object" || typeof value === "function")
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Memoize an aggregate Promise.all for a set of loader segments. Reusing the
80
+ * same aggregate across renders — invalidated only when any underlying
81
+ * loader.loaderData ref changes — keeps React's `use()` in "known fulfilled"
82
+ * state and prevents a fresh Promise.all from suspending (and briefly
83
+ * committing the Suspense fallback) on every partial update that doesn't
84
+ * actually change loader data.
85
+ *
86
+ * @internal
87
+ */
88
+ export function getMemoizedLoaderPromise(
89
+ loaders: ResolvedSegment[],
90
+ ): Promise<any[]> | any[] {
91
+ if (loaders.length === 0) {
92
+ return SHARED_EMPTY_LOADER_PROMISE ?? buildLoaderPromise(loaders);
93
+ }
94
+ if (!objectLoaderCache || !primitiveLoaderCache) {
95
+ return buildLoaderPromise(loaders);
96
+ }
97
+
98
+ const sources = loaders.map((loader) => loader.loaderData);
99
+ const first = sources[0];
100
+ const entries = isObjectLike(first)
101
+ ? objectLoaderCache.get(first)
102
+ : primitiveLoaderCache.get(first);
103
+
104
+ if (entries) {
105
+ for (const entry of entries) {
106
+ if (hasSameReferences(entry.sources, sources)) {
107
+ return entry.promise;
108
+ }
109
+ }
110
+ }
111
+
112
+ const promise = buildLoaderPromise(loaders);
113
+ const newEntry: LoaderCacheEntry = { sources, promise };
114
+ if (entries) {
115
+ entries.push(newEntry);
116
+ } else if (isObjectLike(first)) {
117
+ objectLoaderCache.set(first, [newEntry]);
118
+ } else {
119
+ primitiveLoaderCache.set(first, [newEntry]);
120
+ }
121
+ return promise;
122
+ }
@@ -2,18 +2,16 @@ import * as React from "react";
2
2
  import { createElement, type ReactNode, type ComponentType } from "react";
3
3
  import { OutletProvider } from "./client.js";
4
4
  import { MountContextProvider } from "./browser/react/mount-context.js";
5
- import type {
6
- ResolvedSegment,
7
- LoaderDataResult,
8
- RootLayoutProps,
9
- } from "./types.js";
10
- import { isLoaderDataResult } from "./types.js";
5
+ import type { ResolvedSegment, RootLayoutProps } from "./types.js";
6
+ import { decodeLoaderResults } from "./decode-loader-results.js";
11
7
  import { invariant } from "./errors.js";
12
8
  import {
13
9
  RouteContentWrapper,
14
10
  LoaderBoundary,
15
11
  } from "./route-content-wrapper.js";
16
12
  import { RootErrorBoundary } from "./root-error-boundary.js";
13
+ import { getMemoizedContentPromise } from "./segment-content-promise.js";
14
+ import { getMemoizedLoaderPromise } from "./segment-loader-promise.js";
17
15
 
18
16
  // ViewTransition is only available in React experimental.
19
17
  // Access via namespace import to avoid compile-time errors on stable React.
@@ -61,56 +59,6 @@ function restoreParallelLoaderMarkers(
61
59
  return nextSegments ?? segments;
62
60
  }
63
61
 
64
- function hasSameReferences(a: unknown[] | undefined, b: unknown[]): boolean {
65
- if (!a || a.length !== b.length) {
66
- return false;
67
- }
68
-
69
- for (let i = 0; i < a.length; i++) {
70
- if (a[i] !== b[i]) {
71
- return false;
72
- }
73
- }
74
-
75
- return true;
76
- }
77
-
78
- /**
79
- * Resolve loader data from raw results, unwrapping LoaderDataResult wrappers
80
- */
81
- function resolveLoaderData(
82
- resolvedData: any[],
83
- loaderIds: string[],
84
- ): { loaderData: Record<string, any>; errorFallback: ReactNode } {
85
- const loaderData: Record<string, any> = {};
86
- let errorFallback: ReactNode = null;
87
-
88
- for (let i = 0; i < loaderIds.length; i++) {
89
- const id = loaderIds[i];
90
- const result = resolvedData[i];
91
-
92
- if (!isLoaderDataResult(result)) {
93
- // Legacy format - direct data
94
- loaderData[id] = result;
95
- continue;
96
- }
97
-
98
- if (result.ok) {
99
- loaderData[id] = result.data;
100
- continue;
101
- }
102
-
103
- // Error case
104
- if (result.fallback) {
105
- errorFallback = result.fallback;
106
- } else {
107
- throw new Error(result.error.message);
108
- }
109
- }
110
-
111
- return { loaderData, errorFallback };
112
- }
113
-
114
62
  /**
115
63
  * Options for renderSegments
116
64
  */
@@ -147,6 +95,50 @@ export interface RenderSegmentsOptions {
147
95
  rootLayout?: ComponentType<RootLayoutProps>;
148
96
  }
149
97
 
98
+ function createViewTransitionBoundary(
99
+ transition: NonNullable<ResolvedSegment["transition"]>,
100
+ children: ReactNode,
101
+ ): ReactNode {
102
+ // `viewTransition` is a router-specific flag (boundary opt-out), not a React
103
+ // <ViewTransition> prop — strip it so it never reaches React.
104
+ const { viewTransition: _viewTransition, ...vtProps } = transition;
105
+ return createElement(ReactViewTransition, {
106
+ ...vtProps,
107
+ children,
108
+ });
109
+ }
110
+
111
+ function wrapDefaultOutletContent(
112
+ content: ReactNode,
113
+ transition: NonNullable<ResolvedSegment["transition"]>,
114
+ ): ReactNode {
115
+ if (!React.isValidElement(content)) {
116
+ return createViewTransitionBoundary(transition, content);
117
+ }
118
+
119
+ const props = content.props as any;
120
+
121
+ if (content.type === MountContextProvider) {
122
+ return React.cloneElement(content, {
123
+ children: wrapDefaultOutletContent(props.children, transition),
124
+ } as any);
125
+ }
126
+
127
+ if (content.type === OutletProvider && props.segment?.type === "layout") {
128
+ return React.cloneElement(content, {
129
+ content: wrapDefaultOutletContent(props.content, transition),
130
+ } as any);
131
+ }
132
+
133
+ if (content.type === LoaderBoundary && props.segment?.type === "layout") {
134
+ return React.cloneElement(content, {
135
+ outletContent: wrapDefaultOutletContent(props.outletContent, transition),
136
+ } as any);
137
+ }
138
+
139
+ return createViewTransitionBoundary(transition, content);
140
+ }
141
+
150
142
  /**
151
143
  * Render segments into a React tree with proper layout nesting
152
144
  *
@@ -227,6 +219,25 @@ export async function renderSegments(
227
219
  }
228
220
  // Separate segments by type, passing intercept segments for explicit injection
229
221
  const tree = segmentTreeWalk(normalizedSegments, normalizedInterceptSegments);
222
+
223
+ // A route is "in a transition scope" when its own segment OR any layout in
224
+ // its matched chain declares transition(). Both transition() forms land here:
225
+ // the per-route item form sets transition on the route entry, and the block
226
+ // wrapper form sets it on a transparent ancestor layout (dsl-helpers.ts). When
227
+ // in scope, the route and its route-owned layouts use param-agnostic keys so a
228
+ // same-route navigation reconciles (holds content) instead of remounting. The
229
+ // value is a static property of the route's position in the tree, so it is the
230
+ // same on every render of that route (SSR, navigation, action) — the keys
231
+ // never drift. Cross-route navigation still remounts: different routes have
232
+ // different segment ids regardless of transition scope.
233
+ const inTransitionScope = normalizedSegments.some(
234
+ (s) =>
235
+ s.transition != null &&
236
+ (s.type === "layout" ||
237
+ s.type === "route" ||
238
+ s.type === "error" ||
239
+ s.type === "notFound"),
240
+ );
230
241
  // Render content segments as siblings
231
242
  let content: ReactNode = null;
232
243
  for (const node of tree) {
@@ -239,17 +250,31 @@ export async function renderSegments(
239
250
  );
240
251
  const { component, id, params, loading } = node.segment;
241
252
 
242
- // Only include params in key for segments that belong to the route
243
- // - Routes: always include params (they render param-specific content)
244
- // - Error/notFound segments: always include params (they replace failed route content)
245
- // - Route's layouts (orphans): include params (children of parameterized route)
246
- // - Parent chain layouts: exclude params (shared across routes, param-agnostic)
247
- // This prevents unnecessary unmounting when params change
253
+ // Param-agnostic keys are opt-in via the transition() DSL (see
254
+ // inTransitionScope above). A route (and its route-owned layouts) inside a
255
+ // transition scope drops the param from its key, so navigating between two
256
+ // param values of the SAME route (e.g. /product/1 -> /product/2) reconciles
257
+ // the route subtree instead of remounting it. Combined with the
258
+ // startTransition wrap that shouldStartViewTransition already applies to
259
+ // transition routes (browser/partial-update.ts), the previous content stays
260
+ // on screen while the new loaders resolve (stale-while-revalidate) instead
261
+ // of flashing the loading skeleton. This works on stable React; experimental
262
+ // React adds the animated <ViewTransition> cross-fade on top.
263
+ //
264
+ // Outside a transition scope the key stays param-bearing and the route
265
+ // remounts on param change (the default: a fresh skeleton and fresh
266
+ // component state).
267
+ //
268
+ // error/notFound always keep param-bearing keys: createErrorSegment reuses
269
+ // the boundary layout's shortCode as the error segment id (router/
270
+ // error-handling.ts), so a param-agnostic error key could collide with that
271
+ // layout's key within the same render.
248
272
  const includeParams =
249
- node.segment.type === "route" ||
250
273
  node.segment.type === "error" ||
251
274
  node.segment.type === "notFound" ||
252
- (node.segment.type === "layout" && node.segment.belongsToRoute);
275
+ ((node.segment.type === "route" ||
276
+ (node.segment.type === "layout" && node.segment.belongsToRoute)) &&
277
+ !inTransitionScope);
253
278
 
254
279
  const paramStr =
255
280
  includeParams && params && Object.keys(params).length > 0
@@ -278,10 +303,7 @@ export async function renderSegments(
278
303
  loading !== null && loading !== undefined && loading !== false
279
304
  ? createElement(RouteContentWrapper, {
280
305
  key: `suspense-loading-${id}`,
281
- content:
282
- resolvedComponent instanceof Promise
283
- ? resolvedComponent
284
- : Promise.resolve(resolvedComponent),
306
+ content: getMemoizedContentPromise(resolvedComponent),
285
307
  fallback: loading,
286
308
  segmentId: id,
287
309
  })
@@ -292,35 +314,51 @@ export async function renderSegments(
292
314
  // in transitions without adding custom animation classes. Named element-level
293
315
  // <ViewTransition> components inside (with name/share props) morph independently
294
316
  // from the parent's default cross-fade.
295
- if (ReactViewTransition && node.segment.transition) {
296
- nodeContent = createElement(ReactViewTransition, {
297
- ...node.segment.transition,
298
- children: nodeContent,
299
- });
300
- }
301
-
302
- // Common props for OutletProvider
303
- const outletContent: ReactNode =
317
+ //
318
+ // For layouts, wrap the outlet content (what `<Outlet />` renders) rather
319
+ // than the layout component itself. Parallel slots like `<ParallelOutlet
320
+ // name="@modal" />` read from a separate context channel and end up as
321
+ // siblings of the VT in the rendered tree, so modal mounts don't trigger a
322
+ // subtree update on the layout-level VT — which would otherwise make
323
+ // React's commit walker fire `document.startViewTransition` and apply
324
+ // view-transition-names to the underlying main subtree (cover/title/etc.).
325
+ //
326
+ // `transition.viewTransition === false` opts out of the router-owned
327
+ // boundary only. Driving (the startTransition wrap in browser/partial-update.ts
328
+ // and the param-agnostic key/hold below) keys off transition *presence*, not
329
+ // this flag, so a boundary-less transition still holds content and lets
330
+ // consumer-placed <ViewTransition> elements animate. The global
331
+ // createRouter({ viewTransition }) default is resolved into this field
332
+ // during segment resolution (only `false` is stamped; unset/"auto" is left
333
+ // as-is and means "wrap"), so this gate needs no router-option threading.
334
+ let outletContent: ReactNode =
304
335
  node.segment.type === "layout" ? content : null;
305
336
 
337
+ const transition = node.segment.transition;
338
+
339
+ if (
340
+ ReactViewTransition &&
341
+ transition &&
342
+ transition.viewTransition !== false
343
+ ) {
344
+ if (node.segment.type === "layout") {
345
+ outletContent = wrapDefaultOutletContent(outletContent, transition);
346
+ } else {
347
+ nodeContent = createViewTransitionBoundary(transition, nodeContent);
348
+ }
349
+ }
350
+
306
351
  // Prepare loader data if there are loaders
307
352
  const loaderIds = loaderEntries.map((loader) => loader.loaderId!);
308
- const loaderDataPromise =
309
- loaderEntries.length > 0
310
- ? Promise.all(
311
- loaderEntries.map((loader) =>
312
- loader.loaderData instanceof Promise
313
- ? loader.loaderData
314
- : Promise.resolve(loader.loaderData),
315
- ),
316
- )
317
- : Promise.resolve([]);
318
353
 
319
354
  // Use LoaderBoundary when loading is defined to maintain consistent tree structure
320
355
  // This ensures cached segments (which may not have loader segments) have the same
321
356
  // tree structure as fresh segments, preventing React remounts
322
357
  // If forceAwait or isAction is set, pre-resolve promises so LoaderBoundary won't suspend
323
358
  if (loading !== undefined && loading !== null) {
359
+ // Aggregate built here only — the loaderless and no-loading branches don't
360
+ // read it (the latter builds its own per-parallel promises).
361
+ const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
324
362
  content = createElement(LoaderBoundary, {
325
363
  key: `loader-boundary-${key}`,
326
364
  loaderDataPromise:
@@ -364,7 +402,7 @@ export async function renderSegments(
364
402
  )
365
403
  : Promise.resolve([]);
366
404
  const resolvedData = await layoutLoaderDataPromise;
367
- const { loaderData, errorFallback } = resolveLoaderData(
405
+ const { loaderData, errorFallback } = decodeLoaderResults(
368
406
  resolvedData,
369
407
  layoutLoaderIds,
370
408
  );
@@ -396,34 +434,12 @@ export async function renderSegments(
396
434
  continue;
397
435
  }
398
436
 
399
- const parallelLoaderIds = ownedLoaders.map((l) => l.loaderId!);
400
- const parallelLoaderSources = ownedLoaders.map((l) => l.loaderData);
401
- p.loaderIds = parallelLoaderIds;
402
-
403
- const shouldReuseParallelPromise =
404
- p.loaderDataPromise !== undefined &&
405
- hasSameReferences(p.parallelLoaderSources, parallelLoaderSources);
406
-
407
- const parallelLoaderDataPromise = shouldReuseParallelPromise
408
- ? p.loaderDataPromise
409
- : forceAwait || isAction
410
- ? await Promise.all(
411
- ownedLoaders.map((l) =>
412
- l.loaderData instanceof Promise
413
- ? l.loaderData
414
- : Promise.resolve(l.loaderData),
415
- ),
416
- )
417
- : Promise.all(
418
- ownedLoaders.map((l) =>
419
- l.loaderData instanceof Promise
420
- ? l.loaderData
421
- : Promise.resolve(l.loaderData),
422
- ),
423
- );
424
-
425
- p.loaderDataPromise = parallelLoaderDataPromise;
426
- p.parallelLoaderSources = parallelLoaderSources;
437
+ p.loaderIds = ownedLoaders.map((l) => l.loaderId!);
438
+ const aggregated = getMemoizedLoaderPromise(ownedLoaders);
439
+ p.loaderDataPromise =
440
+ (forceAwait || isAction) && aggregated instanceof Promise
441
+ ? await aggregated
442
+ : aggregated;
427
443
  }
428
444
  }
429
445