@rangojs/router 0.2.0 → 0.4.1

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 (105) hide show
  1. package/README.md +19 -1
  2. package/dist/types/browser/event-controller.d.ts +4 -0
  3. package/dist/types/browser/link-interceptor.d.ts +17 -7
  4. package/dist/types/browser/navigation-bridge.d.ts +13 -1
  5. package/dist/types/browser/notify-listeners.d.ts +2 -0
  6. package/dist/types/browser/prefetch/cache.d.ts +1 -1
  7. package/dist/types/browser/prefetch/default-strategy.d.ts +31 -0
  8. package/dist/types/browser/prefetch/invalidation.d.ts +6 -0
  9. package/dist/types/browser/prefetch/loader.d.ts +3 -1
  10. package/dist/types/browser/prefetch/observer.d.ts +3 -6
  11. package/dist/types/browser/prefetch/runtime.d.ts +0 -1
  12. package/dist/types/browser/rango-state.d.ts +4 -0
  13. package/dist/types/browser/react/Link.d.ts +11 -19
  14. package/dist/types/browser/react/context.d.ts +3 -0
  15. package/dist/types/browser/rsc-router.d.ts +7 -2
  16. package/dist/types/browser/types.d.ts +6 -0
  17. package/dist/types/cache/cache-key-utils.d.ts +11 -3
  18. package/dist/types/cache/cache-scope.d.ts +82 -3
  19. package/dist/types/cache/cf/cf-cache-store.d.ts +2 -2
  20. package/dist/types/cache/index.d.ts +3 -1
  21. package/dist/types/cache/search-params-filter.d.ts +64 -0
  22. package/dist/types/cache/shell-snapshot.d.ts +4 -4
  23. package/dist/types/cache/types.d.ts +36 -2
  24. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -2
  25. package/dist/types/index.d.ts +1 -0
  26. package/dist/types/index.rsc.d.ts +1 -0
  27. package/dist/types/router/match-middleware/cache-lookup.d.ts +13 -0
  28. package/dist/types/router/navigation-snapshot.d.ts +29 -0
  29. package/dist/types/router/prefetch-default.d.ts +28 -0
  30. package/dist/types/router/router-interfaces.d.ts +7 -0
  31. package/dist/types/router/router-options.d.ts +54 -0
  32. package/dist/types/rsc/capture-queue.d.ts +6 -0
  33. package/dist/types/rsc/shell-build-manifest.d.ts +7 -1
  34. package/dist/types/rsc/shell-capture.d.ts +15 -7
  35. package/dist/types/rsc/shell-serve.d.ts +11 -4
  36. package/dist/types/rsc/types.d.ts +19 -0
  37. package/dist/types/server/request-context.d.ts +57 -4
  38. package/dist/types/testing/e2e/index.d.ts +2 -2
  39. package/dist/types/testing/e2e/page-helpers.d.ts +24 -0
  40. package/dist/types/testing/render-route.d.ts +10 -1
  41. package/dist/types/testing/shell-status.d.ts +6 -3
  42. package/dist/types/vite/plugin-types.d.ts +1 -1
  43. package/dist/vite/index.js +9 -6
  44. package/package.json +23 -22
  45. package/skills/caching/SKILL.md +39 -0
  46. package/skills/comparison/references/framework-comparison.md +9 -2
  47. package/skills/links/SKILL.md +24 -0
  48. package/skills/ppr/SKILL.md +80 -16
  49. package/skills/router-setup/SKILL.md +9 -0
  50. package/skills/testing/client-components.md +15 -14
  51. package/skills/vercel/SKILL.md +1 -1
  52. package/src/browser/event-controller.ts +110 -11
  53. package/src/browser/link-interceptor.ts +469 -20
  54. package/src/browser/navigation-bridge.ts +71 -2
  55. package/src/browser/navigation-store.ts +24 -1
  56. package/src/browser/notify-listeners.ts +22 -0
  57. package/src/browser/prefetch/cache.ts +4 -2
  58. package/src/browser/prefetch/default-strategy.ts +74 -0
  59. package/src/browser/prefetch/invalidation.ts +30 -0
  60. package/src/browser/prefetch/loader.ts +18 -17
  61. package/src/browser/prefetch/observer.ts +50 -22
  62. package/src/browser/prefetch/runtime.ts +0 -1
  63. package/src/browser/rango-state.ts +21 -0
  64. package/src/browser/react/Link.tsx +111 -101
  65. package/src/browser/react/NavigationProvider.tsx +1 -0
  66. package/src/browser/react/context.ts +4 -0
  67. package/src/browser/rsc-router.tsx +30 -9
  68. package/src/browser/types.ts +6 -0
  69. package/src/cache/cache-key-utils.ts +18 -4
  70. package/src/cache/cache-runtime.ts +7 -2
  71. package/src/cache/cache-scope.ts +152 -23
  72. package/src/cache/cf/cf-cache-store.ts +55 -16
  73. package/src/cache/document-cache.ts +7 -1
  74. package/src/cache/index.ts +7 -0
  75. package/src/cache/search-params-filter.ts +118 -0
  76. package/src/cache/shell-snapshot.ts +8 -4
  77. package/src/cache/types.ts +39 -2
  78. package/src/cache/vercel/vercel-cache-store.ts +22 -3
  79. package/src/index.rsc.ts +4 -0
  80. package/src/index.ts +6 -0
  81. package/src/router/match-middleware/cache-lookup.ts +146 -33
  82. package/src/router/match-middleware/cache-store.ts +70 -0
  83. package/src/router/navigation-snapshot.ts +46 -6
  84. package/src/router/prefetch-default.ts +59 -0
  85. package/src/router/router-interfaces.ts +8 -0
  86. package/src/router/router-options.ts +59 -1
  87. package/src/router.ts +7 -0
  88. package/src/rsc/capture-queue.ts +24 -1
  89. package/src/rsc/full-payload.ts +1 -0
  90. package/src/rsc/handler.ts +10 -0
  91. package/src/rsc/response-cache-serve.ts +1 -1
  92. package/src/rsc/rsc-rendering.ts +367 -40
  93. package/src/rsc/shell-build-manifest.ts +8 -1
  94. package/src/rsc/shell-capture.ts +99 -50
  95. package/src/rsc/shell-serve.ts +14 -6
  96. package/src/rsc/types.ts +19 -0
  97. package/src/server/request-context.ts +62 -3
  98. package/src/testing/dispatch.ts +21 -3
  99. package/src/testing/e2e/index.ts +6 -0
  100. package/src/testing/e2e/page-helpers.ts +47 -0
  101. package/src/testing/e2e/parity.ts +13 -0
  102. package/src/testing/render-route.tsx +86 -17
  103. package/src/testing/shell-status.ts +20 -3
  104. package/src/vite/plugin-types.ts +1 -1
  105. package/src/vite/plugins/vercel-output.ts +2 -2
