@rangojs/router 0.0.0-experimental.133 → 0.0.0-experimental.135

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 (71) hide show
  1. package/dist/bin/rango.js +7 -2
  2. package/dist/vite/index.js +41 -27
  3. package/package.json +23 -24
  4. package/skills/composability/SKILL.md +0 -1
  5. package/skills/handler-use/SKILL.md +7 -7
  6. package/skills/intercept/SKILL.md +38 -13
  7. package/skills/loader/SKILL.md +10 -0
  8. package/skills/migrate-nextjs/SKILL.md +3 -3
  9. package/skills/migrate-react-router/SKILL.md +144 -1
  10. package/skills/prerender/SKILL.md +20 -17
  11. package/skills/router-setup/SKILL.md +1 -2
  12. package/skills/testing/SKILL.md +1 -0
  13. package/skills/testing/render-handler.md +15 -14
  14. package/skills/use-cache/SKILL.md +11 -0
  15. package/skills/view-transitions/SKILL.md +43 -0
  16. package/src/browser/navigation-bridge.ts +65 -16
  17. package/src/browser/navigation-client.ts +27 -1
  18. package/src/browser/navigation-store.ts +82 -8
  19. package/src/browser/network-error-handler.ts +34 -7
  20. package/src/browser/partial-update.ts +43 -3
  21. package/src/browser/prefetch/cache.ts +8 -0
  22. package/src/browser/prefetch/fetch.ts +32 -4
  23. package/src/browser/react/NavigationProvider.tsx +195 -4
  24. package/src/browser/react/deferred-handle-resolution.ts +75 -0
  25. package/src/browser/response-adapter.ts +38 -9
  26. package/src/browser/types.ts +32 -1
  27. package/src/cache/cache-runtime.ts +26 -5
  28. package/src/cache/document-cache.ts +17 -1
  29. package/src/cache/profile-registry.ts +15 -0
  30. package/src/cache/read-through-swr.ts +15 -1
  31. package/src/handles/MetaTags.tsx +6 -0
  32. package/src/index.rsc.ts +6 -1
  33. package/src/index.ts +6 -4
  34. package/src/internal-debug.ts +11 -8
  35. package/src/render-error-thrower.tsx +20 -0
  36. package/src/route-content-wrapper.tsx +12 -5
  37. package/src/route-definition/dsl-helpers.ts +21 -32
  38. package/src/route-definition/helper-factories.ts +0 -2
  39. package/src/route-definition/helpers-types.ts +38 -39
  40. package/src/route-definition/index.ts +1 -2
  41. package/src/route-definition/resolve-handler-use.ts +0 -1
  42. package/src/route-definition/use-item-types.ts +3 -6
  43. package/src/route-types.ts +0 -5
  44. package/src/router/match-api.ts +5 -1
  45. package/src/router/match-middleware/background-revalidation.ts +40 -23
  46. package/src/router/match-middleware/cache-store.ts +39 -24
  47. package/src/router/segment-resolution/fresh.ts +4 -0
  48. package/src/router/segment-resolution/loader-cache.ts +14 -2
  49. package/src/router/segment-resolution/revalidation.ts +3 -0
  50. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  51. package/src/rsc/progressive-enhancement.ts +56 -2
  52. package/src/rsc/rsc-rendering.ts +7 -2
  53. package/src/rsc/server-action.ts +25 -2
  54. package/src/rsc/transition-gate.ts +89 -0
  55. package/src/segment-system.tsx +59 -8
  56. package/src/server/context.ts +13 -0
  57. package/src/server/loader-registry.ts +13 -1
  58. package/src/server/request-context.ts +52 -3
  59. package/src/testing/index.ts +6 -0
  60. package/src/testing/render-handler.ts +14 -0
  61. package/src/testing/run-transition-when.ts +164 -0
  62. package/src/types/handler-context.ts +1 -1
  63. package/src/types/index.ts +2 -0
  64. package/src/types/segments.ts +100 -0
  65. package/src/urls/path-helper-types.ts +10 -7
  66. package/src/urls/urls-function.ts +0 -1
  67. package/src/vite/inject-client-debug.ts +36 -0
  68. package/src/vite/plugins/version-injector.ts +22 -7
  69. package/src/vite/plugins/virtual-entries.ts +28 -9
  70. package/src/vite/router-discovery.ts +8 -13
  71. package/src/network-error-thrower.tsx +0 -18
@@ -10,6 +10,7 @@ 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,
@@ -316,14 +317,47 @@ export async function renderSegments(
316
317
  resolvedComponent = await component;
317
318
  }
