@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
@@ -105,6 +105,10 @@ import { getRouterContext } from "../router-context.js";
105
105
  import type { GeneratorMiddleware } from "./cache-lookup.js";
106
106
  import { debugLog, debugWarn, getOrCreateRequestId } from "../logging.js";
107
107
  import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
108
+ import {
109
+ runWithRequestContext,
110
+ type RequestContext,
111
+ } from "../../server/request-context.js";
108
112
 
109
113
  /**
110
114
  * Creates background revalidation middleware
@@ -183,33 +187,46 @@ export function withBackgroundRevalidation<TEnv>(
183
187
  const freshLoaderPromises = new Map<string, Promise<any>>();
184
188
  setupLoaderAccess(freshHandlerContext, freshLoaderPromises);
185
189
 
186
- const freshSegments = await ctx.Store.run(() =>
187
- resolveAllSegments(
188
- ctx.entries,
189
- ctx.routeKey,
190
- ctx.matched.params,
191
- freshHandlerContext,
192
- freshLoaderPromises,
193
- { skipLoaders: true },
194
- ),
190
+ // Re-establish the request-context ALS around the re-render. ctx.Store
191
+ // is a different ALS (DSL build context); on workerd a waitUntil task
192
+ // runs detached from the request's I/O context, so a handler/component
193
+ // that reads the ambient getRequestContext() during this background
194
+ // re-render would otherwise throw "called outside of a request context".
195
+ const freshSegments = await runWithRequestContext(
196
+ requestCtx as RequestContext<TEnv>,
197
+ () =>
198
+ ctx.Store.run(() =>
199
+ resolveAllSegments(
200
+ ctx.entries,
201
+ ctx.routeKey,
202
+ ctx.matched.params,
203
+ freshHandlerContext,
204
+ freshLoaderPromises,
205
+ { skipLoaders: true },
206
+ ),
207
+ ),
195
208
  );
196
209
 
197
210
  let freshInterceptSegments: ResolvedSegment[] = [];
198
211
  if (ctx.interceptResult) {
199
- freshInterceptSegments = await ctx.Store.run(() =>
200
- resolveInterceptEntry(
201
- ctx.interceptResult!.intercept,
202
- ctx.interceptResult!.entry,
203
- ctx.matched.params,
204
- freshHandlerContext,
205
- true,
206
- undefined,
207
- // Skip intercept middleware: this is a post-response background
208
- // re-render to refresh a stale cached route. The foreground
209
- // already ran the middleware; re-running it would double its side
210
- // effects and a short-circuit Response would abort the write.
211
- { skipMiddleware: true },
212
- ),
212
+ freshInterceptSegments = await runWithRequestContext(
213
+ requestCtx as RequestContext<TEnv>,
214
+ () =>
215
+ ctx.Store.run(() =>
216
+ resolveInterceptEntry(
217
+ ctx.interceptResult!.intercept,
218
+ ctx.interceptResult!.entry,
219
+ ctx.matched.params,
220
+ freshHandlerContext,
221
+ true,
222
+ undefined,
223
+ // Skip intercept middleware: this is a post-response background
224
+ // re-render to refresh a stale cached route. The foreground
225
+ // already ran the middleware; re-running it would double its
226
+ // side effects and a short-circuit Response would abort the write.
227
+ { skipMiddleware: true },
228
+ ),
229
+ ),
213
230
  );
214
231
  }
215
232
 
@@ -101,7 +101,10 @@
101
101
  * - Non-GET request (only GET requests are cacheable)
102
102
  */
103
103
  import type { ResolvedSegment } from "../../types.js";
104
- import { getRequestContext } from "../../server/request-context.js";
104
+ import {
105
+ getRequestContext,
106
+ runWithRequestContext,
107
+ } from "../../server/request-context.js";
105
108
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
106
109
  import { getRouterContext } from "../router-context.js";
107
110
  import { debugLog, debugWarn, getOrCreateRequestId } from "../logging.js";
