@rangojs/router 0.21.0 → 0.22.1

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 (244) hide show
  1. package/dist/bin/rango.js +13 -1
  2. package/dist/types/browser/event-controller.d.ts +30 -13
  3. package/dist/types/browser/navigation-store.d.ts +9 -0
  4. package/dist/types/browser/partial-update.d.ts +2 -1
  5. package/dist/types/browser/prefetch/default-strategy.d.ts +16 -20
  6. package/dist/types/browser/react/NavigationProvider.d.ts +14 -1
  7. package/dist/types/browser/react/context.d.ts +14 -0
  8. package/dist/types/browser/rsc-router.d.ts +13 -2
  9. package/dist/types/browser/types.d.ts +10 -0
  10. package/dist/types/build/generate-manifest.d.ts +2 -0
  11. package/dist/types/cache/background-task.d.ts +22 -0
  12. package/dist/types/cache/cache-key-utils.d.ts +50 -5
  13. package/dist/types/cache/cache-policy.d.ts +17 -0
  14. package/dist/types/cache/cache-scope.d.ts +1 -1
  15. package/dist/types/cache/cf/cf-cache-store.d.ts +7 -1
  16. package/dist/types/cache/document-cache.d.ts +8 -5
  17. package/dist/types/cache/handle-snapshot.d.ts +23 -0
  18. package/dist/types/cache/index.d.ts +1 -1
  19. package/dist/types/cache/invalidation-order.d.ts +14 -0
  20. package/dist/types/cache/memory-segment-store.d.ts +33 -1
  21. package/dist/types/cache/shell-snapshot.d.ts +10 -1
  22. package/dist/types/cache/store-scope.d.ts +22 -0
  23. package/dist/types/cache/tag-invalidation.d.ts +7 -6
  24. package/dist/types/cache/types.d.ts +63 -14
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +7 -1
  26. package/dist/types/errors.d.ts +6 -0
  27. package/dist/types/handles/deferred-resolution.d.ts +7 -0
  28. package/dist/types/internal-suspense-audit.d.ts +1 -0
  29. package/dist/types/loader-store.d.ts +4 -8
  30. package/dist/types/outlet-provider.d.ts +2 -1
  31. package/dist/types/prerender/cloudflare.d.ts +49 -0
  32. package/dist/types/prerender/create-prerender-trigger.d.ts +121 -0
  33. package/dist/types/prerender/index.d.ts +12 -0
  34. package/dist/types/prerender/memory-prerender-store.d.ts +29 -0
  35. package/dist/types/prerender/on-demand.d.ts +272 -0
  36. package/dist/types/prerender/producer-guard.d.ts +33 -0
  37. package/dist/types/prerender/shell-manifest-key.d.ts +1 -18
  38. package/dist/types/prerender/warm-request.d.ts +65 -0
  39. package/dist/types/prerender/warm.d.ts +65 -0
  40. package/dist/types/prerender/writable-store.d.ts +170 -0
  41. package/dist/types/prerender.d.ts +27 -0
  42. package/dist/types/reverse.d.ts +1 -1
  43. package/dist/types/route-content-wrapper.d.ts +3 -1
  44. package/dist/types/router/error-handling.d.ts +2 -0
  45. package/dist/types/router/handler-context.d.ts +1 -1
  46. package/dist/types/router/match-middleware/cache-lookup.d.ts +14 -0
  47. package/dist/types/router/pattern-matching.d.ts +2 -0
  48. package/dist/types/router/prefetch-default.d.ts +4 -4
  49. package/dist/types/router/prerender-match.d.ts +23 -1
  50. package/dist/types/router/route-trie-builder.d.ts +8 -1
  51. package/dist/types/router/router-context.d.ts +1 -0
  52. package/dist/types/router/router-interfaces.d.ts +30 -0
  53. package/dist/types/router/router-options.d.ts +24 -0
  54. package/dist/types/router/segment-resolution/fresh.d.ts +7 -0
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +2 -1
  56. package/dist/types/router/segment-resolution/revalidation.d.ts +5 -1
  57. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +43 -0
  58. package/dist/types/router/trie-matching.d.ts +2 -0
  59. package/dist/types/rsc/shell-build-manifest.d.ts +6 -4
  60. package/dist/types/rsc/shell-capture-constants.d.ts +20 -5
  61. package/dist/types/rsc/shell-capture.d.ts +14 -1
  62. package/dist/types/rsc/shell-serve.d.ts +2 -2
  63. package/dist/types/rsc/ssr-module-loader.d.ts +15 -0
  64. package/dist/types/rsc/types.d.ts +8 -0
  65. package/dist/types/segment-boundary-content.d.ts +17 -0
  66. package/dist/types/segment-loader-promise.d.ts +2 -6
  67. package/dist/types/server/build-version-table.d.ts +9 -0
  68. package/dist/types/server/context.d.ts +12 -4
  69. package/dist/types/server/handle-store.d.ts +2 -0
  70. package/dist/types/server/request-context.d.ts +63 -2
  71. package/dist/types/suspense-audit-react.d.ts +26 -0
  72. package/dist/types/suspense-audit.d.ts +157 -0
  73. package/dist/types/testing/action-form.d.ts +34 -0
  74. package/dist/types/testing/flight.entry.d.ts +2 -0
  75. package/dist/types/testing/index.d.ts +4 -0
  76. package/dist/types/testing/internal/server-modules.d.ts +8 -0
  77. package/dist/types/testing/serve-shell-request.d.ts +34 -4
  78. package/dist/types/testing/shell-status.d.ts +14 -6
  79. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +9 -6
  80. package/dist/types/thenable-status.d.ts +6 -0
  81. package/dist/types/types/handler-context.d.ts +7 -0
  82. package/dist/types/types/route-entry.d.ts +5 -0
  83. package/dist/types/urls/path-helper.d.ts +18 -0
  84. package/dist/types/vite/discovery/state.d.ts +11 -0
  85. package/dist/types/vite/inject-client-debug.d.ts +6 -25
  86. package/dist/types/vite/utils/bundle-analysis.d.ts +6 -2
  87. package/dist/vite/index.js +180 -497
  88. package/package.json +13 -3
  89. package/skills/cache-guide/SKILL.md +18 -17
  90. package/skills/caching/SKILL.md +40 -4
  91. package/skills/catalog.json +1 -1
  92. package/skills/cloudflare/SKILL.md +131 -0
  93. package/skills/comparison/references/framework-comparison.md +1 -1
  94. package/skills/deployment-caching/SKILL.md +33 -12
  95. package/skills/document-cache/SKILL.md +16 -14
  96. package/skills/hooks/SKILL.md +22 -0
  97. package/skills/hooks/handle-and-actions.md +3 -4
  98. package/skills/hooks/navigation.md +10 -0
  99. package/skills/hooks/state.md +5 -4
  100. package/skills/host-router/SKILL.md +18 -0
  101. package/skills/loader/SKILL.md +6 -4
  102. package/skills/middleware/SKILL.md +1 -1
  103. package/skills/migrate-nextjs/SKILL.md +82 -9
  104. package/skills/ppr/SKILL.md +31 -8
  105. package/skills/prerender/SKILL.md +645 -8
  106. package/skills/rango/SKILL.md +1 -0
  107. package/skills/response-routes/SKILL.md +5 -5
  108. package/skills/route/SKILL.md +29 -0
  109. package/skills/testing/SKILL.md +2 -1
  110. package/skills/testing/cache-prerender.md +18 -17
  111. package/skills/testing/serve-shell-request.md +104 -8
  112. package/skills/use-cache/SKILL.md +9 -5
  113. package/skills/vercel/SKILL.md +2 -0
  114. package/skills/view-transitions/SKILL.md +9 -4
  115. package/src/browser/event-controller.ts +69 -25
  116. package/src/browser/link-interceptor.ts +2 -2
  117. package/src/browser/navigation-bridge.ts +75 -35
  118. package/src/browser/navigation-store.ts +26 -0
  119. package/src/browser/navigation-transaction.ts +9 -2
  120. package/src/browser/network-error-handler.ts +2 -0
  121. package/src/browser/partial-update.ts +48 -12
  122. package/src/browser/prefetch/default-strategy.ts +22 -42
  123. package/src/browser/react/Link.tsx +37 -42
  124. package/src/browser/react/NavigationProvider.tsx +63 -3
  125. package/src/browser/react/context.ts +17 -0
  126. package/src/browser/react/location-state.ts +5 -19
  127. package/src/browser/react/use-action.ts +4 -1
  128. package/src/browser/react/use-handle.ts +4 -20
  129. package/src/browser/react/use-link-status.ts +4 -3
  130. package/src/browser/react/use-navigation.ts +4 -1
  131. package/src/browser/react/use-params.ts +4 -1
  132. package/src/browser/react/use-pathname.ts +4 -1
  133. package/src/browser/react/use-search-params.ts +5 -3
  134. package/src/browser/react/use-segments.ts +36 -54
  135. package/src/browser/rsc-router.tsx +79 -39
  136. package/src/browser/segment-reconciler.ts +3 -3
  137. package/src/browser/server-action-bridge.ts +26 -6
  138. package/src/browser/types.ts +11 -0
  139. package/src/build/generate-manifest.ts +17 -0
  140. package/src/build/merge-full-manifests.ts +5 -0
  141. package/src/build/route-trie.ts +1 -0
  142. package/src/cache/background-task.ts +61 -0
  143. package/src/cache/cache-key-utils.ts +91 -12
  144. package/src/cache/cache-policy.ts +11 -2
  145. package/src/cache/cache-runtime.ts +23 -23
  146. package/src/cache/cache-scope.ts +38 -36
  147. package/src/cache/cf/cf-cache-store.ts +16 -11
  148. package/src/cache/document-cache.ts +35 -14
  149. package/src/cache/handle-snapshot.ts +50 -0
  150. package/src/cache/index.ts +1 -0
  151. package/src/cache/invalidation-order.ts +53 -0
  152. package/src/cache/memory-segment-store.ts +52 -1
  153. package/src/cache/shell-snapshot.ts +17 -0
  154. package/src/cache/store-scope.ts +36 -0
  155. package/src/cache/tag-invalidation.ts +7 -5
  156. package/src/cache/types.ts +69 -14
  157. package/src/cache/vercel/vercel-cache-store.ts +20 -6
  158. package/src/client.tsx +40 -3
  159. package/src/errors.ts +13 -6
  160. package/src/handles/deferred-resolution.ts +7 -2
  161. package/src/internal-suspense-audit.ts +12 -0
  162. package/src/loader-store.ts +4 -9
  163. package/src/outlet-provider.tsx +25 -1
  164. package/src/prerender/cloudflare.ts +130 -0
  165. package/src/prerender/create-prerender-trigger.ts +780 -0
  166. package/src/prerender/index.ts +39 -0
  167. package/src/prerender/memory-prerender-store.ts +115 -0
  168. package/src/prerender/on-demand.ts +339 -0
  169. package/src/prerender/producer-guard.ts +66 -0
  170. package/src/prerender/shell-manifest-key.ts +12 -17
  171. package/src/prerender/warm-request.ts +154 -0
  172. package/src/prerender/warm.ts +193 -0
  173. package/src/prerender/writable-store.ts +278 -0
  174. package/src/prerender.ts +29 -0
  175. package/src/reverse.ts +1 -1
  176. package/src/route-content-wrapper.tsx +107 -13
  177. package/src/router/error-handling.ts +12 -12
  178. package/src/router/find-match.ts +1 -0
  179. package/src/router/handler-context.ts +2 -0
  180. package/src/router/intercept-resolution.ts +15 -8
  181. package/src/router/loader-resolution.ts +2 -10
  182. package/src/router/match-middleware/background-revalidation.ts +2 -0
  183. package/src/router/match-middleware/cache-lookup.ts +230 -38
  184. package/src/router/pattern-matching.ts +14 -0
  185. package/src/router/prefetch-default.ts +4 -4
  186. package/src/router/prerender-match.ts +460 -352
  187. package/src/router/route-trie-builder.ts +9 -0
  188. package/src/router/router-context.ts +1 -0
  189. package/src/router/router-interfaces.ts +33 -0
  190. package/src/router/router-options.ts +30 -0
  191. package/src/router/segment-resolution/fresh.ts +20 -19
  192. package/src/router/segment-resolution/loader-cache.ts +15 -17
  193. package/src/router/segment-resolution/revalidation.ts +34 -24
  194. package/src/router/segment-resolution/streamed-handler-boundary.ts +133 -0
  195. package/src/router/trie-matching.ts +3 -0
  196. package/src/router.ts +107 -1
  197. package/src/rsc/handler.ts +51 -4
  198. package/src/rsc/progressive-enhancement.ts +5 -1
  199. package/src/rsc/response-cache-serve.ts +10 -4
  200. package/src/rsc/rsc-rendering.ts +53 -11
  201. package/src/rsc/shell-build-manifest.ts +10 -8
  202. package/src/rsc/shell-capture-constants.ts +39 -7
  203. package/src/rsc/shell-capture.ts +108 -19
  204. package/src/rsc/shell-serve.ts +12 -6
  205. package/src/rsc/ssr-module-loader.ts +26 -0
  206. package/src/rsc/types.ts +8 -0
  207. package/src/segment-boundary-content.ts +25 -0
  208. package/src/segment-loader-promise.ts +28 -38
  209. package/src/segment-system.tsx +117 -57
  210. package/src/server/build-version-table.ts +15 -0
  211. package/src/server/context.ts +40 -17
  212. package/src/server/cookie-store.ts +5 -0
  213. package/src/server/handle-store.ts +13 -0
  214. package/src/server/loader-registry.ts +6 -1
  215. package/src/server/request-context.ts +87 -1
  216. package/src/ssr/ssr-root.tsx +20 -13
  217. package/src/suspense-audit-react.tsx +98 -0
  218. package/src/suspense-audit.ts +1030 -0
  219. package/src/testing/action-form.ts +66 -0
  220. package/src/testing/flight-runtime.d.ts +27 -0
  221. package/src/testing/flight.entry.ts +3 -0
  222. package/src/testing/index.ts +19 -0
  223. package/src/testing/internal/server-modules.ts +32 -0
  224. package/src/testing/render-route.tsx +25 -12
  225. package/src/testing/serve-shell-request.ts +190 -34
  226. package/src/testing/shell-status.ts +17 -6
  227. package/src/testing/vitest-stubs/plugin-rsc.ts +37 -11
  228. package/src/thenable-status.ts +10 -0
  229. package/src/types/handler-context.ts +7 -0
  230. package/src/types/route-entry.ts +6 -0
  231. package/src/urls/path-helper.ts +69 -9
  232. package/src/use-loader.tsx +57 -30
  233. package/src/vite/discovery/bundle-postprocess.ts +4 -0
  234. package/src/vite/discovery/discover-routers.ts +15 -0
  235. package/src/vite/discovery/shell-prerender-phase.ts +5 -5
  236. package/src/vite/discovery/state.ts +17 -1
  237. package/src/vite/inject-client-debug.ts +42 -20
  238. package/src/vite/plugins/virtual-entries.ts +8 -1
  239. package/src/vite/router-discovery.ts +14 -0
  240. package/src/vite/utils/bundle-analysis.ts +39 -7
  241. package/dist/types/browser/react/subscribe-to-nothing.d.ts +0 -7
  242. package/dist/types/segment-content-promise.d.ts +0 -13
  243. package/src/browser/react/subscribe-to-nothing.ts +0 -7
  244. package/src/segment-content-promise.ts +0 -67