318
319
 
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);
320
+ let nodeContent: ReactNode = null;
321
+ if (isRenderableLoading(loading)) {
322
+ // forceAwait (popstate, stale-revalidation, fully-prefetched nav) renders a
323
+ // loading() route with the route content ALREADY resolved, so its
324
+ // RouteContentWrapper Suspender does not suspend for a microtask and flash
325
+ // the loading() fallback on a NORMAL (non-transition) commit. The router
326
+ // data is known-ready on these paths, so awaiting the content here is free.
327
+ // The wrapper tree is unchanged (RouteContentWrapper is still created with
328
+ // the same key/fallback) — only the `content` prop is a resolved node
329
+ // instead of a pending promise, which Suspender renders synchronously. This
330
+ // mirrors the forceAwait loaderData unwrap above; a CLIENT component that
331
+ // suspends on mount inside the content still reveals a fallback (it is not
332
+ // pre-resolved).
333
+ const contentPromise = getMemoizedContentPromise(resolvedComponent);
334
+ const loadingContent: Promise<ReactNode> | ReactNode = forceAwait
335
+ ? await contentPromise
336
+ : contentPromise;
337
+ nodeContent = createElement(RouteContentWrapper, {
338
+ key: `suspense-loading-${id}`,
339
+ content: loadingContent,
340
+ fallback: loading,
341
+ segmentId: id,
342
+ });
343
+ } else {
344
+ // [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. A segment in the no-loading()
345
+ // branch whose component decodes as a Promise/lazy gets registered into
346
+ // temporalLazyRefs and awaited before commit (see below) — which on builds
347
+ // where the segment component arrives deferred defeats client-nav streaming.
348
+ if (INTERNAL_RANGO_DEBUG && typeof window === "object") {
349
+ const c = resolvedComponent as unknown;
350
+ console.log("[VT-DIAG] renderSegments no-loading-branch segment", {
351
+ id,
352
+ type: node.segment.type,
353
+ componentIsPromise: c instanceof Promise,
354
+ componentIsLazy:
355
+ c != null && typeof c === "object" && "_payload" in c,
356
+ componentTypeof: typeof c,
357
+ });
358
+ }
359
+ nodeContent = registerLazyRef(resolvedComponent);
360
+ }
327
361
 
328
362
  // Wrap with <ViewTransition> if transition config exists (React experimental only).
329
363
  // An empty config ({}) creates a bare <ViewTransition> boundary that participates
@@ -462,7 +496,24 @@ export async function renderSegments(
462
496
  children: content,
463
497
  });
464
498
  if (typeof window === "object") {
499
+ // [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. If this await dominates the
500
+ // navigation time, a deferred/lazy segment component is being fully resolved
501
+ // before commit, which defeats client-nav streaming. The await itself is
502
+ // functional (it preloads lazy chunk refs); only the timing log is gated.
503
+ const vtDebug = INTERNAL_RANGO_DEBUG && temporalLazyRefs.length > 0;
504
+ const vtDebugStart = vtDebug ? performance.now() : 0;
505
+ if (vtDebug) {
506
+ console.log("[VT-DIAG] renderSegments awaiting temporalLazyRefs", {
507
+ count: temporalLazyRefs.length,
508
+ });
509
+ }
465
510
  await Promise.allSettled(temporalLazyRefs);
511
+ if (vtDebug) {
512
+ console.log("[VT-DIAG] renderSegments temporalLazyRefs settled", {
513
+ count: temporalLazyRefs.length,
514
+ ms: Math.round(performance.now() - vtDebugStart),
515
+ });
516
+ }
466
517
  }
467
518
 
468
519
  let result: ReactNode = errorBoundaryWrapped;
@@ -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
@@ -67,7 +67,19 @@ export async function getLoaderLazy(
67
67
  }
68
68
  }
69
69
 
70
- // Dev fallback: parse ID (format: "src/path/to/file.ts#ExportName") and import
70
+ // The remaining dev fallback (parse the id as "src/path/file.ts#ExportName"
71
+ // and import it by path) only makes sense in dev, where ids ARE file paths
72
+ // and the dev loader manifest is intentionally empty. In production ids are
73
+ // hashed ("<hash>#ExportName") and every resolvable loader is reached above
74
+ // via the in-memory registry or the lazy import manifest. The hash is not a
75
+ // path, so a production fall-through would run import("/<hash>") and throw a
76
+ // misleading "No such module <hash>" 500 instead of reporting the loader as
77
+ // unregistered. Return undefined in production so a genuinely unknown loader
78
+ // is a clean 404 "not found in registry" from handleLoaderFetch.
79
+ if (process.env.NODE_ENV === "production") {
80
+ return undefined;
81
+ }
82
+
71
83
  const hashIndex = id.indexOf("#");
