@rangojs/router 0.0.0-experimental.122 → 0.0.0-experimental.125

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 (260) hide show
  1. package/dist/bin/rango.js +10 -6
  2. package/dist/testing/vitest.js +82 -0
  3. package/dist/vite/index.js +55 -48
  4. package/package.json +61 -21
  5. package/skills/caching/SKILL.md +2 -1
  6. package/skills/hooks/SKILL.md +40 -29
  7. package/skills/host-router/SKILL.md +16 -2
  8. package/skills/intercept/SKILL.md +4 -2
  9. package/skills/layout/SKILL.md +11 -6
  10. package/skills/loader/SKILL.md +6 -2
  11. package/skills/middleware/SKILL.md +4 -2
  12. package/skills/migrate-nextjs/SKILL.md +3 -1
  13. package/skills/parallel/SKILL.md +9 -4
  14. package/skills/rango/SKILL.md +12 -0
  15. package/skills/route/SKILL.md +10 -2
  16. package/skills/testing/SKILL.md +129 -0
  17. package/skills/testing/bindings.md +89 -0
  18. package/skills/testing/cache-prerender.md +98 -0
  19. package/skills/testing/client-components.md +122 -0
  20. package/skills/testing/e2e-parity.md +125 -0
  21. package/skills/testing/flight.md +89 -0
  22. package/skills/testing/handles.md +129 -0
  23. package/skills/testing/loader.md +128 -0
  24. package/skills/testing/middleware.md +99 -0
  25. package/skills/testing/render-handler.md +118 -0
  26. package/skills/testing/response-routes.md +95 -0
  27. package/skills/testing/reverse-and-types.md +84 -0
  28. package/skills/testing/server-actions.md +107 -0
  29. package/skills/testing/server-tree.md +128 -0
  30. package/skills/testing/setup.md +120 -0
  31. package/src/__internal.ts +0 -65
  32. package/src/browser/action-coordinator.ts +1 -1
  33. package/src/browser/action-fence.ts +47 -0
  34. package/src/browser/cookie-name.ts +140 -0
  35. package/src/browser/event-controller.ts +1 -83
  36. package/src/browser/invalidate-client-cache.ts +52 -0
  37. package/src/browser/navigation-bridge.ts +14 -1
  38. package/src/browser/navigation-client.ts +14 -1
  39. package/src/browser/navigation-store-handle.ts +38 -0
  40. package/src/browser/navigation-store.ts +26 -51
  41. package/src/browser/navigation-transaction.ts +0 -32
  42. package/src/browser/partial-update.ts +1 -83
  43. package/src/browser/prefetch/cache.ts +6 -45
  44. package/src/browser/prefetch/fetch.ts +7 -0
  45. package/src/browser/prefetch/queue.ts +6 -3
  46. package/src/browser/rango-state.ts +157 -99
  47. package/src/browser/react/Link.tsx +0 -2
  48. package/src/browser/react/NavigationProvider.tsx +2 -1
  49. package/src/browser/react/ScrollRestoration.tsx +10 -6
  50. package/src/browser/react/filter-segment-order.ts +0 -2
  51. package/src/browser/react/index.ts +0 -51
  52. package/src/browser/react/location-state-shared.ts +0 -13
  53. package/src/browser/react/location-state.ts +0 -1
  54. package/src/browser/react/use-action.ts +6 -15
  55. package/src/browser/react/use-handle.ts +0 -5
  56. package/src/browser/react/use-link-status.ts +0 -4
  57. package/src/browser/react/use-navigation.ts +0 -3
  58. package/src/browser/react/use-params.ts +0 -2
  59. package/src/browser/react/use-search-params.ts +0 -5
  60. package/src/browser/react/use-segments.ts +0 -13
  61. package/src/browser/rsc-router.tsx +12 -4
  62. package/src/browser/server-action-bridge.ts +77 -15
  63. package/src/browser/types.ts +7 -2
  64. package/src/browser/validate-redirect-origin.ts +4 -5
  65. package/src/build/route-trie.ts +3 -0
  66. package/src/build/route-types/param-extraction.ts +6 -3
  67. package/src/build/route-types/router-processing.ts +0 -8
  68. package/src/cache/cache-policy.ts +0 -54
  69. package/src/cache/cache-runtime.ts +27 -24
  70. package/src/cache/cache-scope.ts +0 -27
  71. package/src/cache/cache-tag.ts +0 -37
  72. package/src/cache/cf/cf-cache-store.ts +94 -46
  73. package/src/cache/cf/index.ts +0 -24
  74. package/src/cache/document-cache.ts +11 -36
  75. package/src/cache/handle-snapshot.ts +0 -40
  76. package/src/cache/index.ts +0 -27
  77. package/src/cache/memory-segment-store.ts +2 -48
  78. package/src/cache/profile-registry.ts +7 -3
  79. package/src/cache/read-through-swr.ts +41 -11
  80. package/src/cache/segment-codec.ts +0 -16
  81. package/src/cache/types.ts +0 -98
  82. package/src/client.rsc.tsx +1 -22
  83. package/src/client.tsx +14 -38
  84. package/src/component-utils.ts +19 -0
  85. package/src/deps/ssr.ts +0 -1
  86. package/src/handle.ts +28 -18
  87. package/src/handles/MetaTags.tsx +0 -14
  88. package/src/handles/meta.ts +0 -39
  89. package/src/host/cookie-handler.ts +0 -36
  90. package/src/host/errors.ts +0 -24
  91. package/src/host/index.ts +6 -0
  92. package/src/host/pattern-matcher.ts +7 -50
  93. package/src/host/router.ts +1 -65
  94. package/src/host/testing.ts +40 -27
  95. package/src/host/types.ts +6 -2
  96. package/src/href-client.ts +0 -4
  97. package/src/index.rsc.ts +42 -3
  98. package/src/index.ts +31 -1
  99. package/src/internal-debug.ts +2 -4
  100. package/src/loader.rsc.ts +19 -9
  101. package/src/loader.ts +12 -4
  102. package/src/network-error-thrower.tsx +1 -6
  103. package/src/outlet-provider.tsx +1 -5
  104. package/src/prerender/param-hash.ts +10 -11
  105. package/src/prerender/store.ts +23 -30
  106. package/src/prerender.ts +58 -3
  107. package/src/root-error-boundary.tsx +1 -19
  108. package/src/route-content-wrapper.tsx +1 -44
  109. package/src/route-definition/dsl-helpers.ts +7 -19
  110. package/src/route-definition/helpers-types.ts +3 -3
  111. package/src/route-definition/redirect.ts +11 -1
  112. package/src/route-map-builder.ts +0 -16
  113. package/src/router/basename.ts +14 -0
  114. package/src/router/content-negotiation.ts +0 -13
  115. package/src/router/error-handling.ts +12 -16
  116. package/src/router/find-match.ts +4 -30
  117. package/src/router/intercept-resolution.ts +10 -1
  118. package/src/router/lazy-includes.ts +1 -57
  119. package/src/router/loader-resolution.ts +3 -2
  120. package/src/router/logging.ts +0 -6
  121. package/src/router/manifest.ts +1 -25
  122. package/src/router/match-api.ts +0 -20
  123. package/src/router/match-context.ts +0 -22
  124. package/src/router/match-handlers.ts +57 -58
  125. package/src/router/match-middleware/background-revalidation.ts +0 -7
  126. package/src/router/match-middleware/cache-lookup.ts +1 -54
  127. package/src/router/match-middleware/cache-store.ts +0 -31
  128. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  129. package/src/router/match-middleware/segment-resolution.ts +0 -21
  130. package/src/router/match-pipelines.ts +1 -42
  131. package/src/router/match-result.ts +1 -52
  132. package/src/router/metrics.ts +0 -34
  133. package/src/router/middleware-cookies.ts +0 -13
  134. package/src/router/middleware-types.ts +0 -115
  135. package/src/router/middleware.ts +7 -30
  136. package/src/router/navigation-snapshot.ts +0 -51
  137. package/src/router/params-util.ts +23 -0
  138. package/src/router/pattern-matching.ts +1 -33
  139. package/src/router/prerender-match.ts +33 -45
  140. package/src/router/request-classification.ts +1 -38
  141. package/src/router/revalidation.ts +5 -58
  142. package/src/router/router-context.ts +0 -26
  143. package/src/router/router-interfaces.ts +7 -0
  144. package/src/router/router-options.ts +30 -0
  145. package/src/router/segment-resolution/fresh.ts +25 -57
  146. package/src/router/segment-resolution/helpers.ts +34 -0
  147. package/src/router/segment-resolution/loader-cache.ts +10 -13
  148. package/src/router/segment-resolution/revalidation.ts +5 -42
  149. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  150. package/src/router/segment-resolution.ts +4 -1
  151. package/src/router/state-cookie-name.ts +33 -0
  152. package/src/router/telemetry-otel.ts +0 -20
  153. package/src/router/telemetry.ts +96 -19
  154. package/src/router/timeout.ts +0 -20
  155. package/src/router/trie-matching.ts +63 -40
  156. package/src/router/types.ts +1 -63
  157. package/src/router/url-params.ts +0 -5
  158. package/src/router.ts +40 -9
  159. package/src/rsc/handler.ts +14 -2
  160. package/src/rsc/helpers.ts +34 -0
  161. package/src/rsc/origin-guard.ts +0 -12
  162. package/src/rsc/progressive-enhancement.ts +4 -1
  163. package/src/rsc/rsc-rendering.ts +4 -7
  164. package/src/rsc/runtime-warnings.ts +14 -0
  165. package/src/rsc/server-action.ts +30 -28
  166. package/src/rsc/types.ts +2 -1
  167. package/src/runtime-env.ts +18 -0
  168. package/src/search-params.ts +0 -16
  169. package/src/segment-loader-promise.ts +14 -2
  170. package/src/segment-system.tsx +79 -88
  171. package/src/server/cookie-store.ts +52 -1
  172. package/src/server/handle-store.ts +7 -24
  173. package/src/server/loader-registry.ts +5 -24
  174. package/src/server/request-context.ts +74 -77
  175. package/src/ssr/index.tsx +14 -14
  176. package/src/static-handler.ts +10 -13
  177. package/src/testing/cache-status.ts +119 -0
  178. package/src/testing/collect-handle.ts +40 -0
  179. package/src/testing/dispatch.ts +581 -0
  180. package/src/testing/dom.entry.ts +22 -0
  181. package/src/testing/e2e/fixture.ts +188 -0
  182. package/src/testing/e2e/index.ts +127 -0
  183. package/src/testing/e2e/matchers.ts +35 -0
  184. package/src/testing/e2e/page-helpers.ts +272 -0
  185. package/src/testing/e2e/parity.ts +387 -0
  186. package/src/testing/e2e/server.ts +195 -0
  187. package/src/testing/flight-matchers.ts +97 -0
  188. package/src/testing/flight-normalize.ts +11 -0
  189. package/src/testing/flight-runtime.d.ts +57 -0
  190. package/src/testing/flight-tree.ts +682 -0
  191. package/src/testing/flight.entry.ts +52 -0
  192. package/src/testing/flight.ts +186 -0
  193. package/src/testing/generated-routes.ts +183 -0
  194. package/src/testing/index.ts +98 -0
  195. package/src/testing/internal/context.ts +348 -0
  196. package/src/testing/internal/flight-client-globals.ts +30 -0
  197. package/src/testing/internal/seed-vars.ts +54 -0
  198. package/src/testing/render-handler.ts +311 -0
  199. package/src/testing/render-route.tsx +504 -0
  200. package/src/testing/run-loader.ts +378 -0
  201. package/src/testing/run-middleware.ts +205 -0
  202. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  203. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  204. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  205. package/src/testing/vitest-stubs/version.ts +5 -0
  206. package/src/testing/vitest.ts +305 -0
  207. package/src/theme/ThemeProvider.tsx +0 -52
  208. package/src/theme/ThemeScript.tsx +0 -6
  209. package/src/theme/constants.ts +0 -12
  210. package/src/theme/index.ts +0 -7
  211. package/src/theme/theme-context.ts +1 -5
  212. package/src/theme/theme-script.ts +0 -14
  213. package/src/theme/use-theme.ts +0 -3
  214. package/src/types/boundaries.ts +0 -35
  215. package/src/types/error-types.ts +25 -89
  216. package/src/types/global-namespace.ts +15 -15
  217. package/src/types/handler-context.ts +16 -13
  218. package/src/types/index.ts +0 -10
  219. package/src/types/request-scope.ts +0 -19
  220. package/src/types/route-config.ts +6 -50
  221. package/src/types/route-entry.ts +0 -6
  222. package/src/types/segments.ts +0 -13
  223. package/src/urls/include-helper.ts +0 -4
  224. package/src/urls/index.ts +0 -6
  225. package/src/urls/path-helper-types.ts +2 -2
  226. package/src/urls/path-helper.ts +0 -54
  227. package/src/urls/urls-function.ts +0 -13
  228. package/src/use-loader.tsx +0 -186
  229. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  230. package/src/vite/discovery/discover-routers.ts +6 -7
  231. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  232. package/src/vite/plugin-types.ts +3 -1
  233. package/src/vite/plugins/cjs-to-esm.ts +0 -11
  234. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  235. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  236. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  237. package/src/vite/plugins/expose-action-id.ts +2 -73
  238. package/src/vite/plugins/expose-id-utils.ts +0 -55
  239. package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
  240. package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
  241. package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
  242. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  243. package/src/vite/plugins/expose-internal-ids.ts +10 -0
  244. package/src/vite/plugins/performance-tracks.ts +0 -3
  245. package/src/vite/plugins/use-cache-transform.ts +0 -36
  246. package/src/vite/plugins/version-injector.ts +0 -20
  247. package/src/vite/plugins/version-plugin.ts +1 -49
  248. package/src/vite/plugins/virtual-entries.ts +0 -15
  249. package/src/vite/rango.ts +1 -108
  250. package/src/vite/router-discovery.ts +2 -1
  251. package/src/vite/utils/ast-handler-extract.ts +0 -16
  252. package/src/vite/utils/bundle-analysis.ts +6 -13
  253. package/src/vite/utils/client-chunks.ts +0 -6
  254. package/src/vite/utils/forward-user-plugins.ts +0 -22
  255. package/src/vite/utils/manifest-utils.ts +0 -4
  256. package/src/vite/utils/package-resolution.ts +1 -73
  257. package/src/vite/utils/prerender-utils.ts +0 -35
  258. package/src/vite/utils/shared-utils.ts +3 -35
  259. package/src/browser/react/use-client-cache.ts +0 -58
  260. package/src/browser/shallow.ts +0 -40
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { createElement, type ReactNode, type ComponentType } from "react";
3
- import { OutletProvider } from "./client.js";
3
+ import { OutletProvider } from "./outlet-provider.js";
4
4
  import { MountContextProvider } from "./browser/react/mount-context.js";
