@rangojs/router 0.0.0-experimental.112 → 0.0.0-experimental.114

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 (48) hide show
  1. package/dist/bin/rango.js +74 -3
  2. package/dist/vite/index.js +133 -18
  3. package/package.json +1 -1
  4. package/skills/cache-guide/SKILL.md +35 -24
  5. package/skills/caching/SKILL.md +115 -7
  6. package/skills/document-cache/SKILL.md +78 -55
  7. package/skills/hooks/SKILL.md +40 -22
  8. package/skills/links/SKILL.md +10 -10
  9. package/skills/loader/SKILL.md +3 -3
  10. package/skills/rango/SKILL.md +16 -10
  11. package/skills/react-compiler/SKILL.md +168 -0
  12. package/skills/use-cache/SKILL.md +34 -5
  13. package/skills/view-transitions/SKILL.md +85 -3
  14. package/src/browser/react/location-state-shared.ts +93 -3
  15. package/src/browser/react/use-reverse.ts +19 -12
  16. package/src/build/route-types/per-module-writer.ts +4 -1
  17. package/src/build/route-types/router-processing.ts +14 -1
  18. package/src/build/route-types/source-scan.ts +118 -0
  19. package/src/cache/cache-scope.ts +28 -42
  20. package/src/cache/cf/cf-cache-store.ts +49 -6
  21. package/src/handle.ts +3 -5
  22. package/src/loader-store.ts +62 -25
  23. package/src/loader.rsc.ts +2 -5
  24. package/src/loader.ts +3 -10
  25. package/src/missing-id-error.ts +68 -0
  26. package/src/reverse.ts +16 -13
  27. package/src/route-definition/dsl-helpers.ts +5 -2
  28. package/src/route-definition/helpers-types.ts +31 -10
  29. package/src/router/loader-resolution.ts +16 -2
  30. package/src/router/match-middleware/cache-lookup.ts +44 -91
  31. package/src/router/match-middleware/cache-store.ts +3 -2
  32. package/src/router/router-options.ts +24 -0
  33. package/src/router/segment-resolution/fresh.ts +17 -4
  34. package/src/router/segment-resolution/revalidation.ts +17 -4
  35. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  36. package/src/router/types.ts +8 -0
  37. package/src/router.ts +2 -0
  38. package/src/segment-system.tsx +59 -10
  39. package/src/server/context.ts +26 -0
  40. package/src/server/cookie-store.ts +28 -4
  41. package/src/types/handler-context.ts +5 -2
  42. package/src/types/segments.ts +18 -1
  43. package/src/urls/path-helper-types.ts +9 -1
  44. package/src/use-loader.tsx +89 -42
  45. package/src/vite/plugins/expose-ids/export-analysis.ts +68 -12
  46. package/src/vite/plugins/expose-internal-ids.ts +12 -4
  47. package/src/vite/plugins/use-cache-transform.ts +12 -10
  48. package/src/vite/router-discovery.ts +14 -2
@@ -282,6 +282,38 @@ async function* yieldFromStore<TEnv>(
282
282
  }
283
283
  }
284
284
 
285
+ /**
286
+ * Look up a prerendered (build-time cached) entry for the current route and, on
287
+ * a hit, yield its segments. Returns true when an entry was served (the caller
288
+ * should stop the pipeline) and false on a miss. Intercept navigations consult
289
+ * only the intercept-specific entry (`paramHash + "/i"`); a miss there falls
290
+ * through to the normal pipeline so intercept-resolution can run. Callers must
291
+ * guard on `prerenderStoreInstance` after `ensurePrerenderDeps()`.
292
+ */
293
+ async function* tryPrerenderLookup<TEnv>(
294
+ ctx: MatchContext<TEnv>,
295
+ state: MatchPipelineState,
296
+ pipelineStart: number,
297
+ handleStoreRef?: HandleStore,
298
+ ): AsyncGenerator<ResolvedSegment, boolean> {
299
+ const paramHash = _hashParams!(ctx.matched.params);
300
+ const isPassthroughPrerenderRoute = ctx.entries.some(
301
+ (entry) => entry.type === "route" && entry.isPassthrough === true,
302
+ );
303
+ const lookupHash = ctx.isIntercept ? paramHash + "/i" : paramHash;
304
+ const entry = await prerenderStoreInstance!.get(
305
+ ctx.matched.routeKey,
306
+ lookupHash,
307
+ {
308
+ pathname: ctx.pathname,
309
+ isPassthroughRoute: isPassthroughPrerenderRoute,
310
+ },
311
+ );
312
+ if (!entry) return false;
313
+ yield* yieldFromStore(entry, ctx, state, pipelineStart, handleStoreRef);
314
+ return true;
315
+ }
316
+
285
317
  /**
286
318
  * Async generator middleware type
287
319
  */
