@rangojs/router 0.23.0 → 0.24.0

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 (128) hide show
  1. package/dist/bin/rango.js +4 -3
  2. package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
  3. package/dist/types/browser/partial-update.d.ts +6 -4
  4. package/dist/types/browser/react/NavigationProvider.d.ts +11 -7
  5. package/dist/types/browser/react/context.d.ts +3 -2
  6. package/dist/types/browser/types.d.ts +5 -1
  7. package/dist/types/build/route-types/scan-filter.d.ts +7 -1
  8. package/dist/types/cache/document-cache-policy.d.ts +24 -0
  9. package/dist/types/cache/document-cache.d.ts +2 -10
  10. package/dist/types/context-var.d.ts +12 -0
  11. package/dist/types/errors.d.ts +10 -0
  12. package/dist/types/handles/pushed-by.d.ts +8 -0
  13. package/dist/types/index.d.ts +4 -0
  14. package/dist/types/index.rsc.d.ts +2 -0
  15. package/dist/types/response-utils.d.ts +7 -0
  16. package/dist/types/reverse.d.ts +8 -8
  17. package/dist/types/route-definition/rewrite.d.ts +56 -0
  18. package/dist/types/route-map-builder.d.ts +7 -2
  19. package/dist/types/router/basename.d.ts +7 -0
  20. package/dist/types/router/handler-context.d.ts +1 -5
  21. package/dist/types/router/internal-params.d.ts +8 -0
  22. package/dist/types/router/middleware-types.d.ts +15 -0
  23. package/dist/types/router/revalidation.d.ts +1 -2
  24. package/dist/types/router/route-trie-builder.d.ts +10 -0
  25. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  26. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +36 -13
  27. package/dist/types/rsc/full-payload.d.ts +11 -0
  28. package/dist/types/rsc/handler.d.ts +33 -0
  29. package/dist/types/rsc/helpers.d.ts +8 -0
  30. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  31. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  32. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  33. package/dist/types/server/request-context.d.ts +39 -2
  34. package/dist/types/server/rewrite-trail.d.ts +49 -0
  35. package/dist/types/server/slot-accessors.d.ts +29 -0
  36. package/dist/types/testing/index.d.ts +1 -1
  37. package/dist/types/testing/render-route.d.ts +8 -0
  38. package/dist/types/testing/run-middleware.d.ts +20 -0
  39. package/dist/types/testing/serve-shell-request.d.ts +2 -1
  40. package/dist/types/thenable-status.d.ts +6 -0
  41. package/dist/types/types/request-scope.d.ts +7 -0
  42. package/dist/types/vite/discovery/state.d.ts +12 -9
  43. package/dist/vite/index.js +164 -103
  44. package/package.json +1 -1
  45. package/skills/caching/SKILL.md +12 -0
  46. package/skills/catalog.json +1 -1
  47. package/skills/client-urls/SKILL.md +84 -0
  48. package/skills/hooks/SKILL.md +11 -6
  49. package/skills/hooks/data.md +3 -1
  50. package/skills/loader/SKILL.md +7 -1
  51. package/skills/middleware/SKILL.md +183 -2
  52. package/skills/ppr/SKILL.md +9 -0
  53. package/skills/rango/SKILL.md +1 -1
  54. package/skills/server-actions/SKILL.md +27 -0
  55. package/skills/testing/client-components.md +1 -0
  56. package/skills/testing/middleware.md +31 -10
  57. package/skills/testing/response-routes.md +1 -0
  58. package/skills/testing/serve-shell-request.md +19 -1
  59. package/skills/use-cache/SKILL.md +4 -2
  60. package/src/__augment-tests__/augmented.check.ts +20 -0
  61. package/src/browser/event-controller.ts +3 -0
  62. package/src/browser/merge-segment-loaders.ts +31 -18
  63. package/src/browser/navigation-bridge.ts +19 -3
  64. package/src/browser/partial-update.ts +27 -12
  65. package/src/browser/react/Link.tsx +7 -9
  66. package/src/browser/react/NavigationProvider.tsx +46 -26
  67. package/src/browser/react/context.ts +3 -2
  68. package/src/browser/react/use-router.ts +2 -4
  69. package/src/browser/server-action-bridge.ts +6 -0
  70. package/src/browser/types.ts +5 -1
  71. package/src/build/route-types/scan-filter.ts +11 -4
  72. package/src/cache/document-cache-policy.ts +131 -0
  73. package/src/cache/document-cache.ts +12 -119
  74. package/src/context-var.ts +32 -2
  75. package/src/errors.ts +19 -0
  76. package/src/handles/pushed-by.ts +26 -0
  77. package/src/index.rsc.ts +8 -0
  78. package/src/index.ts +7 -0
  79. package/src/response-utils.ts +19 -0
  80. package/src/reverse.ts +17 -14
  81. package/src/route-definition/redirect.ts +2 -15
  82. package/src/route-definition/rewrite.ts +146 -0
  83. package/src/route-map-builder.ts +14 -11
  84. package/src/router/basename.ts +20 -0
  85. package/src/router/handler-context.ts +3 -13
  86. package/src/router/intercept-resolution.ts +29 -12
  87. package/src/router/internal-params.ts +16 -0
  88. package/src/router/loader-resolution.ts +2 -0
  89. package/src/router/match-middleware/background-revalidation.ts +11 -9
  90. package/src/router/match-middleware/cache-lookup.ts +20 -15
  91. package/src/router/match-middleware/cache-store.ts +9 -11
  92. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  93. package/src/router/middleware-types.ts +16 -0
  94. package/src/router/middleware.ts +127 -49
  95. package/src/router/revalidation.ts +1 -1
  96. package/src/router/route-trie-builder.ts +28 -1
  97. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  98. package/src/router/segment-resolution/mask-nested.ts +8 -4
  99. package/src/router/segment-resolution/prefetch-deferral.ts +57 -17
  100. package/src/router/segment-resolution/revalidation.ts +57 -33
  101. package/src/rsc/full-payload.ts +37 -5
  102. package/src/rsc/handler.ts +82 -55
  103. package/src/rsc/helpers.ts +24 -0
  104. package/src/rsc/loader-fetch.ts +79 -24
  105. package/src/rsc/response-cache-serve.ts +18 -2
  106. package/src/rsc/response-route-handler.ts +4 -7
  107. package/src/rsc/rewrite-passes.ts +203 -0
  108. package/src/rsc/rsc-rendering.ts +4 -2
  109. package/src/rsc/ssr-setup.ts +11 -5
  110. package/src/server/cookie-parse.ts +36 -12
  111. package/src/server/cookie-store.ts +7 -3
  112. package/src/server/handle-store.ts +48 -17
  113. package/src/server/request-context.ts +154 -63
  114. package/src/server/request-headers.ts +4 -14
  115. package/src/server/rewrite-trail.ts +190 -0
  116. package/src/server/slot-accessors.ts +57 -0
  117. package/src/ssr/index.tsx +118 -16
  118. package/src/testing/dispatch.ts +138 -67
  119. package/src/testing/index.ts +1 -0
  120. package/src/testing/render-route.tsx +16 -3
  121. package/src/testing/run-loader.ts +2 -0
  122. package/src/testing/run-middleware.ts +56 -11
  123. package/src/testing/serve-shell-request.ts +15 -2
  124. package/src/thenable-status.ts +11 -0
  125. package/src/types/request-scope.ts +8 -0
  126. package/src/vite/discovery/state.ts +21 -13
  127. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  128. package/src/vite/router-discovery.ts +29 -27