@@ -17,10 +17,15 @@
17
17
  *
18
18
  * Without the condition React's server build refuses to load, so the codec
19
19
  * functions throw and a "use cache" call runs uncached. Any other load failure
20
- * (a moved vendored path, a missing peer) throws. The action entries stay
21
- * inert either way: no primitive decodes an action request.
20
+ * (a moved vendored path, a missing peer) throws. decodeAction and
21
+ * decodeFormState decode a no-JS form POST under the condition, resolving its
22
+ * action from the modules createActionForm() registered, as do
23
+ * `loadServerAction` and `decodeReply` for a plain form action. Without the
24
+ * condition they stay inert.
22
25
  */
23
26
 
27
+ import { loadServerModule } from "../internal/server-modules.js";
28
+
24
29
  /** React's server build throws this on load without the react-server condition. */
25
30
  const MISSING_REACT_SERVER_CONDITION =
26
31
  /"react-server" condition must be enabled/;
@@ -52,11 +57,7 @@ async function loadFlightRuntime() {
52
57
  // Resolves the `$$decode-client:` / `$$preserve:` ids a cached value's client
53
58
  // and server references decode to; any other id would load a server module.
54
59
  core.setRequireModule({
55
- load: (id) => {
56
- throw new Error(
57
- `plugin-rsc stub: server reference module "${id}" is not loadable in a test`,
58
- );
59
- },
60
+ load: (id) => loadServerModule(id),
60
61
  });
61
62
  return { server, client, core };
62
63
  }