@@ -334,54 +366,13 @@ export function withCacheLookup<TEnv>(
334
366
  if (!ctx.isAction && !isHmr && ctx.matched.pr) {
335
367
  await ensurePrerenderDeps();
336
368
  if (prerenderStoreInstance) {
337
- const paramHash = _hashParams!(ctx.matched.params);
338
- const isPassthroughPrerenderRoute = ctx.entries.some(
339
- (entry) => entry.type === "route" && entry.isPassthrough === true,
369
+ const served = yield* tryPrerenderLookup(
370
+ ctx,
371
+ state,
372
+ pipelineStart,
373
+ handleStoreRef,
340
374
  );
341
-
342
- if (ctx.isIntercept) {
343
- // Intercept navigation: try intercept-specific prerender entry
344
- const entry = await prerenderStoreInstance.get(
345
- ctx.matched.routeKey,
346
- paramHash + "/i",
347
- {
348
- pathname: ctx.pathname,
349
- isPassthroughRoute: isPassthroughPrerenderRoute,
350
- },
351
- );
352
- if (entry) {
353
- yield* yieldFromStore(
354
- entry,
355
- ctx,
356
- state,
357
- pipelineStart,
358
- handleStoreRef,
359
- );
360
- return;
361
- }
362
- // No intercept prerender -- fall through to normal pipeline
363
- // (skip non-intercept prerender to let intercept-resolution run)
364
- } else {
365
- // Normal navigation: existing behavior
366
- const entry = await prerenderStoreInstance.get(
367
- ctx.matched.routeKey,
368
- paramHash,
369
- {
370
- pathname: ctx.pathname,
371
- isPassthroughRoute: isPassthroughPrerenderRoute,
372
- },
373
- );
374
- if (entry) {
375
- yield* yieldFromStore(
376
- entry,
377
- ctx,
378
- state,
379
- pipelineStart,
380
- handleStoreRef,
381
- );
382
- return;
383
- }
384
- }
375
+ if (served) return;
385
376
  }
386
377
  }
387
378
 
@@ -404,51 +395,13 @@ export function withCacheLookup<TEnv>(
404
395
  if (hasStatic) {
405
396
  await ensurePrerenderDeps();
406
397
  if (prerenderStoreInstance) {
407
- const paramHash = _hashParams!(ctx.matched.params);
408
- const isPassthroughPrerenderRoute = ctx.entries.some(
409
- (entry) => entry.type === "route" && entry.isPassthrough === true,
398
+ const served = yield* tryPrerenderLookup(
399
+ ctx,
400
+ state,
401
+ pipelineStart,
402
+ handleStoreRef,
410
403
  );
411
-
412
- if (ctx.isIntercept) {
413
- const entry = await prerenderStoreInstance.get(
414
- ctx.matched.routeKey,
415
- paramHash + "/i",
416
- {
417
- pathname: ctx.pathname,
418
- isPassthroughRoute: isPassthroughPrerenderRoute,
419
- },
420
- );
421
- if (entry) {
422
- yield* yieldFromStore(
423
- entry,
424
- ctx,
425
- state,
426
- pipelineStart,
427
- handleStoreRef,
428
- );
429
- return;
430
- }
431
- // No intercept prerender -- fall through to normal pipeline
432
- } else {
433
- const entry = await prerenderStoreInstance.get(
434
- ctx.matched.routeKey,
435
- paramHash,
436
- {
437
- pathname: ctx.pathname,
438
- isPassthroughRoute: isPassthroughPrerenderRoute,
439
- },
440
- );
441
- if (entry) {
442
- yield* yieldFromStore(
443
- entry,
444
- ctx,
445
- state,
446
- pipelineStart,
447
- handleStoreRef,
448
- );
449
- return;
450
- }
451
- }
404
+ if (served) return;
452
405
  }
453
406
  }
454
407
  }
@@ -169,10 +169,11 @@ export function withCacheStore<TEnv>(
169
169
  // skip (client already had them). Segments where the handler intentionally
170
170
  // returned null are not revalidation skips — re-rendering them will still
171
171
  // produce null, so proactive caching would be wasted work.
172
- const clientIdSet = new Set(ctx.clientSegmentIds);
173
172
  const hasNullComponents = allSegmentsToCache.some(
174
173
  (s) =>
175
- s.component === null && s.type !== "loader" && clientIdSet.has(s.id),
174
+ s.component === null &&
175
+ s.type !== "loader" &&
176
+ ctx.clientSegmentSet.has(s.id),
176
177
  );
177
178
 
178
179
  const requestCtx = getRequestContext();
@@ -357,6 +357,30 @@ export interface RangoOptions<TEnv = any> {
357
357
  */
358
358
  theme?: import("../theme/types.js").ThemeConfig | true;
359
359
 
360
+ /**
361
+ * Default for whether the router wraps `transition()` segments in its own
362
+ * React `<ViewTransition>` boundary (experimental React only).
363
+ *
364
+ * - "auto" (default): every route/layout that opts in via `transition()`
365
+ * gets a router-owned cross-fade.
366
+ * - false: the router never places its own boundary. Routes that use
367
+ * `transition()` still drive navigation through startTransition (so loaders
368
+ * hold instead of flashing a skeleton) and still let consumer-placed
369
+ * `<ViewTransition>` elements animate — the router just contributes no
370
+ * cross-fade of its own. This is the "router triggers, you place the
371
+ * transitions" model.
372
+ *
373
+ * A per-segment `transition({ viewTransition })` overrides this default.
374
+ *
375
+ * @example
376
+ * ```typescript
377
+ * // App-wide: drive + hold, but never auto-wrap. Place <ViewTransition>
378
+ * // yourself in components where you want a morph.
379
+ * const router = createRouter<AppEnv>({ viewTransition: false });
380
+ * ```
381
+ */
382
+ viewTransition?: "auto" | false;
383
+
360
384
  /**
361
385
  * URL patterns to register with the router.
362
386
  *
@@ -28,6 +28,7 @@ import {
28
28
  resolveLayoutComponent,
29
29
  resolveWithErrorBoundary,
30
30
  } from "./helpers.js";
31
+ import { applyViewTransitionDefault } from "./view-transition-default.js";
31
32
  import { getRouterContext } from "../router-context.js";
32
33
  import { resolveSink, safeEmit } from "../telemetry.js";
33
34
  import {
@@ -224,7 +225,10 @@ export async function resolveSegment<TEnv>(
224
225
  index: 0,
225
226
  component,
226
227
  loading: entry.loading === false ? null : entry.loading,
227
- transition: entry.transition,
228
+ transition: applyViewTransitionDefault(
229
+ entry.transition,
230
+ deps.viewTransitionDefault,
231
+ ),
228
232
  params,
229
233
  belongsToRoute: false,
230
234
  layoutName: entry.id,
@@ -359,7 +363,10 @@ export async function resolveSegment<TEnv>(
359
363
  index: 0,
360
364
  component: component ?? null,
361
365
  loading: entry.loading === false ? null : entry.loading,
362
- transition: entry.transition,
366
+ transition: applyViewTransitionDefault(
367
+ entry.transition,
368
+ deps.viewTransitionDefault,
369
+ ),
363
370
  params,
364
371
  belongsToRoute: true,
365
372
  ...(entry.mountPath ? { mountPath: entry.mountPath } : {}),
@@ -443,7 +450,10 @@ export async function resolveOrphanLayout<TEnv>(
443
450
  belongsToRoute,
444
451
  layoutName: orphan.id,
445
452
  loading: orphan.loading === false ? null : orphan.loading,
446
- transition: orphan.transition,
453
+ transition: applyViewTransitionDefault(
454
+ orphan.transition,
455
+ deps.viewTransitionDefault,
456
+ ),
447
457
  ...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
448
458
  });
449
459
 
@@ -565,7 +575,10 @@ export async function resolveParallelEntry<TEnv>(
565
575
  index: 0,
566
576
  component,
567
577
  loading: parallelEntry.loading === false ? null : parallelEntry.loading,
568
- transition: parallelEntry.transition,
578
+ transition: applyViewTransitionDefault(
579
+ parallelEntry.transition,
580
+ deps.viewTransitionDefault,
581
+ ),
569
582
  params,
570
583
  slot,
571
584
  belongsToRoute,
@@ -39,6 +39,7 @@ import {
39
39
  resolveLayoutComponent,
40
40
  resolveWithErrorBoundary,
41
41
  } from "./helpers.js";
42
+ import { applyViewTransitionDefault } from "./view-transition-default.js";
42
43
  import { getRouterContext } from "../router-context.js";
43
44
  import { resolveSink, safeEmit } from "../telemetry.js";
44
45
  import {
@@ -593,7 +594,10 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
593
594
  index: 0,
594
595
  component,
595
596
  loading: parallelEntry.loading === false ? null : parallelEntry.loading,
596
- transition: parallelEntry.transition,
597
+ transition: applyViewTransitionDefault(
598
+ parallelEntry.transition,
599
+ deps.viewTransitionDefault,
600
+ ),
597
601
  params,
598
602
  slot,
599
603
  _handlerRan: handlerRan,
@@ -803,7 +807,10 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
803
807
  index: 0,
804
808
  component: resolvedComponent,
805
809
  loading: entry.loading === false ? null : entry.loading,
806
- transition: entry.transition,
810
+ transition: applyViewTransitionDefault(
811
+ entry.transition,
812
+ deps.viewTransitionDefault,
813
+ ),
807
814
  params,
808
815
  belongsToRoute,
809
816
  ...(entry.type === "layout" || entry.type === "cache"
@@ -1137,7 +1144,10 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1137
1144
  belongsToRoute,
1138
1145
  layoutName: orphan.id,
1139
1146
  loading: orphan.loading === false ? null : orphan.loading,
1140
- transition: orphan.transition,
1147
+ transition: applyViewTransitionDefault(
1148
+ orphan.transition,
1149
+ deps.viewTransitionDefault,
1150
+ ),
1141
1151
  ...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
1142
1152
  });
1143
1153
 
@@ -1294,7 +1304,10 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1294
1304
  index: 0,
1295
1305
  component,
1296
1306
  loading: parallelEntry.loading === false ? null : parallelEntry.loading,
1297
- transition: parallelEntry.transition,
1307
+ transition: applyViewTransitionDefault(
1308
+ parallelEntry.transition,
1309
+ deps.viewTransitionDefault,
1310
+ ),
1298
1311
  params,
1299
1312
  slot,
1300
1313
  _handlerRan: handlerRan,
@@ -0,0 +1,36 @@
1
+ /**
2
+ * View-transition boundary default resolution.
3
+ *
4
+ * Kept in its own module (rather than helpers.ts) because several resolution
5
+ * tests mock helpers.ts with an explicit export list; a shared util here is
6
+ * never mocked, so the fresh and revalidation paths always get the real
7
+ * implementation.
8
+ */
9
+
10
+ import type { EntryData } from "../../server/context";
11
+
12
+ /**
13
+ * Resolve the effective `viewTransition` for a segment's transition config.
14
+ *
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.
23
+ */
24
+ export function applyViewTransitionDefault(
25
+ transition: EntryData["transition"],
26
+ viewTransitionDefault: "auto" | false | undefined,
27
+ ): EntryData["transition"] {
28
+ if (!transition) return transition;
29
+ if (
30
+ transition.viewTransition === undefined &&
31
+ viewTransitionDefault === false
32
+ ) {
33
+ return { ...transition, viewTransition: false };
34
+ }
35
+ return transition;
36
+ }
@@ -98,6 +98,14 @@ export interface SegmentResolutionDeps<TEnv = any> {
98
98
  ) => ReactNode | NotFoundBoundaryHandler | null;
99
99
  notFoundComponent?: ReactNode | ((props: { pathname: string }) => ReactNode);
100
100
  callOnError: (error: unknown, phase: ErrorPhase, context: any) => void;
101
+ /**
102
+ * Router-level default for the per-segment `transition({ viewTransition })`
103
+ * flag, from createRouter({ viewTransition }). Resolved into each segment's
104
+ * transition config during resolution (only `false` is stamped) so the render
105
+ * gate reads the boundary decision off the segment on both server and client.
106
+ * Undefined is treated as "auto" (wrap).
107
+ */
108
+ viewTransitionDefault?: "auto" | false;
101
109
  }
102
110
 
103
111
  /**
package/src/router.ts CHANGED
@@ -155,6 +155,7 @@ export function createRouter<TEnv = any>(
155
155
  timeouts: timeoutsOption,
156
156
  onTimeout,
157
157
  originCheck: originCheckOption,
158
+ viewTransition: viewTransitionOption = "auto",
158
159
  } = options;
159
160
 
160
161
  // Normalize basename: ensure leading slash, strip trailing slash.
@@ -534,6 +535,7 @@ export function createRouter<TEnv = any>(
534
535
  findNearestNotFoundBoundary,
535
536
  notFoundComponent: notFound,
536
537
  callOnError,
538
+ viewTransitionDefault: viewTransitionOption,
537
539
  };
538
540
 
539
541
  // Match API dependencies
@@ -99,8 +99,11 @@ function createViewTransitionBoundary(
99
99
  transition: NonNullable<ResolvedSegment["transition"]>,
100
100
  children: ReactNode,
101
101
  ): ReactNode {
102
+ // `viewTransition` is a router-specific flag (boundary opt-out), not a React
103
+ // <ViewTransition> prop — strip it so it never reaches React.
104
+ const { viewTransition: _viewTransition, ...vtProps } = transition;
102
105
  return createElement(ReactViewTransition, {
103
- ...transition,
106
+ ...vtProps,
104
107
  children,
105
108
  });
106
109
  }
@@ -216,6 +219,25 @@ export async function renderSegments(
216
219
  }
217
220
  // Separate segments by type, passing intercept segments for explicit injection
218
221
  const tree = segmentTreeWalk(normalizedSegments, normalizedInterceptSegments);
222
+
223
+ // A route is "in a transition scope" when its own segment OR any layout in
224
+ // its matched chain declares transition(). Both transition() forms land here:
225
+ // the per-route item form sets transition on the route entry, and the block
226
+ // wrapper form sets it on a transparent ancestor layout (dsl-helpers.ts). When
227
+ // in scope, the route and its route-owned layouts use param-agnostic keys so a
228
+ // same-route navigation reconciles (holds content) instead of remounting. The
229
+ // value is a static property of the route's position in the tree, so it is the
230
+ // same on every render of that route (SSR, navigation, action) — the keys
231
+ // never drift. Cross-route navigation still remounts: different routes have
232
+ // different segment ids regardless of transition scope.
233
+ const inTransitionScope = normalizedSegments.some(
234
+ (s) =>
235
+ s.transition != null &&
236
+ (s.type === "layout" ||
237
+ s.type === "route" ||
238
+ s.type === "error" ||
239
+ s.type === "notFound"),
240
+ );
219
241
  // Render content segments as siblings
220
242
  let content: ReactNode = null;
221
243
  for (const node of tree) {
@@ -228,17 +250,31 @@ export async function renderSegments(
228
250
  );
229
251
  const { component, id, params, loading } = node.segment;
230
252
 
231
- // Only include params in key for segments that belong to the route
232
- // - Routes: always include params (they render param-specific content)
233
- // - Error/notFound segments: always include params (they replace failed route content)
234
- // - Route's layouts (orphans): include params (children of parameterized route)
235
- // - Parent chain layouts: exclude params (shared across routes, param-agnostic)
236
- // This prevents unnecessary unmounting when params change
253
+ // Param-agnostic keys are opt-in via the transition() DSL (see
254
+ // inTransitionScope above). A route (and its route-owned layouts) inside a
255
+ // transition scope drops the param from its key, so navigating between two
256
+ // param values of the SAME route (e.g. /product/1 -> /product/2) reconciles
257
+ // the route subtree instead of remounting it. Combined with the
258
+ // startTransition wrap that shouldStartViewTransition already applies to
259
+ // transition routes (browser/partial-update.ts), the previous content stays
260
+ // on screen while the new loaders resolve (stale-while-revalidate) instead
261
+ // of flashing the loading skeleton. This works on stable React; experimental
262
+ // React adds the animated <ViewTransition> cross-fade on top.
263
+ //
264
+ // Outside a transition scope the key stays param-bearing and the route
265
+ // remounts on param change (the default: a fresh skeleton and fresh
266
+ // component state).
267
+ //
268
+ // error/notFound always keep param-bearing keys: createErrorSegment reuses
269
+ // the boundary layout's shortCode as the error segment id (router/
270
+ // error-handling.ts), so a param-agnostic error key could collide with that
271
+ // layout's key within the same render.
237
272
  const includeParams =
238
- node.segment.type === "route" ||
239
273
  node.segment.type === "error" ||
240
274
  node.segment.type === "notFound" ||
241
- (node.segment.type === "layout" && node.segment.belongsToRoute);
275
+ ((node.segment.type === "route" ||
276
+ (node.segment.type === "layout" && node.segment.belongsToRoute)) &&
277
+ !inTransitionScope);
242
278
 
243
279
  const paramStr =
244
280
  includeParams && params && Object.keys(params).length > 0
@@ -286,12 +322,25 @@ export async function renderSegments(
286
322
  // subtree update on the layout-level VT — which would otherwise make
287
323
  // React's commit walker fire `document.startViewTransition` and apply
288
324
  // view-transition-names to the underlying main subtree (cover/title/etc.).
325
+ //
326
+ // `transition.viewTransition === false` opts out of the router-owned
327
+ // boundary only. Driving (the startTransition wrap in browser/partial-update.ts
328
+ // and the param-agnostic key/hold below) keys off transition *presence*, not
329
+ // this flag, so a boundary-less transition still holds content and lets
330
+ // consumer-placed <ViewTransition> elements animate. The global
331
+ // createRouter({ viewTransition }) default is resolved into this field
332
+ // during segment resolution (only `false` is stamped; unset/"auto" is left
333
+ // as-is and means "wrap"), so this gate needs no router-option threading.
289
334
  let outletContent: ReactNode =
290
335
  node.segment.type === "layout" ? content : null;
291
336
 
292
337
  const transition = node.segment.transition;
293
338
 
294
- if (ReactViewTransition && transition) {
339
+ if (
340
+ ReactViewTransition &&
341
+ transition &&
342
+ transition.viewTransition !== false
343
+ ) {
295
344
  if (node.segment.type === "layout") {
296
345
  outletContent = wrapDefaultOutletContent(outletContent, transition);
297
346
  } else {
@@ -748,6 +748,17 @@ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
748
748
  globalThis as any
749
749
  )[LOADER_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
750
750
 
751
+ // Purity-only scope: marks that a loader FUNCTION BODY is executing, regardless
752
+ // of how the loader was invoked (DSL via runInsideLoaderScope, or handler-
753
+ // invoked via ctx.use). Consulted ONLY by isInsideCacheScope() to exempt
754
+ // request-scoped reads. It deliberately does NOT affect isInsideLoaderScope(),
755
+ // so rendered()/barrier/deadlock gating (which must distinguish DSL from
756
+ // handler-invoked loaders) is unchanged.
757
+ const LOADER_BODY_SCOPE_KEY = Symbol.for("rangojs-router:loader-body-scope");
758
+ const loaderBodyScopeALS: AsyncLocalStorage<{ active: true }> = ((
759
+ globalThis as any
760
+ )[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
761
+
751
762
  /**
752
763
  * Check if the current execution is inside a cache() DSL boundary.
753
764
  * Returns false inside loader execution — loaders are always fresh
@@ -759,6 +770,10 @@ export function isInsideCacheScope(): boolean {
759
770
  // function re-executes on every request. Skip the guard when running
760
771
  // inside a loader.
761
772
  if (loaderScopeALS.getStore()?.active) return false;
773
+ // Also exempt handler-invoked loaders: their bodies run in a loader-body
774
+ // scope (not the DSL loader scope above), so request-scoped reads inside any
775
+ // loader — however invoked — are safe (loaders always re-run fresh).
776
+ if (loaderBodyScopeALS.getStore()?.active) return false;
762
777
  return true;
763
778
  }
764
779
 
@@ -779,3 +794,14 @@ export function isInsideLoaderScope(): boolean {
779
794
  export function runInsideLoaderScope<T>(fn: () => T): T {
780
795
  return loaderScopeALS.run({ active: true }, fn);
781
796
  }
797
+
798
+ /**
799
+ * Run `fn` inside a loader BODY scope. Marks loader-function execution for the
800
+ * cache-purity guard only (isInsideCacheScope), WITHOUT affecting
801
+ * isInsideLoaderScope()/rendered() gating. Applied to every loader body (DSL
802
+ * and handler-invoked via ctx.use) so request-scoped reads inside a loader
803
+ * never trip the cache-scope guards — loaders always run fresh.
804
+ */
805
+ export function runInsideLoaderBodyScope<T>(fn: () => T): T {
806
+ return loaderBodyScopeALS.run({ active: true }, fn);
807
+ }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { CookieOptions } from "../router/middleware-types.js";
11
11
  import { getRequestContext } from "./request-context.js";
12
+ import { isInsideCacheScope } from "./context.js";
12
13
  import { INSIDE_CACHE_EXEC } from "../cache/taint.js";
13
14
 
14
15
  /**
@@ -84,10 +85,23 @@ export interface ReadonlyHeaders {
84
85
  type HeadersIterator<T> = IterableIterator<T>;
85
86
 
86
87
  /**
87
- * Throw if called inside a "use cache" function.
88
- * Reading request-scoped data (cookies, headers) inside a cached function
89
- * produces results that vary per request but the cache key does not include
90
- * those values, leading to one user's data being served to another.
88
+ * Throw if called inside a cache boundary — either a "use cache" function
89
+ * (`INSIDE_CACHE_EXEC` stamped on ctx by the cache runtime) or a `cache()`
90
+ * DSL boundary (`isInsideCacheScope()` the render-store flag set while
91
+ * resolving a `type: "cache"` route entry).
92
+ *
93
+ * Reading request-scoped data (cookies, headers) inside a cached scope
94
+ * produces per-request values that are NOT reflected in the cache key, so
95
+ * they would be frozen into the shared cache entry and served to the wrong
96
+ * users. This is the same hazard for both scopes: a `cache()` boundary caches
97
+ * everything except loaders (it is the document-level "PPR shell"), so a read
98
+ * here is baked into the shell exactly like a `"use cache"` return value is
99
+ * baked into its cache entry.
100
+ *
101
+ * `isInsideCacheScope()` returns false inside loaders (loaders always run
102
+ * fresh on every request, even on a cache hit), so reading cookies()/headers()
103
+ * from a loader is allowed — loaders are the dynamic "holes" of a cached
104
+ * document.
91
105
  */
92
106
  function assertNotInsideCacheContext(ctx: unknown, fnName: string): void {
93
107
  if (
@@ -106,6 +120,16 @@ function assertNotInsideCacheContext(ctx: unknown, fnName: string): void {
106
120
  ` const data = await getCachedData(locale); // locale is now in the cache key`,
107
121
  );
108
122
  }
123
+ if (isInsideCacheScope()) {
124
+ throw new Error(
125
+ `${fnName}() cannot be called inside a cache() boundary. ` +
126
+ `A cache() scope caches everything except loaders, so request-scoped ` +
127
+ `data (cookies, headers) read here would be frozen into the shared ` +
128
+ `cached shell and served to other users. Read it inside a loader ` +
129
+ `instead — loaders always run fresh on every request, even on a cache hit:\n\n` +
130
+ ` loader("user", () => getUser(cookies().get("session")?.value));`,
131
+ );
132
+ }
109
133
  }
110
134
 
111
135
  const HEADERS_MUTATION_METHODS = new Set(["set", "append", "delete"]);
@@ -567,8 +567,11 @@ export type ShouldRevalidateFn<TParams = GenericParams, TEnv = any> = (args: {
567
567
 
568
568
  // ── Segment metadata (which segment is being evaluated) ──────────────
569
569
 
570
- /** The type of segment being revalidated. */
571
- segmentType: "layout" | "route" | "parallel";
570
+ /**
571
+ * The type of segment being revalidated. `"loader"` is passed to revalidate
572
+ * functions attached to a `loader(Fn, () => [revalidate(...)])` registration.
573
+ */
574
+ segmentType: "layout" | "route" | "parallel" | "loader";
572
575
  /** Layout name (e.g., `"root"`, `"shop"`, `"auth"`). Only set for layout segments. */
573
576
  layoutName?: string;
574
577
  /** Slot name (e.g., `"@sidebar"`, `"@modal"`). Only set for parallel segments. */
@@ -10,7 +10,10 @@ export type ViewTransitionClass = Record<string, string> | string;
10
10
 
11
11
  /**
12
12
  * Configuration for React's <ViewTransition> component.
13
- * Maps directly to ViewTransitionProps (minus children/ref/callbacks).
13
+ *
14
+ * The phase fields (enter/exit/update/share/default/name) map directly to
15
+ * ViewTransitionProps (minus children/ref/callbacks). The `viewTransition`
16
+ * field is router-specific and is stripped before the config reaches React.
14
17
  */
15
18
  export interface TransitionConfig {
16
19
  enter?: ViewTransitionClass;
@@ -19,6 +22,20 @@ export interface TransitionConfig {
19
22
  share?: ViewTransitionClass;
20
23
  default?: ViewTransitionClass;
21
24
  name?: string;
25
+ /**
26
+ * Whether the router wraps this segment's content in its own
27
+ * <ViewTransition> boundary.
28
+ *
29
+ * - "auto" (default): the router places the boundary, producing the
30
+ * router-owned cross-fade described by the phase fields above.
31
+ * - false: the router places no boundary. The navigation commit is still
32
+ * driven through startTransition (so loaders hold instead of flashing a
33
+ * skeleton, and consumer-placed <ViewTransition> elements still animate),
34
+ * but the router contributes no cross-fade of its own.
35
+ *
36
+ * When unset, inherits the createRouter({ viewTransition }) default.
37
+ */
38
+ viewTransition?: "auto" | false;
22
39
  }
23
40
 
24
41
  /**
@@ -350,7 +350,15 @@ export type PathHelpers<TEnv> = {
350
350
  };
351
351
 
352
352
  /**
353
- * Attach a ViewTransition boundary to the current segment or a group of routes
353
+ * Opt a route (or group of routes) into transition-driven navigation.
354
+ *
355
+ * Two independent layers: (1) startTransition, on all React versions, holds
356
+ * the previous content across a same-route nav (no skeleton flash) and is the
357
+ * precondition for any view transition; (2) on experimental React, an
358
+ * additional `<ViewTransition>` boundary cross-fades/morphs the swap. Pass
359
+ * `{ viewTransition: false }` to keep #1 without the router boundary. A view
360
+ * transition cannot fire without a startTransition. See
361
+ * skills/view-transitions for the startTransition x ViewTransition matrix.
354
362
  */
355
363
  transition: {
356
364
  (): TransitionItem;