@@ -32,7 +32,7 @@ import {
32
32
  IS_BROWSER_DEBUG,
33
33
  } from "./logging.js";
34
34
  import { validatePayloadRedirect } from "./validate-redirect-origin.js";
35
- import type { NavigationUpdate, RscMetadata } from "./types.js";
35
+ import type { HandleData, NavigationUpdate, RscMetadata } from "./types.js";
36
36
  import { OPTIMISTIC_COMMIT_TRANSITION_TYPE } from "./optimistic-commit.js";
37
37
  import type { LoaderStore } from "../loader-store.js";
38
38
  import { setActiveInterceptTargets } from "../client-urls/navigation.js";
@@ -200,9 +200,11 @@ export type UpdateMode =
200
200
  * when we send cached segment IDs to the server - if the server returns empty diff,
201
201
  * we use the same segments we told the server we have. */
202
202
  targetCacheSegments?: ResolvedSegment[];
203
- /** Cached handle data for the target URL. When server returns empty diff and we're
204
- * rendering from cache, this is passed to the UI to restore breadcrumbs etc. */
205
- targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
203
+ /** The handle state of `targetCacheSegments`: the entry's pushes, and
204
+ * the live pushes of the page on screen for each segment taken from it.
205
+ * Read at commit; the page starts from it and the response's handles,
206
+ * when it has any, merge onto it (NavigationProvider). */
207
+ targetCacheHandleData?: () => HandleData;
206
208
  /** Source URL for intercept restore (popstate cache miss) */
207
209
  interceptSourceUrl?: string;