5
5
  import type { ResolvedSegment, RootLayoutProps } from "./types.js";
6
6
  import { decodeLoaderResults } from "./decode-loader-results.js";
@@ -11,13 +11,25 @@ import {
11
11
  } from "./route-content-wrapper.js";
12
12
  import { RootErrorBoundary } from "./root-error-boundary.js";
13
13
  import { getMemoizedContentPromise } from "./segment-content-promise.js";
14
- import { getMemoizedLoaderPromise } from "./segment-loader-promise.js";
14
+ import {
15
+ buildLoaderPromise,
16
+ getMemoizedLoaderPromise,
17
+ } from "./segment-loader-promise.js";
15
18
 
16
19
  // ViewTransition is only available in React experimental.
17
20
  // Access via namespace import to avoid compile-time errors on stable React.
18
21
  const ReactViewTransition: any =
19
22
  "ViewTransition" in React ? (React as any).ViewTransition : null;
20
23
 
24
+ // A loading skeleton is renderable only when it is a real ReactNode value.
25
+ // `false` is treated as "not renderable" here. This is the three-term gate;
26
+ // the distinct two-term gate at the LoaderBoundary site deliberately treats
27
+ // `false` as "create a boundary without a RouteContentWrapper"
28
+ // (tree-structure.md), so it must NOT use this helper.
29
+ function isRenderableLoading(loading: ReactNode): boolean {
30
+ return loading !== undefined && loading !== null && loading !== false;
31
+ }
32
+
21
33
  function restoreParallelLoaderMarkers(
22
34
  segments: ResolvedSegment[],
23
35
  ): ResolvedSegment[] {
@@ -28,12 +40,7 @@ function restoreParallelLoaderMarkers(
28
40
  const segment = segments[i];
29
41
 
30
42
  if (segment.type === "parallel") {
31
- if (
32
- segment.namespace &&
33
- segment.loading !== undefined &&
34
- segment.loading !== null &&
35
- segment.loading !== false
36
- ) {
43
+ if (segment.namespace && isRenderableLoading(segment.loading)) {
37
44
  parallelLoadingByNamespace.set(segment.namespace, segment.loading);
38
45
  }
39
46
  continue;
@@ -142,8 +149,14 @@ function wrapDefaultOutletContent(
142
149
  /**
143
150
  * Render segments into a React tree with proper layout nesting
144
151
  *
145
- * Layouts nest using OutletProvider, while route + parallel + error + notFound segments
146
- * render as siblings in a Fragment.
152
+ * Layouts nest using OutletProvider; a layout receives the inner content via
153
+ * its `<Outlet />`. Parallel segments do NOT render as inline Fragment siblings
154
+ * — they flow through OutletContext.parallel and are resolved where a layout
155
+ * places `<ParallelOutlet name="@sidebar" />` (or `<Outlet name="@sidebar" />`).
156
+ *
157
+ * The result is always wrapped in RootErrorBoundary so unhandled errors never
158
+ * blank the screen. When `options.rootLayout` is provided it wraps the error
159
+ * boundary at the OUTERMOST level (so the app shell survives errors).
147
160
  *
148
161
  * Error segments are treated like route segments - they render their fallback
149
162
  * component in place of the failed segment. When an error occurs in a handler,
@@ -155,27 +168,30 @@ function wrapDefaultOutletContent(
155
168
  * notFoundBoundary's fallback component.
156
169
  *
157
170
  * @param segments - Array of resolved segments to render
158
- * @returns ReactNode representing the component tree
171
+ * @returns Promise resolving to the ReactNode tree (the function is async)
159
172
  *
160
173
  * @example
161
174
  * ```typescript
162
175
  * const segments = [
163
- * { id: 'L0.0', type: 'layout', component: <RootLayout /> },
164
- * { id: 'L1.0', type: 'layout', component: <BlogLayout /> },
165
- * { id: 'R2.0', type: 'route', component: <BlogPost /> },
166
- * { id: 'P3.0', type: 'parallel', component: <Sidebar />, slot: '@sidebar' }
176
+ * { id: 'L0.0', type: 'layout', component: <BlogLayout /> },
177
+ * { id: 'L0R1', type: 'route', component: <BlogPost /> },
178
+ * { id: 'L0R1.@sidebar', type: 'parallel', component: <Sidebar />, slot: '@sidebar' }
167
179
  * ];
168
180
  *
169
- * const tree = renderSegments(segments);
170
- * // Results in:
171
- * // <OutletProvider><RootLayout>
172
- * // <OutletProvider><BlogLayout>
173
- * // <><BlogPost /><Sidebar /></>
174
- * // </BlogLayout></OutletProvider>
175
- * // </RootLayout></OutletProvider>
181
+ * // BlogLayout renders <Outlet /> for the route and
182
+ * // <ParallelOutlet name="@sidebar" /> for the parallel slot.
183
+ * const tree = await renderSegments(segments, { rootLayout: RootLayout });
184
+ * // Results in (outermost first):
185
+ * // <RootLayout>
186
+ * // <RootErrorBoundary>
187
+ * // <OutletProvider segment={BlogLayout} parallel={[Sidebar]}>
188
+ * // <BlogPost />
189
+ * // </OutletProvider>
190
+ * // </RootErrorBoundary>
191
+ * // </RootLayout>
176
192
  *
177
193
  * // For server actions, pass isAction to await components:
178
- * const tree = renderSegments(segments, { isAction: true });
194
+ * const tree = await renderSegments(segments, { isAction: true });
179
195
  * ```
180
196
  */
181
197
  export async function renderSegments(
@@ -283,31 +299,25 @@ export async function renderSegments(
283
299
  .map(([k, v]) => `${k}=${v}`)
284
300
  .join(",")
285
301
  : "";
286
- const key = `${paramStr ? `${id}-${paramStr}` : id}`;
302
+ const key = paramStr ? `${id}-${paramStr}` : id;
287
303
 
288
- // Get loader entries for this node
289
304
  const loaderEntries = node.loaders.filter(
290
305
  (loader) => loader.loaderId && loader.loaderData !== undefined,
291
306
  );
292
307
 
293
- // Determine the component content (with or without Suspense wrapper)
294
- // Wrap when loading skeleton defined OR component is Promise (needs Suspense)
295
- // During actions, await component Promise to prevent Suspense from triggering
296
- // This keeps existing content visible instead of showing loading skeleton
297
308
  let resolvedComponent = component;
298
309
  if (isAction && component instanceof Promise) {
299
310
  resolvedComponent = await component;
300
311
  }
301
312
 
302
- let nodeContent: ReactNode =
303
- loading !== null && loading !== undefined && loading !== false
304
- ? createElement(RouteContentWrapper, {
305
- key: `suspense-loading-${id}`,
306
- content: getMemoizedContentPromise(resolvedComponent),
307
- fallback: loading,
308
- segmentId: id,
309
- })
310
- : registerLazyRef(resolvedComponent);
313
+ let nodeContent: ReactNode = isRenderableLoading(loading)
314
+ ? createElement(RouteContentWrapper, {
315
+ key: `suspense-loading-${id}`,
316
+ content: getMemoizedContentPromise(resolvedComponent),
317
+ fallback: loading,
318
+ segmentId: id,
319
+ })
320
+ : registerLazyRef(resolvedComponent);
311
321
 
312
322
  // Wrap with <ViewTransition> if transition config exists (React experimental only).
313
323
  // An empty config ({}) creates a bare <ViewTransition> boundary that participates
@@ -351,13 +361,7 @@ export async function renderSegments(
351
361
  // Prepare loader data if there are loaders
352
362
  const loaderIds = loaderEntries.map((loader) => loader.loaderId!);
353
363
 
354
- // Use LoaderBoundary when loading is defined to maintain consistent tree structure
355
- // This ensures cached segments (which may not have loader segments) have the same
356
- // tree structure as fresh segments, preventing React remounts
357
- // If forceAwait or isAction is set, pre-resolve promises so LoaderBoundary won't suspend
358
364
  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
365
  const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
362
366
  content = createElement(LoaderBoundary, {
363
367
  key: `loader-boundary-${key}`,
@@ -372,7 +376,6 @@ export async function renderSegments(
372
376
  children: nodeContent,
373
377
  });
374
378
  } else if (loaderEntries.length === 0) {
375
- // No loaders, no loading - simple OutletProvider
376
379
  content = createElement(OutletProvider, {
377
380
  key,
378
381
  content: outletContent,
@@ -381,34 +384,18 @@ export async function renderSegments(
381
384
  children: nodeContent,
382
385
  });
383
386
  } else {
384
- // Has loaders but no loading skeleton.
385
- // Split: parallel-owned loaders stream (their parallel has loading()),
386
- // layout-owned loaders are awaited (they gate the layout content).
387
387
  const layoutLoaders = loaderEntries.filter((l) => !l.parallelLoading);
388
388
  const parallelOwnedLoaders = loaderEntries.filter(
389
389
  (l) => !!l.parallelLoading,
390
390
  );
391
391
 
392
- // Await only layout-owned loaders
393
392
  const layoutLoaderIds = layoutLoaders.map((l) => l.loaderId!);
394
- const layoutLoaderDataPromise =
395
- layoutLoaders.length > 0
396
- ? Promise.all(
397
- layoutLoaders.map((l) =>
398
- l.loaderData instanceof Promise
399
- ? l.loaderData
400
- : Promise.resolve(l.loaderData),
401
- ),
402
- )
403
- : Promise.resolve([]);
404
- const resolvedData = await layoutLoaderDataPromise;
393
+ const resolvedData = await buildLoaderPromise(layoutLoaders);
405
394
  const { loaderData, errorFallback } = decodeLoaderResults(
406
395
  resolvedData,
407
396
  layoutLoaderIds,
408
397
  );
409
398
 
410
- // Parallel-owned loaders: attach to their owning parallel segment
411
- // as loaderDataPromise so ParallelOutlet wraps in LoaderBoundary
412
399
  if (parallelOwnedLoaders.length > 0) {
413
400
  const loadersByParallelNamespace = new Map<string, ResolvedSegment[]>();
414
401
 
@@ -465,8 +452,6 @@ export async function renderSegments(
465
452
  }
466
453
  }
467
454
 
468
- // Always wrap with root error boundary to prevent white screens
469
- // This catches any unhandled errors that bubble up from the segment tree
470
455
  const errorBoundaryWrapped = createElement(RootErrorBoundary, {
471
456
  children: content,
472
457
  });
@@ -474,11 +459,8 @@ export async function renderSegments(
474
459
  await Promise.allSettled(temporalLazyRefs);
475
460
  }
476
461
 
477
- // Build the final result, optionally wrapped with root layout
478
462
  let result: ReactNode = errorBoundaryWrapped;
479
463
 
480
- // If rootLayout is provided, wrap the error boundary with it
481
- // This ensures the app shell stays mounted even during errors (prevents FOUC)
482
464
  if (RootLayout) {
483
465
  result = createElement(RootLayout, {
484
466
  children: errorBoundaryWrapped,
@@ -516,6 +498,28 @@ export async function renderSegments(
516
498
  * @param segments - Main segments from the route tree
517
499
  * @param interceptSegments - Optional intercept segments to inject
518
500
  */
501
+ // Loader segment ids have the grammar `${parentId}D${index}.${loaderId}`.
502
+ // parentId is the parent shortCode (M/L/P/R/C + digits, never "D") for normal
503
+ // loaders, or `${shortCode}.${slotName}` for intercept-slot loaders, where the
504
+ // slot name is user-controlled (`@${string}`) and may contain an uppercase "D"
505
+ // (e.g. "@Detail"). Strip from the first `D<index>.` separator so the slot name
506
+ // is preserved; splitting on a bare "D" mis-cut "@Detail" to "@" and silently
507
+ // dropped the loader's data.
508
+ function loaderParentId(loaderSegmentId: string): string {
509
+ return loaderSegmentId.replace(/D\d+\..*$/, "");
510
+ }
511
+
512
+ // Append a value to the array stored under `key`, creating the array on first
513
+ // use. Single Map lookup (vs the has/get!().push double-lookup idiom).
514
+ function pushToGroup<K, V>(map: Map<K, V[]>, key: K, value: V): void {
515
+ const arr = map.get(key);
516
+ if (arr) {
517
+ arr.push(value);
518
+ } else {
519
+ map.set(key, [value]);
520
+ }
521
+ }
522
+
519
523
  function* segmentTreeWalk(
520
524
  segments: ResolvedSegment[],
521
525
  interceptSegments?: ResolvedSegment[],
@@ -536,19 +540,12 @@ function* segmentTreeWalk(
536
540
  // Extract parent ID from parallel ID
537
541
  // Example: "L0R1L0.@sidebar" → "L0R1L0"
538
542
  const parentId = segment.id.split(".")[0];
539
- if (!parallelsByParent.has(parentId)) {
540
- parallelsByParent.set(parentId, []);
541
- }
542
- parallelsByParent.get(parentId)!.push(segment);
543
+ pushToGroup(parallelsByParent, parentId, segment);
543
544
  } else if (segment.type === "loader") {
544
545
  // Extract parent ID from loader ID
545
- // Example: "L0D0.cart" → "L0"
546
- // Loader ID format: {parentShortCode}D{index}.{loaderId}
547
- const parentId = segment.id.split("D")[0];
548
- if (!loadersByParent.has(parentId)) {
549
- loadersByParent.set(parentId, []);
550
- }
551
- loadersByParent.get(parentId)!.push(segment);
546
+ // Example: "L0D0.cart" → "L0"; "L0.@DetailD0.x" → "L0.@Detail"
547
+ const parentId = loaderParentId(segment.id);
548
+ pushToGroup(loadersByParent, parentId, segment);
552
549
  } else {
553
550
  // Layout, route, error, and notFound segments are all rendered in the tree
554
551
  // Error/notFound segments replace the failed segment with fallback UI
@@ -563,17 +560,11 @@ function* segmentTreeWalk(
563
560
  if (intercept.type === "parallel" && intercept.slot) {
564
561
  // Extract parent ID from intercept ID (e.g., "M4L0L0L2.@modal" → "M4L0L0L2")
565
562
  const parentId = intercept.id.split(".")[0];
566
- if (!parallelsByParent.has(parentId)) {
567
- parallelsByParent.set(parentId, []);
568
- }
569
- parallelsByParent.get(parentId)!.push(intercept);
563
+ pushToGroup(parallelsByParent, parentId, intercept);
570
564
  } else if (intercept.type === "loader") {
571
- // Intercept loaders - extract parent from loader ID
572
- const parentId = intercept.id.split("D")[0];
573
- if (!loadersByParent.has(parentId)) {
574
- loadersByParent.set(parentId, []);
575
- }
576
- loadersByParent.get(parentId)!.push(intercept);
565
+ // Intercept loaders - extract parent from loader ID (slot name preserved)
566
+ const parentId = loaderParentId(intercept.id);
567
+ pushToGroup(loadersByParent, parentId, intercept);
577
568
  }
578
569
  }
579
570
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { CookieOptions } from "../router/middleware-types.js";
11
- import { getRequestContext } from "./request-context.js";
11
+ import { getRequestContext, _getRequestContext } from "./request-context.js";
12
12
  import { isInsideCacheScope } from "./context.js";
13
13
  import { INSIDE_CACHE_EXEC } from "../cache/taint.js";
14
14
 
@@ -168,6 +168,57 @@ export function headers(): ReadonlyHeaders {
168
168
  }) as unknown as ReadonlyHeaders;
169
169
  }
170
170
 
171
+ /**
172
+ * Force the calling client's caches to miss from now on, from the server seat:
173
+ * write a rotated `Set-Cookie` for the rango state. The responding client
174
+ * applies it on receipt, and its history cache is marked stale by the
175
+ * jar-divergence observer at its next read. Per-client and lazy — it rotates
176
+ * only the client that receives this response, not every client.
177
+ *
178
+ * Idempotent within a request (one `Set-Cookie`). Inert (a dev warning) when
179
+ * called outside a request context. Like `cookies()`, it throws inside a
180
+ * `"use cache"` / `cache()` boundary, but is allowed from a loader (loaders are
181
+ * the dynamic holes of a cached document).
182
+ */
183
+ export function invalidateClientCache(): void {
184
+ const ctx = _getRequestContext();
185
+ if (!ctx) {
186
+ if (process.env.NODE_ENV !== "production") {
187
+ console.warn(
188
+ "[rango] invalidateClientCache() was called outside a request context; ignored.",
189
+ );
190
+ }
191
+ return;
192
+ }
193
+ assertNotInsideCacheContext(ctx, "invalidateClientCache");
194
+ ctx._rotateStateCookie();
195
+ }
196
+
197
+ /**
198
+ * Suppress a server action's automatic client-cache invalidation: tell the
199
+ * action bridge this action changed nothing a route renders, so it should leave
200
+ * the client's state and caches alone (no rotation, no prefetch wipe, no
201
+ * broadcast, no revalidation refetch). Per-response, not per-action-definition —
202
+ * only the execution knows whether anything changed.
203
+ *
204
+ * Sets an internal response header the bridge reads. Idempotent within a
205
+ * request. Inert (a dev warning) outside a request context — there is no
206
+ * automatic invalidation to suppress.
207
+ */
208
+ export function keepClientCache(): void {
209
+ const ctx = _getRequestContext();
210
+ if (!ctx) {
211
+ if (process.env.NODE_ENV !== "production") {
212
+ console.warn(
213
+ "[rango] keepClientCache() was called outside a request context; ignored.",
214
+ );
215
+ }
216
+ return;
217
+ }
218
+ assertNotInsideCacheContext(ctx, "keepClientCache");
219
+ ctx._setKeepCacheDirective();
220
+ }
221
+
171
222
  /**
172
223
  * Create a CookieStore backed by a RequestContext.
173
224
  * @internal Shared between cookies() shorthand and context methods.
@@ -45,10 +45,6 @@ function createLateHandlePushError(
45
45
  return error;
46
46
  }
47
47
 
48
- /**
49
- * Deep clone handle data to create a snapshot.
50
- * @internal
51
- */
52
48
  function cloneHandleData(data: HandleData): HandleData {
53
49
  const clone: HandleData = {};
54
50
  for (const handleName in data) {
@@ -205,11 +201,9 @@ export function createHandleStore(): HandleStore {
205
201
  return {
206
202
  track<T>(promise: Promise<T>): Promise<T> {
207
203
  inflightCount++;
208
- // Use .then(onSettle, onSettle) instead of .finally() to avoid
209
- // creating an unhandled rejection branch when the tracked promise
210
- // rejects (e.g. error route handlers). .finally() re-throws the
211
- // rejection on a new branch that nobody catches, which can crash
212
- // the server process.
204
+ // Use .then() instead of .finally() to avoid creating an unhandled rejection
205
+ // branch when the promise rejects. .finally() re-throws on a new branch that
206
+ // can crash the process if not caught.
213
207
  const onSettle = () => {
214
208
  inflightCount--;
215
209
  notifyDrain();
@@ -255,42 +249,32 @@ export function createHandleStore(): HandleStore {
255
249
  },
256
250
 
257
251
  async *stream(): AsyncGenerator<HandleData, void, unknown> {
258
- // Auto-seal: stream() is called after all track() registrations.
259
252
  sealInternal();
260
253
 
261
- // Set up completion handler
262
254
  this.settled.then(() => {
263
255
  completed = true;
264
256
  signalEmission();
265
257
  });
266
258
 
267
- // Initial small delay to batch rapid synchronous pushes
268
- // This allows multiple handles pushing in quick succession to be batched
259
+ // Batch rapid synchronous pushes with initial delay
269
260
  await new Promise((resolve) => setTimeout(resolve, 0));
270
261
 
271
- // If we already have data, yield the accumulated state
272
262
  if (Object.keys(data).length > 0) {
273
- // Clear pending emissions since we're yielding current state
274
263
  pendingEmissions = [];
275
264
  const snapshot = cloneHandleData(data);
276
265
  yield snapshot;
277
266
  }
278
267
 
279
- // Continue streaming on each push
280
268
  while (!completed) {
281
269
  await waitForEmission();
282
270
 
283
- // Yield all pending emissions (yield latest only)
284
271
  if (pendingEmissions.length > 0) {
285
- // Skip intermediate states, yield the latest
286
272
  const latest = pendingEmissions[pendingEmissions.length - 1];
287
273
  pendingEmissions = [];
288
274
  yield latest;
289
275
  }
290
276
  }
291
277
 
292
- // Final yield only if there are pending emissions that weren't yielded
293
- // (handles that pushed after our last yield but before completion)
294
278
  if (pendingEmissions.length > 0) {
295
279
  yield cloneHandleData(data);
296
280
  }
@@ -314,12 +298,11 @@ export function createHandleStore(): HandleStore {
314
298
  if (!data[handleName]) {
315
299
  data[handleName] = {};
316
300
  }
317
- // Replace with replayed data (not append) to avoid handle bleeding between routes.
318
- // When a cached segment is restored, its handles should replace any existing data
319
- // for that segment, not accumulate on top of data from a different route.
301
+ // Replace (not append) to avoid handle bleeding between routes.
302
+ // Cached segment restoration should replace existing data for that
303
+ // segment, not accumulate on top of data from a different route.
320
304
  data[handleName][segmentId] = [...segmentHandles[handleName]];
321
305
  }
322
- // Trigger emission for streaming
323
306
  pendingEmissions.push(cloneHandleData(data));
324
307
  signalEmission();
325
308
  },
@@ -11,17 +11,10 @@ import {
11
11
  type LoaderRegistryEntry,
12
12
  } from "./fetchable-loader-store.js";
13
13
 
14
- // Server-side cache - maps loader $$id to function and middleware
15
- // This is a CACHE populated by getLoaderLazy() when loaders are first accessed.
16
- // The source of truth is fetchableLoaderRegistry in loader.ts, which is populated
17
- // when createLoader() runs. This cache exists to:
18
- // 1. Avoid repeated lookups/imports for the same loader
19
- // 2. Support lazy loading in production (loaders imported on-demand)
20
- // 3. Provide a stable reference for the RSC handler
14
+ // Cache populated by getLoaderLazy() when loaders are first accessed.
15
+ // Source of truth is fetchableLoaderRegistry in loader.ts (populated on createLoader).
21
16
  const loaderRegistry = new Map<string, LoaderRegistryEntry>();
22
17
 
23
- // Lazy import map - set by the loader manifest
24
- // Maps loader $$id to a function that imports the loader module
25
18
  type LazyLoaderImport = () => Promise<{ $$id: string }>;
26
19
  let lazyLoaderImports: Map<string, LazyLoaderImport> | null = null;
27
20
 
@@ -44,30 +37,25 @@ export function setLoaderImports(
44
37
  export async function getLoaderLazy(
45
38
  id: string,
46
39
  ): Promise<LoaderRegistryEntry | undefined> {
47
- // Always check fetchableLoaderRegistry first — it's the source of truth.
48
- // createLoader() updates it during module re-evaluation (HMR), so checking
49
- // here ensures we pick up the fresh function after a loader file change.
40
+ // Check fetchableLoaderRegistry first — it's the source of truth.
41
+ // createLoader() updates it on HMR, ensuring fresh functions after file changes.
50
42
  const fetchable = getFetchableLoader(id);
51
43
  if (fetchable) {
52
44
  loaderRegistry.set(id, fetchable);
53
45
  return fetchable;
54
46
  }
55
47
 
56
- // Fall back to local cache (populated by previous lazy imports in production)
57
48
  const existing = loaderRegistry.get(id);
58
49
  if (existing) {
59
50
  return existing;
60
51
  }
61
52
 
62
- // Try to lazy load from the import map (production mode)
63
53
  if (lazyLoaderImports && lazyLoaderImports.size > 0) {
64
54
  const lazyImport = lazyLoaderImports.get(id);
65
55
  if (lazyImport) {
66
56
  try {
67
- // Import the loader module - this triggers createLoader which registers fn
68
57
  await lazyImport();
69
58
 
70
- // Now try to get from fetchable registry (createLoader registered it)
71
59
  const registered = getFetchableLoader(id);
72
60
  if (registered) {
73
61
  loaderRegistry.set(id, registered);
@@ -79,18 +67,14 @@ export async function getLoaderLazy(
79
67
  }
80
68
  }
81
69
 
82
- // Dev mode fallback: parse the ID and use Vite's dynamic import
83
- // ID format in dev: "src/path/to/file.ts#ExportName"
70
+ // Dev fallback: parse ID (format: "src/path/to/file.ts#ExportName") and import
84
71
  const hashIndex = id.indexOf("#");
85
72
  if (hashIndex !== -1) {
86
73
  const filePath = id.slice(0, hashIndex);
87
74
 
88
75
  try {
89
- // In dev mode, Vite handles dynamic imports
90
- // Just importing the module triggers createLoader which registers the fn
91
76
  await import(/* @vite-ignore */ `/${filePath}`);
92
77
 
93
- // Now try to get from fetchable registry
94
78
  const registered = getFetchableLoader(id);
95
79
  if (registered) {
96
80
  loaderRegistry.set(id, registered);
@@ -115,15 +99,12 @@ export function registerLoaderById(loader: {
115
99
  if (!loader.$$id) {
116
100
  return;
117
101
  }
118
- // For fetchable loaders, fn is stored in the fetchable registry by $$id.
119
- // Always re-check the fetchable registry so HMR picks up the new function.
120
102
  const fetchable = getFetchableLoader(loader.$$id);
121
103
  if (fetchable) {
122
104
  loaderRegistry.set(loader.$$id, fetchable);
123
105
  return;
124
106
  }
125
107
 
126
- // Fall back to using fn from the loader object (non-fetchable loaders)
127
108
  if (loader.fn) {
128
109
  loaderRegistry.set(loader.$$id, {
129
110
  fn: loader.fn,