@@ -121,7 +122,32 @@ export const encodeReply = (
121
122
  : unavailable("encodeReply");
122
123
  export const createClientTemporaryReferenceSet = (): unknown =>
123
124
  runtime ? runtime.client.createTemporaryReferenceSet() : {};
124
- export const loadServerAction = (): undefined => undefined;
125
- export const decodeReply = (): undefined => undefined;
126
- export const decodeAction = (): undefined => undefined;
127
- export const decodeFormState = (): undefined => undefined;
125
+ export const loadServerAction = (id: string): Promise<unknown> | undefined => {
126
+ if (!runtime) return undefined;
127
+ const [file, name] = id.split("#");
128
+ return Promise.resolve(loadServerModule(file!)[name!]);
129
+ };
130
+ export const decodeReply = (
131
+ body: string | FormData,
132
+ options?: object,
133
+ ): Promise<unknown> | undefined =>
134
+ runtime
135
+ ? runtime.server.decodeReply(body, referenceManifest("$$server:"), options)
136
+ : undefined;
137
+ export const decodeAction = (
138
+ body: FormData,
139
+ ): Promise<unknown> | null | undefined =>
140
+ runtime
141
+ ? runtime.server.decodeAction(body, referenceManifest("$$server:"))
142
+ : undefined;
143
+ export const decodeFormState = (
144
+ actionResult: unknown,
145
+ body: FormData,
146
+ ): Promise<unknown> | undefined =>
147
+ runtime
148
+ ? runtime.server.decodeFormState(
149
+ actionResult,
150
+ body,
151
+ referenceManifest("$$server:"),
152
+ )
153
+ : undefined;
@@ -14,3 +14,13 @@ export function unwrapsSynchronously(stream: Promise<unknown>): boolean {
14
14
  status === "resolved_module"
15
15
  );
16
16
  }