208
210
  /**
@@ -643,6 +645,16 @@ export function createPartialUpdater(
643
645
  : undefined;
644
646
  const cachedSegs = targetCache ?? getCurrentCachedSegments();
645
647
  const cachedSegsSource = targetCache ? "history-cache" : "current-page";
648
+ // Rendered from the history cache, the page starts from the entry's
649
+ // pushes whether or not the response re-renders. Read before tx.commit():
650
+ // it records the controller's handle state (the document snapshot during
651
+ // the hydration window) in the entry it commits, which for a link to the
652
+ // page on screen's own URL is the entry this reads. Written over that
653
+ // record after it.
654
+ const readCachedHandleData =
655
+ targetCache && mode.type === "navigate"
656
+ ? mode.targetCacheHandleData
657
+ : undefined;
646
658
  if (IS_BROWSER_DEBUG) {
647
659
  debugLog(
648
660
  `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
@@ -739,16 +751,16 @@ export function createPartialUpdater(
739
751
  });
740
752
  if (signal?.aborted) return;
741
753
 
754
+ const cachedHandleData = readCachedHandleData?.();
742
755
  const { scroll: commitScroll } = tx.commit(
743
756
  matchedIds,
744
757
  existingSegments,
745
758
  { routeName: payload.metadata.routeName },
746
759
  );
747
-
748
- if (mode.targetCacheHandleData) {
760
+ if (cachedHandleData) {
749
761
  store.updateCacheHandleData(
750
762
  store.getHistoryKey(),
751
- mode.targetCacheHandleData,
763
+ cachedHandleData,
752
764
  );
753
765
  }
754
766
 
@@ -756,10 +768,7 @@ export function createPartialUpdater(
756
768
  payload.metadata!;
757
769
  const cachedUpdate = {
758
770
  root: newTree,
759
- metadata: {
760
- ...metadataWithoutHandles,
761
- cachedHandleData: mode.targetCacheHandleData,
762
- },
771
+ metadata: { ...metadataWithoutHandles, cachedHandleData },
763
772
  ...(commitScroll && { scroll: commitScroll }),
764
773
  };
765
774
 
@@ -1064,11 +1073,15 @@ export function createPartialUpdater(
1064
1073
  }),
1065
1074
  ...(serverLocationState && { serverState: serverLocationState }),
1066
1075
  };
1076
+ const cachedHandleData = readCachedHandleData?.();
1067
1077
  const { scroll: navScroll } = tx.commit(
1068
1078
  heldIds,
1069
1079
  reconciled.segments,
1070
1080
  overrides,
1071
1081
  );
1082
+ if (cachedHandleData) {
1083
+ store.updateCacheHandleData(store.getHistoryKey(), cachedHandleData);
1084
+ }
1072
1085
  const hasTransition = shouldStartViewTransition(
1073
1086
  reconciled.segments,
1074
1087
  gatedOff,
@@ -1118,7 +1131,9 @@ export function createPartialUpdater(
1118
1131
  // navigation-transaction.ts CommitResult.
1119
1132
  const update: NavigationUpdate = {
1120
1133
  root: newTree,
1121
- metadata: payload.metadata!,
1134
+ metadata: cachedHandleData
1135
+ ? { ...payload.metadata, cachedHandleData }
1136
+ : payload.metadata!,
1122
1137
  ...(navScroll && { scroll: navScroll }),
1123
1138
  };
1124
1139
  if (process.env.NODE_ENV !== "production") {
@@ -42,6 +42,7 @@ import {
42
42
  subscribeToAdaptiveStrategyChange,
43
43
  } from "../prefetch/default-strategy.js";
44
44
  import { getAppVersion } from "../app-version.js";
45
+ import { prefixBasename } from "../../router/basename.js";
45
46
  import type { PrefetchStrategy } from "../../router/prefetch-default.js";
46
47
 
47
48
  // The PrefetchStrategy union is defined in router/prefetch-default.ts (both
@@ -244,15 +245,12 @@ export const Link: ForwardRefExoticComponent<
244
245
  // store and no origin: links stay internal.
245
246
  const isExternal = isExternalUrl(to, ctx?.origin);
246
247
 
247
- // Auto-prefix with basename for app-local paths.
248
- // Skip if external, already prefixed, or not a root-relative path.
249
- const resolvedTo = useMemo(() => {
250
- if (isExternal) return to;
251
- const bn = ctx?.basename;
252
- if (!bn || !to.startsWith("/") || to.startsWith(bn + "/") || to === bn)
253
- return to;
254
- return to === "/" ? bn : bn + to;
255
- }, [to, isExternal, ctx?.basename]);
248
+ // Auto-prefix with basename for app-local paths (prefixBasename: not when
249
+ // external, already prefixed, or not root-relative).
250
+ const resolvedTo = useMemo(
251
+ () => (isExternal ? to : prefixBasename(to, ctx?.basename)),
252
+ [to, isExternal, ctx?.basename],
253
+ );
256
254
 
257
255
  // No explicit `prefetch` prop: fall back to the router-wide default from
258
256
  // the router store. Adaptive is resolved against the input capability when a
@@ -311,13 +311,17 @@ export interface NavigationProviderProps {
311
311
  * The document this provider hydrates (initBrowserApp; renderRoute with
312
312
  * `hydrate: true`), whose store EventController.lockHydration locked.
313
313
  * `settled` resolves once the stream has ended and React has revealed every
314
- * boundary. Until then location state is `undefined`, as SSR rendered it;
315
- * then one transition gives the entry's state and changes
316
- * HydrationBarrierContext, and the store is released after that commit
317
- * (handle data lands in that release notification). A navigation committed
318
- * earlier releases the store before its update, for the whole hold when the
319
- * navigation is held: an old-page boundary hydrating meanwhile reads
320
- * the destination's values and can mismatch.
314
+ * boundary. Then a transition changes HydrationBarrierContext alone, and
315
+ * React commits it once every boundary has hydrated, one still suspended on
316
+ * the client (a lazy chunk, a deferred-hydration gate) included. Until that
317
+ * commit location state is `undefined`, as SSR rendered it, through any
318
+ * commit no payload follows (a shallow navigation), and the store is
319
+ * locked; after it the entry's state lands in a transition of its own
320
+ * and the store is released (handle data lands in that release
321
+ * notification). A navigation committed earlier releases the store before
322
+ * its update, for the whole hold when the navigation is held: an old-page
323
+ * boundary hydrating meanwhile reads the destination's values and can
324
+ * mismatch.
321
325
  * Absent for a provider mounted on the client.
322
326
  */
