@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
@@ -29,6 +29,10 @@ import { isAutoGeneratedRouteName } from "../route-name.js";
29
29
  */
30
30
  interface RouterWithRouteMap {
31
31
  routeMap: Record<string, unknown>;
32
+ // findMatch is async (it may await an async include provider's import before
33
+ // the route's names are spliced into routeMap), so expandLazyIncludes awaits
34
+ // it. Typed `unknown` (not `unknown | Promise<unknown>`, which collapses to
35
+ // the same thing) — the awaited value is never read, only its side effect.
32
36
  findMatch?: (pathname: string) => unknown;
33
37
  }
34
38
 
@@ -42,16 +46,28 @@ function concretePath(pattern: string): string {
42
46
  );
43
47
  }
44
48
 
45
- function expandLazyIncludes(
49
+ async function expandLazyIncludes(
46
50
  router: RouterWithRouteMap,
47
51
  patterns: Iterable<string>,
48
- ): void {
52
+ ): Promise<void> {
49
53
  const findMatch = router.findMatch;
50
54
  if (typeof findMatch !== "function") return;
51
55
  for (const pattern of patterns) {
52
56
  try {
53
- findMatch.call(router, concretePath(pattern));
54
- } catch {}
57
+ // Await: an async include(`() => import()`) resolves its module + splices
58
+ // its routes into routeMap only after findMatch's Promise settles. Without
59
+ // the await, the routeMap read below races the import and reports the
60
+ // split-group routes as missing.
61
+ await findMatch.call(router, concretePath(pattern));
62
+ } catch (e) {
63
+ // A findMatch throw here is almost always a failing async include()
64
+ // provider (its `() => import()` rejected). Surface it — swallowed, it
65
+ // resurfaces downstream as a misleading "missing route" in the diff.
66
+ console.warn(
67
+ `[@rangojs/router] assertGeneratedRoutesMatch: could not expand include ` +
68
+ `for "${pattern}": ${(e as Error)?.message ?? String(e)}`,
69
+ );
70
+ }
55
71
  }
56
72
  }
57
73
 
@@ -91,13 +107,13 @@ function patternOf(value: unknown): string {
91
107
  return String(value);
92
108
  }
93
109
 