17
+
18
+ /**
19
+ * A resolved thenable for a use() whose result is ignored: React reads the
20
+ * "fulfilled" stamp and returns at once. `_debugInfo` keeps React DevTools from
21
+ * listing a 0 ms promise on every reader that calls it.
22
+ */
23
+ export const SETTLED_THENABLE: Promise<undefined> = Object.assign(
24
+ Promise.resolve(undefined),
25
+ { status: "fulfilled", value: undefined, _debugInfo: [] },
26
+ );
@@ -449,6 +449,13 @@ export type InternalHandlerContext<
449
449
  _responseType?: string;
450
450
  /** Route name for cache key scoping (prevents cross-route collisions). */
451
451
  _routeName?: string;
452
+ /**
453
+ * @internal The on-demand overlay holds a "removed" marker for this request
454
+ * (tryPrerenderLookup, cache-lookup.ts); gateOnDemandProducer reads it. On
455
+ * this context, not the request's: withCacheLookup documents that the
456
+ * request ALS can be lost mid-pipeline on workerd.
457
+ */
458
+ _prerenderRemoved?: true;
452
459
  /**
453
460
  * @internal Loader-cache override table: loaderId -> memoized data promise.
454
461
  * A single stable ctx.use interceptor consults this instead of chaining one
@@ -82,6 +82,12 @@ export interface RouteEntry<TEnv = any> {
82
82
  */
83
83
  passthroughRouteKeys?: Set<string>;
84
84
 
85
+ /**
86
+ * Route keys in this entry that opted into on-demand prerender.
87
+ * Used by the non-trie match path to set the `od` flag.
88
+ */
89
+ onDemandRouteKeys?: Set<string>;
90
+
85
91
  // === Lazy evaluation fields ===
86
92
 