@@ -231,34 +234,46 @@ export function withCacheStore<TEnv>(
231
234
  setupLoaderAccess(proactiveHandlerContext, proactiveLoaderPromises);
232
235
 
233
236
  const Store = ctx.Store;
234
- const freshSegments = await Store.run(() =>
235
- resolveAllSegments(
236
- ctx.entries,
237
- ctx.routeKey,
238
- ctx.matched.params,
239
- proactiveHandlerContext,
240
- proactiveLoaderPromises,
241
- { skipLoaders: true },
237
+ // Re-establish the request-context ALS around the re-render. Store
238
+ // is a different ALS (DSL build context); on workerd a waitUntil
239
+ // task runs detached from the request's I/O context, so a handler/
240
+ // component that reads the ambient getRequestContext() during this
241
+ // background re-render would otherwise throw "called outside of a
242
+ // request context".
243
+ const freshSegments = await runWithRequestContext(requestCtx, () =>
244
+ Store.run(() =>
245
+ resolveAllSegments(
246
+ ctx.entries,
247
+ ctx.routeKey,
248
+ ctx.matched.params,
249
+ proactiveHandlerContext,
250
+ proactiveLoaderPromises,
251
+ { skipLoaders: true },
252
+ ),
242
253
  ),
243
254
  );
244
255
 
245
256
  let freshInterceptSegments: ResolvedSegment[] = [];
246
257
  if (ctx.interceptResult) {
247
- freshInterceptSegments = await Store.run(() =>
248
- resolveInterceptEntry(
249
- ctx.interceptResult!.intercept,
250
- ctx.interceptResult!.entry,
251
- ctx.matched.params,
252
- proactiveHandlerContext,
253
- true, // belongsToRoute
254
- // No revalidationContext = render fresh
255
- undefined,
256
- // Skip intercept middleware: the foreground already ran it
257
- // before the response was sent. Re-running here (post-response,
258
- // background) would fire side effects twice and a short-circuit
259
- // Response would silently abort this cache write.
260
- { skipMiddleware: true },
261
- ),
258
+ freshInterceptSegments = await runWithRequestContext(
259
+ requestCtx,
260
+ () =>
261
+ Store.run(() =>
262
+ resolveInterceptEntry(
263
+ ctx.interceptResult!.intercept,
264
+ ctx.interceptResult!.entry,
265
+ ctx.matched.params,
266
+ proactiveHandlerContext,
267
+ true, // belongsToRoute
268
+ // No revalidationContext = render fresh
269
+ undefined,
270
+ // Skip intercept middleware: the foreground already ran it
271
+ // before the response was sent. Re-running here (post-
272
+ // response, background) would fire side effects twice and a
273
+ // short-circuit Response would silently abort this write.
274
+ { skipMiddleware: true },
275
+ ),
276
+ ),
262
277
  );
263
278
  }
264
279
 
@@ -202,6 +202,7 @@ export async function resolveSegment<TEnv>(
202
202
  transition: applyViewTransitionDefault(
203
203
  entry.transition,
204
204
  deps.viewTransitionDefault,
205
+ entry.shortCode,
205
206
  ),
206
207
  params,
207
208
  belongsToRoute: false,
@@ -345,6 +346,7 @@ export async function resolveSegment<TEnv>(
345
346
  transition: applyViewTransitionDefault(
346
347
  entry.transition,
347
348
  deps.viewTransitionDefault,
349
+ entry.shortCode,
348
350
  ),
349
351
  params,
350
352
  belongsToRoute: true,
@@ -432,6 +434,7 @@ export async function resolveOrphanLayout<TEnv>(
432
434
  transition: applyViewTransitionDefault(
433
435
  orphan.transition,
434
436
  deps.viewTransitionDefault,
437
+ orphan.shortCode,
435
438
  ),
436
439
  ...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
437
440
  });
@@ -565,6 +568,7 @@ export async function resolveParallelEntry<TEnv>(
565
568
  transition: applyViewTransitionDefault(
566
569
  parallelEntry.transition,
567
570
  deps.viewTransitionDefault,
571
+ `${parentShortCode}.${slot}`,
568
572
  ),
569
573
  params,
570
574
  slot,
@@ -21,7 +21,10 @@
21
21
  import type { LoaderEntry } from "../../server/context.js";
22
22
  import type { HandlerContext, InternalHandlerContext } from "../../types.js";
23
23
  import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
24
- import { getRequestContext } from "../../server/request-context.js";
24
+ import {
25
+ getRequestContext,
26
+ runWithRequestContext,
27
+ } from "../../server/request-context.js";
25
28
  import { sortedRouteParams } from "../../cache/cache-key-utils.js";
26
29
  import {
27
30
  resolveTtl,
@@ -202,11 +205,20 @@ export function resolveLoaderData<TEnv>(
202
205
  ctx.params,
203
206
  );
204
207
 
208
+ // Capture the request context up front (foreground, ALS present) so the
209
+ // background stale revalidation can re-establish it. On workerd a waitUntil
210
+ // task runs detached from the request's I/O context, so a loader body that
211
+ // reads the ambient getRequestContext() would otherwise throw "called
212
+ // outside of a request context" and the revalidation would fail silently.
213
+ // The wrap is applied via wrapBackground (background path only); the
214
+ // foreground miss runs execute() directly since its context is present.
215
+ const requestCtxForExecute = getRequestContext();
205
216
  return readThroughItem({
206
217
  getItem: (k) => store.getItem!(k),
207
218
  setItem: (k, v, o) => store.setItem!(k, v, o),
208
219
  key,
209
220
  execute: () => runMiss(loaderEntry.loader),
221
+ wrapBackground: (run) => runWithRequestContext(requestCtxForExecute, run),
210
222
  serialize: (d) => codec.serializeResult(d),
211
223
  deserialize: (v) => codec.deserializeResult(v),
212
224
  storeOptions: { ttl, swr, tags },
@@ -214,7 +226,7 @@ export function resolveLoaderData<TEnv>(
214
226
  onStale: () => debugLoaderCacheLog(`[LoaderCache] STALE: ${key}`),
215
227
  onMiss: () => debugLoaderCacheLog(`[LoaderCache] MISS: ${key}`),
216
228
  onCached: () => debugLoaderCacheLog(`[LoaderCache] Cached: ${key}`),
217
- host: getRequestContext(),
229
+ host: requestCtxForExecute,
218
230
  });
219
231
  })();
220
232
 
@@ -660,6 +660,7 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
660
660
  transition: applyViewTransitionDefault(
661
661
  parallelEntry.transition,
662
662
  deps.viewTransitionDefault,
663
+ parallelId,
663
664
  ),
664
665
  params,
665
666
  slot,
@@ -861,6 +862,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
861
862
  transition: applyViewTransitionDefault(
862
863
  entry.transition,
863
864
  deps.viewTransitionDefault,
865
+ entry.shortCode,
864
866
  ),
865
867
  params,
866
868
  belongsToRoute,
@@ -1197,6 +1199,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1197
1199
  transition: applyViewTransitionDefault(
1198
1200
  orphan.transition,
1199
1201
  deps.viewTransitionDefault,
1202
+ orphan.shortCode,
1200
1203
  ),
1201
1204
  ...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
1202
1205
  });
@@ -8,29 +8,49 @@
8
8
  */
9
9
 
10
10
  import type { EntryData } from "../../server/context";
11
+ import { getRequestContext } from "../../server/request-context.js";
11
12
 
12
13
  /**
13
- * Resolve the effective `viewTransition` for a segment's transition config.
14
+ * Resolve a segment's transition config: stamp the `viewTransition` default and
15
+ * peel off a transition({ when }) predicate.
14
16
  *
15
- * The per-segment value (set via the transition() DSL) always wins. When it is
16
- * unset, the router-level createRouter({ viewTransition }) default is stamped
17
- * in so the render gate reads the boundary decision off the segment — server
18
- * and client, via the serialized segment — without the router option being
19
- * threaded to the client. Only `false` is ever stamped; an unset (or "auto")
20
- * value is left untouched because it already means "wrap" at the gate, which
21
- * also avoids needless object allocation and payload growth. Used by both the
22
- * fresh and revalidation resolution paths.
17
+ * `viewTransition`: the per-segment value (set via the transition() DSL) always
18
+ * wins. When it is unset, the router-level createRouter({ viewTransition })
19
+ * default is stamped in so the render gate reads the boundary decision off the
20
+ * segment — server and client, via the serialized segment — without the router
21
+ * option being threaded to the client. Only `false` is ever stamped; an unset
22
+ * (or "auto") value is left untouched because it already means "wrap" at the
23
+ * gate, which also avoids needless object allocation and payload growth.
24
+ *
25
+ * `when`: a server-only predicate. It is STRIPPED from the returned config (a
26
+ * function cannot cross Flight or the segment cache) and recorded on the request
27
+ * context keyed by `segmentId`, so rsc-rendering can evaluate it post-handler —
28
+ * outside any cache scope — and drop this segment's transition when it returns
29
+ * false. Used by both the fresh and revalidation resolution paths.
23
30
  */
24
31
  export function applyViewTransitionDefault(
25
32
  transition: EntryData["transition"],
26
33
  viewTransitionDefault: "auto" | false | undefined,
34
+ segmentId?: string,
27
35
  ): EntryData["transition"] {
28
36
  if (!transition) return transition;
29
- if (
30
- transition.viewTransition === undefined &&
31
- viewTransitionDefault === false
32
- ) {
33
- return { ...transition, viewTransition: false };
37
+ let result = transition;
38
+ if (result.when) {
39
+ if (segmentId !== undefined) {
40
+ try {
41
+ const ctx = getRequestContext();
42
+ (ctx._transitionWhen ??= []).push({ id: segmentId, when: result.when });
43
+ } catch {
44
+ // No active request context (e.g. a unit test calling this util
45
+ // directly). Skip collection; the strip below still applies so the
46
+ // serialized config never carries the function.
47
+ }
48
+ }
49
+ const { when: _when, ...rest } = result;
50
+ result = rest;
51
+ }
52
+ if (result.viewTransition === undefined && viewTransitionDefault === false) {
53
+ return { ...result, viewTransition: false };
34
54
  }
35
- return transition;
55
+ return result;
36
56
  }
@@ -14,6 +14,7 @@ import { getSSRSetup } from "./ssr-setup.js";
14
14
  import type { MiddlewareFn } from "../router/middleware.js";
15
15
  import { executeMiddleware } from "../router/middleware.js";
16
16
  import { observePhase, PHASES } from "../router/instrument.js";
17
+ import { gateTransitions } from "./transition-gate.js";
17
18
  import type { RscPayload, ReactFormState } from "./types.js";
18
19
  import {
19
20
  createResponseWithMergedHeaders,
@@ -152,6 +153,7 @@ export async function handleProgressiveEnhancement<TEnv>(
152
153
  handleStore,
153
154
  nonce,
154
155
  useActionStateId,
156
+ true, // an action ran and threw
155
157
  );
156
158
  if (errorHtml) return errorHtml;
157
159
 
@@ -205,6 +207,7 @@ export async function handleProgressiveEnhancement<TEnv>(
205
207
  handleStore,
206
208
  nonce,
207
209
  directActionId,
210
+ true, // an action ran and threw
208
211
  );
209
212
  if (errorHtml) return errorHtml;
210
213
 
@@ -269,6 +272,14 @@ export async function handleProgressiveEnhancement<TEnv>(
269
272
  headers,
270
273
  });
271
274
 
275
+ // JS/PE parity: this is an action's revalidation render, so mark it BEFORE
276
+ // matching — a stale `foregroundOnAction` cache entry must re-execute in the
277
+ // foreground during the re-render, exactly as the JS path's
278
+ // revalidateAfterAction does. The transition({ when }) gate fields below are
279
+ // set post-match (the gate reads them after rendering); foregroundOnAction
280
+ // reads _inActionRevalidation during the match, so it must be set here.
281
+ getRequestContext()._inActionRevalidation = true;
282
+
272
283
  const match = await ctx.router.match(renderRequest, { env });
273
284
 
274
285
  if (match.redirect) {
@@ -278,12 +289,26 @@ export async function handleProgressiveEnhancement<TEnv>(
278
289
  });
279
290
  }
280
291
 
292
+ // Expose the no-JS action to the transition({ when }) gate. currentUrl/Params
293
+ // are absent on this full-render path (no navigation snapshot); useActionState
294
+ // ids are block-scoped, so only a direct action id is available here.
295
+ // actionUrl is the page the action was submitted from (this request's url).
296
+ const peReqCtx = getRequestContext();
297
+ peReqCtx._gateActionId = directActionId ?? undefined;
298
+ peReqCtx._gateActionUrl = new URL(url);
299
+ peReqCtx._gateActionResult = actionResult;
300
+ peReqCtx._gateFormData = formData;
301
+
281
302
  const payload: RscPayload = {
282
303
  metadata: {
283
304
  pathname: url.pathname,
284
305
  routerId: ctx.router.id,
285
306
  basename: ctx.router.basename,
286
- segments: match.segments,
307
+ segments: gateTransitions(
308
+ match.segments,
309
+ getRequestContext(),
310
+ ctx.router.onError,
311
+ ),
287
312
  matched: match.matched,
288
313
  diff: match.diff,
289
314
  resolvedIds: match.resolvedIds,
@@ -368,7 +393,22 @@ async function renderPeErrorBoundary<TEnv>(
368
393
  handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
369
394
  nonce: string | undefined,
370
395
  actionId?: string | null,
396
+ // True when an action actually ran and threw (vs a malformed form body, where
397
+ // no action executed). Drives _inActionRevalidation for JS/PE parity — it must
398
+ // NOT be inferred from actionId, since a useActionState bound action can run
399
+ // and throw with no $$id (actionId === undefined) yet still be an action error.
400
+ actionRan = false,
371
401
  ): Promise<Response | null> {
402
+ // JS/PE parity for an action-triggered error re-render: a stale
403
+ // `foregroundOnAction` cache entry inside the error boundary must foreground
404
+ // too, exactly as the JS path (revalidateAfterAction sets this unconditionally
405
+ // before rendering the error boundary). Set BEFORE matchError (the cached fn
406
+ // runs during it). Gated on actionRan, NOT actionId — a malformed form body
407
+ // (actionRan=false) ran no action and must keep SWR.
408
+ if (actionRan) {
409
+ getRequestContext()._inActionRevalidation = true;
410
+ }
411
+
372
412
  let errorResult;
373
413
  try {
374
414
  errorResult = await ctx.router.matchError(request, { env }, error, "route");
@@ -395,12 +435,26 @@ async function renderPeErrorBoundary<TEnv>(
395
435
 
396
436
  setRequestContextParams(errorResult.params, errorResult.routeName);
397
437
 
438
+ // Only the failing action id + URL are in scope here (no formData/actionResult
439
+ // thread into this helper). Expose the URL only when the action id is known:
440
+ // this helper also handles malformed form bodies before action detection, and
441
+ // those should not look like action-triggered renders to transition({ when }).
442
+ if (actionId != null) {
443
+ const peErrCtx = getRequestContext();
444
+ peErrCtx._gateActionId = actionId;
445
+ peErrCtx._gateActionUrl = new URL(url);
446
+ }
447
+
398
448
  const payload: RscPayload = {
399
449
  metadata: {
400
450
  pathname: url.pathname,
401
451
  routerId: ctx.router.id,
402
452
  basename: ctx.router.basename,
403
- segments: errorResult.segments,
453
+ segments: gateTransitions(
454
+ errorResult.segments,
455
+ getRequestContext(),
456
+ ctx.router.onError,
457
+ ),
404
458
  matched: errorResult.matched,
405
459
  diff: errorResult.diff,
406
460
  resolvedIds: errorResult.resolvedIds,
@@ -21,6 +21,7 @@ import {
21
21
  attachLocationStateIfPresent,
22
22
  } from "./helpers.js";
23
23
  import type { HandlerContext } from "./handler-context.js";
24
+ import { gateTransitions } from "./transition-gate.js";
24
25
 
25
26
  export function handleRscRendering<TEnv>(
26
27
  ctx: HandlerContext<TEnv>,
@@ -70,7 +71,7 @@ async function handleRscRenderingInner<TEnv>(
70
71
  pathname: url.pathname,
71
72
  routerId: ctx.router.id,
72
73
  basename: ctx.router.basename,
73
- segments: m.segments,
74
+ segments: gateTransitions(m.segments, reqCtx, ctx.router.onError),
74
75
  matched: m.matched,
75
76
  diff: m.diff,
76
77
  resolvedIds: m.resolvedIds,
@@ -126,7 +127,11 @@ async function handleRscRenderingInner<TEnv>(
126
127
  // intercepted server-side (X-RSC-Reload) and never delivers a
127
128
  // different-router payload to the client.
128
129
  routerId: ctx.router.id,
129
- segments: result.segments,
130
+ segments: gateTransitions(
131
+ result.segments,
132
+ reqCtx,
133
+ ctx.router.onError,
134
+ ),
130
135
  matched: result.matched,
131
136
  diff: result.diff,
132
137
  resolvedIds: result.resolvedIds,
@@ -21,6 +21,7 @@ import {
21
21
  } from "../server/request-context.js";
22
22
  import { appendMetric } from "../router/metrics.js";
23
23
  import { observePhase, PHASES } from "../router/instrument.js";
24
+ import { gateTransitions } from "./transition-gate.js";
24
25
  import type { RscPayload } from "./types.js";
25
26
  import {
26
27
  hasBodyContent,
@@ -363,6 +364,20 @@ async function revalidateAfterActionInner<TEnv>(
363
364
  const reqCtx = getRequestContext();
364
365
  const metricsStore = reqCtx._metricsStore;
365
366
 
367
+ // Expose the action that triggered this revalidation to the transition({ when })
368
+ // gate (covers both the error-boundary and success gate calls below). Mirrors
369
+ // the action fields a revalidate() predicate sees.
370
+ reqCtx._gateActionId = actionContext?.actionId;
371
+ reqCtx._gateActionUrl = actionContext?.actionUrl;
372
+ reqCtx._gateActionResult = actionContext?.actionResult;
373
+ reqCtx._gateFormData = actionContext?.formData;
374
+
375
+ // Mark the rest of this request as an action revalidation render. The "use
376
+ // cache" runtime reads this to re-execute a stale entry in the foreground
377
+ // (fresh data in the action response) rather than serving stale + revalidating
378
+ // in the background. See registerCachedFunction in cache/cache-runtime.ts.
379
+ reqCtx._inActionRevalidation = true;
380
+
366
381
  // Action threw and a boundary matched: render the (already-matched) error
367
382
  // boundary here so it runs inside the route-middleware wrapper, exactly like
368
383
  // the success branch below. setRequestContextParams + the payload mirror the
@@ -376,7 +391,11 @@ async function revalidateAfterActionInner<TEnv>(
376
391
  // routerId exposed for the frontend (current app identity); see
377
392
  // rsc-rendering.ts partial branch.
378
393
  routerId: ctx.router.id,
379
- segments: errorBoundary.segments,
394
+ segments: gateTransitions(
395
+ errorBoundary.segments,
396
+ reqCtx,
397
+ ctx.router.onError,
398
+ ),
380
399
  isPartial: true,
381
400
  matched: errorBoundary.matched,
382
401
  diff: errorBoundary.diff,
@@ -460,7 +479,11 @@ async function revalidateAfterActionInner<TEnv>(
460
479
  // routerId exposed for the frontend (current app identity); see
461
480
  // rsc-rendering.ts partial branch.
462
481
  routerId: ctx.router.id,
463
- segments: matchResult.segments,
482
+ segments: gateTransitions(
483
+ matchResult.segments,
484
+ reqCtx,
485
+ ctx.router.onError,
486
+ ),
464
487
  isPartial: true,
465
488
  matched: matchResult.matched,
466
489
  diff: matchResult.diff,
@@ -0,0 +1,89 @@
1
+ import type { MatchResult } from "../types.js";
2
+ import type { TransitionWhenContext } from "../types/segments.js";
3
+ import type { getRequestContext } from "../server/request-context.js";
4
+ import { invokeOnError } from "../router/error-handling.js";
5
+ import type { OnErrorCallback } from "../types/error-types.js";
6
+
7
+ /**
8
+ * Apply transition({ when }) gates to a payload's segments.
9
+ *
10
+ * The predicates were collected during resolution (keyed by segment id) and
11
+ * stripped from the serialized config; here — after handlers ran and outside any
12
+ * cache scope — we evaluate each and drop the segment's transition when the
13
+ * predicate does not hold, so the navigation streams its loading fallback
14
+ * instead of holding the previous content. A predicate that throws is reported
15
+ * to the router's onError (phase "rendering") and then treated as "do not hold"
16
+ * (conservative), so a buggy predicate degrades to no transition rather than
17
+ * failing the response.
18
+ *
19
+ * Mutating the segments here is safe: the segment cache stores a serialized copy
20
+ * (segment-codec), written during match() BEFORE this gate runs, so dropping a
21
+ * transition never corrupts a cache entry. The flip side is that a cache hit
22
+ * skips resolution, collects no predicate, and replays the cached transition
23
+ * as-is (it was serialized before the gate) — combining transition({ when })
24
+ * with cache() on the same segment freezes the gate to its cached state, so
25
+ * avoid caching a route whose transition decision is request-dependent.
26
+ *
27
+ * Returns the same array (mutated) for inline use at the payload's `segments`
28
+ * field.
29
+ */
30
+ export function gateTransitions(
31
+ segments: MatchResult["segments"],
32
+ ctx: ReturnType<typeof getRequestContext>,
33
+ onError?: OnErrorCallback,
34
+ ): MatchResult["segments"] {
35
+ const predicates = ctx._transitionWhen;
36
+ if (predicates && predicates.length) {
37
+ for (const { id, when } of predicates) {
38
+ let drop: boolean;
39
+ try {
40
+ // Assemble the ShouldRevalidateFn-shaped predicate context from the
41
+ // request context. Source fields (currentUrl/currentParams/fromRouteName)
42
+ // were stashed at match time from the navigation snapshot; action fields
43
+ // at the action-bearing gate call sites. nextUrl/nextParams/toRouteName/
44
+ // method/get/env come straight off ctx (setRequestContextParams ran
45
+ // before the gate). Source/action fields are undefined when absent —
46
+ // never fabricated (see TransitionWhenContext).
47
+ const whenCtx: TransitionWhenContext = {
48
+ currentUrl: ctx._gateCurrentUrl,
49
+ currentParams: ctx._gateCurrentParams,
50
+ fromRouteName:
51
+ ctx._prevRouteKey as TransitionWhenContext["fromRouteName"],
52
+ nextUrl: ctx.url,
53
+ nextParams: ctx.params,
54
+ toRouteName: ctx.routeName,
55
+ actionId: ctx._gateActionId,
56
+ actionUrl: ctx._gateActionUrl,
57
+ actionResult: ctx._gateActionResult,
58
+ formData: ctx._gateFormData,
59
+ method: ctx.request.method,
60
+ get: ctx.get,
61
+ env: ctx.env,
62
+ };
63
+ drop = when(whenCtx) === false;
64
+ } catch (error) {
65
+ // A throwing predicate must not fail the response: report it and treat
66
+ // the transition as gated off (do not hold). invokeOnError no-ops when
67
+ // onError is undefined.
68
+ drop = true;
69
+ invokeOnError(
70
+ onError,
71
+ error,
72
+ "rendering",
73
+ {
74
+ request: ctx.request,
75
+ url: ctx.url,
76
+ params: ctx.params,
77
+ segmentId: id,
78
+ },
79
+ "RSC",
80
+ );
81
+ }
82
+ if (drop) {
83
+ const seg = segments.find((s) => s.id === id);
84
+ if (seg) seg.transition = undefined;
85
+ }
86
+ }
87
+ }
88
+ return segments;
89
+ }