94
- export function diffGeneratedRoutes(
110
+ export async function diffGeneratedRoutes(
95
111
  router: RouterWithRouteMap,
96
112
  generatedMap?: Record<string, unknown>,
97
- ): GeneratedRoutesDiff {
113
+ ): Promise<GeneratedRoutesDiff> {
98
114
  const generated = generatedMap ?? getGlobalRouteMap();
99
115
 
100
- expandLazyIncludes(
116
+ await expandLazyIncludes(
101
117
  router,
102
118
  Object.values(generated).map((v) => patternOf(v)),
103
119
  );
@@ -145,14 +161,14 @@ export function diffGeneratedRoutes(
145
161
  * import generated from "./router.named-routes.gen";
146
162
  * import { router } from "./router";
147
163
  *
148
- * assertGeneratedRoutesMatch(router, generated);
164
+ * await assertGeneratedRoutesMatch(router, generated);
149
165
  * ```
150
166
  */
151
- export function assertGeneratedRoutesMatch(
167
+ export async function assertGeneratedRoutesMatch(
152
168
  router: RouterWithRouteMap,
153
169
  generatedMap?: Record<string, unknown>,
154
- ): void {
155
- const diff = diffGeneratedRoutes(router, generatedMap);
170
+ ): Promise<void> {
171
+ const diff = await diffGeneratedRoutes(router, generatedMap);
156
172
  if (diff.ok) return;
157
173
 
158
174
  const lines: string[] = [
@@ -47,6 +47,12 @@ export type {
47
47
  TestLoaderContext,
48
48
  } from "./run-loader.js";
49
49
 
50
+ export { runTransitionWhen } from "./run-transition-when.js";
51
+ export type {
52
+ RunTransitionWhenOptions,
53
+ RunTransitionWhenResult,
54
+ } from "./run-transition-when.js";
55
+
50
56
  export { dispatch } from "./dispatch.js";
51
57
  export type { DispatchOptions } from "./dispatch.js";
52
58
 
@@ -116,6 +116,15 @@ export interface RenderHandlerOptions<TEnv = any> {
116
116
  * `"use cache: profileName"` resolution once a `cacheStore` is wired.
117
117
  */
118
118
  cacheProfiles?: Record<string, CacheProfile>;
119
+ /**
120
+ * Render as if inside a server action's revalidation render (production sets
121
+ * this in revalidateAfterAction). A stale `"use cache"` entry whose profile
122
+ * opts into `foregroundOnAction` then re-executes in the FOREGROUND (fresh
123
+ * result in this render) instead of being served stale + revalidated in the
124
+ * background. Without it, a stale entry keeps SWR. Pair with `cacheStore` +
125
+ * `cacheProfiles` to exercise the `foregroundOnAction` opt-in.
126
+ */
127
+ inActionRevalidation?: boolean;
119
128
  /**
120
129
  * Theme config in the same shape `createRouter({ theme })` takes (e.g. `true`
121
130
  * or `{ themes: [...] }`). Without it `ctx.theme`/`ctx.setTheme` are inert,
@@ -238,6 +247,11 @@ export async function renderHandler<TEnv = any>(
238
247
  opts.theme === undefined ? undefined : resolveThemeConfig(opts.theme),
239
248
  });
240
249
 
250
+ // Simulate an action revalidation render (production sets this in
251
+ // revalidateAfterAction) so a `foregroundOnAction` cache profile foregrounds a
252
+ // stale entry. See the foregroundOnAction option doc.
253
+ if (opts.inActionRevalidation) reqCtx._inActionRevalidation = true;
254
+
241
255
  const loaderSeeds = new Map<unknown, unknown>(opts.loaders ?? []);
242
256
  const handlePushes = new Map<Handle<any, any>, unknown[]>();
243
257
 
@@ -46,11 +46,15 @@ import {
46
46
  generateHistoryKey,
47
47
  } from "../browser/navigation-store.js";
48
48
  import { createEventController } from "../browser/event-controller.js";
49
+ import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
49
50
  import type { NavigationStore, NavigationBridge } from "../browser/types.js";
50
51
  import type { EventController } from "../browser/event-controller.js";
51
52
  import type { ResolvedSegment, RscMetadata } from "../browser/types.js";
52
53
  import { NavigationProvider } from "../browser/react/NavigationProvider.js";
53
- import { compilePattern } from "../router/pattern-matching.js";
54
+ import {
55
+ compilePattern,
56
+ buildParamsFromMatch,
57
+ } from "../router/pattern-matching.js";
54
58
  import { normalizeBasename } from "../router/basename.js";
55
59
  import type { LoaderDefinition } from "../types.js";
56
60
  import type { LocationStateDefinition } from "../browser/react/location-state-shared.js";
@@ -302,14 +306,9 @@ function matchLeaf(
302
306
  const compiled = compilePattern(pattern);
303
307
  const match = compiled.regex.exec(pathname);
304
308
  if (!match) return null;
305
- const params: Record<string, string> = {};
306
- compiled.paramNames.forEach((name, index) => {
307
- const value = match[index + 1];
308
- if (value !== undefined) {
309
- params[name] = decodeURIComponent(value);
310
- }
311
- });
312
- return params;
309
+ // Reuse the production param builder so the harness matches the real matcher
310
+ // exactly (named catch-all "" binding, decoding) instead of forking it.
311
+ return buildParamsFromMatch(match, compiled.paramNames, compiled.catchAll);
313
312
  }
314
313
 
315
314
  function staticPrefix(pattern: string): string {
@@ -481,8 +480,12 @@ export async function renderRoute(
481
480
 
482
481
  const eventController = createEventController({ initialLocation: url });
483
482
  eventController.setParams(initialMatch.params);
483
+ // Resolve-by-default: resolve any deferred (Promise) seeded handle values
484
+ // before applying, so the seeded handles reach collect/useHandle resolved —
485
+ // matching what the server/client do in a real app.
486
+ const resolvedSeed = await resolveDeferredHandleValues(handleSeed);
484
487
  eventController.setHandleData(
485
- handleSeed,
488
+ resolvedSeed,
486
489
  initialSegments.map((s) => s.id),
487
490
  );
488
491
 
@@ -0,0 +1,164 @@
1
+ /**
2
+ * runTransitionWhen — unit-test a transition({ when }) predicate in isolation.
3
+ *
4
+ * Runs the SAME two server functions the router uses — applyViewTransitionDefault
5
+ * (strips the `when` function from the serialized config and records the
6
+ * predicate on the request context) and gateTransitions (assembles the
7
+ * TransitionWhenContext and evaluates the predicate post-handler). So the
8
+ * predicate sees exactly the navigation/action metadata it would at runtime
9
+ * (currentUrl/currentParams/fromRouteName, nextUrl/nextParams/toRouteName,
10
+ * actionId/actionUrl/actionResult/formData/method, get/env), and `kept` reflects
11
+ * whether the transition would apply this request. The result also exposes the
12
+ * assembled `whenContext` so tests can assert the exact fields without reaching
13
+ * into private request-context state.
14
+ *
15
+ * This is the public way to exercise a transition gate: the full
16
+ * match -> render pipeline that wires these together only runs under real RSC
17
+ * rendering (which the Flight primitives do not drive), so without this primitive
18
+ * a consumer could not test their predicate through @rangojs/router/testing.
19
+ *
20
+ * Synchronous: a transition predicate returns a boolean and the gate has no I/O.
21
+ */
22
+
23
+ import {
24
+ runWithRequestContext,
25
+ type RequestContext,
26
+ } from "../server/request-context.js";
27
+ import { applyViewTransitionDefault } from "../router/segment-resolution/view-transition-default.js";
28
+ import { gateTransitions } from "../rsc/transition-gate.js";
29
+ import { createTestRequestContext, type VarsInit } from "./internal/context.js";
30
+ import type {
31
+ ResolvedSegment,
32
+ TransitionConfig,
33
+ TransitionWhenContext,
34
+ } from "../types/segments.js";
35
+ import type { OnErrorCallback } from "../types/error-types.js";
36
+
37
+ const toURL = (v: string | URL, base: URL): URL =>
38
+ typeof v === "string" ? new URL(v, base.origin) : v;
39
+
40
+ /**
41
+ * Options for runTransitionWhen. All navigation/action fields are optional and
42
+ * default to "absent", matching what the gate sees for an initial full load with
43
+ * no action: omit `currentUrl`/`currentParams`/`fromRouteName` to model the
44
+ * navigation source being unavailable, and omit the `action*` fields to model a
45
+ * plain (non-action) navigation.
46
+ */
47
+ export interface RunTransitionWhenOptions<TEnv = any> {
48
+ /** The navigation TARGET request (drives `nextUrl`): a Request or URL/path string. Defaults to `http://localhost/`. */
49
+ request?: Request | string;
50
+ /** Route params for the target (`nextParams`). */
51
+ params?: Record<string, string>;
52
+ /** Target route name (`toRouteName`). */
53
+ toRouteName?: string;
54
+ /** Environment bindings surfaced as `env` (and `ctx.env`). */
55
+ env?: TEnv;
56
+ /** Variables a handler/middleware would have set this request, readable via the predicate's `get()`. */
57
+ vars?: VarsInit;
58
+ /** Navigation SOURCE url (`currentUrl`): a URL or path string. */
59
+ currentUrl?: string | URL;
60
+ /** Source route params (`currentParams`). */
61
+ currentParams?: Record<string, string>;
62
+ /** Source route name (`fromRouteName`). */
63
+ fromRouteName?: string;
64
+ /** Id of the action that triggered a revalidation (`actionId`). */
65
+ actionId?: string;
66
+ /** Url the action was submitted from (`actionUrl`). */
67
+ actionUrl?: string | URL;
68
+ /** The action's return value (`actionResult`). */
69
+ actionResult?: unknown;
70
+ /** FormData from a form action (`formData`). */
71
+ formData?: FormData;
72
+ /** Receives an error thrown by the predicate (the gate reports to `router.onError`, phase `"rendering"`). */
73
+ onError?: OnErrorCallback;
74
+ }
75
+
76
+ /**
77
+ * Result of runTransitionWhen.
78
+ */
79
+ export interface RunTransitionWhenResult<TEnv = any> {
80
+ /** True if the transition would apply this request (predicate returned non-false, or there is no `when`). */
81
+ kept: boolean;
82
+ /** Convenience inverse of `kept`. */
83
+ dropped: boolean;
84
+ /**
85
+ * The production-assembled predicate context. Undefined when the config has
86
+ * no `when` predicate.
87
+ */
88
+ whenContext?: TransitionWhenContext<Record<string, string>, TEnv>;
89
+ /** The underlying RequestContext, for additional assertions (`ctx.get(...)`, etc.). */
90
+ ctx: RequestContext<TEnv>;
91
+ }
92
+
93
+ export function runTransitionWhen<TEnv = any>(
94
+ config: TransitionConfig,
95
+ opts: RunTransitionWhenOptions<TEnv> = {},
96
+ ): RunTransitionWhenResult<TEnv> {
97
+ const { ctx } = createTestRequestContext<TEnv>({
98
+ env: opts.env,
99
+ request: opts.request,
100
+ vars: opts.vars,
101
+ params: opts.params,
102
+ });
103
+ const reqCtx = ctx as unknown as RequestContext<TEnv>;
104
+
105
+ // Target route name (the public field the gate reads for `toRouteName`).
106
+ if (opts.toRouteName !== undefined)
107
+ reqCtx.routeName = opts.toRouteName as RequestContext<TEnv>["routeName"];
108
+ // Source (match-time) data the gate reads for currentUrl/currentParams/fromRouteName.
109
+ if (opts.currentUrl !== undefined)
110
+ reqCtx._gateCurrentUrl = toURL(opts.currentUrl, reqCtx.url);
111
+ if (opts.currentParams !== undefined)
112
+ reqCtx._gateCurrentParams = opts.currentParams;
113
+ if (opts.fromRouteName !== undefined)
114
+ reqCtx._prevRouteKey = opts.fromRouteName;
115
+ // Action data the gate reads at the action-bearing call sites.
116
+ if (opts.actionId !== undefined) reqCtx._gateActionId = opts.actionId;
117
+ if (opts.actionUrl !== undefined)
118
+ reqCtx._gateActionUrl = toURL(opts.actionUrl, reqCtx.url);
119
+ if (opts.actionResult !== undefined)
120
+ reqCtx._gateActionResult = opts.actionResult;
121
+ if (opts.formData !== undefined) reqCtx._gateFormData = opts.formData;
122
+
123
+ let whenContext:
124
+ | TransitionWhenContext<Record<string, string>, TEnv>
125
+ | undefined;
126
+ const when = config.when;
127
+ const configForGate: TransitionConfig = when
128
+ ? {
129
+ ...config,
130
+ when: (c) => {
131
+ whenContext = c as TransitionWhenContext<
132
+ Record<string, string>,
133
+ TEnv
134
+ >;
135
+ return when(c);
136
+ },
137
+ }
138
+ : config;
139
+
140
+ return runWithRequestContext(reqCtx, () => {
141
+ // The real resolution-time collection + post-handler gate, so the predicate
142
+ // sees the production-assembled TransitionWhenContext.
143
+ const serialized = applyViewTransitionDefault(
144
+ configForGate,
145
+ undefined,
146
+ "tx-when-seg",
147
+ );
148
+ const segment = {
149
+ id: "tx-when-seg",
150
+ namespace: "r",
151
+ type: "route",
152
+ index: 0,
153
+ component: null,
154
+ transition: serialized,
155
+ } as ResolvedSegment;
156
+ gateTransitions(
157
+ [segment],
158
+ reqCtx as Parameters<typeof gateTransitions>[1],
159
+ opts.onError,
160
+ );
161
+ const kept = segment.transition !== undefined;
162
+ return { kept, dropped: !kept, whenContext, ctx: reqCtx };
163
+ });
164
+ }
@@ -109,27 +109,6 @@ function applyThemeToDocument(theme: Theme, config: ResolvedThemeConfig): void {
109
109
  }
110
110
  }
111
111
 
112
- function getStoredTheme(config: ResolvedThemeConfig): Theme {
113
- const { storageKey, themes, defaultTheme, enableSystem } = config;
114
-
115
- let stored = readThemeFromCookie(storageKey);
116
-
117
- if (!stored) {
118
- stored = readThemeFromStorage(storageKey);
119
- }
120
-
121
- if (stored) {
122
- if (stored === "system" && enableSystem) {
123
- return "system";
124
- }
125
- if (themes.includes(stored)) {
126
- return stored as Theme;
127
- }
128
- }
129
-
130
- return defaultTheme;
131
- }
132
-
133
112
  export function ThemeProvider({
134
113
  config,
135
114
  initialTheme,
@@ -137,17 +116,48 @@ export function ThemeProvider({
137
116
  }: ThemeProviderProps): React.ReactNode {
138
117
  const [mounted, setMounted] = useState(false);
139
118
 
140
- const [theme, setThemeState] = useState<Theme>(() => {
141
- if (initialTheme) return initialTheme;
142
- if (typeof window === "undefined") return config.defaultTheme;
143
- return getStoredTheme(config);
144
- });
119
+ // HYDRATION PARITY: this initializer is the server (SSR/resume) render AND
120
+ // the client's hydration render — both must produce the same value. It must
121
+ // NEVER read cookie/localStorage: whenever the payload's initialTheme
122
+ // differs from the visitor's stored theme (a PPR shell HIT deliberately
123
+ // replays the CAPTURE's initialTheme), a storage-reading initializer makes
124
+ // the client's first render diverge from the server tree. Any raw-theme text
125
+ // (a toggle label) then fails hydration, React regenerates the tree, and the
126
+ // FOUC-applied class is wiped from <html>. The visitor's stored theme is
127
+ // applied by the post-mount re-sync effect below instead.
128
+ const [theme, setThemeState] = useState<Theme>(
129
+ () => initialTheme ?? config.defaultTheme,
130
+ );
145
131
 
146
132
  const [systemTheme, setSystemTheme] = useState<ResolvedTheme>("light");
147
133
 
148
134
  useEffect(() => {
149
135
  setMounted(true);
150
136
  setSystemTheme(getSystemTheme());
137
+ // Re-sync state from an EXPLICITLY stored theme after mount. initialTheme
138
+ // comes from the payload and can legitimately differ from the visitor's
139
+ // stored theme — on a PPR shell HIT it is deliberately the CAPTURE's theme
140
+ // (the resume tree must match the frozen prelude; see
141
+ // ShellCacheEntry.initialTheme). The FOUC script already applied the stored
142
+ // theme to the document pre-paint; this brings the provider state (toggles,
143
+ // useTheme readers) in line with it, and is the ONLY place the provider
144
+ // reads storage (the initializer must not — see the parity note above).
145
+ // Only an explicit cookie/localStorage value re-syncs — a defaultTheme
146
+ // fallback must not override a server-provided initialTheme when the
147
+ // visitor never chose a theme.
148
+ // First VALID candidate wins — an empty/garbage cookie value (e.g. a
149
+ // deleted cookie leaving "theme=") must not shadow a valid localStorage
150
+ // value behind a bare null-coalesce.
151
+ const explicit =
152
+ [
153
+ readThemeFromCookie(config.storageKey),
154
+ readThemeFromStorage(config.storageKey),
155
+ ].find((v) => v !== null && isValidTheme(v, config)) ?? null;
156
+ if (explicit !== null && explicit !== theme) {
157
+ setThemeState(explicit as Theme);
158
+ applyThemeToDocument(explicit as Theme, config);
159
+ }
160
+ // eslint-disable-next-line react-hooks/exhaustive-deps
151
161
  }, []);
152
162
 
153
163
  const setTheme = useCallback(
@@ -504,7 +504,7 @@ export type RevalidateParams<TParams = GenericParams, TEnv = any> = Parameters<
504
504
  * @param args.context - App context (db, user, etc.)
505
505
  * @param args.actionResult - Result from action (future support)
506
506
  * @param args.formData - Form data from action (future support)
507
- * @param args.formMethod - HTTP method from action (future support)
507
+ * @param args.method - HTTP method: "GET" for navigation, "POST" for server actions
508
508
  *
509
509
  * @returns Hard decision (boolean), soft suggestion (object), or defer
510
510
  * (`void` / `null` / `undefined`) to keep the running suggestion as-is.
@@ -48,6 +48,8 @@ export type {
48
48
  export type {
49
49
  ViewTransitionClass,
50
50
  TransitionConfig,
51
+ TransitionWhenFn,
52
+ TransitionWhenContext,
51
53
  ResolvedSegment,
52
54
  SegmentMetadata,
53
55
  SlotState,
@@ -10,20 +10,29 @@ export type DocumentProps = {
10
10
  type ParseConstraint<T extends string> =
11
11
  T extends `${infer First}|${infer Rest}` ? First | ParseConstraint<Rest> : T;
12
12
 
13
+ // Named catch-all (`:name*` / `:name+`) is matched BEFORE the `?`/suffix
14
+ // branches. Its modifier is anchored to the END of the token (no trailing
15
+ // `${string}`) so it is a true suffix and never mis-splits a constraint body
16
+ // such as `id(\d+)`. Both are a required `string`: a matched catch-all always
17
+ // binds a value (possibly ""), so the key is always present.
13
18
  type ExtractParamInfo<T extends string> =
14
19
  T extends `${infer Name}(${infer Constraint})?${string}`
15
20
  ? { name: Name; optional: true; type: ParseConstraint<Constraint> }
16
21
  : T extends `${infer Name}(${infer Constraint})${string}`
17
22
  ? { name: Name; optional: false; type: ParseConstraint<Constraint> }
18
- : T extends `${infer Name}?${string}`
19
- ? { name: Name; optional: true; type: string }
20
- : T extends `${infer Name}.${string}`
23
+ : T extends `${infer Name}*`
24
+ ? { name: Name; optional: false; type: string }
25
+ : T extends `${infer Name}+`
21
26
  ? { name: Name; optional: false; type: string }
22
- : T extends `${infer Name}-${string}`
23
- ? { name: Name; optional: false; type: string }
24
- : T extends `${infer Name}~${string}`
27
+ : T extends `${infer Name}?${string}`
28
+ ? { name: Name; optional: true; type: string }
29
+ : T extends `${infer Name}.${string}`
25
30
  ? { name: Name; optional: false; type: string }
26
- : { name: T; optional: false; type: string };
31
+ : T extends `${infer Name}-${string}`
32
+ ? { name: Name; optional: false; type: string }
33
+ : T extends `${infer Name}~${string}`
34
+ ? { name: Name; optional: false; type: string }
35
+ : { name: T; optional: false; type: string };
27
36
 
28
37
  type ParamFromInfo<Info> = Info extends {
29
38
  name: infer N extends string;
@@ -51,12 +60,15 @@ type MergeParams<A, B> = Pick<A, keyof A> & Pick<B, keyof B> extends infer O
51
60
  * - Optional params: /:locale? -> { locale?: string }
52
61
  * - Constrained params: /:locale(en|gb) -> { locale: "en" | "gb" }
53
62
  * - Optional + constrained: /:locale(en|gb)? -> { locale?: "en" | "gb" }
63
+ * - Named catch-all: /:path+ (one-or-more), /:slug* (zero-or-more) -> string
54
64
  *
55
65
  * @example
56
66
  * ExtractParams<"/products/:id"> // { id: string }
57
67
  * ExtractParams<"/:locale?/blog/:slug"> // { locale?: string; slug: string }
58
68
  * ExtractParams<"/:locale(en|gb)/blog"> // { locale: "en" | "gb" }
59
69
  * ExtractParams<"/:locale(en|gb)?/blog/:slug"> // { locale?: "en" | "gb"; slug: string }
70
+ * ExtractParams<"/docs/:slug*"> // { slug: string }
71
+ * ExtractParams<"/shop/:path+"> // { path: string }
60
72
  */
61
73
  export type ExtractParams<
62
74
  T extends string,
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { ErrorInfo, NotFoundInfo } from "./boundaries.js";
3
+ import type { RevalidateParams, HandlerContext } from "./handler-context.js";
3
4
 
4
5
  /**
5
6
  * CSS class(es) for a ViewTransition phase.
@@ -8,6 +9,96 @@ import type { ErrorInfo, NotFoundInfo } from "./boundaries.js";
8
9
  */
9
10
  export type ViewTransitionClass = Record<string, string> | string;
10
11
 
12
+ /**
13
+ * The context a transition({ when }) predicate receives.
14
+ *
15
+ * It mirrors the {@link ShouldRevalidateFn} args a `revalidate()` predicate
16
+ * gets — the same navigation/action metadata — so the two read the same shape,
17
+ * plus `get`/`env` for post-handler reads. There is no full `HandlerContext`
18
+ * here: the gate runs at the RSC-payload layer with the request context, not a
19
+ * handler context, so handler-only sugar (`search`/`build`/`dev`/`headers`) is
20
+ * absent by design. `get` is the way to read what the handler/middleware set
21
+ * via `ctx.set(...)` this request.
22
+ *
23
+ * Field availability (all source fields are optional — never fabricated):
24
+ * - `currentUrl` / `currentParams` / `fromRouteName` (the navigation SOURCE) are
25
+ * populated on soft navigations and action-success revalidations. They are
26
+ * undefined on an initial full document load and on action-error / no-JS error
27
+ * paths that skip the navigation snapshot — there is no prior page to name.
28
+ * - `nextUrl` / `nextParams` / `get` / `env` / `method` are always present;
29
+ * `toRouteName` is present only when the target route is named (undefined for
30
+ * unnamed/auto-generated routes, like `fromRouteName`).
31
+ * - `actionId` / `actionUrl` / `actionResult` / `formData` are populated only
32
+ * when a server action triggered the render; `method` is "POST" then, "GET"
33
+ * otherwise. On no-JS (progressive-enhancement) action paths `actionId` may be
34
+ * undefined when React cannot surface the action's stable id: the success
35
+ * re-render still sets `actionUrl`/`formData` for a recognized action, but the
36
+ * error-boundary re-render exposes `actionUrl` only when `actionId` resolved.
37
+ * Malformed form bodies that fail before action detection expose no action
38
+ * fields. Treat `actionId` as "the action, if known", not as "was this an
39
+ * action".
40
+ *
41
+ * PREFETCH / CACHE CAVEAT (read this before gating on the source): the gate runs
42
+ * server-side during resolution. A PREFETCHED navigation renders at prefetch
43
+ * time, so `currentUrl`/`currentParams`/`fromRouteName` reflect the page the
44
+ * prefetch fired from, NOT necessarily the page the user actually navigates from
45
+ * — the decision is baked into the stored Flight payload and replayed verbatim.
46
+ * A `cache()`/prerender hit replays the stored transition with the predicate NOT
47
+ * re-run at all. So a source-sensitive predicate can be frozen to prefetch-time
48
+ * or store-time state. This is accepted (~99% of navigations match), but if your
49
+ * gate must reflect the exact click-time source, source-scope the prefetch
50
+ * (`<Link prefetchKey=":source">`) and do not `cache()` that segment.
51
+ */
52
+ export type TransitionWhenContext<
53
+ TParams = Record<string, string>,
54
+ TEnv = unknown,
55
+ > = Partial<
56
+ Pick<
57
+ RevalidateParams<TParams, TEnv>,
58
+ "currentUrl" | "currentParams" | "fromRouteName"
59
+ >
60
+ > &
61
+ Pick<
62
+ RevalidateParams<TParams, TEnv>,
63
+ | "nextUrl"
64
+ | "nextParams"
65
+ | "toRouteName"
66
+ | "actionId"
67
+ | "actionUrl"
68
+ | "actionResult"
69
+ | "formData"
70
+ | "method"
71
+ > &
72
+ Pick<HandlerContext<any, TEnv>, "get" | "env">;
73
+
74
+ /**
75
+ * Predicate that gates whether a transition() applies for the current request.
76
+ *
77
+ * Evaluated server-side AFTER the route's handler runs (so `get(...)` can read
78
+ * handler/middleware-set state) and outside any cache scope. Return false to
79
+ * drop this segment's transition for the request; return true to apply it. The
80
+ * context ({@link TransitionWhenContext}) carries the same navigation/action
81
+ * metadata a `revalidate()` predicate sees plus `get`/`env`. If it throws, the
82
+ * error is reported to the router's onError (phase "rendering") and the
83
+ * transition is dropped (the navigation does not hold).
84
+ *
85
+ * Distinct from intercept()'s `when` config selector, which runs at MATCH time
86
+ * over `{ from, to, params, segments, … }`; a transition `when` runs
87
+ * post-handler over the resolved payload.
88
+ *
89
+ * Scope: dropping a transition removes only THIS segment's contribution to the
90
+ * navigation's hold. The startTransition hold is navigation-wide — it engages if
91
+ * any matched segment still has a transition — so `when: false` makes the
92
+ * navigation stream its loading fallback only when no other matched segment
93
+ * keeps a transition (the common case: a single transition on the route).
94
+ *
95
+ * Evaluated on every fresh (cache-miss) resolution; it is NOT re-run when a
96
+ * segment is replayed from the runtime cache or a build-time prerender, and a
97
+ * prefetched navigation freezes it to prefetch-time state — see the caveat on
98
+ * {@link TransitionWhenContext}.
99
+ */
100
+ export type TransitionWhenFn = (ctx: TransitionWhenContext) => boolean;
101
+
11
102
  /**
12
103
  * Configuration for React's <ViewTransition> component.
13
104
  *
@@ -36,6 +127,15 @@ export interface TransitionConfig {
36
127
  * When unset, inherits the createRouter({ viewTransition }) default.
37
128
  */
38
129
  viewTransition?: "auto" | false;
130
+ /**
131
+ * Optional server-side predicate that gates this transition per request. When
132
+ * present and it returns false (evaluated post-handler), the router drops this
133
+ * segment's transition for the request, so the navigation streams its loading
134
+ * fallback instead of holding. The predicate is server-only and never
135
+ * serialized to the client; only its resolved effect (transition kept or
136
+ * dropped) crosses. See {@link TransitionWhenFn}.
137
+ */
138
+ when?: TransitionWhenFn;
39
139
  }
40
140
 
41
141
  /**
@@ -9,6 +9,7 @@ import {
9
9
  validateUserRouteName,
10
10
  } from "../route-name.js";
11
11
  import type { UrlPatterns, IncludeOptions } from "./pattern-types.js";
12
+ import type { IncludeProvider } from "./include-provider.js";
12
13
  import type { IncludeFn } from "./path-helper-types.js";
13
14
 
14
15
  function hasExplicitNameOption(options: IncludeOptions | undefined): boolean {
@@ -60,7 +61,11 @@ export function processItems(items: readonly AllUseItems[]): AllUseItems[] {
60
61
  export function createIncludeHelper<TEnv>(): IncludeFn<TEnv> {
61
62
  return (
62
63
  prefix: string,
63
- patterns: UrlPatterns<TEnv>,
64
+ // A `urls()` value (eager) OR an async provider thunk
65
+ // (`() => import("./routes")`) whose evaluation is deferred to the first
66
+ // request matching `prefix`. The provider is stored unevaluated and
67
+ // resolved by the runtime lazy-include expansion / build-time discovery.
68
+ patterns: UrlPatterns<TEnv> | IncludeProvider<TEnv>,
64
69
  options?: IncludeOptions,
65
70
  ): IncludeItem => {
66
71
  const { ctx } = requireDslContext("include() must be called inside urls()");
@@ -81,10 +86,9 @@ export function createIncludeHelper<TEnv>(): IncludeFn<TEnv> {
81
86
  ? capturedUrlPrefix + prefix.slice(1)
82
87
  : capturedUrlPrefix + prefix
83
88
  : prefix;
84
- const internalScope = !hasExplicitName
85
- ? allocateInternalIncludeScopeId(ctx.counters)
86
- : undefined;
87
- const nextSegment = hasExplicitName ? explicitName : internalScope;
89
+ const nextSegment = hasExplicitName
90
+ ? explicitName
91
+ : allocateInternalIncludeScopeId(ctx.counters);
88
92
  const fullNamePrefix =
89
93
  nextSegment !== undefined && nextSegment !== ""
90
94
  ? capturedNamePrefix
@@ -117,9 +121,7 @@ export function createIncludeHelper<TEnv>(): IncludeFn<TEnv> {
117
121
  if (capturedParent?.shortCode) {
118
122
  const includeCounterKey = `${capturedParent.shortCode}${parentScope}_include`;
119
123
  ctx.counters[includeCounterKey] ??= 0;
120
- const includeIdx = ctx.counters[includeCounterKey];
121
- ctx.counters[includeCounterKey] = includeIdx + 1;
122
- includeScope = `${parentScope}I${includeIdx}`;
124
+ includeScope = `${parentScope}I${ctx.counters[includeCounterKey]++}`;
123
125
  }
124
126
 
125
127
  // Snapshot parent's counters AFTER allocating the include scope so lazy