87
93
  /**
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
- import type { Handler } from "../types.js";
2
+ import type { Handler, InternalHandlerContext } from "../types.js";
3
3
  import type { RouteItem, RouteUseItem, UseItems } from "../route-types.js";
4
4
  import {
5
5
  getUrlPrefix,
@@ -8,7 +8,7 @@ import {
8
8
  requireDslContext,
9
9
  stampStaticDefScope,
10
10
  } from "../server/context";
11
- import { invariant, DataNotFoundError } from "../errors";
11
+ import { invariant, DataNotFoundError, isDevEnvironment } from "../errors";
12
12
  import { validateUserRouteName } from "../route-name.js";
13
13
  import {
14
14
  isPrerenderHandler,
@@ -60,6 +60,41 @@ function resolveResponseType(
60
60
  return options?.[RESPONSE_TYPE];
61
61
  }
62
62
 
63
+ const NO_PRERENDER_DATA_MESSAGE = "No prerender data found for this route";
64
+
65
+ /**
66
+ * A retained on-demand producer must never render on a live production
67
+ * request: bundle eviction is skipped for onDemand routes (the producer body
68
+ * stays available for router.prerender()), so without this gate an
69
+ * overlay+manifest miss on a plain Prerender(..., { onDemand }) route would
70
+ * execute the producer in-request — uncached per-request renders with the
71
+ * personalization guard unarmed. Keep the pre-retention miss contract instead
72
+ * (DataNotFoundError -> 404). Producer runs (build-time bake and
73
+ * router.prerender()) arrive with ctx.build true (createPrerenderContext);
74
+ * Vite dev keeps the documented live fall-through on a dev-store miss, except
75
+ * for a page the overlay marks removed (`_prerenderRemoved`): a live render
76
+ * would show in dev a page production answers with a 404.
77
+ * Pass-through when the route is not onDemand.
78
+ *
79
+ * @internal Exported for the unit test.
80
+ */
81
+ export function gateOnDemandProducer<TEnv>(
82
+ producer: Handler<any, any, TEnv>,
83
+ isOnDemand: boolean,
84
+ ): Handler<any, any, TEnv> {
85
+ if (!isOnDemand) return producer;
86
+ return (ctx) => {
87
+ if (
88
+ !ctx.build &&
89
+ (!isDevEnvironment() ||
90
+ (ctx as InternalHandlerContext<any, TEnv>)._prerenderRemoved)
91
+ ) {
92
+ throw new DataNotFoundError(NO_PRERENDER_DATA_MESSAGE);
93
+ }
94
+ return producer(ctx);
95
+ };
96
+ }
97
+
63
98
  export function createPathHelper<TEnv>(): PathFn<TEnv> {
64
99
  return ((
65
100
  pattern: string,
@@ -117,6 +152,31 @@ export function createPathHelper<TEnv>(): PathFn<TEnv> {
117
152
 
118
153
  const namespace = `${ctx.namespace}.${store.getNextIndex("route")}.${routeName}`;
119
154
 
155
+ // On-demand opt-in lives on the (inner) Prerender def's options for both
156
+ // plain Prerender and Passthrough-wrapped routes. Presence (truthy, not
157
+ // false) marks the route ISR-eligible; the trie od flag + producer retention
158
+ // key off it.
159
+ const onDemandDef = isPassthroughHandler(handler)
160
+ ? handler.prerenderDef
161
+ : isPrerenderHandler(handler)
162
+ ? handler
163
+ : undefined;
164
+ const onDemandOpt = onDemandDef?.options?.onDemand;
165
+ const isOnDemand = onDemandOpt != null && onDemandOpt !== false;
166
+
167
+ // onDemand routes are excluded from the PPR shell lane (a writable refresh
168
+ // cannot atomically replace the captured shell), so ppr has no effect there.
169
+ // Thrown ahead of the forRoute early return so lazy evaluation of another
170
+ // route cannot skip it.
171
+ if (isOnDemand && options?.ppr !== undefined && options.ppr !== false) {
172
+ throw new Error(
173
+ `[rango] Route "${routeName}" sets both ppr and onDemand: onDemand ` +
174
+ `routes are excluded from the PPR shell lane, so ppr has no ` +
175
+ `effect here and the route serves without the shell fast path. ` +
176
+ `Drop one of the two options.`,
177
+ );
178
+ }
179
+
120
180
  if (ctx.forRoute && routeName !== ctx.forRoute) {
121
181
  store.getShortCode("route");
122
182
  return { type: "route" } as RouteItem;
@@ -129,17 +189,16 @@ export function createPathHelper<TEnv>(): PathFn<TEnv> {
129
189
  ? typeof handler.prerenderDef.handler === "function"
130
190
  ? (handler.prerenderDef.handler as Handler<any, any, TEnv>)
131
191
  : () => {
132
- throw new DataNotFoundError(
133
- "No prerender data found for this route",
134
- );
192
+ throw new DataNotFoundError(NO_PRERENDER_DATA_MESSAGE);
135
193
  }
136
194
  : isPrerenderHandler(handler)
137
195
  ? typeof handler.handler === "function"
138
- ? (handler.handler as Handler<any, any, TEnv>)
196
+ ? gateOnDemandProducer(
197
+ handler.handler as Handler<any, any, TEnv>,
198
+ isOnDemand,
199
+ )
139
200
  : () => {
140
- throw new DataNotFoundError(
141
- "No prerender data found for this route",
142
- );
201
+ throw new DataNotFoundError(NO_PRERENDER_DATA_MESSAGE);
143
202
  }
144
203
  : isStaticHandler(handler)
145
204
  ? (handler.handler as Handler<any, any, TEnv>)
@@ -168,6 +227,7 @@ export function createPathHelper<TEnv>(): PathFn<TEnv> {
168
227
  prerenderDef: handler as PrerenderHandlerDefinition,
169
228
  }
170
229
  : {}),
230
+ ...(isOnDemand ? { isOnDemand: true as const } : {}),
171
231
  ...(isStaticHandler(handler)
172
232
  ? {
173
233
  isStaticPrerender: true as const,
@@ -14,9 +14,17 @@ import {
14
14
  type ReactNode,
15
15
  } from "react";
16
16
  import { OutletContext, type OutletContextValue } from "./outlet-context.js";
17
- import { loaderStore, type LoaderEntry } from "./loader-store.js";
17
+ import {
18
+ EMPTY_LOADER_SNAPSHOT,
19
+ type LoaderEntry,
20
+ type LoaderStore,
21
+ } from "./loader-store.js";
22
+ import { NavigationStoreContext } from "./browser/react/context.js";
18
23
  import { decodeLoaderEntry } from "./decode-loader-results.js";
19
24
  import { warnAwaitedSsrSuspension } from "./ssr-suspension-warning.js";
25
+ import { SETTLED_THENABLE } from "./thenable-status.js";
26
+ import { useReadAudit } from "./suspense-audit-react.js";
27
+ import { INTERNAL_RANGO_SUSPENSE_AUDIT } from "./internal-suspense-audit.js";
20
28
  import type { LoaderDefinition, LoadOptions } from "./types.js";
21
29
 
22
30
  function isShareableGet(options: LoadOptions | undefined): boolean {
@@ -202,15 +210,18 @@ function useLoaderInternal<T>(
202
210
  }
203
211
  return { contextData: undefined, hasContextData: false };
204
212
  }, [context, loader.$$id]);
205
-
206
213
  // SPIKE (streaming useLoader): a pending per-loader stream suspends HERE —
207
- // the implicit-suspense read. use() is exempt from hook-order rules, so the
208
- // conditional call is legal; on replay after resolution it returns
214
+ // the implicit-suspense read. On replay after resolution use() returns
209
215
  // synchronously and the decoded value takes the contextData slot. Hooks
210
216
  // below never run in a suspended render, so their order is stable across
211
217
  // every COMPLETED render.
212
218
  const { hasContextData } = walk;
213
219
  let contextData = walk.contextData;
220
+ // Dev only (suspense-audit.ts): the stream this reader is handed. The test
221
+ // is a build-time constant, so the hook is called on every render or never.
222
+ if (process.env.NODE_ENV !== "production" && INTERNAL_RANGO_SUSPENSE_AUDIT) {
223
+ useReadAudit(loader.$$id, walk.pendingStream);
224
+ }
214
225
  if (walk.pendingStream) {
215
226
  warnAwaitedSsrSuspension(
216
227
  loader.$$id,
@@ -218,6 +229,10 @@ function useLoaderInternal<T>(
218
229
  walk.pendingStream,
219
230
  );
220
231
  contextData = decodeLoaderEntry(use(walk.pendingStream)) as T;
232
+ } else if (hasContextData) {
233
+ // React logs a render that finishes without use() at a place whose reader
234
+ // suspended in use() on mount, so every read with route context calls it.
235
+ use(SETTLED_THENABLE);
221
236
  }
222
237
 
223
238
  const loaderId = loader.$$id;
@@ -245,10 +260,15 @@ function useLoaderInternal<T>(
245
260
  const bucketKey =
246
261
  effectiveKey === undefined ? loaderId : `${loaderId}::${effectiveKey}`;
247
262
 
263
+ // The router store's loader data (loader-store.ts). None on the server,
264
+ // where the hook reads the route's loader data only.
265
+ const loaders: LoaderStore | undefined = useContext(NavigationStoreContext)
266
+ ?.store?.loaders;
267
+
248
268
  const groupRefetch = useCallback(async (): Promise<void> => {
249
- if (!loaderId) return;
250
- const requestId = loaderStore.reserveRequestId(bucketKey);
251
- loaderStore.beginRequest(bucketKey, requestId);
269
+ if (!loaderId || !loaders) return;
270
+ const requestId = loaders.reserveRequestId(bucketKey);
271
+ loaders.beginRequest(bucketKey, requestId);
252
272
  try {
253
273
  const url = new URL(window.location.href);
254
274
  url.searchParams.set("_rsc_loader", loaderId);
@@ -261,15 +281,15 @@ function useLoaderInternal<T>(
261
281
  if (payload.loaderError) {
262
282
  throw new Error(payload.loaderError.message);
263
283
  }
264
- loaderStore.finishData(bucketKey, requestId, payload.loaderResult);
284
+ loaders.finishData(bucketKey, requestId, payload.loaderResult);
265
285
  } catch (e) {
266
286
  const err = e instanceof Error ? e : new Error(String(e));
267
- loaderStore.finishError(bucketKey, requestId, err);
287
+ loaders.finishError(bucketKey, requestId, err);
268
288
  throw err;
269
289
  } finally {
270
- loaderStore.setLoading(bucketKey, requestId, false);
290
+ loaders.setLoading(bucketKey, requestId, false);
271
291
  }
272
- }, [loaderId, bucketKey]);
292
+ }, [loaderId, bucketKey, loaders]);
273
293
 
274
294
  // Held-navigation pin: a transition commit whose stream for this loader is
275
295
  // still pending announces it (loader-store.ts announcePendingStreams, which
@@ -283,14 +303,17 @@ function useLoaderInternal<T>(
283
303
  snapshot: LoaderEntry;
284
304
  }>(() => ({
285
305
  bucketKey,
286
- snapshot: loaderStore.getSnapshot(bucketKey),
306
+ snapshot: loaders?.getSnapshot(bucketKey) ?? EMPTY_LOADER_SNAPSHOT,
287
307
  }));
308
+ // A new bucket (its key changed) reads nothing from the store during
309
+ // render: it renders empty until the effect below delivers its snapshot.
288
310
  const sharedSnapshot =
289
311
  sharedState.bucketKey === bucketKey
290
312
  ? sharedState.snapshot
291
- : loaderStore.getSnapshot(bucketKey);
313
+ : EMPTY_LOADER_SNAPSHOT;
292
314
  useEffect(() => {
293
- const initial = loaderStore.getSnapshot(bucketKey);
315
+ if (!loaders) return;
316
+ const initial = loaders.getSnapshot(bucketKey);
294
317
  if (initial !== sharedSnapshot) {
295
318
  startTransition(() => {
296
319
  setSharedState({ bucketKey, snapshot: initial });
@@ -299,10 +322,10 @@ function useLoaderInternal<T>(
299
322
  // ephemeral: a reader with no route context has no route-context reset
300
323
  // trigger, so its keyed bucket is reference-counted by the store. A
301
324
  // route-registered reader makes the bucket sticky (reset via clearFamily).
302
- return loaderStore.subscribe(
325
+ return loaders.subscribe(
303
326
  bucketKey,
304
327
  () => {
305
- const next = loaderStore.getSnapshot(bucketKey);
328
+ const next = loaders.getSnapshot(bucketKey);
306
329
  startTransition(() => {
307
330
  setSharedState({ bucketKey, snapshot: next });
308
331
  });
@@ -323,7 +346,7 @@ function useLoaderInternal<T>(
323
346
  // groupKey, and groupRefetch are the only inputs that require a fresh
324
347
  // subscription (groupList is memoized on groupKey; groupRefetch is stable
325
348
  // per bucketKey).
326
- }, [bucketKey, hasContextData, groupKey, groupRefetch]);
349
+ }, [bucketKey, hasContextData, groupKey, groupRefetch, loaders]);
327
350
 
328
351
  // Local state holds the result of:
329
352
  // - parameterized / mutation `load()` calls (load({ params }), POST,
@@ -369,10 +392,10 @@ function useLoaderInternal<T>(
369
392
  lastSharedRequestIdRef.current = null;
370
393
  // Reset every sticky bucket of this loader (keyed or not). Ephemeral
371
394
  // (unregistered keyed) buckets are left to their refcount lifecycle.
372
- loaderStore.clearFamily(loaderId);
395
+ loaders?.clearFamily(loaderId);
373
396
  prevContextDataRef.current = contextData;
374
397
  }
375
- }, [contextData, loaderId]);
398
+ }, [contextData, loaderId, loaders]);
376
399
 
377
400
  // Read priority: a committed parameterized load() result overrides the shared
378
401
  // snapshot; a committed shared snapshot overrides the server-seeded context.
@@ -408,15 +431,17 @@ function useLoaderInternal<T>(
408
431
  const bucket = bucketKeyRef.current;
409
432
  const hasDedicatedBucket = bucket !== id;
410
433
 
411
- const shared = hasDedicatedBucket
412
- ? isShareableGet(loadOptions)
413
- : isPlainRefetch(loadOptions) && hasContextDataRef.current;
434
+ const shared =
435
+ loaders !== undefined &&
436
+ (hasDedicatedBucket
437
+ ? isShareableGet(loadOptions)
438
+ : isPlainRefetch(loadOptions) && hasContextDataRef.current);
414
439
  let sharedRequestId = -1;
415
440
  let localRequestId = -1;
416
441
  if (shared) {
417
- sharedRequestId = loaderStore.reserveRequestId(bucket);
442
+ sharedRequestId = loaders.reserveRequestId(bucket);
418
443
  lastSharedRequestIdRef.current = sharedRequestId;
419
- loaderStore.beginRequest(bucket, sharedRequestId);
444
+ loaders.beginRequest(bucket, sharedRequestId);
420
445
  } else {
421
446
  localRequestId = ++localRequestIdRef.current;
422
447
  setLocalIsLoading(true);
@@ -503,7 +528,7 @@ function useLoaderInternal<T>(
503
528
 
504
529
  const result = payload.loaderResult;
505
530
  if (shared) {
506
- loaderStore.finishData(bucket, sharedRequestId, result);
531
+ loaders.finishData(bucket, sharedRequestId, result);
507
532
  } else if (localRequestId === localRequestIdRef.current) {
508
533
  startTransition(() => {
509
534
  setLocalFetchedData({ has: true, value: result });
@@ -514,7 +539,7 @@ function useLoaderInternal<T>(
514
539
  } catch (e) {
515
540
  const err = e instanceof Error ? e : new Error(String(e));
516
541
  if (shared) {
517
- loaderStore.finishError(bucket, sharedRequestId, err);
542
+ loaders.finishError(bucket, sharedRequestId, err);
518
543
  } else if (localRequestId === localRequestIdRef.current) {
519
544
  setLocalError(err);
520
545
  setLocalIsLoading(false);
@@ -525,11 +550,11 @@ function useLoaderInternal<T>(
525
550
  return dataRef.current as T;
526
551
  } finally {
527
552
  if (shared) {
528
- loaderStore.setLoading(bucket, sharedRequestId, false);
553
+ loaders.setLoading(bucket, sharedRequestId, false);
529
554
  }
530
555
  }
531
556
  },
532
- [throwOnError],
557
+ [throwOnError, loaders],
533
558
  );
534
559
 
535
560
  if (throwOnError) {
@@ -708,8 +733,10 @@ export function useFetchLoader<T>(
708
733
  export function useRefreshLoaders(): (
709
734
  groups: string | string[],
710
735
  ) => Promise<void> {
736
+ const loaders = useContext(NavigationStoreContext)?.store?.loaders;
711
737
  return useCallback(
712
- (groups: string | string[]) => loaderStore.refreshGroups(groups),
713
- [],
738
+ (groups: string | string[]) =>
739
+ loaders ? loaders.refreshGroups(groups) : Promise.resolve(),
740
+ [loaders],
714
741
  );
715
742
  }
@@ -33,6 +33,10 @@ export function postprocessBundle(state: DiscoveryState): void {
33
33
  );
34
34
 
35
35
  // 1. Evict handler code from whichever chunks contain handler exports.
36
+ // onDemand retention needs no cross-check here: the chunk scan marks
37
+ // onDemand exports directly from state.onDemandHandlerIds (the evaluated
38
+ // source's $$id set), so retention and the runtime od trie flag share one
39
+ // derivation and cannot disagree.
36
40
  // handlerChunkInfoMap/staticHandlerChunkInfoMap are populated by generateBundle
37
41
  // after the production RSC build. In Vite 6 multi-environment builds, the
38
42
  // RSC build runs twice (analysis + production). The maps are cleared at the
@@ -337,6 +337,20 @@ export async function discoverRouters(
337
337
  (performance.now() - trieStart).toFixed(1),
338
338
  );
339
339
 
340
+ // handlerId -> route name for onDemand routes, from the evaluated source —
341
+ // the authoritative side of postprocessBundle's retention cross-check.
342
+ const newOnDemandHandlerIds = new Map<string, string>();
343
+ for (const { manifest } of allManifests) {
344
+ if (!manifest.onDemandRoutes) continue;
345
+ const defs = manifest._prerenderDefs || {};
346
+ for (const routeName of manifest.onDemandRoutes) {
347
+ const handlerId = defs[routeName]?.$$id;
348
+ if (typeof handlerId === "string") {
349
+ newOnDemandHandlerIds.set(handlerId, routeName);
350
+ }
351
+ }
352
+ }
353
+
340
354
  // Commit all local state to the shared discovery state atomically.
341
355
  // This ensures a failed re-discovery (e.g. from a transient module
342
356
  // evaluation error) preserves the last known-good state.
@@ -345,6 +359,7 @@ export async function discoverRouters(
345
359
  state.perRouterManifestDataMap = newPerRouterManifestDataMap;
346
360
  state.perRouterPrecomputedMap = newPerRouterPrecomputedMap;
347
361
  state.perRouterTrieMap = newPerRouterTrieMap;
362
+ state.onDemandHandlerIds = newOnDemandHandlerIds;
348
363
 
349
364
  // Install the route tries into the RSC realm BEFORE prerender collection.
350
365
  // matchForPrerender resolves each enumerated URL via findMatch, and without
@@ -206,10 +206,10 @@ export async function runShellPrerenderPhase(
206
206
  const staged: Array<{ key: string; value: string }> = [];
207
207
  let skipCount = 0;
208
208
 
209
- // Manifest keys are pathname-only (see shell-manifest-key.ts): two
210
- // candidates on one pathname (two routers prerendering the same path)
211
- // would be ambiguous at serve time — decline ALL of that pathname's
212
- // entries rather than bake a wrong-router shell.
209
+ // A candidate names no router (the sweep below gives it to the first one
210
+ // whose match lands on its route): two candidates on one pathname (two
211
+ // routers prerendering the same path) would be ambiguous — decline ALL of
212
+ // that pathname's entries rather than bake a wrong-router shell.
213
213
  const pathCounts = new Map<string, number>();
214
214
  for (const cand of candidates) {
215
215
  pathCounts.set(cand.urlPath, (pathCounts.get(cand.urlPath) ?? 0) + 1);
@@ -282,7 +282,7 @@ export async function runShellPrerenderPhase(
282
282
  tags: res.tags,
283
283
  };
284
284
  staged.push({
285
- key: buildShellManifestKey(cand.urlPath),
285
+ key: buildShellManifestKey(routerId, cand.urlPath),
286
286
  value: JSON.stringify(value),
287
287
  });
288
288
  console.log(
@@ -61,7 +61,12 @@ export interface PrecomputedEntry {
61
61
 
62
62
  export interface ChunkInfo {
63
63
  fileName: string;
64
- exports: Array<{ name: string; handlerId: string; passthrough: boolean }>;
64
+ exports: Array<{
65
+ name: string;
66
+ handlerId: string;
67
+ passthrough: boolean;
68
+ onDemand: boolean;
69
+ }>;
65
70
  }
66
71
 
67
72
  export interface PerRouterManifestEntry {
@@ -163,6 +168,16 @@ export interface DiscoveryState {
163
168
  buildData: import("./build-versions.js").BuildDataRecord[];
164
169
  handlerChunkInfoMap: Map<string, ChunkInfo>;
165
170
  staticHandlerChunkInfoMap: Map<string, ChunkInfo>;
171
+ /**
172
+ * handlerId ($$id) -> route name for every route the evaluated source marks
173
+ * onDemand. Written during discovery (buildStart), consumed by the
174
+ * generateBundle chunk scan (extractHandlerExportsFromChunk) as the
175
+ * authoritative onDemand set driving producer retention — the same source
176
+ * that sets the runtime od trie flag, so any truthy onDemand spelling
177
+ * (literal, spread, imported const) both marks the route and retains its
178
+ * producer.
179
+ */
180
+ onDemandHandlerIds: Map<string, string>;
166
181
  rscEntryFileName: string | null;
167
182
  resolvedPrerenderModules: Map<string, string[]> | undefined;
168
183
  resolvedStaticModules: Map<string, string[]> | undefined;
@@ -225,6 +240,7 @@ export function createDiscoveryState(
225
240
  buildData: [],
226
241
  handlerChunkInfoMap: new Map(),
227
242
  staticHandlerChunkInfoMap: new Map(),
243
+ onDemandHandlerIds: new Map(),
228
244
  rscEntryFileName: null,
229
245
  resolvedPrerenderModules: undefined,
230
246
  resolvedStaticModules: undefined,
@@ -12,37 +12,59 @@ import type { Connect } from "vite";
12
12
  * runs on the module regardless of how (or whether) the define is delivered, in
13
13
  * both dev and build and for every environment, so the discovery plugin uses this
14
14
  * to replace the module with the resolved literal.
15
+ *
16
+ * internal-suspense-audit.ts takes the same path for INTERNAL_RANGO_SUSPENSE_AUDIT,
17
+ * the repo-only switch of the dev suspense audit.
15
18
  */
16
19
 
20
+ // The router's flag modules, by file name: each exports one boolean named after
21
+ // the env var it is baked from.
22
+ const FLAG_MODULE =
23
+ /\/(internal-debug|internal-suspense-audit)\.[cm]?[jt]sx?(\?|$)/;
24
+ const FLAG_NAMES: Record<string, string> = {
25
+ "internal-debug": "INTERNAL_RANGO_DEBUG",
26
+ "internal-suspense-audit": "INTERNAL_RANGO_SUSPENSE_AUDIT",
27
+ };
28
+
17
29
  /**
18
- * Scope to the router's own internal-debug module: the published package
19
- * (`/@rangojs/router/`, incl. pnpm's nested layout) or the monorepo workspace
20
- * (`/packages/rangojs-router/`). The package-anchored path avoids matching a
21
- * consumer file that merely sits under a directory named `rangojs-router`.
22
- * Accepts module ids and dev-server URLs (`/@fs/...internal-debug.ts?v=abc`).
30
+ * The env var a router flag module is baked from, or undefined. Scoped to the
31
+ * router's own modules: the published package (`/@rangojs/router/`, incl.
32
+ * pnpm's nested layout) or the monorepo workspace (`/packages/rangojs-router/`).
33
+ * The package-anchored path avoids matching a consumer file that merely sits
34
+ * under a directory named `rangojs-router`. Accepts module ids and dev-server
35
+ * URLs (`/@fs/...internal-debug.ts?v=abc`).
23
36
  */
24
- export function isRouterInternalDebugId(id: string): boolean {
25
- if (!id.includes("internal-debug")) return false;
37
+ function routerFlagName(id: string): string | undefined {
38
+ if (!id.includes("internal-")) return undefined;
26
39
  const norm = id.replace(/\\/g, "/");
27
- return (
28
- /\/internal-debug\.[cm]?[jt]sx?(\?|$)/.test(norm) &&
29
- (norm.includes("/@rangojs/router/") ||
30
- norm.includes("/packages/rangojs-router/"))
31
- );
40
+ if (
41
+ !norm.includes("/@rangojs/router/") &&
42
+ !norm.includes("/packages/rangojs-router/")
43
+ ) {
44
+ return undefined;
45
+ }
46
+ const match = FLAG_MODULE.exec(norm);
47
+ return match ? FLAG_NAMES[match[1]!] : undefined;
48
+ }
49
+
50
+ /** A router flag module: internal-debug.ts or internal-suspense-audit.ts. */
51
+ export function isRouterInternalDebugId(id: string): boolean {
52
+ return routerFlagName(id) !== undefined;
32
53
  }
33
54
 
34
55
  /**
35
- * Transform: replace the module with the resolved literal.
36
- * Returns null for any module that is not the router's internal-debug module.
56
+ * Transform: replace a router flag module with the resolved literal.
57
+ * Returns null for any other module.
37
58
  */
38
59
  export function injectClientDebugFlag(
39
60
  id: string,
40
61
  ): { code: string; map: null } | null {
41
62
  // Cheap early-out: this hook runs on every module in every environment.
42
- if (!isRouterInternalDebugId(id)) return null;
43
- // Emit the whole module: internal-debug.ts has a single export, kept in sync.
63
+ const name = routerFlagName(id);
64
+ if (name === undefined) return null;
65
+ // Emit the whole module: each flag module has a single export, kept in sync.
44
66
  return {
45
- code: `export const INTERNAL_RANGO_DEBUG = ${!!process.env.INTERNAL_RANGO_DEBUG};\n`,
67
+ code: `export const ${name} = ${!!process.env[name]};\n`,
46
68
  map: null,
47
69
  };
48
70
  }
@@ -63,9 +85,9 @@ export function injectClientDebugFlag(
63
85
  * immune -- workspace source is outside node_modules and served no-cache --
64
86
  * which is why this bit only npm consumers.
65
87
  *
66
- * internal-debug.ts is the ONLY flag-varying module in the graph (its importers
67
- * are byte-identical across flag states), so forcing revalidation for this one
68
- * tiny module is sufficient and costs one conditional request per session. The
88
+ * The two flag modules are the only flag-varying modules in the graph (their
89
+ * importers are byte-identical across flag states), so forcing revalidation for
90
+ * them is sufficient and costs a conditional request each per session. The
69
91
  * middleware is unconditional (not gated on the flag) so an already-poisoned
70
92
  * cache heals in both directions. Alternatives that do not work: a plugin
71
93
  * `resolveId` appending a flag query never fires in Vite 8 dev for
@@ -28,6 +28,12 @@ async function initializeApp() {
28
28
  // is the default; createRouter({ strictMode: false }) ships the opt-out in the
29
29
  // payload metadata. StrictMode emits no DOM, so toggling never changes markup.
30
30
  const { strictMode, initialPayload } = await initBrowserApp({ rscStream, deps });
31
+ // Set only on a document a no-JS useActionState POST rendered: React needs
32
+ // the server's form state to hydrate the hook without resetting it. A state
33
+ // Flight could not carry rejects: hydrate without it, as before.
34
+ const formState = await Promise.resolve(initialPayload.formState).catch(
35
+ () => undefined
36
+ );
31
37
 
32
38
  if (import.meta.hot) {
33
39
  const { startDevDiscoveryHandshake } = await import(
@@ -42,7 +48,8 @@ async function initializeApp() {
42
48
  const app = createElement(Rango);
43
49
  hydrateRoot(
44
50
  document,
45
- strictMode === false ? app : createElement(StrictMode, null, app)
51
+ strictMode === false ? app : createElement(StrictMode, null, app),
52
+ formState ? { formState } : undefined
46
53
  );
47
54
  }
48
55