@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

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 (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Integrated PPR shell serving (Axis 2, see docs/design/ppr-shell-resume.md).
3
+ *
4
+ * PPR is opt-in per PAGE ROUTE via the `ppr` path option
5
+ * (`path(pattern, Handler, { name, ppr: true | PartialPrerenderProps })`) and the
6
+ * serving logic is INTEGRAL to the render pipeline — there is no middleware to
7
+ * mount. This module owns the config/key/store plumbing the render layer
8
+ * (rsc-rendering.ts) uses at its COMMIT POINT, which sits after the WHOLE
9
+ * middleware chain (global `router.use()` chain AND route DSL `middleware()`,
10
+ * both of which wrap the render pass): any middleware rejection/redirect wins
11
+ * before a single shell byte is written.
12
+ *
13
+ * The shell store is the app-level `createRouter({ cache })` store
14
+ * (`requestCtx._cacheStore`). A store without the `getShell`/`putShell` family
15
+ * degrades a ppr route to axis 1 with a once-per-key warning (the declared
16
+ * intent cannot be honored — unlike an undeclared route, which is silent).
17
+ */
18
+
19
+ import React from "react";
20
+ import type { EntryData } from "../server/context.js";
21
+ import { sortedSearchString } from "../cache/cache-key-utils.js";
22
+ import type { ShellCacheEntry, SegmentCacheStore } from "../cache/types.js";
23
+
24
+ /** Debug/status header the browser (and e2e assertions) can read: HIT | MISS. */
25
+ export const SHELL_STATUS_HEADER = "x-rango-shell";
26
+
27
+ /**
28
+ * Default shell ttl (seconds) for `ppr: true` and for a PartialPrerenderProps
29
+ * that omits `ttl`.
30
+ */
31
+ export const DEFAULT_PPR_TTL_SECONDS = 300;
32
+
33
+ /** The route's ppr option normalized to a concrete policy. */
34
+ export interface ResolvedPprConfig {
35
+ ttl: number;
36
+ swr?: number;
37
+ tags?: string[];
38
+ }
39
+
40
+ /**
41
+ * Normalize the matched page route's `ppr` path option. Returns null when the
42
+ * route does not declare `ppr` (or declares `ppr: false`) — the caller then does
43
+ * NOTHING: no store read, no capture, no logs. Pure axis 1, zero cost.
44
+ *
45
+ * PPR is a DOCUMENT-level property of the page route; there is no subtree
46
+ * inheritance (declaring it on a layout is not supported — a follow-up).
47
+ */
48
+ export function resolvePprConfig(
49
+ entry: EntryData | undefined | null,
50
+ ): ResolvedPprConfig | null {
51
+ if (!entry || entry.type !== "route") return null;
52
+ const ppr = entry.ppr;
53
+ if (ppr === undefined || ppr === false) return null;
54
+ if (ppr === true) return { ttl: DEFAULT_PPR_TTL_SECONDS };
55
+ return {
56
+ ttl: ppr.ttl ?? DEFAULT_PPR_TTL_SECONDS,
57
+ swr: ppr.swr,
58
+ tags: ppr.tags,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Shell cache key: host + pathname + sorted search + a `:shell` namespace suffix
64
+ * (so it can never collide with a document-cache key; the store further isolates
65
+ * the shell family internally).
66
+ *
67
+ * The key includes the request HOST: in a multi-tenant host-router deployment
68
+ * (one worker, one shared KV/runtime-cache store) a host-less key would serve
69
+ * tenant A's captured shell to tenant B's users.
70
+ */
71
+ export function buildShellKey(url: URL): string {
72
+ const sorted = sortedSearchString(url.searchParams);
73
+ const searchSuffix = sorted ? `?${sorted}` : "";
74
+ return `${url.host}${url.pathname}${searchSuffix}:shell`;
75
+ }
76
+
77
+ /**
78
+ * Version gates for a stored shell: reactVersion AND buildVersion must both
79
+ * match the running server. The postponed blob encodes hole positions against
80
+ * one exact tree, so resuming it under a different React OR a different app
81
+ * build tree-mismatches inside resume() — after the 200 + prelude committed,
82
+ * with no recovery. Either mismatch is a miss: the recapture overwrites the
83
+ * same key (self-healing) and the entry otherwise ages out via TTL. An entry
84
+ * with no buildVersion (stored before the field existed) is a miss for the
85
+ * same reason — its build is unknown, so it cannot be proven resumable.
86
+ */
87
+ export function isValidShellHit(
88
+ entry: ShellCacheEntry,
89
+ buildVersion: string,
90
+ ): boolean {
91
+ return (
92
+ entry.reactVersion === React.version && entry.buildVersion === buildVersion
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Payload integrity gate, run BEFORE the HIT response commits: a stored entry
98
+ * whose prelude is not decodable base64 or whose postponed blob is not
99
+ * parseable JSON would otherwise throw AFTER the 200 + full static prelude
100
+ * flushed (`serveShellHit` decodes at stream construction, `resumeShellHTML`
101
+ * parses in the tail) — the client gets a visually complete page that never
102
+ * hydrates, re-served on every request until the entry ages out (no eviction
103
+ * path exists; failure schedules no recapture by itself). Checking here turns
104
+ * a corrupt entry (store-layer fault) into a plain MISS the recapture
105
+ * overwrites. Cost: one duplicate decode/parse per HIT, sub-ms against a
106
+ * prelude flush that dominates the path.
107
+ */
108
+ export function hasIntactShellPayload(entry: ShellCacheEntry): boolean {
109
+ try {
110
+ base64ToBytes(entry.prelude);
111
+ if (entry.postponed !== null) JSON.parse(entry.postponed);
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /** Decode a base64 prelude back into bytes for stream composition. */
119
+ export function base64ToBytes(b64: string): Uint8Array {
120
+ const binary = atob(b64);
121
+ const bytes = new Uint8Array(binary.length);
122
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
123
+ return bytes;
124
+ }
125
+
126
+ /** True when the store implements the shell entry family. */
127
+ export function hasShellFamily(
128
+ store: SegmentCacheStore | undefined,
129
+ ): store is SegmentCacheStore & {
130
+ getShell: NonNullable<SegmentCacheStore["getShell"]>;
131
+ putShell: NonNullable<SegmentCacheStore["putShell"]>;
132
+ } {
133
+ return !!store?.getShell && !!store?.putShell;
134
+ }
135
+
136
+ /** Keys already warned about a missing shell store family (once per key). */
137
+ const warnedMissingStore = new Set<string>();
138
+
139
+ /**
140
+ * Warn once per key that a route declared `ppr` but the app-level cache store
141
+ * does not implement the shell family (getShell/putShell), so the route stays on
142
+ * axis 1. Unlike an undeclared route (silent), a declared route that cannot be
143
+ * honored deserves a diagnostic.
144
+ */
145
+ export function warnShellStoreMissingOnce(key: string): void {
146
+ if (warnedMissingStore.has(key)) return;
147
+ warnedMissingStore.add(key);
148
+ console.warn(
149
+ `[rango] Route for "${key}" declares the ppr path option, but the app-level ` +
150
+ "cache store does not implement the shell family (getShell/putShell), so " +
151
+ "the route is served on axis 1 without a shell. Use MemorySegmentCacheStore, " +
152
+ "CFCacheStore, or VercelCacheStore (or add the family to your custom store) " +
153
+ "via createRouter({ cache }).",
154
+ );
155
+ }
156
+
157
+ /** Keys already warned about an active per-request nonce (once per key). */
158
+ const warnedNonceActive = new Set<string>();
159
+
160
+ /**
161
+ * Warn once per key that a route declared `ppr` but a per-request CSP nonce is
162
+ * active for the request, so the route stays on axis 1 (a shared shell would
163
+ * freeze one request's nonce for every visitor — useNonce() renders it into every
164
+ * nonced script/style/meta and the browser's CSP would then reject the frozen
165
+ * nonce for all but the capture request). The nonce blocks capture whether it came
166
+ * from the `createRouter({ nonce })` provider or from a direct `ctx.set(nonce, …)`
167
+ * token write in middleware. Same declared-intent-cannot-be-honored doctrine as
168
+ * the missing-store warning above (an undeclared route stays silent).
169
+ */
170
+ export function warnPprNonceActiveOnce(key: string): void {
171
+ if (warnedNonceActive.has(key)) return;
172
+ warnedNonceActive.add(key);
173
+ console.warn(
174
+ `[rango] Route for "${key}" declares the ppr path option, but a per-request ` +
175
+ "CSP nonce is active for this request (from createRouter({ nonce }) or a " +
176
+ "ctx.set(nonce, …) token write in middleware), so the route is served on " +
177
+ "axis 1 without a shell. A shell is shared per host+URL; baking one " +
178
+ "request's nonce into it would break CSP for every other visitor. Drop the " +
179
+ "ppr option on this route, or stop setting a per-request nonce for it.",
180
+ );
181
+ }
@@ -0,0 +1,89 @@
1
+ import type { MatchResult } from "../types.js";
2
+ import type { TransitionWhenContext } from "../types/segments.js";
3
+ import type { getRequestContext } from "../server/request-context.js";
4
+ import { invokeOnError } from "../router/error-handling.js";
5
+ import type { OnErrorCallback } from "../types/error-types.js";
6
+
7
+ /**
8
+ * Apply transition({ when }) gates to a payload's segments.
9
+ *
10
+ * The predicates were collected during resolution (keyed by segment id) and
11
+ * stripped from the serialized config; here — after handlers ran and outside any
12
+ * cache scope — we evaluate each and drop the segment's transition when the
13
+ * predicate does not hold, so the navigation streams its loading fallback
14
+ * instead of holding the previous content. A predicate that throws is reported
15
+ * to the router's onError (phase "rendering") and then treated as "do not hold"
16
+ * (conservative), so a buggy predicate degrades to no transition rather than
17
+ * failing the response.
18
+ *
19
+ * Mutating the segments here is safe: the segment cache stores a serialized copy
20
+ * (segment-codec), written during match() BEFORE this gate runs, so dropping a
21
+ * transition never corrupts a cache entry. The flip side is that a cache hit
22
+ * skips resolution, collects no predicate, and replays the cached transition
23
+ * as-is (it was serialized before the gate) — combining transition({ when })
24
+ * with cache() on the same segment freezes the gate to its cached state, so
25
+ * avoid caching a route whose transition decision is request-dependent.
26
+ *
27
+ * Returns the same array (mutated) for inline use at the payload's `segments`
28
+ * field.
29
+ */
30
+ export function gateTransitions(
31
+ segments: MatchResult["segments"],
32
+ ctx: ReturnType<typeof getRequestContext>,
33
+ onError?: OnErrorCallback,
34
+ ): MatchResult["segments"] {
35
+ const predicates = ctx._transitionWhen;
36
+ if (predicates && predicates.length) {
37
+ for (const { id, when } of predicates) {
38
+ let drop: boolean;
39
+ try {
40
+ // Assemble the ShouldRevalidateFn-shaped predicate context from the
41
+ // request context. Source fields (currentUrl/currentParams/fromRouteName)
42
+ // were stashed at match time from the navigation snapshot; action fields
43
+ // at the action-bearing gate call sites. nextUrl/nextParams/toRouteName/
44
+ // method/get/env come straight off ctx (setRequestContextParams ran
45
+ // before the gate). Source/action fields are undefined when absent —
46
+ // never fabricated (see TransitionWhenContext).
47
+ const whenCtx: TransitionWhenContext = {
48
+ currentUrl: ctx._gateCurrentUrl,
49
+ currentParams: ctx._gateCurrentParams,
50
+ fromRouteName:
51
+ ctx._prevRouteKey as TransitionWhenContext["fromRouteName"],
52
+ nextUrl: ctx.url,
53
+ nextParams: ctx.params,
54
+ toRouteName: ctx.routeName,
55
+ actionId: ctx._gateActionId,
56
+ actionUrl: ctx._gateActionUrl,
57
+ actionResult: ctx._gateActionResult,
58
+ formData: ctx._gateFormData,
59
+ method: ctx.request.method,
60
+ get: ctx.get,
61
+ env: ctx.env,
62
+ };
63
+ drop = when(whenCtx) === false;
64
+ } catch (error) {
65
+ // A throwing predicate must not fail the response: report it and treat
66
+ // the transition as gated off (do not hold). invokeOnError no-ops when
67
+ // onError is undefined.
68
+ drop = true;
69
+ invokeOnError(
70
+ onError,
71
+ error,
72
+ "rendering",
73
+ {
74
+ request: ctx.request,
75
+ url: ctx.url,
76
+ params: ctx.params,
77
+ segmentId: id,
78
+ },
79
+ "RSC",
80
+ );
81
+ }
82
+ if (drop) {
83
+ const seg = segments.find((s) => s.id === id);
84
+ if (seg) seg.transition = undefined;
85
+ }
86
+ }
87
+ }
88
+ return segments;
89
+ }
package/src/rsc/types.ts CHANGED
@@ -43,6 +43,10 @@ export interface RscPayload {
43
43
  version?: string;
44
44
  /** TTL in milliseconds for the client-side in-memory prefetch cache */
45
45
  prefetchCacheTTL?: number;
46
+ /** Max entries in the client-side in-memory prefetch cache (FIFO eviction) */
47
+ prefetchCacheSize?: number;
48
+ /** Max concurrent speculative prefetch requests on the client */
49
+ prefetchConcurrency?: number;
46
50
  /** Server-resolved rango state cookie name; the client reads it verbatim. */
47
51
  stateCookieName?: string;
48
52
  /** Theme configuration for FOUC prevention */
@@ -160,6 +164,32 @@ export interface SSRModule {
160
164
  rscStream: ReadableStream<Uint8Array>,
161
165
  options?: SSRRenderOptions,
162
166
  ) => Promise<ReadableStream<Uint8Array>>;
167
+
168
+ /**
169
+ * PPR shell CAPTURE strategy (Axis 2). Prerenders the loader-masked shell over
170
+ * the Flight stream, aborts once quiescent, and returns the prelude bytes plus
171
+ * the postponed resume state — or null when the prelude degraded and must not
172
+ * be stored. Present only when the SSR virtual entry wires
173
+ * createShellCaptureHandler; the render layer feature-detects it. See
174
+ * docs/design/ppr-shell-resume.md.
175
+ */
176
+ captureShellHTML?: (
177
+ rscStream: ReadableStream<Uint8Array>,
178
+ options: { quiesce: Promise<void>; maxWaitMs?: number },
179
+ ) => Promise<{ prelude: Uint8Array; postponed: string | null } | null>;
180
+
181
+ /**
182
+ * PPR shell RESUME strategy (Axis 2). Produces the per-request live portion of
183
+ * the document: resumes fizz over a fresh SsrRoot to emit only the postponed
184
+ * holes (or, for the DATA variant with postponed === null, just the fresh Flight
185
+ * payload scripts). The caller prepends the stored prelude bytes to form the
186
+ * composite response. Present only when the SSR virtual entry wires
187
+ * createShellResumeHandler; the render layer feature-detects it.
188
+ */
189
+ resumeShellHTML?: (
190
+ rscStream: ReadableStream<Uint8Array>,
191
+ options: { postponed: string | null; nonce?: string },
192
+ ) => Promise<ReadableStream<Uint8Array>>;
163
193
  }
164
194
 
165
195
  /**
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedSegment } from "./types.js";
2
+ import { INTERNAL_RANGO_DEBUG } from "./internal-debug.js";
2
3
 
3
4
  /**
4
5
  * Cache of aggregate Promise.all results keyed on the first loader's
@@ -83,6 +84,23 @@ export function buildLoaderPromise(loaders: ResolvedSegment[]): Promise<any[]> {
83
84
  if (loaders.length === 0) {
84
85
  return Promise.resolve([]);
85
86
  }
87
+ // Debug tap (browser only): log when each PENDING loader promise settles —
88
+ // i.e. when its data actually lands from the flight stream — independent of
89
+ // when the tree build awaits it. `.then(cb, cb)` observes on a branch, so
90
+ // rejections still propagate to the real consumers untouched.
91
+ if (INTERNAL_RANGO_DEBUG && IS_BROWSER) {
92
+ const tapStart = performance.now();
93
+ for (const loader of loaders) {
94
+ if (loader.loaderData instanceof Promise) {
95
+ const settle = (outcome: string) => () =>
96
+ console.log(
97
+ `[Browser][segments] loader ${loader.loaderId} ${outcome} @ ${Math.round(performance.now())}ms`,
98
+ { msSinceRequested: Math.round(performance.now() - tapStart) },
99
+ );
100
+ loader.loaderData.then(settle("settled"), settle("rejected"));
101
+ }
102
+ }
103
+ }
86
104
  return Promise.all(
87
105
  loaders.map((loader) =>
88
106
  loader.loaderData instanceof Promise
@@ -10,12 +10,29 @@ import {
10
10
  LoaderBoundary,
11
11
  } from "./route-content-wrapper.js";
12
12
  import { RootErrorBoundary } from "./root-error-boundary.js";
13
+ import { INTERNAL_RANGO_DEBUG } from "./internal-debug.js";
13
14
  import { getMemoizedContentPromise } from "./segment-content-promise.js";
14
15
  import {
15
16
  buildLoaderPromise,
16
17
  getMemoizedLoaderPromise,
17
18
  } from "./segment-loader-promise.js";
18
19
 
20
+ /**
21
+ * Client-only debug log for the segment tree build. Gated on the baked flag
22
+ * AND `typeof window` (renderSegments also runs during SSR/RSC, which must
23
+ * stay silent). Timestamped so tree-build steps line up with the
24
+ * `[Browser][boot]` sequence around hydrateRoot.
25
+ */
26
+ function segDebugLog(msg: string, details?: Record<string, unknown>): void {
27
+ if (!(INTERNAL_RANGO_DEBUG && typeof window === "object")) return;
28
+ const prefix = `[Browser][segments] ${msg} @ ${Math.round(performance.now())}ms`;
29
+ if (details) {
30
+ console.log(prefix, details);
31
+ return;
32
+ }
33
+ console.log(prefix);
34
+ }
35
+
19
36
  // ViewTransition is only available in React experimental.
20
37
  // Access via namespace import to avoid compile-time errors on stable React.
21
38
  const ReactViewTransition: any =
@@ -211,6 +228,17 @@ export async function renderSegments(
211
228
  rootLayout: RootLayout,
212
229
  } = options || {};
213
230
 
231
+ const segDebug = INTERNAL_RANGO_DEBUG && typeof window === "object";
232
+ const segDebugStart = segDebug ? performance.now() : 0;
233
+ if (segDebug) {
234
+ segDebugLog("renderSegments start", {
235
+ segments: segments.map((s) => `${s.id}:${s.type}`),
236
+ isAction: !!isAction,
237
+ forceAwait: !!forceAwait,
238
+ intercepts: interceptSegments?.length ?? 0,
239
+ });
240
+ }
241
+
214
242
  const temporalLazyRefs: Promise<any>[] = [];
215
243
  const normalizedSegments = restoreParallelLoaderMarkers(segments);
216
244
  const normalizedInterceptSegments = interceptSegments
@@ -272,6 +300,15 @@ export async function renderSegments(
272
300
  );
273
301
  const { component, id, params, loading } = node.segment;
274
302
 
303
+ if (segDebug) {
304
+ segDebugLog(`segment ${id}`, {
305
+ type: node.segment.type,
306
+ loaders: node.loaders.map((l) => l.loaderId).filter(Boolean),
307
+ hasLoading: loading !== undefined && loading !== null,
308
+ parallel: node.parallel.map((p) => p.id),
309
+ });
310
+ }
311
+
275
312
  // Param-agnostic keys are opt-in via the transition() DSL (see
276
313
  // inTransitionScope above). A route (and its route-owned layouts) inside a
277
314
  // transition scope drops the param from its key, so navigating between two
@@ -316,14 +353,47 @@ export async function renderSegments(
316
353
  resolvedComponent = await component;
317
354
  }
318
355
 
319
- let nodeContent: ReactNode = isRenderableLoading(loading)
320
- ? createElement(RouteContentWrapper, {
321
- key: `suspense-loading-${id}`,
322
- content: getMemoizedContentPromise(resolvedComponent),
323
- fallback: loading,
324
- segmentId: id,
325
- })
326
- : registerLazyRef(resolvedComponent);
356
+ let nodeContent: ReactNode = null;
357
+ if (isRenderableLoading(loading)) {
358
+ // forceAwait (popstate, stale-revalidation, fully-prefetched nav) renders a
359
+ // loading() route with the route content ALREADY resolved, so its
360
+ // RouteContentWrapper Suspender does not suspend for a microtask and flash
361
+ // the loading() fallback on a NORMAL (non-transition) commit. The router
362
+ // data is known-ready on these paths, so awaiting the content here is free.
363
+ // The wrapper tree is unchanged (RouteContentWrapper is still created with
364
+ // the same key/fallback) — only the `content` prop is a resolved node
365
+ // instead of a pending promise, which Suspender renders synchronously. This
366
+ // mirrors the forceAwait loaderData unwrap above; a CLIENT component that
367
+ // suspends on mount inside the content still reveals a fallback (it is not
368
+ // pre-resolved).
369
+ const contentPromise = getMemoizedContentPromise(resolvedComponent);
370
+ const loadingContent: Promise<ReactNode> | ReactNode = forceAwait
371
+ ? await contentPromise
372
+ : contentPromise;
373
+ nodeContent = createElement(RouteContentWrapper, {
374
+ key: `suspense-loading-${id}`,
375
+ content: loadingContent,
376
+ fallback: loading,
377
+ segmentId: id,
378
+ });
379
+ } else {
380
+ // [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. A segment in the no-loading()
381
+ // branch whose component decodes as a Promise/lazy gets registered into
382
+ // temporalLazyRefs and awaited before commit (see below) — which on builds
383
+ // where the segment component arrives deferred defeats client-nav streaming.
384
+ if (INTERNAL_RANGO_DEBUG && typeof window === "object") {
385
+ const c = resolvedComponent as unknown;
386
+ console.log("[VT-DIAG] renderSegments no-loading-branch segment", {
387
+ id,
388
+ type: node.segment.type,
389
+ componentIsPromise: c instanceof Promise,
390
+ componentIsLazy:
391
+ c != null && typeof c === "object" && "_payload" in c,
392
+ componentTypeof: typeof c,
393
+ });
394
+ }
395
+ nodeContent = registerLazyRef(resolvedComponent);
396
+ }
327
397
 
328
398
  // Wrap with <ViewTransition> if transition config exists (React experimental only).
329
399
  // An empty config ({}) creates a bare <ViewTransition> boundary that participates
@@ -369,10 +439,25 @@ export async function renderSegments(
369
439
 
370
440
  if (loading !== undefined && loading !== null) {
371
441
  const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
442
+ let boundaryLoaderData: Promise<any[]> | any[] = loaderDataPromise;
443
+ if (forceAwait || isAction) {
444
+ const awaitStart = segDebug ? performance.now() : 0;
445
+ boundaryLoaderData = await loaderDataPromise;
446
+ if (segDebug) {
447
+ segDebugLog(`segment ${id}: loaders awaited (forceAwait/action)`, {
448
+ loaderIds,
449
+ ms: Math.round(performance.now() - awaitStart),
450
+ });
451
+ }
452
+ } else if (segDebug) {
453
+ segDebugLog(
454
+ `segment ${id}: streaming loaders via LoaderBoundary (suspense)`,
455
+ { loaderIds },
456
+ );
457
+ }
372
458
  content = createElement(LoaderBoundary, {
373
459
  key: `loader-boundary-${key}`,
374
- loaderDataPromise:
375
- forceAwait || isAction ? await loaderDataPromise : loaderDataPromise,
460
+ loaderDataPromise: boundaryLoaderData,
376
461
  loaderIds,
377
462
  fallback: loading,
378
463
  outletKey: key,
@@ -396,7 +481,17 @@ export async function renderSegments(
396
481
  );
397
482
 
398
483
  const layoutLoaderIds = layoutLoaders.map((l) => l.loaderId!);
484
+ // No loading() on this segment, so its loader data cannot stream behind
485
+ // a Suspense fallback — the tree build BLOCKS here until the data
486
+ // arrives. On the initial document this await runs before hydrateRoot.
487
+ const layoutAwaitStart = segDebug ? performance.now() : 0;
399
488
  const resolvedData = await buildLoaderPromise(layoutLoaders);
489
+ if (segDebug) {
490
+ segDebugLog(`segment ${id}: layout loaders awaited (blocking)`, {
491
+ loaderIds: layoutLoaderIds,
492
+ ms: Math.round(performance.now() - layoutAwaitStart),
493
+ });
494
+ }
400
495
  const { loaderData, errorFallback } = decodeLoaderResults(
401
496
  resolvedData,
402
497
  layoutLoaderIds,
@@ -429,10 +524,27 @@ export async function renderSegments(
429
524
 
430
525
  p.loaderIds = ownedLoaders.map((l) => l.loaderId!);
431
526
  const aggregated = getMemoizedLoaderPromise(ownedLoaders);
432
- p.loaderDataPromise =
433
- (forceAwait || isAction) && aggregated instanceof Promise
434
- ? await aggregated
435
- : aggregated;
527
+ if ((forceAwait || isAction) && aggregated instanceof Promise) {
528
+ const parallelAwaitStart = segDebug ? performance.now() : 0;
529
+ p.loaderDataPromise = await aggregated;
530
+ if (segDebug) {
531
+ segDebugLog(
532
+ `segment ${id}: parallel ${p.id} loaders awaited (forceAwait/action)`,
533
+ {
534
+ loaderIds: p.loaderIds,
535
+ ms: Math.round(performance.now() - parallelAwaitStart),
536
+ },
537
+ );
538
+ }
539
+ } else {
540
+ p.loaderDataPromise = aggregated;
541
+ if (segDebug) {
542
+ segDebugLog(
543
+ `segment ${id}: parallel ${p.id} loaders streaming (suspense)`,
544
+ { loaderIds: p.loaderIds },
545
+ );
546
+ }
547
+ }
436
548
  }
437
549
  }
438
550
 
@@ -462,7 +574,24 @@ export async function renderSegments(
462
574
  children: content,
463
575
  });
464
576
  if (typeof window === "object") {
577
+ // [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. If this await dominates the
578
+ // navigation time, a deferred/lazy segment component is being fully resolved
579
+ // before commit, which defeats client-nav streaming. The await itself is
580
+ // functional (it preloads lazy chunk refs); only the timing log is gated.
581
+ const vtDebug = INTERNAL_RANGO_DEBUG && temporalLazyRefs.length > 0;
582
+ const vtDebugStart = vtDebug ? performance.now() : 0;
583
+ if (vtDebug) {
584
+ console.log("[VT-DIAG] renderSegments awaiting temporalLazyRefs", {
585
+ count: temporalLazyRefs.length,
586
+ });
587
+ }
465
588
  await Promise.allSettled(temporalLazyRefs);
589
+ if (vtDebug) {
590
+ console.log("[VT-DIAG] renderSegments temporalLazyRefs settled", {
591
+ count: temporalLazyRefs.length,
592
+ ms: Math.round(performance.now() - vtDebugStart),
593
+ });
594
+ }
466
595
  }
467
596
 
468
597
  let result: ReactNode = errorBoundaryWrapped;
@@ -473,6 +602,12 @@ export async function renderSegments(
473
602
  });
474
603
  }
475
604
 
605
+ if (segDebug) {
606
+ segDebugLog("renderSegments complete", {
607
+ ms: Math.round(performance.now() - segDebugStart),
608
+ });
609
+ }
610
+
476
611
  return result;
477
612
  }
478
613
 
@@ -150,6 +150,19 @@ export type InterceptWhenFn<TEnv = any> = (
150
150
  ctx: InterceptSelectorContext<TEnv>,
151
151
  ) => boolean;
152
152
 
153
+ /**
154
+ * Config object passed to intercept() (its 4th argument). `when` gates whether
155
+ * the intercept activates on a soft navigation — a single match-time selector or
156
+ * an array of them (ALL must return true; omit to always activate). This is the
157
+ * intercept counterpart to transition({ when }); both express conditional
158
+ * behavior as a config field rather than a separate DSL helper.
159
+ *
160
+ * @internal This type is an implementation detail and may change without notice.
161
+ */
162
+ export interface InterceptConfig<TEnv = any> {
163
+ when?: InterceptWhenFn<TEnv> | InterceptWhenFn<TEnv>[];
164
+ }
165
+
153
166
  /**
154
167
  * Intercept entry stored in EntryData
155
168
  * Contains the slot name, route to intercept, and handler
@@ -220,6 +233,13 @@ export type EntryData =
220
233
  staticHandlerId?: string;
221
234
  /** Response type for non-RSC routes (json, text, image, any) */
222
235
  responseType?: string;
236
+ /**
237
+ * PPR (partial pre-rendering) opt-in from the path() `ppr` option. A
238
+ * document-level property of the page route: `true` uses the default
239
+ * shell policy, an object carries ttl/swr/tags. Read by the integrated
240
+ * PPR serve path (rsc/shell-serve.ts resolvePprConfig).
241
+ */
242
+ ppr?: boolean | import("../urls/pattern-types.js").PartialPrerenderProps;
223
243
  } & EntryPropCommon &
224
244
  EntryPropDatas &
225
245
  EntryPropSegments &
@@ -750,14 +770,23 @@ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
750
770
 
751
771
  // Purity-only scope: marks that a loader FUNCTION BODY is executing, regardless
752
772
  // of how the loader was invoked (DSL via runInsideLoaderScope, or handler-
753
- // invoked via ctx.use). Consulted ONLY by isInsideCacheScope() to exempt
754
- // request-scoped reads. It deliberately does NOT affect isInsideLoaderScope(),
755
- // so rendered()/barrier/deadlock gating (which must distinguish DSL from
756
- // handler-invoked loaders) is unchanged.
773
+ // invoked via ctx.use). Consulted by isInsideCacheScope() to exempt
774
+ // request-scoped reads, by getCurrentLoaderBodyId() for guard-warning
775
+ // attribution, and by isInsideHandlerInvokedLoaderBody() for the
776
+ // consumption-lane rule (the shell-capture guard exemption). It deliberately
777
+ // does NOT affect isInsideLoaderScope(), so rendered()/barrier/deadlock
778
+ // gating (which must distinguish DSL from handler-invoked loaders) is
779
+ // unchanged.
757
780
  const LOADER_BODY_SCOPE_KEY = Symbol.for("rangojs-router:loader-body-scope");
758
- const loaderBodyScopeALS: AsyncLocalStorage<{ active: true }> = ((
759
- globalThis as any
760
- )[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
781
+ const loaderBodyScopeALS: AsyncLocalStorage<{
782
+ active: true;
783
+ loaderId?: string;
784
+ handlerInvoked?: boolean;
785
+ }> = ((globalThis as any)[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<{
786
+ active: true;
787
+ loaderId?: string;
788
+ handlerInvoked?: boolean;
789
+ }>());
761
790
 
762
791
  /**
763
792
  * Check if the current execution is inside a cache() DSL boundary.
@@ -802,8 +831,37 @@ export function runInsideLoaderScope<T>(fn: () => T): T {
802
831
  * and handler-invoked via ctx.use) so request-scoped reads inside a loader
803
832
  * never trip the cache-scope guards — loaders always run fresh.
804
833
  */
805
- export function runInsideLoaderBodyScope<T>(fn: () => T): T {
806
- return loaderBodyScopeALS.run({ active: true }, fn);
834
+ export function runInsideLoaderBodyScope<T>(
835
+ fn: () => T,
836
+ loaderId?: string,
837
+ handlerInvoked?: boolean,
838
+ ): T {
839
+ return loaderBodyScopeALS.run({ active: true, loaderId, handlerInvoked }, fn);
840
+ }
841
+
842
+ /**
843
+ * The $$id of the loader whose body is currently executing, or undefined
844
+ * outside any loader body. Used by the shell-capture identity guard
845
+ * (cookie-store.ts) so its refusal warning can name the loader that read
846
+ * cookies()/headers() instead of blaming a lane it cannot see — the old
847
+ * hardcoded "bake-lane loader" text misled a live-lane debugging session
848
+ * (issue #672, secondary).
849
+ */
850
+ export function getCurrentLoaderBodyId(): string | undefined {
851
+ return loaderBodyScopeALS.getStore()?.loaderId;
852
+ }
853
+
854
+ /**
855
+ * True while a HANDLER-invoked loader body (`await ctx.use(Loader)` from a
856
+ * handler, not the DSL segment funnel) is executing. The consumption-lane
857
+ * rule keys off this: handler consumption yields a BAKED copy in every shared
858
+ * artifact — cache(), "use cache", and the PPR shell — so the shell-capture
859
+ * identity guard (cookie-store.ts) permits cookies()/headers() here, exactly
860
+ * like the cache-purity guards do. DSL segment loaders (live lane masked at
861
+ * capture, bake lane guarded) never set the flag.
862
+ */
863
+ export function isInsideHandlerInvokedLoaderBody(): boolean {
864
+ return loaderBodyScopeALS.getStore()?.handlerInvoked === true;
807
865
  }
808
866
 
809
867
  // Scope for handle PUSH CALLBACKS (push(() => ...), including async ones).