@@ -106,6 +106,7 @@ import {
106
106
  runWithRequestContext,
107
107
  } from "../../server/request-context.js";
108
108
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
109
+ import { createShellImplicitDocScope } from "../../cache/cache-scope.js";
109
110
  import { getRouterContext } from "../router-context.js";
110
111
  import { debugLog, debugWarn, getOrCreateRequestId } from "../logging.js";
111
112
  import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
@@ -134,6 +135,14 @@ export function withCacheStore<TEnv>(
134
135
 
135
136
  const ownStart = performance.now();
136
137
 
138
+ // Shell capture composition (capture side): a route-derived cache() scope
139
+ // keeps its normal store write below, but the capture's shell entry still
140
+ // needs the CANONICAL doc segment record for navigation replay. Recorded
141
+ // before the skip check because it must run on the explicit tier's hit
142
+ // path too (state.cacheHit skips the normal write, yet the served
143
+ // segments were collected into allSegments either way).
144
+ recordShellCaptureDocRecord(ctx, state, allSegments);
145
+
137
146
  if (
138
147
  !ctx.cacheScope?.enabled ||
139
148
  ctx.isAction ||
@@ -348,3 +357,64 @@ export function withCacheStore<TEnv>(
348
357
  }
349
358
  };
350
359
  }
