@rangojs/router 0.0.0-experimental.28 → 0.0.0-experimental.29

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.
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
1745
1745
  // package.json
1746
1746
  var package_default = {
1747
1747
  name: "@rangojs/router",
1748
- version: "0.0.0-experimental.28",
1748
+ version: "0.0.0-experimental.29",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.28",
3
+ "version": "0.0.0-experimental.29",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -108,6 +108,11 @@ interface RSCRouterOptions<TEnv> {
108
108
  // Connection warmup (default: true)
109
109
  warmup?: boolean;
110
110
 
111
+ // Prefetch cache TTL in seconds (default: 300)
112
+ // Controls in-memory cache duration and Cache-Control max-age for prefetch responses.
113
+ // Set to false to disable prefetch caching.
114
+ prefetchCacheTTL?: number | false;
115
+
111
116
  // CSP nonce provider (for router.fetch)
112
117
  nonce?: (
113
118
  request: Request,
@@ -17,6 +17,7 @@ import {
17
17
  emptyResponse,
18
18
  teeWithCompletion,
19
19
  } from "./response-adapter.js";
20
+ import { buildPrefetchKey, consumePrefetch } from "./prefetch/cache.js";
20
21
 
21
22
  /**
22
23
  * Create a navigation client for fetching RSC payloads
@@ -24,21 +25,12 @@ import {
24
25
  * The client handles building URLs with RSC parameters and
25
26
  * deserializing the response using the RSC runtime.
26
27
  *
28
+ * Checks the in-memory prefetch cache before making a network request.
29
+ * The cache key is source-dependent (includes the previous URL) so
30
+ * prefetch responses match the exact diff the server would produce.
31
+ *
27
32
  * @param deps - RSC browser dependencies (createFromFetch)
28
33
  * @returns NavigationClient instance
29
- *
30
- * @example
31
- * ```typescript
32
- * import { createFromFetch } from "@vitejs/plugin-rsc/browser";
33
- *
34
- * const client = createNavigationClient({ createFromFetch });
35
- *
36
- * const payload = await client.fetchPartial({
37
- * targetUrl: "/shop/products",
38
- * segmentIds: ["root", "shop"],
39
- * previousUrl: "/",
40
- * });
41
- * ```
42
34
  */
43
35
  export function createNavigationClient(
44
36
  deps: Pick<RscBrowserDependencies, "createFromFetch">,
@@ -47,8 +39,9 @@ export function createNavigationClient(
47
39
  /**
48
40
  * Fetch a partial RSC payload for navigation
49
41
  *
50
- * Sends current segment IDs to the server so it can determine
51
- * which segments need to be re-rendered (diff).
42
+ * First checks the in-memory prefetch cache for a matching entry.
43
+ * If found, uses the cached response instantly. Otherwise sends
44
+ * current segment IDs to the server for diff-based rendering.
52
45
  *
53
46
  * @param options - Fetch options
54
47
  * @returns RSC payload with segments and metadata, plus stream completion promise
@@ -80,7 +73,8 @@ export function createNavigationClient(
80
73
  });
81
74
  }
82
75
 
83
- // Build fetch URL with partial rendering params
76
+ // Build fetch URL with partial rendering params (used for both
77
+ // cache key lookup and actual fetch if cache misses)
84
78
  const fetchUrl = new URL(targetUrl, window.location.origin);
85
79
  fetchUrl.searchParams.set("_rsc_partial", "true");
86
80
  fetchUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
@@ -90,11 +84,17 @@ export function createNavigationClient(
90
84
  if (version) {
91
85
  fetchUrl.searchParams.set("_rsc_v", version);
92
86
  }
93
- if (tx) {
94
- browserDebugLog(tx, "fetching", {
95
- path: `${fetchUrl.pathname}${fetchUrl.search}`,
96
- });
97
- }
87
+
88
+ // Check in-memory prefetch cache before making a network request.
89
+ // The cache key includes the source URL (previousUrl) because the
90
+ // server's diff response depends on the source page context.
91
+ // Skip cache for stale revalidation (needs fresh data), HMR (needs
92
+ // fresh modules), and intercept contexts (source-dependent responses).
93
+ const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
94
+ const cachedResponse =
95
+ !staleRevalidation && !hmr && !interceptSourceUrl
96
+ ? consumePrefetch(cacheKey)
97
+ : null;
98
98
 
99
99
  // Track when the stream completes
100
100
  let resolveStreamComplete: () => void;
@@ -102,63 +102,88 @@ export function createNavigationClient(
102
102
  resolveStreamComplete = resolve;
103
103
  });
104
104
 
105
- // Create a response promise that tracks stream completion
106
- const responsePromise = fetch(fetchUrl, {
107
- headers: {
108
- "X-RSC-Router-Client-Path": previousUrl,
109
- "X-Rango-State": getRangoState(),
110
- ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
111
- ...(interceptSourceUrl && {
112
- "X-RSC-Router-Intercept-Source": interceptSourceUrl,
113
- }),
114
- ...(hmr && { "X-RSC-HMR": "1" }),
115
- },
116
- signal,
117
- }).then((response) => {
118
- // Check for version mismatch - server wants us to reload
119
- const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
120
- if (reload === "blocked") {
121
- resolveStreamComplete();
122
- return emptyResponse();
123
- }
124
- if (reload) {
125
- if (tx) {
126
- browserDebugLog(tx, "version mismatch, reloading", {
127
- reloadUrl: reload.url,
128
- });
129
- }
130
- window.location.href = reload.url;
131
- return new Promise<Response>(() => {});
132
- }
105
+ let responsePromise: Promise<Response>;
133
106
 
134
- // Server-side redirect without state: the server returned 204 with
135
- // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
136
- // to a URL rendering full HTML). Throw ServerRedirect so the
137
- // navigation bridge catches it and re-navigates with _skipCache.
138
- const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
139
- if (redirect === "blocked") {
140
- resolveStreamComplete();
141
- return emptyResponse();
107
+ if (cachedResponse) {
108
+ if (tx) {
109
+ browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
142
110
  }
143
- if (redirect) {
144
- if (tx) {
145
- browserDebugLog(tx, "server redirect", {
146
- redirectUrl: redirect.url,
147
- });
148
- }
149
- resolveStreamComplete();
150
- throw new ServerRedirect(redirect.url, undefined);
111
+ // Cached response body is already fully buffered (arrayBuffer),
112
+ // so stream completion is immediate.
113
+ responsePromise = Promise.resolve(cachedResponse).then((response) => {
114
+ return teeWithCompletion(
115
+ response,
116
+ () => {
117
+ if (tx) browserDebugLog(tx, "stream complete (from cache)");
118
+ resolveStreamComplete();
119
+ },
120
+ signal,
121
+ );
122
+ });
123
+ } else {
124
+ if (tx) {
125
+ browserDebugLog(tx, "fetching", {
126
+ path: `${fetchUrl.pathname}${fetchUrl.search}`,
127
+ });
151
128
  }
152
129
 
153
- return teeWithCompletion(
154
- response,
155
- () => {
156
- if (tx) browserDebugLog(tx, "stream complete");
157
- resolveStreamComplete();
130
+ responsePromise = fetch(fetchUrl, {
131
+ headers: {
132
+ "X-RSC-Router-Client-Path": previousUrl,
133
+ "X-Rango-State": getRangoState(),
134
+ ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
135
+ ...(interceptSourceUrl && {
136
+ "X-RSC-Router-Intercept-Source": interceptSourceUrl,
137
+ }),
138
+ ...(hmr && { "X-RSC-HMR": "1" }),
158
139
  },
159
140
  signal,
160
- );
161
- });
141
+ }).then((response) => {
142
+ // Check for version mismatch - server wants us to reload
143
+ const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
144
+ if (reload === "blocked") {
145
+ resolveStreamComplete();
146
+ return emptyResponse();
147
+ }
148
+ if (reload) {
149
+ if (tx) {
150
+ browserDebugLog(tx, "version mismatch, reloading", {
151
+ reloadUrl: reload.url,
152
+ });
153
+ }
154
+ window.location.href = reload.url;
155
+ return new Promise<Response>(() => {});
156
+ }
157
+
158
+ // Server-side redirect without state: the server returned 204 with
159
+ // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
160
+ // to a URL rendering full HTML). Throw ServerRedirect so the
161
+ // navigation bridge catches it and re-navigates with _skipCache.
162
+ const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
163
+ if (redirect === "blocked") {
164
+ resolveStreamComplete();
165
+ return emptyResponse();
166
+ }
167
+ if (redirect) {
168
+ if (tx) {
169
+ browserDebugLog(tx, "server redirect", {
170
+ redirectUrl: redirect.url,
171
+ });
172
+ }
173
+ resolveStreamComplete();
174
+ throw new ServerRedirect(redirect.url, undefined);
175
+ }
176
+
177
+ return teeWithCompletion(
178
+ response,
179
+ () => {
180
+ if (tx) browserDebugLog(tx, "stream complete");
181
+ resolveStreamComplete();
182
+ },
183
+ signal,
184
+ );
185
+ });
186
+ }
162
187
 
163
188
  try {
164
189
  // Deserialize RSC payload
@@ -1,47 +1,127 @@
1
1
  /**
2
- * Prefetch Tracking
2
+ * Prefetch Cache
3
3
  *
4
- * Tracks in-flight and completed prefetches for deduplication.
5
- * The actual response caching is handled by the browser's HTTP cache
6
- * via Vary: X-Rango-State.
4
+ * In-memory cache storing prefetch Response objects for instant cache hits
5
+ * on subsequent navigation. Cache key is source-dependent (includes the
6
+ * current page URL) because the server's diff-based response depends on
7
+ * where the user navigates from.
8
+ *
9
+ * Replaces the previous browser HTTP cache approach which was unreliable
10
+ * due to response draining race conditions and browser inconsistencies.
7
11
  */
8
12
 
9
13
  import { cancelAllPrefetches } from "./queue.js";
10
14
  import { invalidateRangoState } from "../rango-state.js";
11
15
 
16
+ // Default TTL: 5 minutes. Overridden by initPrefetchCache() with
17
+ // the server-configured prefetchCacheTTL from router options.
18
+ // 0 disables the in-memory cache entirely.
19
+ let cacheTTL = 300_000;
20
+
21
+ /**
22
+ * Initialize the prefetch cache with the configured TTL.
23
+ * Called once at app startup with the value from server metadata.
24
+ * A TTL of 0 disables the in-memory cache.
25
+ */
26
+ export function initPrefetchCache(ttlMs: number): void {
27
+ cacheTTL = ttlMs;
28
+ }
29
+ const MAX_PREFETCH_CACHE_SIZE = 50;
30
+
31
+ interface PrefetchCacheEntry {
32
+ response: Response;
33
+ timestamp: number;
34
+ }
35
+
36
+ const cache = new Map<string, PrefetchCacheEntry>();
12
37
  const inflight = new Set<string>();
13
- const prefetched = new Set<string>();
14
38
 
15
39
  // Generation counter incremented on each clearPrefetchCache(). Fetches that
16
- // started before a clear carry a stale generation and must not re-add their
17
- // key to the prefetched set (the browser HTTP cache entry is already invalid
18
- // due to Rango-State rotation).
40
+ // started before a clear carry a stale generation and must not store their
41
+ // response (the data may be stale due to a server action invalidation).
19
42
  let generation = 0;
20
43
 
21
44
  /**
22
- * Check if a prefetch is already in-flight or completed for the given key.
45
+ * Build a source-dependent cache key.
46
+ * Includes the source page href so the same target prefetched from
47
+ * different pages gets separate entries — the server response varies
48
+ * based on the source page context (diff-based rendering).
49
+ */
50
+ export function buildPrefetchKey(sourceHref: string, targetUrl: URL): string {
51
+ return sourceHref + "\0" + targetUrl.pathname + targetUrl.search;
52
+ }
53
+
54
+ /**
55
+ * Check if a prefetch is already cached, in-flight, or queued for the given key.
23
56
  */
24
57
  export function hasPrefetch(key: string): boolean {
25
- return prefetched.has(key) || inflight.has(key);
58
+ if (inflight.has(key)) return true;
59
+ if (cacheTTL <= 0) return false;
60
+ const entry = cache.get(key);
61
+ if (!entry) return false;
62
+ if (Date.now() - entry.timestamp > cacheTTL) {
63
+ cache.delete(key);
64
+ return false;
65
+ }
66
+ return true;
26
67
  }
27
68
 
28
69
  /**
29
- * Capture the current generation. The returned value is passed to
30
- * markPrefetched so it can detect stale completions.
70
+ * Consume a cached prefetch response. Returns null if not found or expired.
71
+ * One-time consumption: the entry is deleted after retrieval.
72
+ * Returns null when caching is disabled (TTL <= 0).
31
73
  */
32
- export function currentGeneration(): number {
33
- return generation;
74
+ export function consumePrefetch(key: string): Response | null {
75
+ if (cacheTTL <= 0) return null;
76
+ const entry = cache.get(key);
77
+ if (!entry) return null;
78
+ if (Date.now() - entry.timestamp > cacheTTL) {
79
+ cache.delete(key);
80
+ return null;
81
+ }
82
+ cache.delete(key);
83
+ return entry.response;
34
84
  }
35
85
 
36
86
  /**
37
- * Mark a key as successfully prefetched (response is in browser HTTP cache).
38
- * Skips if the generation has changed since the fetch started (cache was
39
- * invalidated mid-flight, so the response uses a stale X-Rango-State).
87
+ * Store a prefetch response in the in-memory cache.
88
+ * The response body must be fully buffered (e.g. via arrayBuffer()) before
89
+ * storing, so the cached Response is self-contained and network-independent.
90
+ *
91
+ * Skips storage if the generation has changed since the fetch started
92
+ * (a server action invalidated the cache mid-flight).
40
93
  */
41
- export function markPrefetched(key: string, fetchGeneration: number): void {
42
- if (fetchGeneration === generation) {
43
- prefetched.add(key);
94
+ export function storePrefetch(
95
+ key: string,
96
+ response: Response,
97
+ fetchGeneration: number,
98
+ ): void {
99
+ if (cacheTTL <= 0) return;
100
+ if (fetchGeneration !== generation) return;
101
+
102
+ // Evict expired entries
103
+ const now = Date.now();
104
+ for (const [k, entry] of cache) {
105
+ if (now - entry.timestamp > cacheTTL) {
106
+ cache.delete(k);
107
+ }
44
108
  }
109
+
110
+ // FIFO eviction if at capacity
111
+ if (cache.size >= MAX_PREFETCH_CACHE_SIZE) {
112
+ const oldest = cache.keys().next().value;
113
+ if (oldest) cache.delete(oldest);
114
+ }
115
+
116
+ cache.set(key, { response, timestamp: now });
117
+ }
118
+
119
+ /**
120
+ * Capture the current generation. The returned value is passed to
121
+ * storePrefetch so it can detect stale completions.
122
+ */
123
+ export function currentGeneration(): number {
124
+ return generation;
45
125
  }
46
126
 
47
127
  export function markPrefetchInflight(key: string): void {
@@ -53,15 +133,14 @@ export function clearPrefetchInflight(key: string): void {
53
133
  }
54
134
 
55
135
  /**
56
- * Invalidate prefetch state. Called when server actions mutate data.
57
- * Updates the localStorage state key so next fetch has a different
58
- * X-Rango-State value, causing Vary mismatch in browser HTTP cache.
59
- * Also cancels any in-flight or queued speculative prefetches.
136
+ * Invalidate all prefetch state. Called when server actions mutate data.
137
+ * Clears the in-memory cache, cancels in-flight prefetches, and rotates
138
+ * the Rango state key so CDN-cached responses are also invalidated.
60
139
  */
61
140
  export function clearPrefetchCache(): void {
62
141
  generation++;
63
142
  inflight.clear();
64
- prefetched.clear();
143
+ cache.clear();
65
144
  cancelAllPrefetches();
66
145
  invalidateRangoState();
67
146
  }
@@ -2,15 +2,17 @@
2
2
  * Prefetch Fetch
3
3
  *
4
4
  * Fetch-based prefetch logic used by Link (hover/viewport/render strategies)
5
- * and useRouter().prefetch(). Sends low-priority fetch requests with
6
- * X-Rango-State and X-Rango-Prefetch headers so the browser HTTP cache
7
- * can serve the response on subsequent navigation.
5
+ * and useRouter().prefetch(). Sends the same headers and segment IDs as a
6
+ * real navigation so the server returns a proper diff. The Response is fully
7
+ * buffered and stored in an in-memory cache for instant consumption on
8
+ * subsequent navigation.
8
9
  */
9
10
 
10
11
  import {
12
+ buildPrefetchKey,
11
13
  hasPrefetch,
12
14
  markPrefetchInflight,
13
- markPrefetched,
15
+ storePrefetch,
14
16
  clearPrefetchInflight,
15
17
  currentGeneration,
16
18
  } from "./cache.js";
@@ -20,9 +22,9 @@ import { shouldPrefetch } from "./policy.js";
20
22
 
21
23
  /**
22
24
  * Build an RSC partial URL for prefetching.
23
- * Includes _rsc_v for version mismatch detection when available.
24
- * Returns null for malformed or cross-origin URLs to prevent
25
- * leaking router headers to external origins.
25
+ * Includes _rsc_segments so the server can diff against currently mounted
26
+ * segments, and _rsc_v for version mismatch detection.
27
+ * Returns null for malformed or cross-origin URLs.
26
28
  */
27
29
  function buildPrefetchUrl(
28
30
  url: string,
@@ -49,18 +51,9 @@ function buildPrefetchUrl(
49
51
  }
50
52
 
51
53
  /**
52
- * Build the dedup key for prefetch tracking.
53
- * Includes the source page pathname so the same target prefetched from
54
- * different pages gets separate entries the server response varies on
55
- * X-RSC-Router-Client-Path (source page context).
56
- */
57
- function buildPrefetchKey(targetUrl: URL): string {
58
- return window.location.href + "\0" + targetUrl.pathname + targetUrl.search;
59
- }
60
-
61
- /**
62
- * Core prefetch fetch logic. Returns a Promise and accepts an optional
63
- * AbortSignal for cancellation by the prefetch queue.
54
+ * Core prefetch fetch logic. Fetches the response, fully buffers the body,
55
+ * and stores it in the in-memory cache. Returns a Promise and accepts an
56
+ * optional AbortSignal for cancellation by the prefetch queue.
64
57
  */
65
58
  function executePrefetchFetch(
66
59
  key: string,
@@ -79,14 +72,19 @@ function executePrefetchFetch(
79
72
  "X-Rango-Prefetch": "1",
80
73
  },
81
74
  })
82
- .then((response) => {
83
- // Drain body to ensure full download for browser HTTP cache.
84
- // pipeTo avoids decoding the stream into a JS string (unlike .text()).
85
- if (response.ok && response.body) {
86
- return response.body
87
- .pipeTo(new WritableStream())
88
- .then(() => markPrefetched(key, gen));
89
- }
75
+ .then(async (response) => {
76
+ if (!response.ok) return;
77
+ // Fully buffer the response body so the cached Response is
78
+ // self-contained and doesn't depend on the network connection.
79
+ // This eliminates the race condition where the user clicks before
80
+ // the response body has been fully downloaded.
81
+ const buffer = await response.arrayBuffer();
82
+ const cachedResponse = new Response(buffer, {
83
+ headers: response.headers,
84
+ status: response.status,
85
+ statusText: response.statusText,
86
+ });
87
+ storePrefetch(key, cachedResponse, gen);
90
88
  })
91
89
  .catch(() => {
92
90
  // Silently ignore prefetch failures (including abort)
@@ -97,7 +95,7 @@ function executePrefetchFetch(
97
95
  }
98
96
 
99
97
  /**
100
- * Prefetch (direct): fetch with low priority and store in browser HTTP cache.
98
+ * Prefetch (direct): fetch with low priority and store in in-memory cache.
101
99
  * Used by hover strategy -- fires immediately without queueing.
102
100
  */
103
101
  export function prefetchDirect(
@@ -109,7 +107,7 @@ export function prefetchDirect(
109
107
 
110
108
  const targetUrl = buildPrefetchUrl(url, segmentIds, version);
111
109
  if (!targetUrl) return;
112
- const key = buildPrefetchKey(targetUrl);
110
+ const key = buildPrefetchKey(window.location.href, targetUrl);
113
111
  if (hasPrefetch(key)) return;
114
112
  executePrefetchFetch(key, targetUrl.toString());
115
113
  }
@@ -127,7 +125,7 @@ export function prefetchQueued(
127
125
  if (!shouldPrefetch()) return "";
128
126
  const targetUrl = buildPrefetchUrl(url, segmentIds, version);
129
127
  if (!targetUrl) return "";
130
- const key = buildPrefetchKey(targetUrl);
128
+ const key = buildPrefetchKey(window.location.href, targetUrl);
131
129
  if (hasPrefetch(key)) return key;
132
130
  const fetchUrlStr = targetUrl.toString();
133
131
  enqueuePrefetch(key, (signal) =>
@@ -22,6 +22,7 @@ import type {
22
22
  import type { EventController } from "./event-controller.js";
23
23
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
24
24
  import { initRangoState } from "./rango-state.js";
25
+ import { initPrefetchCache } from "./prefetch/cache.js";
25
26
  import {
26
27
  isInterceptSegment,
27
28
  splitInterceptSegments,
@@ -201,10 +202,17 @@ export async function initBrowserApp(
201
202
  const rootLayout = initialPayload.metadata?.rootLayout;
202
203
  const version = initialPayload.metadata?.version;
203
204
 
204
- // Initialize the localStorage state key for browser HTTP cache invalidation.
205
+ // Initialize the localStorage state key for cache invalidation.
205
206
  // Uses the build version so a new deploy automatically busts all cached prefetches.
206
207
  initRangoState(version ?? "0");
207
208
 
209
+ // Initialize the in-memory prefetch cache TTL from server config.
210
+ // A value of 0 disables the cache; undefined falls back to the module default.
211
+ const prefetchCacheTTL = initialPayload.metadata?.prefetchCacheTTL;
212
+ if (prefetchCacheTTL !== undefined) {
213
+ initPrefetchCache(prefetchCacheTTL);
214
+ }
215
+
208
216
  // Create a bound renderSegments that includes rootLayout
209
217
  const renderSegments = (
210
218
  segments: ResolvedSegment[],
@@ -55,6 +55,11 @@ export interface RscMetadata {
55
55
  * Used to detect version mismatches after HMR/deployment.
56
56
  */
57
57
  version?: string;
58
+ /**
59
+ * TTL in milliseconds for the client-side in-memory prefetch cache.
60
+ * Sent on initial render so the browser can configure its cache duration.
61
+ */
62
+ prefetchCacheTTL?: number;
58
63
  /**
59
64
  * Theme configuration from router.
60
65
  * Included when theme is enabled in router config.
@@ -12,6 +12,8 @@ import type {
12
12
  DefaultVars,
13
13
  } from "../types/global-namespace.js";
14
14
  import type { ScopedReverseFunction } from "../reverse.js";
15
+ import type { Theme } from "../theme/types.js";
16
+ import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
15
17
 
16
18
  /**
17
19
  * Get variable function type
@@ -79,12 +81,25 @@ export interface MiddlewareContext<
79
81
  */
80
82
  readonly res: Response;
81
83
 
84
+ /**
85
+ * Shorthand for ctx.res.headers — response headers.
86
+ * Before `next()`, returns headers from the shared response stub.
87
+ * After `next()`, returns headers from the downstream response.
88
+ */
89
+ readonly headers: Headers;
90
+
82
91
  /** Get a context variable (shared with route handlers) */
83
92
  get: GetVariableFn;
84
93
 
85
94
  /** Set a context variable (shared with route handlers) */
86
95
  set: SetVariableFn;
87
96
 
97
+ /**
98
+ * Middleware-injected variables.
99
+ * Same shared dictionary as `ctx.get()`/`ctx.set()`.
100
+ */
101
+ var: DefaultVars;
102
+
88
103
  /**
89
104
  * Set a response header - can be called before or after `next()`
90
105
  *
@@ -113,6 +128,25 @@ export interface MiddlewareContext<
113
128
  */
114
129
  debugPerformance(): void;
115
130
 
131
+ /**
132
+ * Current theme (from cookie or default).
133
+ * Only available when theme is enabled in router config.
134
+ */
135
+ theme?: Theme;
136
+
137
+ /**
138
+ * Set the theme (only available when theme is enabled in router config).
139
+ * Sets a cookie with the new theme value.
140
+ */
141
+ setTheme?: (theme: Theme) => void;
142
+
143
+ /**
144
+ * Attach location state entries to this response.
145
+ * State is delivered to the client via history.pushState and accessible
146
+ * through the useLocationState() hook.
147
+ */
148
+ setLocationState(entries: LocationStateEntry | LocationStateEntry[]): void;
149
+
116
150
  /**
117
151
  * Generate URLs from route names.
118
152
  * - `name` — global route, from the named-routes definition
@@ -34,22 +34,6 @@ export type {
34
34
  } from "./middleware-types.js";
35
35
  export { parseCookies, serializeCookie } from "./middleware-cookies.js";
36
36
 
37
- // W5: Deduplicate by function reference so each distinct middleware warns once,
38
- // regardless of whether it is named or anonymous.
39
- let warnedRedirectMiddleware = new WeakSet<Function>();
40
-
41
- function warnCtxSetBeforeRedirect(handler: Function): void {
42
- if (warnedRedirectMiddleware.has(handler)) return;
43
- warnedRedirectMiddleware.add(handler);
44
- const label = handler.name || "(anonymous)";
45
- console.warn(
46
- `[rango] Route middleware "${label}" called ctx.set() then returned a ` +
47
- `redirect. Context variables are per-request and won't be available ` +
48
- `on the redirect target. Use cookies to persist state across ` +
49
- `redirects, or move ctx.set() to the target route's middleware.`,
50
- );
51
- }
52
-
53
37
  const MIDDLEWARE_METRIC_DEPTH = 1;
54
38
  /** Ignore post-next() durations below this threshold (measurement noise). */
55
39
  const POST_METRIC_MIN_DURATION_MS = 0.01;
@@ -75,11 +59,6 @@ function getMiddlewareMetricLabel<TEnv>(
75
59
  return `middleware:${getMiddlewareMetricBase(entry, ordinal)}`;
76
60
  }
77
61
 
78
- /** Reset W5 deduplication state (for tests only). */
79
- export function _resetW5Warnings(): void {
80
- warnedRedirectMiddleware = new WeakSet();
81
- }
82
-
83
62
  /**
84
63
  * Parse a route pattern into regex and param names
85
64
  * Supports: *, /path, /path/*, /path/:param, /path/:param/*
@@ -221,6 +200,10 @@ export function createMiddlewareContext<TEnv>(
221
200
  );
222
201
  },
223
202
 
203
+ get headers(): Headers {
204
+ return this.res.headers;
205
+ },
206
+
224
207
  get: ((keyOrVar: any) =>
225
208
  contextGet(variables, keyOrVar)) as MiddlewareContext<TEnv>["get"],
226
209
 
@@ -228,6 +211,8 @@ export function createMiddlewareContext<TEnv>(
228
211
  contextSet(variables, keyOrVar, value);
229
212
  }) as MiddlewareContext<TEnv>["set"],
230
213
 
214
+ var: variables as MiddlewareContext<TEnv>["var"],
215
+
231
216
  header(name: string, value: string): void {
232
217
  // Before next(): delegate to shared RequestContext stub
233
218
  if (isPreNext()) {
@@ -246,6 +231,24 @@ export function createMiddlewareContext<TEnv>(
246
231
  responseHolder.response.headers.set(name, value);
247
232
  },
248
233
 
234
+ get theme(): MiddlewareContext<TEnv>["theme"] {
235
+ return _getRequestContext()?.theme;
236
+ },
237
+
238
+ get setTheme(): MiddlewareContext<TEnv>["setTheme"] {
239
+ return _getRequestContext()?.setTheme;
240
+ },
241
+
242
+ setLocationState(entries) {
243
+ const reqCtx = _getRequestContext();
244
+ if (!reqCtx) {
245
+ throw new Error(
246
+ "setLocationState() is not available outside a request context",
247
+ );
248
+ }
249
+ reqCtx.setLocationState(entries);
250
+ },
251
+
249
252
  reverse:
250
253
  reverse ??
251
254
  ((name: string) => {
@@ -425,16 +428,6 @@ export async function executeMiddleware<TEnv>(
425
428
  return nextPromise;
426
429
  };
427
430
 
428
- // W5: track whether ctx.set() is called during this middleware
429
- let ctxSetCalled = false;
430
- if (process.env.NODE_ENV !== "production") {
431
- const originalSet = ctx.set;
432
- ctx.set = ((...args: any[]) => {
433
- ctxSetCalled = true;
434
- return (originalSet as Function).apply(ctx, args);
435
- }) as typeof ctx.set;
436
- }
437
-
438
431
  let result: Response | void;
439
432
  try {
440
433
  result = await entry.handler(ctx, wrappedNext);
@@ -464,16 +457,6 @@ export async function executeMiddleware<TEnv>(
464
457
  // RequestContext stub headers (from ctx.setCookie) into the
465
458
  // returned Response so they are not lost.
466
459
  if (result instanceof Response) {
467
- // W5: warn if ctx.set() was called but middleware returned a redirect
468
- if (
469
- process.env.NODE_ENV !== "production" &&
470
- ctxSetCalled &&
471
- result.status >= 300 &&
472
- result.status < 400
473
- ) {
474
- warnCtxSetBeforeRedirect(entry.handler);
475
- }
476
-
477
460
  const mergedHeaders = new Headers(result.headers);
478
461
  stubResponse.headers.forEach((value, name) => {
479
462
  if (name.toLowerCase() === "set-cookie") {
@@ -524,19 +507,6 @@ export async function executeMiddleware<TEnv>(
524
507
  // If middleware called next(), await it and return the response
525
508
  if (nextPromise) {
526
509
  await nextPromise;
527
-
528
- // W5: warn if ctx.set() was called but the downstream response is a redirect.
529
- // The ctx.set() values will be lost because the redirect navigates away.
530
- if (
531
- process.env.NODE_ENV !== "production" &&
532
- ctxSetCalled &&
533
- responseHolder.response &&
534
- responseHolder.response.status >= 300 &&
535
- responseHolder.response.status < 400
536
- ) {
537
- warnCtxSetBeforeRedirect(entry.handler);
538
- }
539
-
540
510
  return responseHolder.response!;
541
511
  }
542
512
 
@@ -258,10 +258,17 @@ export interface RSCRouterInternal<
258
258
 
259
259
  /**
260
260
  * Cache-Control header value for prefetch responses.
261
- * False means no browser caching of prefetch responses.
261
+ * False means no caching of prefetch responses.
262
+ * Derived from prefetchCacheTTL.
262
263
  */
263
264
  readonly prefetchCacheControl: string | false;
264
265
 
266
+ /**
267
+ * TTL in milliseconds for the client-side in-memory prefetch cache.
268
+ * 0 means caching is disabled.
269
+ */
270
+ readonly prefetchCacheTTL: number;
271
+
265
272
  /**
266
273
  * Whether connection warmup is enabled.
267
274
  * When true, the client sends HEAD /?_rsc_warmup after idle periods
@@ -415,16 +415,21 @@ export interface RSCRouterOptions<TEnv = any> {
415
415
  version?: string;
416
416
 
417
417
  /**
418
- * Cache-Control header value for prefetch responses.
419
- * Only applied to non-intercept partial responses that include the
420
- * `X-Rango-Prefetch` header (sent by the Link component's prefetch fetch).
421
- * Navigation responses are never cached by the browser.
418
+ * TTL (in seconds) for the in-memory prefetch cache and the
419
+ * Cache-Control header on prefetch responses.
422
420
  *
423
- * Set to `false` to disable browser caching of prefetch responses entirely.
421
+ * Controls how long prefetch responses are kept in the client-side
422
+ * in-memory cache and sets `Cache-Control: private, max-age=<ttl>`
423
+ * on server responses for CDN/edge caching.
424
424
  *
425
- * @default "private, max-age=300"
425
+ * The cache is automatically invalidated on server actions regardless
426
+ * of TTL, so this is primarily a staleness safety net.
427
+ *
428
+ * Set to `false` to disable prefetch caching entirely.
429
+ *
430
+ * @default 300 (5 minutes)
426
431
  */
427
- prefetchCacheControl?: string | false;
432
+ prefetchCacheTTL?: number | false;
428
433
 
429
434
  /**
430
435
  * Enable connection warmup to keep TCP+TLS alive after idle periods.
package/src/router.ts CHANGED
@@ -147,7 +147,7 @@ export function createRouter<TEnv = any>(
147
147
  $$sourceFile: injectedSourceFile,
148
148
  nonce,
149
149
  version,
150
- prefetchCacheControl: prefetchCacheControlOption,
150
+ prefetchCacheTTL: prefetchCacheTTLOption,
151
151
  warmup: warmupOption,
152
152
  allowDebugManifest: allowDebugManifestOption = false,
153
153
  telemetry: telemetrySink,
@@ -200,11 +200,17 @@ export function createRouter<TEnv = any>(
200
200
  const routerId =
201
201
  userProvidedId ?? injectedId ?? `router_${nextRouterAutoId()}`;
202
202
 
203
- // Resolve prefetch cache control (default: 'private, max-age=300')
204
- const prefetchCacheControl =
205
- prefetchCacheControlOption !== undefined
206
- ? prefetchCacheControlOption
207
- : "private, max-age=300";
203
+ // Resolve prefetch cache TTL (default: 300 seconds / 5 minutes)
204
+ // Clamp to a non-negative integer for valid Cache-Control max-age.
205
+ const rawTTL =
206
+ prefetchCacheTTLOption !== undefined ? prefetchCacheTTLOption : 300;
207
+ const prefetchCacheTTLSeconds =
208
+ rawTTL === false ? 0 : Math.max(0, Math.floor(rawTTL));
209
+ const prefetchCacheTTL = prefetchCacheTTLSeconds * 1000;
210
+ const prefetchCacheControl: string | false =
211
+ prefetchCacheTTLSeconds === 0
212
+ ? false
213
+ : `private, max-age=${prefetchCacheTTLSeconds}`;
208
214
 
209
215
  // Resolve warmup enabled flag (default: true)
210
216
  const warmupEnabled = warmupOption !== false;
@@ -879,8 +885,9 @@ export function createRouter<TEnv = any>(
879
885
  // Expose resolved cache profiles for per-request resolution
880
886
  cacheProfiles: resolvedCacheProfiles,
881
887
 
882
- // Expose prefetch cache control for RSC handler
888
+ // Expose prefetch cache settings
883
889
  prefetchCacheControl,
890
+ prefetchCacheTTL,
884
891
 
885
892
  // Expose warmup enabled flag for handler and client
886
893
  warmupEnabled,
@@ -62,6 +62,7 @@ export async function handleRscRendering<TEnv>(
62
62
  rootLayout: ctx.router.rootLayout,
63
63
  handles: handleStore.stream(),
64
64
  version: ctx.version,
65
+ prefetchCacheTTL: ctx.router.prefetchCacheTTL,
65
66
  themeConfig: ctx.router.themeConfig,
66
67
  initialTheme: reqCtx.theme,
67
68
  },
package/src/rsc/types.ts CHANGED
@@ -32,6 +32,8 @@ export interface RscPayload {
32
32
  handles?: AsyncGenerator<HandleData, void, unknown>;
33
33
  /** RSC version string for cache invalidation */
34
34
  version?: string;
35
+ /** TTL in milliseconds for the client-side in-memory prefetch cache */
36
+ prefetchCacheTTL?: number;
35
37
  /** Theme configuration for FOUC prevention */
36
38
  themeConfig?: ResolvedThemeConfig | null;
37
39
  /** Initial theme from cookie (for SSR hydration) */