@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.
- package/README.md +19 -1
- package/dist/types/browser/event-controller.d.ts +4 -0
- package/dist/types/browser/link-interceptor.d.ts +17 -7
- package/dist/types/browser/navigation-bridge.d.ts +13 -1
- package/dist/types/browser/notify-listeners.d.ts +2 -0
- package/dist/types/browser/prefetch/cache.d.ts +1 -1
- package/dist/types/browser/prefetch/default-strategy.d.ts +31 -0
- package/dist/types/browser/prefetch/invalidation.d.ts +6 -0
- package/dist/types/browser/prefetch/loader.d.ts +3 -1
- package/dist/types/browser/prefetch/observer.d.ts +3 -6
- package/dist/types/browser/prefetch/runtime.d.ts +0 -1
- package/dist/types/browser/rango-state.d.ts +4 -0
- package/dist/types/browser/react/Link.d.ts +11 -19
- package/dist/types/browser/react/context.d.ts +3 -0
- package/dist/types/browser/rsc-router.d.ts +7 -2
- package/dist/types/browser/types.d.ts +6 -0
- package/dist/types/cache/cache-key-utils.d.ts +11 -3
- package/dist/types/cache/cache-scope.d.ts +82 -3
- package/dist/types/cache/cf/cf-cache-store.d.ts +2 -2
- package/dist/types/cache/index.d.ts +3 -1
- package/dist/types/cache/search-params-filter.d.ts +64 -0
- package/dist/types/cache/shell-snapshot.d.ts +4 -4
- package/dist/types/cache/types.d.ts +36 -2
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.rsc.d.ts +1 -0
- package/dist/types/router/match-middleware/cache-lookup.d.ts +13 -0
- package/dist/types/router/navigation-snapshot.d.ts +29 -0
- package/dist/types/router/prefetch-default.d.ts +28 -0
- package/dist/types/router/router-interfaces.d.ts +7 -0
- package/dist/types/router/router-options.d.ts +54 -0
- package/dist/types/rsc/capture-queue.d.ts +6 -0
- package/dist/types/rsc/shell-build-manifest.d.ts +7 -1
- package/dist/types/rsc/shell-capture.d.ts +15 -7
- package/dist/types/rsc/shell-serve.d.ts +11 -4
- package/dist/types/rsc/types.d.ts +19 -0
- package/dist/types/server/request-context.d.ts +57 -4
- package/dist/types/testing/e2e/index.d.ts +2 -2
- package/dist/types/testing/e2e/page-helpers.d.ts +24 -0
- package/dist/types/testing/render-route.d.ts +10 -1
- package/dist/types/testing/shell-status.d.ts +6 -3
- package/dist/types/vite/plugin-types.d.ts +1 -1
- package/dist/vite/index.js +9 -6
- package/package.json +23 -22
- package/skills/caching/SKILL.md +39 -0
- package/skills/comparison/references/framework-comparison.md +9 -2
- package/skills/links/SKILL.md +24 -0
- package/skills/ppr/SKILL.md +80 -16
- package/skills/router-setup/SKILL.md +9 -0
- package/skills/testing/client-components.md +15 -14
- package/skills/vercel/SKILL.md +1 -1
- package/src/browser/event-controller.ts +110 -11
- package/src/browser/link-interceptor.ts +469 -20
- package/src/browser/navigation-bridge.ts +71 -2
- package/src/browser/navigation-store.ts +24 -1
- package/src/browser/notify-listeners.ts +22 -0
- package/src/browser/prefetch/cache.ts +4 -2
- package/src/browser/prefetch/default-strategy.ts +74 -0
- package/src/browser/prefetch/invalidation.ts +30 -0
- package/src/browser/prefetch/loader.ts +18 -17
- package/src/browser/prefetch/observer.ts +50 -22
- package/src/browser/prefetch/runtime.ts +0 -1
- package/src/browser/rango-state.ts +21 -0
- package/src/browser/react/Link.tsx +111 -101
- package/src/browser/react/NavigationProvider.tsx +1 -0
- package/src/browser/react/context.ts +4 -0
- package/src/browser/rsc-router.tsx +30 -9
- package/src/browser/types.ts +6 -0
- package/src/cache/cache-key-utils.ts +18 -4
- package/src/cache/cache-runtime.ts +7 -2
- package/src/cache/cache-scope.ts +152 -23
- package/src/cache/cf/cf-cache-store.ts +55 -16
- package/src/cache/document-cache.ts +7 -1
- package/src/cache/index.ts +7 -0
- package/src/cache/search-params-filter.ts +118 -0
- package/src/cache/shell-snapshot.ts +8 -4
- package/src/cache/types.ts +39 -2
- package/src/cache/vercel/vercel-cache-store.ts +22 -3
- package/src/index.rsc.ts +4 -0
- package/src/index.ts +6 -0
- package/src/router/match-middleware/cache-lookup.ts +146 -33
- package/src/router/match-middleware/cache-store.ts +70 -0
- package/src/router/navigation-snapshot.ts +46 -6
- package/src/router/prefetch-default.ts +59 -0
- package/src/router/router-interfaces.ts +8 -0
- package/src/router/router-options.ts +59 -1
- package/src/router.ts +7 -0
- package/src/rsc/capture-queue.ts +24 -1
- package/src/rsc/full-payload.ts +1 -0
- package/src/rsc/handler.ts +10 -0
- package/src/rsc/response-cache-serve.ts +1 -1
- package/src/rsc/rsc-rendering.ts +367 -40
- package/src/rsc/shell-build-manifest.ts +8 -1
- package/src/rsc/shell-capture.ts +99 -50
- package/src/rsc/shell-serve.ts +14 -6
- package/src/rsc/types.ts +19 -0
- package/src/server/request-context.ts +62 -3
- package/src/testing/dispatch.ts +21 -3
- package/src/testing/e2e/index.ts +6 -0
- package/src/testing/e2e/page-helpers.ts +47 -0
- package/src/testing/e2e/parity.ts +13 -0
- package/src/testing/render-route.tsx +86 -17
- package/src/testing/shell-status.ts +20 -3
- package/src/vite/plugin-types.ts +1 -1
- 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
|
-
|
|
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
|
-
| {
|
|
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.
|
package/src/rsc/capture-queue.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
+
taskPromise,
|
|
57
80
|
new Promise<void>((resolve) => {
|
|
58
81
|
capTimer = setTimeout(resolve, QUEUE_LINK_CAP_MS);
|
|
59
82
|
(capTimer as { unref?: () => void }).unref?.();
|
package/src/rsc/full-payload.ts
CHANGED
|
@@ -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
|
package/src/rsc/handler.ts
CHANGED
|
@@ -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).
|