360
+
361
+ /**
362
+ * During a SHELL CAPTURE of a route with a route-derived cache() scope, write
363
+ * the matched non-loader segments as the canonical doc segment record through
364
+ * the capture marker's SnapshotOnlySegmentStore — IN ADDITION to the explicit
365
+ * scope's normal real-store write. The record rides only inside the shell
366
+ * entry (a real doc-keyed write would poison the next capture's lookup; see
367
+ * RecordingShellStore.recordSegmentWrite), so no real-store behavior changes.
368
+ * Without it, navigation replay was structurally dead for any ppr route under
369
+ * a cache() scope (including an app-wide one): the snapshot held only
370
+ * explicit-tier-keyed records a partial lookup can never resolve.
371
+ *
372
+ * Records nothing when:
373
+ * - not a capture render, or the route derived no scope (the implicit doc
374
+ * scope's own cacheRoute below already records the doc record);
375
+ * - the scope is STATICALLY disabled (cache(false)) — the replay gate
376
+ * pre-decides that shape as `cache-disabled` before any shell read, so a
377
+ * record would be dead weight;
378
+ * - the prerender store supplied the match: those partials are served from
379
+ * build-time segments and report `prerender-store`.
380
+ *
381
+ * A false condition() suppresses the record too — the consumer's write
382
+ * refusal is absolute. The prelude-already-bakes-it argument does NOT excuse
383
+ * recording: a NAVIGATION-ONLY capture's prelude is never served as a
384
+ * document, so for those entries the segment record would be the sole
385
+ * persisted copy of a render the consumer refused to cache — and a later
386
+ * request where condition() flips true could consume it through the seeded
387
+ * fallback. The lookup-time `cache-disabled` report stays reachable without
388
+ * the record: matchPartialWithPprReplay installs a REPORT-ONLY marker (no
389
+ * store) on the no-eligible-snapshot path, so the lookup's own refusal is
390
+ * still surfaced while the fallback has nothing to serve.
391
+ *
392
+ * Wrapped in requestCtx.waitUntil — during a capture that is the tracked-write
393
+ * override, so captureAndStoreShell's settleWrites awaits the record before
394
+ * draining the snapshot.
395
+ */
396
+ function recordShellCaptureDocRecord<TEnv>(
397
+ ctx: MatchContext<TEnv>,
398
+ state: MatchPipelineState,
399
+ segments: ResolvedSegment[],
400
+ ): void {
401
+ const requestCtx = getRequestContext();
402
+ if (!requestCtx?._shellCaptureRun) return;
403
+ const marker = requestCtx._shellImplicitCache;
404
+ if (!marker?.store) return;
405
+ const scope = ctx.cacheScope;
406
+ if (!scope || scope.isShellImplicitDocScope) return;
407
+ if (ctx.isAction || ctx.isIntercept || ctx.request.method !== "GET") return;
408
+ if (state.cacheSource === "prerender") return;
409
+ if (!scope.allowsCache("write")) return;
410
+
411
+ const docScope = createShellImplicitDocScope(marker);
412
+ requestCtx.waitUntil(() =>
413
+ docScope.cacheRoute(
414
+ ctx.pathname,
415
+ ctx.matched.params,
416
+ segments,
417
+ ctx.isIntercept,
418
+ ),
419
+ );
420
+ }
@@ -30,6 +30,50 @@ export interface ResolveNavigationDeps {
30
30
  ) => RouteMatchResult | null | Promise<RouteMatchResult | null>;