323
327
  hydration?: { settled: Promise<void> };
@@ -432,18 +436,18 @@ export function NavigationProvider({
432
436
  return startConnectionWarmup();
433
437
  }, [warmupEnabled]);
434
438
 
435
- // The document has been revealed: one transition gives the entry's location
436
- // state and moves the barrier, so React hydrates every boundary still
437
- // dehydrated before it commits.
439
+ // The document has been revealed. The barrier must be the only update in
440
+ // its transition lane: then React holds the commit until every boundary
441
+ // still dehydrated beneath it has hydrated. Any other update in the lane
442
+ // (location state in its own startTransition here included) can let React
443
+ // commit around a boundary still suspended on the client. Why:
444
+ // docs/internal/execution-model.md, "The reveal barrier is alone in its lane".
438
445
  useEffect(() => {
439
446
  if (!hydration) return;
440
447
  let cancelled = false;
441
448
  void hydration.settled.then(() => {
442
449
  if (cancelled || !hydrationOpen.current) return;
443
- startTransition(() => {
444
- setLocationState(eventController.getLocationState());
445
- setHydrationBarrier((n) => n + 1);
446
- });
450
+ startTransition(() => setHydrationBarrier((n) => n + 1));
447
451
  });
448
452
  return () => {
449
453
  cancelled = true;
@@ -451,20 +455,31 @@ export function NavigationProvider({
451
455
  }, []);
452
456
 
453
457
  // After the barrier's commit no boundary under this provider is dehydrated:
454
- // release the store, and its handle writes reach their readers.
458
+ // the entry's location state lands in a transition of its own, and the
459
+ // store is released so its handle writes reach their readers. A navigation
460
+ // that committed first has done both (store onUpdate below).
455
461
  useEffect(() => {
456
- if (hydrationBarrier === 0) return;
462
+ if (hydrationBarrier === 0 || !hydrationOpen.current) return;
457
463
  hydrationOpen.current = false;
464
+ startTransition(() => setLocationState(eventController.getLocationState()));
458
465
  eventController.releaseHydrationLock();
459
466
  }, [hydrationBarrier]);
460
467
 
461
468
  // A commit no payload follows (a shallow navigation, a commit that keeps
462
469
  // every segment, merged server-set state): the tree on screen is already
463
- // the entry's, so its state is taken with the commit's notification.
470
+ // the entry's, so its state is taken with the commit's notification. Not
471
+ // while the hydration window is open: a boundary of the document may still
472
+ // be dehydrated, an update a reader outside it takes commits around it, and
473
+ // it then hydrates against the new state. The barrier's effect gives the
474
+ // controller's state, this commit's included. The window stays open: this
475
+ // commit mounts nothing that must read the live store.
464
476
  useEffect(
465
477
  () =>
466
478
  eventController.subscribe(() => {
467
- if (eventController.takeTreelessLocationState()) {
479
+ if (
480
+ eventController.takeTreelessLocationState() &&
481
+ !hydrationOpen.current
482
+ ) {
468
483
  setLocationState(eventController.getLocationState());
469
484
  }
470
485
  }),
@@ -541,18 +556,23 @@ export function NavigationProvider({
541
556
  eventController.setParams(update.metadata.params);
542
557
  }
543
558
 
559
+ // A page rendered from a history-cache entry (back/forward, or a link
560
+ // to it) starts from the entry's pushes, and its response's handles
561
+ // merge onto them: the page on screen's pushes are not its segments'.
562
+ const { matched, cachedHandleData } = update.metadata;
563
+ if (matched && cachedHandleData) {
564
+ eventController.setHandleData(cachedHandleData, matched);
565
+ }
544
566
  // Update handle data progressively as it streams in
545
567
  if (update.metadata.handles) {
546
568
  consumeHandles(update.metadata, eventController, store);
547
- } else if (update.metadata.matched) {
548
- // cachedHandleData present -> full restore (back/forward); absent ->
549
- // partial cleanup of segments no longer matched.
550
- const cached = update.metadata.cachedHandleData;
569
+ } else if (matched && !cachedHandleData) {
570
+ // Partial cleanup of segments no longer matched.
551
571
  eventController.setHandleData(
552
- cached ?? {},
553
- update.metadata.matched,
554
- cached === undefined,
555
- cached === undefined ? update.metadata.resolvedIds : undefined,
572
+ {},
573
+ matched,
574
+ true,
575
+ update.metadata.resolvedIds,
556
576
  );
557
577
  }
558
578
 
@@ -91,8 +91,9 @@ export const LocationStateContext: Context<LocationStateSnapshot> =
91
91
  * Read by no component. NavigationProvider changes its value in a transition so
92
92
  * that React hydrates every boundary still dehydrated beneath it before that
93
93
  * commit: React hydrates a dehydrated boundary first when a provider above it
94
- * changes, and nothing re-renders for a value nobody reads. See
95
- * NavigationProviderProps.hydration.
94
+ * changes, and nothing re-renders for a value nobody reads. It must stay
95
+ * unread and alone in its lane (docs/internal/execution-model.md, "The reveal
96
+ * barrier is alone in its lane"). See NavigationProviderProps.hydration.
96
97
  */
97
98
  export const HydrationBarrierContext: Context<number> =
98
99
  createContext<number>(0);
@@ -5,6 +5,7 @@ import { NavigationStoreContext } from "./context.js";
5
5
  import { useMount } from "./use-mount.js";
6
6
  import { prefetchDirect } from "../prefetch/loader.js";
7
7
  import { getAppVersion } from "../app-version.js";
8
+ import { prefixBasename } from "../../router/basename.js";
8
9
  import type { RouterInstance, RouterNavigateOptions } from "../types.js";
9
10
 
10
11
  /**
@@ -51,10 +52,7 @@ export function useRouter(): RouterInstance {
51
52
  return useMemo<RouterInstance>(() => {
52
53
  /** Prefix a root-relative path with basename if not already prefixed. */
53
54
  function withBasename(url: string): string {
54
- const bn = ctx!.basename;
55
- if (!bn || !url.startsWith("/") || url.startsWith(bn + "/") || url === bn)
56
- return url;
57
- return url === "/" ? bn : bn + url;
55
+ return prefixBasename(url, ctx!.basename);
58
56
  }
59
57
 
60
58
  /**
@@ -906,6 +906,12 @@ export function createServerActionBridge(
906
906
 
907
907
  handle.complete(returnData);
908
908
  return returnData;
909
+ } catch (error) {
910
+ // What the call rejects with (a revalidation render that threw, a
911
+ // failed decode) is the action's error, for useAction as for the
912
+ // caller. A no-op for an aborted or already-settled action.
913
+ handle.fail(error);
914
+ throw error;
909
915
  } finally {
910
916
  // The single deferred invalidation + fence release for this action. Runs
911
917
  // for every terminal that settles (normal, navigated-away, error, abort,
@@ -85,7 +85,11 @@ export interface RscMetadata {
85
85
  * positions and must complete at the handler barrier.
86
86
  */
87
87
  handlesLate?: AsyncGenerator<HandleData, void, unknown>;
88
- /** Cached handle data (for back/forward navigation from cache) */
88
+ /**
89
+ * Handle data of the history-cache entry the update renders from (a
90
+ * back/forward restore, or a link to a cached page). It replaces the handle
91
+ * state, and `handles`, when present, merge onto it.
92
+ */
89
93
  cachedHandleData?: HandleData;
90
94
  /**
91
95
  * RSC version string from the server.
@@ -75,9 +75,18 @@ export function isInSkippedScanDir(
75
75
  return dirs.some(isUniversallySkippedScanDir);
76
76
  }
77
77
 
78
+ /**
79
+ * Directory names the source-file scans skip: findTsFiles() and the build
80
+ * loader pre-scan (expose-internal-ids.ts). The isUniversallySkippedScanDir()
81
+ * set plus `build`, which the router scans still enter.
82
+ */
83
+ export function isSkippedSourceScanDir(name: string): boolean {
84
+ return isUniversallySkippedScanDir(name) || name === "build";
85
+ }
86
+
78
87
  /**
79
88
  * Recursively find .ts/.tsx files under a directory, skipping the directories
80
- * isUniversallySkippedScanDir() names, `build`, and .gen. files.
89
+ * isSkippedSourceScanDir() names and .gen. files.
81
90
  */
82
91
  export function findTsFiles(dir: string, filter?: ScanFilter): string[] {
83
92
  const results: string[] = [];
@@ -93,9 +102,7 @@ export function findTsFiles(dir: string, filter?: ScanFilter): string[] {
93
102
  for (const entry of entries) {
94
103
  const fullPath = join(dir, entry.name);
95
104
  if (entry.isDirectory()) {
96
- if (isUniversallySkippedScanDir(entry.name) || entry.name === "build") {
97
- continue;
98
- }
105
+ if (isSkippedSourceScanDir(entry.name)) continue;
99
106
  results.push(...findTsFiles(fullPath, filter));
100
107
  } else if (
101
108
  (entry.name.endsWith(".ts") ||
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Document cache storage policy: which responses the document cache stores.
3
+ * A leaf (types and the per-client signal only), so request-scoped code can
4
+ * ask whether a render will be stored (documentCacheStoresRender) without
5
+ * importing the cache middleware.
6
+ */
7
+
8
+ import { hasPerClientSignal } from "../browser/cookie-name.js";
9
+ import type { RequestContext } from "../server/request-context.js";
10
+
11
+ export interface CacheDirectives {
12
+ sMaxAge?: number;
13
+ staleWhileRevalidate?: number;
14
+ }
15
+
16
+ /**
17
+ * Parse Cache-Control header for s-maxage and stale-while-revalidate directives
18
+ */
19
+ function parseCacheControl(header: string | null): CacheDirectives | null {
20
+ if (!header) return null;
21
+
22
+ // RFC 7234: in a SHARED cache, `private` and `no-store` forbid storage and
23
+ // MUST win over `s-maxage` even though `private, s-maxage` is contradictory.
24
+ // The document cache is a shared app store, so refuse both regardless of any
25
+ // s-maxage / stale-while-revalidate also present. Match standalone directive
26
+ // tokens (start/end, whitespace, comma, semicolon, or `=` bounded), not a
27
+ // substring, so a value containing "private" cannot false-veto.
28
+ if (/(^|[\s,;])(private|no-store)(?=$|[\s,;=])/i.test(header)) {
29
+ return null;
30
+ }
31
+
32
+ // RFC 7234 §5.2.2.2: a shared cache MUST NOT serve a stored `no-cache`
33
+ // response without successful origin validation. This store's hit path has no
34
+ // validation step, so serving a stored no-cache response within s-maxage
35
+ // would hand the client content the origin marked must-revalidate. Refuse to
36
+ // store it. Only UNqualified `no-cache` (no `=`) vetoes — the field-name-
37
+ // scoped `no-cache="set-cookie"` form IS storable per the RFC, so the `=`
38
+ // boundary is excluded from the lookahead (unlike private/no-store above).
39
+ if (/(^|[\s,;])no-cache(?=$|[\s,;])/i.test(header)) {
40
+ return null;
41
+ }
42
+
43
+ const directives: CacheDirectives = {};
44
+
45
+ // Parse s-maxage
46
+ const sMaxAgeMatch = header.match(/s-maxage\s*=\s*(\d+)/i);
47
+ if (sMaxAgeMatch) {
48
+ directives.sMaxAge = parseInt(sMaxAgeMatch[1], 10);
49
+ }
50
+
51
+ // Parse stale-while-revalidate
52
+ const swrMatch = header.match(/stale-while-revalidate\s*=\s*(\d+)/i);
53
+ if (swrMatch) {
54
+ directives.staleWhileRevalidate = parseInt(swrMatch[1], 10);
55
+ }
56
+
57
+ // Only return if we have at least s-maxage (required for document caching)
58
+ if (directives.sMaxAge !== undefined) {
59
+ return directives;
60
+ }
61
+
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Check if response should be cached based on Cache-Control headers
67
+ */
68
+ export function shouldCacheResponse(
69
+ response: Response,
70
+ requestCtx?: Pick<
71
+ RequestContext,
72
+ "_payloadVisitorTheme" | "_payloadDeferred" | "_payloadVisitorLocation"
73
+ >,
74
+ ): CacheDirectives | null {
75
+ // Only cache successful responses
76
+ if (response.status !== 200) {
77
+ return null;
78
+ }
79
+
80
+ // Never cache a per-client signal into a SHARED response store. A Set-Cookie
81
+ // (e.g. a rango state rotation from invalidateClientCache(), or any cookie a
82
+ // loader set) would be replayed to every client on a hit — pinning them to
83
+ // one value and even rolling a rotated client back to a prior one. The
84
+ // x-rango-keep-cache directive header is the mirror image: a replayed "keep"
85
+ // would suppress invalidation for every replayed client. Refuse both.
86
+ if (hasPerClientSignal(response.headers)) {
87
+ return null;
88
+ }
89
+
90
+ // The payload carries the visitor's theme (#978): the render ran before the
91
+ // response opted in (a Cache-Control written after next()), so
92
+ // payloadInitialTheme could not render the default.
93
+ if (requestCtx?._payloadVisitorTheme) {
94
+ return null;
95
+ }
96
+
97
+ // A rewritten request's render shows the visitor's URL (renderedLocation,
98
+ // full-payload.ts); this entry is keyed by the rewrite target.
99
+ if (requestCtx?._payloadVisitorLocation) {
100
+ return null;
101
+ }
102
+
103
+ // prefetch: false. The payload carries deferred units: it answers one
104
+ // page's prefetch and nothing else. parseCacheControl refuses the
105
+ // Cache-Control it is sent with (rsc-rendering.ts NOT_REUSED), but a
106
+ // middleware can replace that after next(), and a navigation answered
107
+ // with this body would show a fallback no fill ever replaces.
108
+ if (requestCtx?._payloadDeferred) {
109
+ return null;
110
+ }
111
+
112
+ const cacheControl = response.headers.get("Cache-Control");
113
+ return parseCacheControl(cacheControl);
114
+ }
115
+
116
+ /**
117
+ * Whether this cache will store the response the current render produces, as
118
+ * far as the render can tell: the middleware runs it (a MISS or a stale
119
+ * refresh) and the response stub already opts in. payloadInitialTheme
120
+ * (rsc/full-payload.ts) then renders the no-cookie default theme (#978). A
121
+ * Cache-Control written after next() is not on the stub yet; for that render
122
+ * shouldCacheResponse refuses a payload marked `_payloadVisitorTheme`.
123
+ */
124
+ export function documentCacheStoresRender(
125
+ reqCtx: Pick<RequestContext, "_documentCacheRender" | "res">,
126
+ ): boolean {
127
+ return (
128
+ reqCtx._documentCacheRender === true &&
129
+ shouldCacheResponse(reqCtx.res) !== null
130
+ );
131
+ }
@@ -16,7 +16,10 @@
16
16
  import { requestHeaders } from "../server/request-headers.js";
17
17
  import { FILL_PARAM } from "../prefetch-markers.js";
18
18
  import type { MiddlewareFn, MiddlewareContext } from "../router/middleware.js";
19
- import { hasPerClientSignal } from "../browser/cookie-name.js";
19
+ import {
20
+ documentCacheStoresRender,
21
+ shouldCacheResponse,
22
+ } from "./document-cache-policy.js";
20
23
  import {
21
24
  getRequestContext,
22
25
  runWithRequestContext,
@@ -37,11 +40,14 @@ import {
37
40
  predatesInvalidation,
38
41
  } from "./tag-invalidation.js";
39
42
  import { observePhase, PHASES } from "../router/instrument.js";
43
+ import { isRewrite } from "../route-definition/rewrite.js";
40
44
  import {
41
45
  SEGMENT_FRAGMENT_CAPABILITY_HEADER,
42
46
  SEGMENT_FRAGMENT_RECOVERY_HEADER,
43
47
  } from "../segment-fragments.js";
44
48
 
49
+ export { documentCacheStoresRender };
50
+
45
51
  const CACHE_STATUS_HEADER = "x-document-cache-status";
46
52
 
47
53
  function collectRequestTags(
@@ -62,124 +68,6 @@ function hashSegmentIds(segmentIds: string): string {
62
68
  return Math.abs(hash).toString(36);
63
69
  }
64
70
 
65
- // ============================================================================
66
-
67
- interface CacheDirectives {
68
- sMaxAge?: number;
69
- staleWhileRevalidate?: number;
70
- }
71
-
72
- /**
73
- * Parse Cache-Control header for s-maxage and stale-while-revalidate directives
74
- */
75
- function parseCacheControl(header: string | null): CacheDirectives | null {
76
- if (!header) return null;
77
-
78
- // RFC 7234: in a SHARED cache, `private` and `no-store` forbid storage and
79
- // MUST win over `s-maxage` even though `private, s-maxage` is contradictory.
80
- // The document cache is a shared app store, so refuse both regardless of any
81
- // s-maxage / stale-while-revalidate also present. Match standalone directive
82
- // tokens (start/end, whitespace, comma, semicolon, or `=` bounded), not a
83
- // substring, so a value containing "private" cannot false-veto.
84
- if (/(^|[\s,;])(private|no-store)(?=$|[\s,;=])/i.test(header)) {
85
- return null;
86
- }
87
-
88
- // RFC 7234 §5.2.2.2: a shared cache MUST NOT serve a stored `no-cache`
89
- // response without successful origin validation. This store's hit path has no
90
- // validation step, so serving a stored no-cache response within s-maxage
91
- // would hand the client content the origin marked must-revalidate. Refuse to
92
- // store it. Only UNqualified `no-cache` (no `=`) vetoes — the field-name-
93
- // scoped `no-cache="set-cookie"` form IS storable per the RFC, so the `=`
94
- // boundary is excluded from the lookahead (unlike private/no-store above).
95
- if (/(^|[\s,;])no-cache(?=$|[\s,;])/i.test(header)) {
96
- return null;
97
- }
98
-
99
- const directives: CacheDirectives = {};
100
-
101
- // Parse s-maxage
102
- const sMaxAgeMatch = header.match(/s-maxage\s*=\s*(\d+)/i);
103
- if (sMaxAgeMatch) {
104
- directives.sMaxAge = parseInt(sMaxAgeMatch[1], 10);
105
- }
106
-
107
- // Parse stale-while-revalidate
108
- const swrMatch = header.match(/stale-while-revalidate\s*=\s*(\d+)/i);
109
- if (swrMatch) {
110
- directives.staleWhileRevalidate = parseInt(swrMatch[1], 10);
111
- }
112
-
113
- // Only return if we have at least s-maxage (required for document caching)
114
- if (directives.sMaxAge !== undefined) {
115
- return directives;
116
- }
117
-
118
- return null;
119
- }
120
-
121
- /**
122
- * Check if response should be cached based on Cache-Control headers
123
- */
124
- function shouldCacheResponse(
125
- response: Response,
126
- requestCtx?: Pick<
127
- RequestContext,
128
- "_payloadVisitorTheme" | "_payloadDeferred"
129
- >,
130
- ): CacheDirectives | null {
131
- // Only cache successful responses
132
- if (response.status !== 200) {
133
- return null;
134
- }
135
-
136
- // Never cache a per-client signal into a SHARED response store. A Set-Cookie
137
- // (e.g. a rango state rotation from invalidateClientCache(), or any cookie a
138
- // loader set) would be replayed to every client on a hit — pinning them to
139
- // one value and even rolling a rotated client back to a prior one. The
140
- // x-rango-keep-cache directive header is the mirror image: a replayed "keep"
141
- // would suppress invalidation for every replayed client. Refuse both.
142
- if (hasPerClientSignal(response.headers)) {
143
- return null;
144
- }
145
-
146
- // The payload carries the visitor's theme (#978): the render ran before the
147
- // response opted in (a Cache-Control written after next()), so
148
- // payloadInitialTheme could not render the default.
149
- if (requestCtx?._payloadVisitorTheme) {
150
- return null;
151
- }
152
-
153
- // prefetch: false. The payload carries deferred units: it answers one
154
- // page's prefetch and nothing else. parseCacheControl refuses the
155
- // Cache-Control it is sent with (rsc-rendering.ts NOT_REUSED), but a
156
- // middleware can replace that after next(), and a navigation answered
157
- // with this body would show a fallback no fill ever replaces.
158
- if (requestCtx?._payloadDeferred) {
159
- return null;
160
- }
161
-
162
- const cacheControl = response.headers.get("Cache-Control");
163
- return parseCacheControl(cacheControl);
164
- }
165
-
166
- /**
167
- * Whether this cache will store the response the current render produces, as
168
- * far as the render can tell: the middleware runs it (a MISS or a stale
169
- * refresh) and the response stub already opts in. payloadInitialTheme
170
- * (rsc/full-payload.ts) then renders the no-cookie default theme (#978). A
171
- * Cache-Control written after next() is not on the stub yet; for that render
172
- * shouldCacheResponse refuses a payload marked `_payloadVisitorTheme`.
173
- */
174
- export function documentCacheStoresRender(
175
- reqCtx: Pick<RequestContext, "_documentCacheRender" | "res">,
176
- ): boolean {
177
- return (
178
- reqCtx._documentCacheRender === true &&
179
- shouldCacheResponse(reqCtx.res) !== null
180
- );
181
- }
182
-
183
71
  /**
184
72
  * Throw the first error the drained render reported through Flight or Fizz
185
73
  * onError. A component that throws after the 200 committed does not fail the
@@ -513,6 +401,9 @@ export function createDocumentCacheMiddleware<TEnv = any>(
513
401
  ),
514
402
  );
515
403
  } catch (error) {
404
+ // The route now rewrites (route middleware): nothing to refresh
405
+ // this entry with.
406
+ if (isRewrite(error)) return;
516
407
  // Pass requestCtx explicitly: this runs in a detached waitUntil task
517
408
  // where the ALS context is gone, so onError only fires if we hand it
518
409
  // the captured context (reportCacheError falls back to _getRequestContext
@@ -600,6 +491,8 @@ export function createDocumentCacheMiddleware<TEnv = any>(
600
491
  noteWarmDocument(requestCtx, "not-cacheable");
601
492
  return originalResponse;
602
493
  } catch (error) {
494
+ // A rewrite() thrown downstream re-dispatches the request.
495
+ if (isRewrite(error)) throw error;
603
496
  reportCacheError(error, "cache-read", "[DocumentCache] middleware");
604
497
  if (handlerCalled) {
605
498
  // Post-handler failure (e.g. body.tee()): do not call next() again