@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
@@ -20,7 +20,10 @@ import {
20
20
  encodeReply,
21
21
  createClientTemporaryReferenceSet,
22
22
  } from "@vitejs/plugin-rsc/rsc";
23
- import { getRequestContext } from "../server/request-context.js";
23
+ import {
24
+ getRequestContext,
25
+ runWithRequestContext,
26
+ } from "../server/request-context.js";
24
27
  import { isUnderTestRunner } from "../runtime-env.js";
25
28
  import {
26
29
  isTainted,
@@ -307,7 +310,13 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
307
310
  }
308
311
  }
309
312
 
310
- if (cached?.shouldRevalidate) {
313
+ // foregroundOnAction (opt-in; see CacheProfile.foregroundOnAction): during an
314
+ // action's revalidation render, a stale entry falls through to the foreground
315
+ // miss path below instead of SWR. The flag is set by revalidateAfterAction.
316
+ const foregroundOnActionRevalidation =
317
+ requestCtx?._inActionRevalidation === true &&
318
+ profile.foregroundOnAction === true;
319
+ if (cached?.shouldRevalidate && !foregroundOnActionRevalidation) {
311
320
  // Stale hit: return stale value, revalidate in background
312
321
  try {
313
322
  const result = await serveCached(cached);
@@ -316,8 +325,12 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
316
325
  // live response or throw LateHandlePushError on the completed store.
317
326
  // Same isolation pattern as route-level background-revalidation.ts.
318
327
  runBackground(requestCtx, async () => {
319
- // Reuse closure-captured requestCtx instead of calling
320
- // getRequestContext() — ALS context may be gone inside waitUntil.
328
+ // The closure-captured requestCtx is reused for the framework's own
329
+ // reads (handle store swap, error reporting) AND, below, to
330
+ // re-establish the request-context ALS around the user fn. ALS context
331
+ // may be gone inside waitUntil: on workerd a waitUntil task runs
332
+ // detached from the request's I/O context, so getRequestContext()
333
+ // inside the cached body would otherwise throw.
321
334
  let originalHandleStore:
322
335
  | ReturnType<typeof createHandleStore>
323
336
  | undefined;
@@ -360,7 +373,15 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
360
373
  }
361
374
 
362
375
  try {
363
- const scoped = runWithCacheTagScope(() => fn.apply(this, args));
376
+ // Re-establish the request-context ALS so a "use cache" body that
377
+ // reads the ambient getRequestContext() (e.g.
378
+ // getRequestContext().env.ApiKey) resolves during the background
379
+ // revalidation instead of throwing "called outside of a request
380
+ // context". runWithRequestContext sets the store for fn's
381
+ // synchronous kickoff; its async continuations inherit it.
382
+ const scoped = runWithRequestContext(requestCtx, () =>
383
+ runWithCacheTagScope(() => fn.apply(this, args)),
384
+ );
364
385
  const freshResult = await scoped.result;
365
386
  bgStopCapture?.();
366
387
  // Merge profile/DSL tags with runtime cacheTag() tags, read after
@@ -15,6 +15,7 @@ import type { MiddlewareFn, MiddlewareContext } from "../router/middleware.js";
15
15
  import { hasPerClientSignal } from "../browser/cookie-name.js";
16
16
  import {
17
17
  getRequestContext,
18
+ runWithRequestContext,
18
19
  type RequestContext,
19
20
  } from "../server/request-context.js";
20
21
  import { mayNeedSSR } from "../rsc/ssr-setup.js";
@@ -330,7 +331,13 @@ export function createDocumentCacheMiddleware<TEnv = any>(
330
331
 
331
332
  runBackground(requestCtx, async () => {
332
333
  try {
333
- const fresh = await next();
334
+ // Re-establish the request-context ALS around the background
335
+ // re-render: next() re-runs the full handler pipeline, and on
336
+ // workerd a waitUntil task runs detached from the request's I/O
337
+ // context, so a handler/component reading getRequestContext() would
338
+ // otherwise throw. Same fix as the route-level/use-cache background
339
+ // revalidation paths.
340
+ const fresh = await runWithRequestContext(requestCtx, () => next());
334
341
  const directives = shouldCacheResponse(fresh);
335
342
 
336
343
  if (directives && fresh.body) {
@@ -348,10 +355,15 @@ export function createDocumentCacheMiddleware<TEnv = any>(
348
355
  log(`[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`);
349
356
  }
350
357
  } catch (error) {
358
+ // Pass requestCtx explicitly: this runs in a detached waitUntil task
359
+ // where the ALS context is gone, so onError only fires if we hand it
360
+ // the captured context (reportCacheError falls back to _getRequestContext
361
+ // otherwise, which is null here).
351
362
  reportCacheError(
352
363
  error,
353
364
  "cache-write",
354
365
  "[DocumentCache] revalidation",
366
+ requestCtx,
355
367
  );
356
368
  }
357
369
  });
@@ -401,10 +413,14 @@ export function createDocumentCacheMiddleware<TEnv = any>(
401
413
  collectRequestTags(requestCtx),
402
414
  );
403
415
  } catch (error) {
416
+ // Detached waitUntil task — pass the captured requestCtx so onError
417
+ // fires even though the ALS context is gone (see the revalidation
418
+ // catch above).
404
419
  reportCacheError(
405
420
  error,
406
421
  "cache-write",
407
422
  "[DocumentCache] cache write",
423
+ requestCtx,
408
424
  );
409
425
  }
410
426
  });
@@ -15,6 +15,21 @@ export interface CacheProfile {
15
15
  swr?: number;
16
16
  /** Default cache tags for invalidation */
17
17
  tags?: string[];
18
+ /**
19
+ * When true, a stale entry encountered during a server action's revalidation
20
+ * render is re-executed in the FOREGROUND (the action response reflects the
21
+ * refreshed value) instead of being served stale + revalidated in the
22
+ * background. Only the store write is deferred. Use this for mutation-related
23
+ * cached data that an action should refresh immediately; ordinary navigations
24
+ * always keep SWR. Default false: incidental TTL staleness must not turn an
25
+ * unrelated action into a synchronous cache-refresh barrier — for strong
26
+ * read-your-own-writes after a mutation, prefer updateTag() (a hard purge, so
27
+ * the action's own re-render is a fresh foreground miss).
28
+ *
29
+ * Honored only by `"use cache"` functions; route-level `cache()` and cached
30
+ * loaders ignore it (use updateTag() there).
31
+ */
32
+ foregroundOnAction?: boolean;
18
33
  }
19
34
 
20
35
  const DEFAULT_PROFILE: CacheProfile = { ttl: 900, swr: 1800 };
@@ -36,6 +36,14 @@ export interface ReadThroughItemConfig<T> {
36
36
  key: string;
37
37
  /** Execute the underlying function/loader on miss or revalidation */
38
38
  execute: () => Promise<T>;
39
+ /**
40
+ * Optional wrapper applied to execute() ONLY on the background
41
+ * stale-revalidation path (not the foreground miss, where the caller's context
42
+ * is already established). Used to re-establish the request-context ALS, which
43
+ * a detached waitUntil task loses on workerd. Defaults to calling execute()
44
+ * directly.
45
+ */
46
+ wrapBackground?: (run: () => Promise<T>) => Promise<T>;
39
47
  /** Serialize result for storage. Return null to skip caching. */
40
48
  serialize: (data: T) => Promise<string | null>;
41
49
  /** Deserialize cached value back to the original type */
@@ -77,6 +85,7 @@ export async function readThroughItem<T>(
77
85
  onMiss,
78
86
  onCached,
79
87
  host,
88
+ wrapBackground,
80
89
  } = config;
81
90
 
82
91
  // Cache lookup. An infra read failure (getItem) is reported by the store
@@ -106,7 +115,12 @@ export async function readThroughItem<T>(
106
115
  host,
107
116
  async () => {
108
117
  try {
109
- const fresh = await execute();
118
+ // Re-establish the caller's context (request-context ALS) for the
119
+ // detached background execution; the foreground miss below calls
120
+ // execute() directly since its context is already established.
121
+ const fresh = await (wrapBackground
122
+ ? wrapBackground(execute)
123
+ : execute());
110
124
  const serialized = await serialize(fresh);
111
125
  if (serialized !== null) {
112
126
  await setItem(key, serialized, storeOptions);
@@ -267,6 +267,12 @@ export function MetaTags(): React.ReactNode {
267
267
  />
268
268
  )}
269
269
  {descriptors.map((descriptor, index) => {
270
+ // A descriptor is only a Promise on the SSR/hydration path, where it is
271
+ // use()d to stream the tag into the document. On client navigation the
272
+ // store resolves deferred handle values before applying them (see
273
+ // processHandles), so MetaTags only ever receives resolved descriptors
274
+ // there and never suspends — which would otherwise revert the committed
275
+ // route (MetaTags lives in <head>, above the route's <Suspense>).
270
276
  if (isPromise(descriptor)) {
271
277
  return (
272
278
  <AsyncMetaTag
package/src/index.rsc.ts CHANGED
@@ -65,6 +65,12 @@ export type {
65
65
  ErrorPhase,
66
66
  OnErrorContext,
67
67
  OnErrorCallback,
68
+ // View transition types (also exported from the default entry; route
69
+ // definitions resolve to this react-server entry, so they must be here too)
70
+ TransitionConfig,
71
+ TransitionWhenFn,
72
+ TransitionWhenContext,
73
+ ViewTransitionClass,
68
74
  } from "./types.js";
69
75
 
70
76
  // Router options type (server-only, so import directly)
@@ -97,7 +103,6 @@ export {
97
103
  loading,
98
104
  parallel,
99
105
  intercept,
100
- when,
101
106
  errorBoundary,
102
107
  notFoundBoundary,
103
108
  transition,
package/src/index.ts CHANGED
@@ -82,7 +82,12 @@ export { createLoader } from "./loader.js";
82
82
 
83
83
  // Route definition types (safe to import anywhere)
84
84
  export type { RouteHelpers, RouteHandlers } from "./route-definition.js";
85
- export type { TransitionConfig, ViewTransitionClass } from "./types.js";
85
+ export type {
86
+ TransitionConfig,
87
+ TransitionWhenFn,
88
+ TransitionWhenContext,
89
+ ViewTransitionClass,
90
+ } from "./types.js";
86
91
 
87
92
  // Composition types for reusable callback factories
88
93
  export type {
@@ -279,9 +284,6 @@ export function parallel(): never {
279
284
  export function intercept(): never {
280
285
  throw serverOnlyStubError("intercept");
281
286
  }
282
- export function when(): never {
283
- throw serverOnlyStubError("when");
284
- }
285
287
  export function errorBoundary(): never {
286
288
  throw serverOnlyStubError("errorBoundary");
287
289
  }
@@ -1,9 +1,12 @@
1
- // Vite define for compile-time injection; falls back to process.env (tests, Node).
2
- // Works in all runtimes including Cloudflare Workers where process.env is unavailable.
1
+ // Runtime fallback for non-Vite contexts (Node, tests, raw imports). In the Vite
2
+ // pipeline -- dev and build, every environment -- the router discovery plugin's
3
+ // transform replaces this module with the build-time-resolved flag (see
4
+ // vite/inject-client-debug.ts), so the CLIENT debug flag no longer depends on a
5
+ // `__RANGO_DEBUG__` define being delivered to the browser (which dev did only as
6
+ // an injected global whose presence varied across consumer setups).
7
+ //
8
+ // Keep this module to the single INTERNAL_RANGO_DEBUG export: the transform
9
+ // replaces the whole file, so a second export would be dropped from the bundle.
3
10
  export const INTERNAL_RANGO_DEBUG: boolean =
4
- typeof __RANGO_DEBUG__ !== "undefined"
5
- ? __RANGO_DEBUG__
6
- : typeof process !== "undefined" &&
7
- Boolean((process as any).env?.INTERNAL_RANGO_DEBUG);
8
-
9
- declare const __RANGO_DEBUG__: boolean;
11
+ typeof process !== "undefined" &&
12
+ Boolean((process as any).env?.INTERNAL_RANGO_DEBUG);
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+
5
+ interface RenderErrorThrowerProps {
6
+ error: unknown;
7
+ }
8
+
9
+ /**
10
+ * Client component that throws the given error during render, so the nearest
11
+ * error boundary catches it. Errors thrown during render are caught by error
12
+ * boundaries; async errors (rejected promises) are not -- which is why the
13
+ * navigation bridge funnels processing failures through this component instead
14
+ * of letting them surface as uncaught rejections.
15
+ */
16
+ export function RenderErrorThrower({
17
+ error,
18
+ }: RenderErrorThrowerProps): ReactNode {
19
+ throw error;
20
+ }
@@ -1,7 +1,6 @@
1
1
  "use client";
2
2
  import type { ReactNode } from "react";
3
3
  import { Suspense, use } from "react";
4
- import { invariant } from "./errors";
5
4
  import { OutletProvider } from "./outlet-provider.js";
6
5
  import type { ResolvedSegment } from "./types.js";
7
6
  import { decodeLoaderResults } from "./decode-loader-results.js";
@@ -22,7 +21,9 @@ export function RouteContentWrapper({
22
21
  fallback,
23
22
  segmentId,
24
23
  }: {
25
- content: Promise<ReactNode>;
24
+ // Normally a pending promise (use() suspends -> fallback). forceAwait paths
25
+ // pass an already-resolved node so Suspender renders it without suspending.
26
+ content: Promise<ReactNode> | ReactNode;
26
27
  fallback?: ReactNode;
27
28
  segmentId?: string;
28
29
  }): ReactNode {
@@ -41,9 +42,15 @@ const Suspender = ({
41
42
  }: {
42
43
  content: Promise<ReactNode> | ReactNode;
43
44
  }): ReactNode => {
44
- invariant(content instanceof Promise, "Suspender expects a Promise content");
45
-
46
- return use(content);
45
+ // Normally content is a pending promise -> use() suspends and the wrapping
46
+ // Suspense shows the loading() fallback. forceAwait paths (popstate,
47
+ // stale-revalidation, fully-prefetched nav) instead pass the ALREADY-RESOLVED
48
+ // node so first render does not suspend for a microtask and flash the loading()
49
+ // fallback on a NORMAL (non-transition) commit. The wrapper tree
50
+ // (RouteContentWrapper > Suspense > Suspender) is identical either way, so this
51
+ // preserves tree structure (see docs/tree-structure.md) — only whether use()
52
+ // suspends differs, exactly like LoaderResolver's resolved-data branch.
53
+ return content instanceof Promise ? use(content) : content;
47
54
  };
48
55
 
49
56
  /**
@@ -17,6 +17,7 @@ import {
17
17
  type EntryPropSegments,
18
18
  type HelperContext,
19
19
  type InterceptEntry,
20
+ type InterceptConfig,
20
21
  } from "../server/context";
21
22
  import { invariant } from "../errors";
22
23
  import { validateUserRouteName } from "../route-name.js";
@@ -36,7 +37,6 @@ import type {
36
37
  ErrorBoundaryItem,
37
38
  NotFoundBoundaryItem,
38
39
  LayoutItem,
39
- WhenItem,
40
40
  CacheItem,
41
41
  TransitionItem,
42
42
  UseItems,
@@ -265,34 +265,6 @@ const notFoundBoundary: RouteHelpers<any, any>["notFoundBoundary"] = (
265
265
  return { name, type: "notFoundBoundary" } as NotFoundBoundaryItem;
266
266
  };
267
267
 
268
- /**
269
- * When helper - defines a condition for intercept activation
270
- *
271
- * Only valid inside intercept() use() callback. The when() function
272
- * is captured by the intercept and stored in its `when` array.
273
- * During soft navigation, all when() conditions must return true
274
- * for the intercept to activate.
275
- */
276
- const when: RouteHelpers<any, any>["when"] = (fn) => {
277
- const { store, ctx } = requireDslContext(
278
- "when() must be called inside intercept()",
279
- );
280
-
281
- // The when() function needs to be captured by the intercept's tempParent
282
- // which should have a `when` array. If not present, we're not inside intercept()
283
- const parent = ctx.parent as any;
284
- if (!parent || !("when" in parent)) {
285
- invariant(
286
- false,
287
- "when() can only be used inside intercept() use() callback",
288
- );
289
- }
290
-
291
- const name = `$${store.getNextIndex("when")}`;
292
- parent.when.push(fn);
293
- return { name, type: "when" } as WhenItem;
294
- };
295
-
296
268
  /**
297
269
  * Cache helper - defines caching configuration for segments
298
270
  *
@@ -701,8 +673,19 @@ const intercept = (
701
673
  slotName: `@${string}`,
702
674
  routeName: string,
703
675
  handler: any,
676
+ configOrUse?: InterceptConfig | (() => any[]),
704
677
  use?: () => any[],
705
678
  ) => {
679
+ // arg4 discrimination: a function is the use() callback (no config); an object
680
+ // is the config carrying `when`. With config given, the use() callback is
681
+ // arg5. Keeps the no-config form intercept(slot, route, handler, () => [...])
682
+ // working unchanged.
683
+ const config: InterceptConfig | undefined =
684
+ typeof configOrUse === "function" || configOrUse == null
685
+ ? undefined
686
+ : configOrUse;
687
+ const useFn = typeof configOrUse === "function" ? configOrUse : use;
688
+
706
689
  const { store, ctx } = requireDslContext(
707
690
  "intercept() must be called inside urls()",
708
691
  );
@@ -740,9 +723,17 @@ const intercept = (
740
723
  when: [], // Selector conditions for conditional interception
741
724
  };
742
725
 
726
+ // Conditional interception: `when` from the config object — a single selector
727
+ // or an array (ALL must return true to activate). Replaces the former when()
728
+ // use-item captured inside the callback.
729
+ if (config?.when) {
730
+ const selectors = Array.isArray(config.when) ? config.when : [config.when];
731
+ entry.when.push(...selectors);
732
+ }
733
+
743
734
  // Merge handler.use defaults with explicit use
744
735
  const handlerUseFn = resolveHandlerUse(handler);
745
- const mergedUse = mergeHandlerUse(handlerUseFn, use, "intercept");
736
+ const mergedUse = mergeHandlerUse(handlerUseFn, useFn, "intercept");
746
737
 
747
738
  // Run merged use callback to collect loaders, revalidate, middleware, etc.
748
739
  if (mergedUse) {
@@ -759,7 +750,6 @@ const intercept = (
759
750
  notFoundBoundary: entry.notFoundBoundary,
760
751
  loader: entry.loader,
761
752
  layout: capturedLayouts, // Capture layout() calls
762
- when: entry.when, // Capture when() conditions
763
753
  get loading() {
764
754
  return entry.loading;
765
755
  },
@@ -1114,7 +1104,6 @@ export {
1114
1104
  revalidate,
1115
1105
  parallel,
1116
1106
  intercept,
1117
- when,
1118
1107
  errorBoundary,
1119
1108
  notFoundBoundary,
1120
1109
  route,
@@ -8,7 +8,6 @@ import {
8
8
  revalidate,
9
9
  parallel,
10
10
  intercept,
11
- when,
12
11
  errorBoundary,
13
12
  notFoundBoundary,
14
13
  route,
@@ -40,7 +39,6 @@ function buildRouteHelpers<T extends RouteDefinition, TEnv>(): RouteHelpers<
40
39
  loading,
41
40
  errorBoundary,
42
41
  notFoundBoundary,
43
- when,
44
42
  cache,
45
43
  transition,
46
44
  } as unknown as RouteHelpers<T, TEnv>;
@@ -29,12 +29,10 @@ import type {
29
29
  ParallelUseItem,
30
30
  InterceptUseItem,
31
31
  LoaderUseItem,
32
- WhenItem,
33
32
  CacheItem,
34
33
  TransitionItem,
35
34
  UseItems,
36
35
  } from "../route-types.js";
37
- import type { InterceptWhenFn } from "../server/context";
38
36
 
39
37
  // Re-export route item types for backward compatibility
40
38
  export type {
@@ -52,12 +50,12 @@ export type {
52
50
  RouteUseItem,
53
51
  ParallelUseItem,
54
52
  InterceptUseItem,
55
- WhenItem,
56
53
  CacheItem,
57
54
  } from "../route-types.js";
58
55
 
59
56
  // Re-export intercept selector types for use in handlers
60
57
  export type {
58
+ InterceptConfig,
61
59
  InterceptSelectorContext,
62
60
  InterceptSegmentsState,
63
61
  InterceptWhenFn,
@@ -183,10 +181,26 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
183
181
  * loader(CardModalLoader),
184
182
  * revalidate(() => false),
185
183
  * ])
184
+ *
185
+ * // Conditional activation via the config object's `when` selector
186
+ * intercept("@modal", "card", <CardModal />, {
187
+ * when: ({ from }) => from.pathname.startsWith("/board"),
188
+ * })
189
+ *
190
+ * // Config + other use-items: config is arg 4, use is arg 5
191
+ * intercept(
192
+ * "@modal",
193
+ * "card",
194
+ * <CardModal />,
195
+ * { when: ({ from }) => from.pathname.startsWith("/board") },
196
+ * () => [loader(CardDetailLoader)],
197
+ * )
186
198
  * ```
187
199
  * @param slotName - Named slot (prefixed with @) where intercept renders
188
200
  * @param routeName - Route name to intercept
189
201
  * @param handler - Component or handler for intercepted render
202
+ * @param config - Optional InterceptConfig (e.g. `{ when }`), or the use
203
+ * callback directly when there is no config
190
204
  * @param use - Optional callback for loaders, middleware, revalidate, etc.
191
205
  */
192
206
  intercept: {
@@ -195,6 +209,9 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
195
209
  slotName: `@${string}`,
196
210
  routeName: `.${K}`,
197
211
  handler: ReactNode | Handler<ExtractRouteParams<T, K>, {}, TEnv>,
212
+ config?:
213
+ | import("../server/context.js").InterceptConfig<TEnv>
214
+ | (() => UseItems<InterceptUseItem>),
198
215
  use?: () => UseItems<InterceptUseItem>,
199
216
  ): InterceptItem;
200
217
  // Global: unprefixed, params inferred from global route map
@@ -202,6 +219,9 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
202
219
  slotName: `@${string}`,
203
220
  routeName: K,
204
221
  handler: ReactNode | Handler<K, Rango.GeneratedRouteMap, TEnv>,
222
+ config?:
223
+ | import("../server/context.js").InterceptConfig<TEnv>
224
+ | (() => UseItems<InterceptUseItem>),
205
225
  use?: () => UseItems<InterceptUseItem>,
206
226
  ): InterceptItem;
207
227
  };
@@ -347,40 +367,6 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
347
367
  notFoundBoundary: (
348
368
  fallback: ReactNode | NotFoundBoundaryHandler,
349
369
  ) => NotFoundBoundaryItem;
350
- /**
351
- * Define a condition for when an intercept should activate
352
- *
353
- * Only valid inside intercept() use() callback. When multiple when() calls
354
- * are present, ALL must return true for the intercept to activate.
355
- * If no when() is defined, the intercept always activates on soft navigation.
356
- *
357
- * Context properties:
358
- * - `from` - Source URL (where user is navigating from)
359
- * - `to` - Destination URL (where user is navigating to)
360
- * - `params` - Matched route params
361
- * - `segments` - Client's current segments with `path` and `ids`
362
- *
363
- * ```typescript
364
- * // Only intercept when coming from the board page
365
- * intercept("@modal", "card", <CardModal />, () => [
366
- * when(({ from }) => from.pathname.startsWith("/board")),
367
- * loader(CardDetailLoader),
368
- * ])
369
- *
370
- * // Use segments to check current route context
371
- * intercept("@modal", "card", <CardModal />, () => [
372
- * when(({ segments }) => segments.path[0] === "kanban"),
373
- * ])
374
- *
375
- * // Multiple conditions (AND logic)
376
- * intercept("@modal", "card", <CardModal />, () => [
377
- * when(({ from }) => from.pathname.startsWith("/board")),
378
- * when(({ segments }) => segments.ids.includes("kanban-layout")),
379
- * ])
380
- * ```
381
- * @param fn - Selector function receiving navigation context, returns boolean
382
- */
383
- when: (fn: InterceptWhenFn) => WhenItem;
384
370
  /**
385
371
  * Define cache configuration for segments
386
372
  *
@@ -472,6 +458,13 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
472
458
  * transition({}) is startTransition + ViewTransition under the default and
473
459
  * startTransition only when the router sets viewTransition: false.
474
460
  *
461
+ * Conditional hold: pass `when: (ctx) => boolean` to gate the transition per
462
+ * request. It runs server-side AFTER the route handler (so it can read state
463
+ * the handler set via `ctx.get(...)`); returning false drops this transition
464
+ * for the request, so the navigation streams its loading() skeleton instead of
465
+ * holding. This is a post-handler predicate — distinct from intercept()'s
466
+ * match-time `when` config selector (`intercept(slot, route, Comp, { when })`).
467
+ *
475
468
  * ```typescript
476
469
  * // Attach to a single route
477
470
  * path("/about", AboutPage, { name: "about" }, () => [
@@ -488,10 +481,16 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
488
481
  * path("/product/:id", ProductPage, { name: "product" }, () => [
489
482
  * transition({ viewTransition: false }),
490
483
  * ])
484
+ *
485
+ * // Hold only when the handler decided to (post-handler predicate):
486
+ * path("/product/:id", ProductPage, { name: "product" }, () => [
487
+ * transition({ when: (ctx) => ctx.get(KeepScroll) === true }),
488
+ * ])
491
489
  * ```
492
490
  * @param config - ViewTransition configuration (enter, exit, update, share,
493
- * default, name) plus `viewTransition: "auto" | false` to toggle the router
494
- * boundary (createRouter({ viewTransition }) sets the app-wide default)
491
+ * default, name), `viewTransition: "auto" | false` to toggle the router
492
+ * boundary (createRouter({ viewTransition }) sets the app-wide default), and
493
+ * `when: (ctx) => boolean` to gate the transition per request post-handler
495
494
  * @param children - Optional callback returning child routes to wrap
496
495
  */
497
496
  transition: {
@@ -15,8 +15,8 @@ export type {
15
15
  RouteUseItem,
16
16
  ParallelUseItem,
17
17
  InterceptUseItem,
18
- WhenItem,
19
18
  CacheItem,
19
+ InterceptConfig,
20
20
  InterceptSelectorContext,
21
21
  InterceptSegmentsState,
22
22
  InterceptWhenFn,
@@ -30,7 +30,6 @@ export {
30
30
  revalidate,
31
31
  parallel,
32
32
  intercept,
33
- when,
34
33
  errorBoundary,
35
34
  notFoundBoundary,
36
35
  loader,
@@ -100,7 +100,6 @@ const MOUNT_SITE_ALLOWED_TYPES: Record<string, Set<string>> = {
100
100
  "notFoundBoundary",
101
101
  "layout",
102
102
  "route",
103
- "when",
104
103
  "transition",
105
104
  ]),
106
105
  // LoaderUseItem — only revalidate + cache can attach to a loader entry
@@ -1,16 +1,14 @@
1
- import type { AllUseItems, WhenItem } from "../route-types.js";
1
+ import type { AllUseItems } from "../route-types.js";
2
2
 
3
3
  /**
4
4
  * The set of valid use-item `type` discriminants — the single runtime source of
5
5
  * truth for "is this a well-formed use item?" shape validation.
6
6
  *
7
7
  * Declared via a `Record<...>` so that adding a member to the union without
8
- * updating this map is a compile error. `when` is included because when() items
9
- * are valid inside intercept() even though WhenItem is not part of AllUseItems
10
- * (it lives only in InterceptUseItem). This is shape validation only; per-mount-
8
+ * updating this map is a compile error. This is shape validation only; per-mount-
11
9
  * site rules remain the narrower hand-written tables in resolve-handler-use.ts.
12
10
  */
13
- const USE_ITEM_TYPES: Record<AllUseItems["type"] | WhenItem["type"], true> = {
11
+ const USE_ITEM_TYPES: Record<AllUseItems["type"], true> = {
14
12
  layout: true,
15
13
  route: true,
16
14
  middleware: true,
@@ -21,7 +19,6 @@ const USE_ITEM_TYPES: Record<AllUseItems["type"] | WhenItem["type"], true> = {
21
19
  loading: true,
22
20
  errorBoundary: true,
23
21
  notFoundBoundary: true,
24
- when: true,
25
22
  cache: true,
26
23
  transition: true,
27
24
  include: true,
@@ -98,10 +98,6 @@ export type NotFoundBoundaryItem = {
98
98
  type: "notFoundBoundary";
99
99
  uses?: AllUseItems[];
100
100
  };
101
- export type WhenItem = {
102
- name: string;
103
- type: "when";
104
- };
105
101
  export type CacheItem = {
106
102
  name: string;
107
103
  type: "cache";
@@ -231,7 +227,6 @@ export type InterceptUseItem =
231
227
  | NotFoundBoundaryItem
232
228
  | LayoutItem
233
229
  | RouteItem
234
- | WhenItem
235
230
  | TransitionItem;
236
231
  export type LoaderUseItem = RevalidateItem | CacheItem;
237
232
 
@@ -287,7 +287,11 @@ export async function createMatchContextForPartial<TEnv>(
287
287
  });
288
288
  }
289
289
 
290
- setRequestContextPrevRouteKey(nav.effectiveFromMatch?.routeKey);
290
+ setRequestContextPrevRouteKey(
291
+ nav.effectiveFromMatch?.routeKey,
292
+ nav.effectiveFromUrl,
293
+ nav.effectiveFromMatch?.params ?? nav.prevParams,
294
+ );
291
295
 
292
296
  const interceptSelectorContext: InterceptSelectorContext = {
293
297
  from: nav.effectiveFromUrl,