31
31
  }
32
32
 
33
+ /**
34
+ * The raw navigation-context header for a partial request, or null when the
35
+ * request carries none. Single source of truth shared by resolveNavigation and
36
+ * the PPR replay gate (matchPartialWithPprReplay): a context-less partial can
37
+ * never produce a partial match, so the replay path must decide
38
+ * `no-navigation-context` BEFORE spending shell-store reads — the two
39
+ * predicates drifting apart would misattribute the bypass again.
40
+ */
41
+ export function getNavigationContextHeader(request: Request): string | null {
42
+ return (
43
+ request.headers.get("X-RSC-Router-Client-Path") ||
44
+ request.headers.get("Referer")
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Whether withCacheLookup's prerender short-circuit will serve this partial
50
+ * from the build-time prerender store: a Prerender() match (`matched.pr`)
51
+ * outside dev HMR — the short-circuit is disabled on X-RSC-HMR so HMR
52
+ * navigations pick up edited modules. Shared by withCacheLookup and the PPR
53
+ * replay gate (matchPartialWithPprReplay) for the same drift reason as
54
+ * getNavigationContextHeader: the gate's `prerender-store` bypass must decide
55
+ * exactly what the middleware will actually do. (Both callers additionally
56
+ * exclude actions on their own paths — the gate via its GET-only check.)
57
+ */
58
+ export function prerenderStoreShortCircuits(
59
+ pr: true | undefined,
60
+ request: Request,
61
+ ): boolean {
62
+ return pr === true && !request.headers.get("X-RSC-HMR");
63
+ }
64
+
65
+ /**
66
+ * The intercept-source header for a partial request, or null when the request
67
+ * carries none. Shared by resolveNavigation and the PPR replay gate's
68
+ * prerender probe: intercept navigations consult the intercept-specific
69
+ * prerender artifact (`paramHash + "/i"`), so the probe must check the same
70
+ * variant the middleware will — probing the bare hash for an intercept
71
+ * request would report `prerender-store` for an artifact that cannot serve.
72
+ */
73
+ export function getInterceptSourceHeader(request: Request): string | null {
74
+ return request.headers.get("X-RSC-Router-Intercept-Source");
75
+ }
76
+
33
77
  export async function resolveNavigation(
34
78
  request: Request,
35
79
  url: URL,
@@ -39,12 +83,8 @@ export async function resolveNavigation(
39
83
  const clientSegmentIds =
40
84
  url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
41
85
  const stale = url.searchParams.get("_rsc_stale") === "true";
42
- const previousUrl =
43
- request.headers.get("X-RSC-Router-Client-Path") ||
44
- request.headers.get("Referer");
45
- const interceptSourceUrl = request.headers.get(
46
- "X-RSC-Router-Intercept-Source",
47
- );
86
+ const previousUrl = getNavigationContextHeader(request);
87
+ const interceptSourceUrl = getInterceptSourceHeader(request);
48
88
  const isHmr = !!request.headers.get("X-RSC-HMR");
49
89
 
50
90
  if (!previousUrl) {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Resolve the router-wide default Link prefetch strategy once, at router init.
3
+ * The resolved value ships to the client in payload metadata; the browser
4
+ * entry applies it via `setDefaultPrefetchStrategy` (browser/prefetch/
5
+ * default-strategy.ts) and every `<Link>` without an explicit `prefetch` prop
6
+ * falls back to it.
7
+ *
8
+ * The type lives HERE (not in browser/react/Link.tsx) because both seats need
9
+ * it: this server-side resolver and the client Link/default-strategy modules.
10
+ * Link.tsx re-exports it so the public `PrefetchStrategy` export path is
11
+ * unchanged.
12
+ *
13
+ * Policy note: automatic prefetch stays quiet in development, matching the
14
+ * production-only default used by Next.js. Explicit router and per-Link
15
+ * strategies still work in every mode. Invalid untyped inputs fall back to the
16
+ * current environment's default.
17
+ */
18
+
19
+ /**
20
+ * Prefetch strategy for the Link component
21
+ * - "hover": Prefetch on mouse enter (direct, no queue)
22
+ * - "viewport": Prefetch when link enters viewport (queued, waits for idle)
23
+ * - "render": Prefetch on component mount regardless of visibility (queued, waits for idle)
24
+ * - "adaptive": Hover on pointer devices, viewport on touch devices
25
+ * - "none": No prefetching
26
+ */
27
+ export type PrefetchStrategy =
28
+ | "hover"
29
+ | "viewport"
30
+ | "render"
31
+ | "adaptive"
32
+ | "none";
33
+
34
+ function defaultPrefetchForEnvironment(
35
+ nodeEnv: string | undefined,
36
+ ): PrefetchStrategy {
37
+ return nodeEnv === "production" ? "viewport" : "none";
38
+ }
39
+
40
+ export const DEFAULT_PREFETCH_STRATEGY: PrefetchStrategy =
41
+ defaultPrefetchForEnvironment(process.env.NODE_ENV);
42
+
43
+ const VALID_STRATEGIES: ReadonlySet<string> = new Set([
44
+ "hover",
45
+ "viewport",
46
+ "render",
47
+ "adaptive",
48
+ "none",
49
+ ] satisfies PrefetchStrategy[]);
50
+
51
+ export function resolveDefaultPrefetch(
52
+ raw: PrefetchStrategy | undefined,
53
+ nodeEnv: string | undefined = process.env.NODE_ENV,
54
+ ): PrefetchStrategy {
55
+ if (typeof raw === "string" && VALID_STRATEGIES.has(raw)) {
56
+ return raw;
57
+ }
58
+ return defaultPrefetchForEnvironment(nodeEnv);
59
+ }
@@ -318,6 +318,14 @@ export interface RangoInternal<
318
318
  */
319
319
  readonly prefetchConcurrency: number;
320
320
 
321
+ /**
322
+ * Router-wide default Link prefetch strategy for Links without an explicit
323
+ * `prefetch` prop. Shipped to the client in payload metadata. Derived from
324
+ * the `defaultPrefetch` option (default "none" in development and
325
+ * "viewport" in production).
326
+ */
327
+ readonly defaultPrefetch: import("./prefetch-default.js").PrefetchStrategy;
328
+
321
329
  /**
322
330
  * Resolved rango state cookie name (`{prefix}_{routerId}`), composed once at
323
331
  * router init and shipped to the client in payload metadata. The server-side
@@ -1,5 +1,6 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
2
  import type { SegmentCacheStore } from "../cache/types.js";
3
+ import type { CacheSearchParams } from "../cache/search-params-filter.js";
3
4
  import type {
4
5
  ErrorBoundaryHandler,
5
6
  NotFoundBoundaryHandler,
@@ -11,6 +12,7 @@ import type { ExecutionContext } from "../server/request-context.js";
11
12
  import type { UrlPatterns } from "../urls.js";
12
13
  import type { UrlBuilder } from "../urls/pattern-types.js";
13
14
  import type { NamedRouteEntry } from "./content-negotiation.js";
15
+ import type { PrefetchStrategy } from "./prefetch-default.js";
14
16
  import type { TelemetrySink } from "./telemetry.js";
15
17
  import type { RouterTracingConfig } from "./tracing.js";
16
18
  import type { RouterTimeouts, OnTimeoutCallback } from "./timeout.js";
@@ -303,15 +305,37 @@ export interface RangoOptions<TEnv = any> {
303
305
  * }),
304
306
  * });
305
307
  * ```
308
+ *
309
+ * `searchParams` controls which query params key the cache (default:
310
+ * `"all"`). Cache keys only -- handlers still see the full query string.
311
+ * Excluding a param is a promise that rendered output does not depend on
312
+ * it; if it does, the first variant is cached and served to everyone.
313
+ *
314
+ * @example Ignore tracking params for cache keys
315
+ * ```typescript
316
+ * import { TRACKING_SEARCH_PARAMS } from "@rangojs/router";
317
+ *
318
+ * const router = createRouter({
319
+ * cache: {
320
+ * store: cacheStore,
321
+ * searchParams: { exclude: TRACKING_SEARCH_PARAMS },
322
+ * },
323
+ * });
324
+ * ```
306
325
  */
307
326
  cache?:
308
- | { store: SegmentCacheStore; enabled?: boolean }
327
+ | {
328
+ store: SegmentCacheStore;
329
+ enabled?: boolean;
330
+ searchParams?: CacheSearchParams;
331
+ }
309
332
  | ((
310
333
  env: TEnv,
311
334
  ctx?: ExecutionContext,
312
335
  ) => {
313
336
  store: SegmentCacheStore;
314
337
  enabled?: boolean;
338
+ searchParams?: CacheSearchParams;
315
339
  });
316
340
 
317
341
  /**
@@ -532,6 +556,40 @@ export interface RangoOptions<TEnv = any> {
532
556
  */
533
557
  prefetchConcurrency?: number;
534
558
 
559
+ /**
560
+ * Default prefetch strategy for every `<Link>` that does not set its own
561
+ * `prefetch` prop and every eligible intercepted plain anchor. Plain anchors
562
+ * can opt out with `data-prefetch="false"` or `data-prefetch="none"`. Common
563
+ * static-resource suffixes are excluded unless `data-prefetch="true"` marks
564
+ * the URL as an application route. Mark side-effectful GET links such as
565
+ * `/logout` with an opt-out because the router cannot infer endpoint safety.
566
+ * A per-Link `prefetch` prop always wins in both directions (a Link can opt
567
+ * out with `prefetch="none"`, or opt in under `defaultPrefetch: "none"`).
568
+ *
569
+ * - `"viewport"` (production default): prefetch when the link enters the viewport —
570
+ * idle-gated and queued (see `prefetchConcurrency`), so prefetches never
571
+ * compete with hydration or an active navigation.
572
+ * - `"hover"`: prefetch on mouse enter only. Much lighter on the server —
573
+ * only links the pointer approaches are fetched — at the cost of the
574
+ * ~100-300ms head start viewport prefetch gives.
575
+ * - `"adaptive"`: `"hover"` on pointer devices, `"viewport"` on touch
576
+ * devices (no hover to wait for).
577
+ * - `"render"`: prefetch on mount regardless of visibility.
578
+ * - `"none"`: manual mode — nothing prefetches unless a Link opts in.
579
+ *
580
+ * Automatic prefetch defaults to `"none"` in development and `"viewport"`
581
+ * in production. With the production default, every visible Link triggers an
582
+ * RSC render (loaders included) on the server. Routes served from cache/PPR
583
+ * absorb this cheaply; per-user dynamic routes pay full price per viewport
584
+ * entry. Set `"hover"`, `"adaptive"`, or `"none"` when that cost matters.
585
+ *
586
+ * To disable the prefetch subsystem entirely (including per-Link opt-ins
587
+ * and `useRouter().prefetch()`), set `prefetchCacheTTL: false` instead.
588
+ *
589
+ * @default "none" in development; "viewport" in production
590
+ */
591
+ defaultPrefetch?: PrefetchStrategy;
592
+
535
593
  /**
536
594
  * Prefix for the rango state cookie name. The resolved name is
537
595
  * `{prefix}_{routerId}`; the prefix is sanitized to cookie-name-safe
package/src/router.ts CHANGED
@@ -115,6 +115,7 @@ import {
115
115
  } from "./router/prerender-match.js";
116
116
  import { resolveStateCookieName } from "./router/state-cookie-name.js";
117
117
  import { resolvePrefetchCacheTTL } from "./router/prefetch-cache-ttl.js";
118
+ import { resolveDefaultPrefetch } from "./router/prefetch-default.js";
118
119
  import {
119
120
  resolvePrefetchCacheSize,
120
121
  resolvePrefetchConcurrency,
@@ -160,6 +161,7 @@ export function createRouter<TEnv = any>(
160
161
  prefetchCacheTTL: prefetchCacheTTLOption,
161
162
  prefetchCacheSize: prefetchCacheSizeOption,
162
163
  prefetchConcurrency: prefetchConcurrencyOption,
164
+ defaultPrefetch: defaultPrefetchOption,
163
165
  stateCookiePrefix: stateCookiePrefixOption,
164
166
  warmup: warmupOption,
165
167
  telemetry: telemetrySink,
@@ -268,6 +270,10 @@ export function createRouter<TEnv = any>(
268
270
  prefetchConcurrencyOption,
269
271
  );
270
272
 
273
+ // Resolve the router-wide default Link prefetch strategy ("none" in dev,
274
+ // "viewport" in production). Links without an explicit prop fall back to it.
275
+ const defaultPrefetch = resolveDefaultPrefetch(defaultPrefetchOption);
276
+
271
277
  // Resolve warmup enabled flag (default: true)
272
278
  const warmupEnabled = warmupOption !== false;
273
279
 
@@ -1009,6 +1015,7 @@ export function createRouter<TEnv = any>(
1009
1015
  prefetchCacheTTL,
1010
1016
  prefetchCacheSize,
1011
1017
  prefetchConcurrency,
1018
+ defaultPrefetch,
1012
1019
 
1013
1020
  // Expose the resolved rango state cookie name for the server-side writer
1014
1021
  // (invalidateClientCache) and for shipping to the client in metadata.
@@ -23,6 +23,18 @@
23
23
  * swallow, so one rejected capture can never wedge every later one.
24
24
  */
25
25
  let captureQueue: Promise<void> = Promise.resolve();
26
+ let admittedCaptures = 0;
27
+
28
+ /** Bound queued/running capture closures retained by one isolate. */
29
+ export const MAX_ADMITTED_CAPTURES: number = 32;
30
+
31
+ /** The caller may drop this best-effort capture and retry on a later request. */
32
+ export class CaptureQueueFullError extends Error {
33
+ constructor() {
34
+ super(`shell capture queue is full (${MAX_ADMITTED_CAPTURES})`);
35
+ this.name = "CaptureQueueFullError";
36
+ }
37
+ }
26
38
 
27
39
  /**
28
40
  * Upper bound on how long one queue link may hold the queue. A capture task
@@ -43,6 +55,10 @@ const QUEUE_LINK_CAP_MS = 60_000;
43
55
  export function enqueueSerializedCapture(
44
56
  task: () => Promise<void>,
45
57
  ): Promise<void> {
58
+ if (admittedCaptures >= MAX_ADMITTED_CAPTURES) {
59
+ return Promise.reject(new CaptureQueueFullError());
60
+ }
61
+ admittedCaptures++;
46
62
  const prior = captureQueue;
47
63
  let releaseQueue!: () => void;
48
64
  captureQueue = new Promise<void>((resolve) => {
@@ -51,9 +67,16 @@ export function enqueueSerializedCapture(
51
67
  return (async () => {
52
68
  await prior.catch(() => {});
53
69
  let capTimer: ReturnType<typeof setTimeout> | undefined;
70
+ const taskPromise = Promise.resolve()
71
+ .then(task)
72
+ .finally(() => {
73
+ // A timed-out link releases serialization, but its detached task still
74
+ // counts against admission until it actually settles.
75
+ admittedCaptures--;
76
+ });
54
77
  try {
55
78
  await Promise.race([
56
- task(),
79
+ taskPromise,
57
80
  new Promise<void>((resolve) => {
58
81
  capTimer = setTimeout(resolve, QUEUE_LINK_CAP_MS);
59
82
  (capTimer as { unref?: () => void }).unref?.();
@@ -53,6 +53,7 @@ export function buildFullPayload(
53
53
  prefetchCacheTTL: ctx.router.prefetchCacheTTL,
54
54
  prefetchCacheSize: ctx.router.prefetchCacheSize,
55
55
  prefetchConcurrency: ctx.router.prefetchConcurrency,
56
+ defaultPrefetch: ctx.router.defaultPrefetch,
56
57
  stateCookieName: ctx.router.resolvedStateCookieName,
57
58
  themeConfig: ctx.router.themeConfig,
58
59
  // Carry warmupEnabled on the initial full-render payload so the client
@@ -64,6 +64,10 @@ import {
64
64
  } from "../route-map-builder.js";
65
65
  import type { HandlerContext } from "./handler-context.js";
66
66
  import type { CacheErrorCategory } from "../cache/cache-error.js";
67
+ import {
68
+ compileSearchParamsFilter,
69
+ type SearchParamsFilter,
70
+ } from "../cache/search-params-filter.js";
67
71
  import type { SegmentCacheStore } from "../cache/types.js";
68
72
  import { buildRouterTrieFromUrlpatterns } from "./manifest-init.js";
69
73
  import { handleProgressiveEnhancement } from "./progressive-enhancement.js";
@@ -460,6 +464,7 @@ export function createRSCHandler<
460
464
  // Priority: options.cache (handler override) > router.cache (router default)
461
465
  // Store is enabled only if: config provided, enabled, and no ?__no_cache query param
462
466
  let cacheStore: SegmentCacheStore | undefined;
467
+ let searchParamsFilter: SearchParamsFilter | undefined;
463
468
  const cacheOption = options.cache ?? router.cache;
464
469
  if (cacheOption && !url.searchParams.has("__no_cache")) {
465
470
  const cacheConfig =
@@ -469,6 +474,9 @@ export function createRSCHandler<
469
474
 
470
475
  if (cacheConfig.enabled !== false) {
471
476
  cacheStore = cacheConfig.store;
477
+ searchParamsFilter = compileSearchParamsFilter(
478
+ cacheConfig.searchParams,
479
+ );
472
480
  }
473
481
  }
474
482
 
@@ -526,6 +534,7 @@ export function createRSCHandler<
526
534
  url,
527
535
  variables,
528
536
  cacheStore,
537
+ searchParamsFilter,
529
538
  explicitTaggedStores,
530
539
  cacheProfiles: router.cacheProfiles,
531
540
  executionContext: executionCtx,
@@ -1219,6 +1228,7 @@ export function createRSCHandler<
1219
1228
  prefetchCacheTTL: router.prefetchCacheTTL,
1220
1229
  prefetchCacheSize: router.prefetchCacheSize,
1221
1230
  prefetchConcurrency: router.prefetchConcurrency,
1231
+ defaultPrefetch: router.defaultPrefetch,
1222
1232
  stateCookieName: router.resolvedStateCookieName,
1223
1233
  themeConfig: router.themeConfig,
1224
1234
  warmupEnabled: router.warmupEnabled,
@@ -103,7 +103,7 @@ export async function serveResponseRouteWithCache(
103
103
  // Default key: response:{type}: + host-namespaced base (sorted search, reserved
104
104
  // _rsc*/__* params excluded). Same composition as document/segment tiers so
105
105
  // the host-namespacing and search-normalization rules cannot drift.
106
- let cacheKey = `response:${responseType}:${cacheKeyBase(url.host, url.pathname, url.searchParams)}`;
106
+ let cacheKey = `response:${responseType}:${cacheKeyBase(url.host, url.pathname, url.searchParams, undefined, reqCtx._searchParamsFilter)}`;
107
107
 
108
108
  // Priority 1: route-level key() (full override). Priority 2: store-level
109
109
  // keyGenerator (modifies the default key).