72
84
  if (hashIndex !== -1) {
73
85
  const filePath = id.slice(0, hashIndex);
@@ -48,6 +48,7 @@ import { getFetchableLoader } from "./fetchable-loader-store.js";
48
48
  import type { SegmentCacheStore } from "../cache/types.js";
49
49
  import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
50
50
  import type { ExecutionContext, RequestScope } from "../types/request-scope.js";
51
+ import type { TransitionWhenFn } from "../types/segments.js";
51
52
  import type { ResolvedTracing } from "../router/tracing.js";
52
53
  import { fireAndForgetWaitUntil } from "../types/request-scope.js";
53
54
  import {
@@ -161,6 +162,15 @@ export interface RequestContext<
161
162
  /** @internal Handle store for tracking handle data across segments */
162
163
  _handleStore: HandleStore;
163
164
 
165
+ /**
166
+ * @internal transition({ when }) predicates for segments matched this request,
167
+ * keyed by segment id. Collected during resolution (the function is stripped
168
+ * from the serialized segment config), then evaluated post-handler in
169
+ * rsc-rendering — outside any cache scope — to drop the transition of any
170
+ * segment whose predicate returns false.
171
+ */
172
+ _transitionWhen?: Array<{ id: string; when: TransitionWhenFn }>;
173
+
164
174
  /** @internal Cache store for segment caching (optional, used by CacheScope) */
165
175
  _cacheStore?: SegmentCacheStore;
166
176
 
@@ -292,6 +302,30 @@ export interface RequestContext<
292
302
  /** @internal Previous route key (from the navigation source), used for revalidation */
293
303
  _prevRouteKey?: string;
294
304
 
305
+ /**
306
+ * @internal Navigation/action source data the transition({ when }) gate reads
307
+ * to build its ShouldRevalidateFn-shaped predicate context. currentUrl/Params
308
+ * come from the navigation snapshot (set at match time); action* are stashed
309
+ * at the action-bearing gate call sites. All undefined when there is no source
310
+ * (initial full load) or no action (plain navigation).
311
+ */
312
+ _gateCurrentUrl?: URL;
313
+ _gateCurrentParams?: Record<string, string>;
314
+ _gateActionId?: string;
315
+ _gateActionUrl?: URL;
316
+ _gateActionResult?: unknown;
317
+ _gateFormData?: FormData;
318
+
319
+ /**
320
+ * @internal True while the post-action revalidation render is running (set by
321
+ * revalidateAfterAction). The "use cache" runtime reads this to prefer
322
+ * freshness over a fast stale response during an action: a stale entry
323
+ * re-executes in the foreground (so the action response reflects the refreshed
324
+ * value) with only the store write deferred, instead of serving stale and
325
+ * revalidating in the background. A plain navigation (flag unset) keeps SWR.
326
+ */
327
+ _inActionRevalidation?: boolean;
328
+
295
329
  /**
296
330
  * @internal Render barrier for experimental `rendered()` API.
297
331
  * Resolves when all non-loader segments have settled and handle data
@@ -413,6 +447,7 @@ export type PublicRequestContext<
413
447
  | "setCookie"
414
448
  | "deleteCookie"
415
449
  | "_handleStore"
450
+ | "_transitionWhen"
416
451
  | "_cacheStore"
417
452
  | "_explicitTaggedStores"
418
453
  | "_requestTags"
@@ -422,6 +457,13 @@ export type PublicRequestContext<
422
457
  | "_locationState"
423
458
  | "_routeName"
424
459
  | "_prevRouteKey"
460
+ | "_gateCurrentUrl"
461
+ | "_gateCurrentParams"
462
+ | "_gateActionId"
463
+ | "_gateActionUrl"
464
+ | "_gateActionResult"
465
+ | "_gateFormData"
466
+ | "_inActionRevalidation"
425
467
  | "_reportedErrors"
426
468
  | "_renderBarrier"
427
469
  | "_resolveRenderBarrier"
@@ -527,11 +569,17 @@ export function setRequestContextParams(
527
569
  */
528
570
  export function setRequestContextPrevRouteKey(
529
571
  prevRouteKey: string | undefined,
572
+ currentUrl?: URL,
573
+ currentParams?: Record<string, string>,
530
574
  ): void {
531
575
  const ctx = requestContextStorage.getStore();
532
- if (ctx && prevRouteKey !== undefined) {
533
- ctx._prevRouteKey = prevRouteKey;
534
- }
576
+ if (!ctx) return;
577
+ if (prevRouteKey !== undefined) ctx._prevRouteKey = prevRouteKey;
578
+ // Source URL/params for the transition({ when }) gate (effectiveFromUrl /
579
+ // effectiveFromMatch.params from the navigation snapshot). Same write point as
580
+ // _prevRouteKey, which doubles as fromRouteName.
581
+ if (currentUrl !== undefined) ctx._gateCurrentUrl = currentUrl;
582
+ if (currentParams !== undefined) ctx._gateCurrentParams = currentParams;
535
583
  }
536
584
 
537
585
  /**
@@ -842,6 +890,7 @@ export function createRequestContext<TEnv>(
842
890
  method: request.method,
843
891
 
844
892
  _handleStore: handleStore,
893
+ _transitionWhen: [],
845
894
  _cacheStore: cacheStore,
846
895
  _explicitTaggedStores: explicitTaggedStores,
847
896
  _requestTags: new Set<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
 
@@ -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
+ }
@@ -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,
@@ -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
  /**
@@ -28,7 +28,6 @@ import type {
28
28
  ParallelUseItem,
29
29
  InterceptUseItem,
30
30
  LoaderUseItem,
31
- WhenItem,
32
31
  TypedCacheItem,
33
32
  TransitionItem,
34
33
  TypedTransitionItem,
@@ -42,7 +41,6 @@ import type {
42
41
  PassthroughHandlerDefinition,
43
42
  } from "../prerender.js";
44
43
  import type { StaticHandlerDefinition } from "../static-handler.js";
45
- import type { InterceptWhenFn } from "../server/context";
46
44
  import type {
47
45
  ResponseHandler,
48
46
  ResponseHandlerContext,
@@ -275,12 +273,18 @@ export type PathHelpers<TEnv> = {
275
273
  slotName: `@${string}`,
276
274
  routeName: string,
277
275
  handler: ReactNode | Handler<any, any, TEnv>,
276
+ config?:
277
+ | import("../server/context.js").InterceptConfig<TEnv>
278
+ | (() => InterceptUseItem[]),
278
279
  use?: () => InterceptUseItem[],
279
280
  ) => InterceptItem
280
281
  : (
281
282
  slotName: `@${string}`,
282
283
  routeName: (keyof Rango.GeneratedRouteMap & string) | `.${string}`,
283
284
  handler: ReactNode | Handler<any, any, TEnv>,
285
+ config?:
286
+ | import("../server/context.js").InterceptConfig<TEnv>
287
+ | (() => InterceptUseItem[]),
284
288
  use?: () => InterceptUseItem[],
285
289
  ) => InterceptItem;
286
290
 
@@ -335,11 +339,6 @@ export type PathHelpers<TEnv> = {
335
339
  fallback: ReactNode | NotFoundBoundaryHandler,
336
340
  ) => NotFoundBoundaryItem;
337
341
 
338
- /**
339
- * Define a condition for when an intercept should activate
340
- */
341
- when: (fn: InterceptWhenFn) => WhenItem;
342
-
343
342
  /**
344
343
  * Define cache configuration for segments
345
344
  */
@@ -365,6 +364,10 @@ export type PathHelpers<TEnv> = {
365
364
  * `{ viewTransition: false }` to keep #1 without the router boundary. A view
366
365
  * transition cannot fire without a startTransition. See
367
366
  * skills/view-transitions for the startTransition x ViewTransition matrix.
367
+ *
368
+ * Pass `when: (ctx) => boolean` to gate the transition per request: it runs
369
+ * server-side after the route handler (can read `ctx.get(...)`), and returning
370
+ * false drops the transition so the navigation streams its loading() skeleton.
368
371
  */
369
372
  transition: {
370
373
  (): TransitionItem;
@@ -58,7 +58,6 @@ export function urls<
58
58
  loading: baseHelpers.loading,
59
59
  errorBoundary: baseHelpers.errorBoundary,
60
60
  notFoundBoundary: baseHelpers.notFoundBoundary,
61
- when: baseHelpers.when,
62
61
  cache: baseHelpers.cache as PathHelpers<TEnv>["cache"],
63
62
  transition: baseHelpers.transition as PathHelpers<TEnv>["transition"],
64
63
  };