@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
@@ -37,7 +37,7 @@
37
37
  * subtree, pass the `mount` option so useMount() returns the mounted prefix
38
38
  * (the segment chain is wrapped in a MountContext exactly as in production).
39
39
  */
40
- import type { ComponentType } from "react";
40
+ import { type ComponentType } from "react";
41
41
  import type { RenderResult } from "@testing-library/react";
42
42
  import type { NavigationStore } from "../browser/types.js";
43
43
  import type { EventController } from "../browser/event-controller.js";
@@ -45,6 +45,7 @@ import type { LoaderDefinition } from "../types.js";
45
45
  import type { LocationStateDefinition } from "../browser/react/location-state-shared.js";
46
46
  import type { Handle } from "../handle.js";
47
47
  import type { ThemeConfig } from "../theme/types.js";
48
+ import type { PrefetchStrategy } from "../router/prefetch-default.js";
48
49
  /**
49
50
  * Seed shape for `options.handle`, matching the handle wire format:
50
51
  * `{ [handleName]: { [segmentId]: pushedValues[] } }` (each segment accumulates
@@ -218,6 +219,14 @@ export interface RenderRouteOptions {
218
219
  * expect(getByTestId("nonce").textContent).toBe("test-nonce");
219
220
  */
220
221
  nonce?: string;
222
+ /**
223
+ * Router default prefetch strategy scoped to this rendered tree. This mirrors
224
+ * `createRouter({ defaultPrefetch })` for Links and eligible plain anchors
225
+ * inside `basename`. `data-prefetch="false"`/`"none"` opts out; `"true"`
226
+ * allows an application route with a common static-resource suffix but does
227
+ * not override a `"none"` default.
228
+ */
229
+ defaultPrefetch?: PrefetchStrategy;
221
230
  }
222
231
  /**
223
232
  * Imperative handle returned alongside the RTL result.
@@ -27,13 +27,14 @@
27
27
  * Import from `@rangojs/router/testing` (Vitest) or `@rangojs/router/testing/e2e`
28
28
  * (Playwright — same pure helpers, no Vite virtuals).
29
29
  */
30
+ import { type CacheSearchParams } from "../cache/search-params-filter.js";
30
31
  /** Production header name (`rsc/shell-serve.ts` `SHELL_STATUS_HEADER`). */
31
32
  export declare const SHELL_STATUS_HEADER: string;
32
33
  /** Partial-navigation replay decision header from `rsc/rsc-rendering.ts`. */
33
34
  export declare const PPR_REPLAY_STATUS_HEADER: string;
34
35
  /** Values the serve path writes on `x-rango-shell`. */
35
36
  export type ShellStatus = "HIT" | "MISS";
36
- declare const PPR_REPLAY_BYPASS_REASONS: readonly ["method", "dynamic", "nonce", "store-unavailable", "passive-read-unsupported", "read-error", "no-entry", "invalid-version", "corrupt-entry", "handler-live-holes", "transition-when", "no-segment-snapshot", "snapshot-miss", "stale-build-entry"];
37
+ declare const PPR_REPLAY_BYPASS_REASONS: readonly ["method", "dynamic", "nonce", "store-unavailable", "passive-read-unsupported", "no-navigation-context", "prerender-store", "intercept", "cache-disabled", "read-error", "no-entry", "invalid-version", "corrupt-entry", "handler-live-holes", "transition-when", "no-segment-snapshot", "snapshot-miss", "explicit-cache-hit", "stale-build-entry"];
37
38
  /** Bounded reasons a PPR partial request can fall open to ordinary matching. */
38
39
  export type PprReplayBypassReason = (typeof PPR_REPLAY_BYPASS_REASONS)[number];
39
40
  /** Parsed `x-rango-ppr-replay` value. */
@@ -55,9 +56,11 @@ export type ShellStatusTarget = Response | {
55
56
  * module (it pulls React). A parity test pins the two implementations.
56
57
  *
57
58
  * Accepts a `URL` or an absolute/relative request URL string (relative strings
58
- * resolve against `http://localhost`).
59
+ * resolve against `http://localhost`). Pass the router's `cache.searchParams`
60
+ * config as `searchParams` when the app under test sets one — the production
61
+ * key applies it, so the expected key must too.
59
62
  */
60
- export declare function shellCacheKey(url: URL | string): string;
63
+ export declare function shellCacheKey(url: URL | string, searchParams?: CacheSearchParams): string;
61
64
  /**
62
65
  * Read `x-rango-shell` from a response. Returns `null` when the header is
63
66
  * absent (axis-1 / non-ppr / ineligible request).
@@ -236,7 +236,7 @@ export interface RangoCloudflareOptions extends RangoBaseOptions {
236
236
  * `.vc-config.json` (and `config.json` for `functionName`).
237
237
  */
238
238
  export interface VercelPresetOptions {
239
- /** Node runtime for the function. @default "nodejs22.x" */
239
+ /** Node runtime for the function. @default "nodejs24.x" */
240
240
  runtime?: string;
241
241
  /** Max execution time in seconds. @default 30 */
242
242
  maxDuration?: number;
@@ -2530,7 +2530,7 @@ import { resolve } from "node:path";
2530
2530
  // package.json
2531
2531
  var package_default = {
2532
2532
  name: "@rangojs/router",
2533
- version: "0.2.0",
2533
+ version: "0.4.1",
2534
2534
  description: "Django-inspired RSC router with composable URL patterns",
2535
2535
  keywords: [
2536
2536
  "react",
@@ -2727,7 +2727,8 @@ var package_default = {
2727
2727
  picomatch: "^4.0.4",
2728
2728
  "rsc-html-stream": "^0.0.7",
2729
2729
  srvx: "^0.11.15",
2730
- tinyexec: "^0.3.2"
2730
+ tinyexec: "^0.3.2",
2731
+ typescript: "^5.3.0 || ^6.0.0"
2731
2732
  },
2732
2733
  devDependencies: {
2733
2734
  "@opentelemetry/api": "^1.9.0",
@@ -2745,7 +2746,6 @@ var package_default = {
2745
2746
  jiti: "^2.7.0",
2746
2747
  react: "catalog:",
2747
2748
  "react-dom": "catalog:",
2748
- typescript: "^5.3.0",
2749
2749
  vitest: "^4.1.9"
2750
2750
  },
2751
2751
  peerDependencies: {
@@ -4353,7 +4353,7 @@ export default toNodeHandler(fetchHandler);
4353
4353
  function assertVercelNodeRuntime(runtime) {
4354
4354
  if (runtime != null && !runtime.startsWith("nodejs")) {
4355
4355
  throw new Error(
4356
- `[rango] preset "vercel": runtime "${runtime}" is not supported. This preset emits a Node serverless function; use a "nodejs*" runtime (default "nodejs22.x"). The Edge runtime is not supported.`
4356
+ `[rango] preset "vercel": runtime "${runtime}" is not supported. This preset emits a Node serverless function; use a "nodejs*" runtime (default "nodejs24.x"). The Edge runtime is not supported.`
4357
4357
  );
4358
4358
  }
4359
4359
  }
@@ -4368,7 +4368,7 @@ function assertValidVercelFunctionName(functionName) {
4368
4368
  }
4369
4369
  function buildVercelVcConfig(vercel) {
4370
4370
  const vcConfig = {
4371
- runtime: vercel.runtime ?? "nodejs22.x",
4371
+ runtime: vercel.runtime ?? "nodejs24.x",
4372
4372
  handler: "index.mjs",
4373
4373
  launcherType: "Nodejs",
4374
4374
  shouldAddHelpers: false,
@@ -4715,10 +4715,13 @@ var PUSH_CALLBACK_SCOPE_KEY = /* @__PURE__ */ Symbol.for(
4715
4715
  );
4716
4716
  var pushCallbackScopeALS = globalThis[PUSH_CALLBACK_SCOPE_KEY] ??= new AsyncLocalStorage();
4717
4717
 
4718
+ // src/rsc/shell-capture-constants.ts
4719
+ var SHELL_CAPTURE_MAX_WAIT_MS = 15e3;
4720
+
4718
4721
  // src/rsc/shell-serve.ts
4719
4722
  var DEV_SHELL_PROBE_TIMEOUT_MS = 1e4;
4720
4723
  function normalizeCaptureTimeout(value) {
4721
- return typeof value === "number" && Number.isFinite(value) && value >= 1 ? value : void 0;
4724
+ return typeof value === "number" && Number.isFinite(value) && value >= 1 ? Math.min(value, SHELL_CAPTURE_MAX_WAIT_MS) : void 0;
4722
4725
  }
4723
4726
 
4724
4727
  // src/vite/inject-client-debug.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.2.0",
3
+ "version": "0.4.1",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -176,6 +176,19 @@
176
176
  "access": "public",
177
177
  "tag": "latest"
178
178
  },
179
+ "scripts": {
180
+ "build": "pnpm run build:types && pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
181
+ "build:types": "rm -rf dist/types && pnpm exec tsc -p tsconfig.types.json",
182
+ "prepublishOnly": "pnpm build",
183
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
184
+ "test": "playwright test",
185
+ "test:ui": "playwright test --ui",
186
+ "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-basename --project=hmr-prerender --no-deps --workers=1 && RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
187
+ "test:hmr-routes-local": "RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
188
+ "test:unit": "pnpm run build:types && vitest run",
189
+ "test:unit:watch": "vitest",
190
+ "test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
191
+ },
179
192
  "dependencies": {
180
193
  "@types/debug": "^4.1.12",
181
194
  "@vitejs/plugin-rsc": "^0.5.27",
@@ -184,26 +197,26 @@
184
197
  "picomatch": "^4.0.4",
185
198
  "rsc-html-stream": "^0.0.7",
186
199
  "srvx": "^0.11.15",
187
- "tinyexec": "^0.3.2"
200
+ "tinyexec": "^0.3.2",
201
+ "typescript": "^5.3.0 || ^6.0.0"
188
202
  },
189
203
  "devDependencies": {
190
204
  "@opentelemetry/api": "^1.9.0",
191
205
  "@opentelemetry/context-async-hooks": "^2.9.0",
192
206
  "@opentelemetry/sdk-trace-base": "^2.9.0",
193
207
  "@playwright/test": "^1.49.1",
208
+ "@shared/e2e": "workspace:*",
194
209
  "@testing-library/dom": "^10.4.1",
195
210
  "@testing-library/react": "^16.3.2",
196
211
  "@types/node": "^24.10.1",
197
- "@types/react": "^19.2.7",
198
- "@types/react-dom": "^19.2.3",
212
+ "@types/react": "catalog:",
213
+ "@types/react-dom": "catalog:",
199
214
  "esbuild": "^0.28.1",
200
215
  "happy-dom": "^20.10.1",
201
216
  "jiti": "^2.7.0",
202
- "react": "^19.2.6",
203
- "react-dom": "^19.2.6",
204
- "typescript": "^5.3.0",
205
- "vitest": "^4.1.9",
206
- "@shared/e2e": "0.0.1"
217
+ "react": "catalog:",
218
+ "react-dom": "catalog:",
219
+ "vitest": "^4.1.9"
207
220
  },
208
221
  "peerDependencies": {
209
222
  "@cloudflare/vite-plugin": "^1.42.1",
@@ -242,17 +255,5 @@
242
255
  },
243
256
  "engines": {
244
257
  "node": ">=24.0.0"
245
- },
246
- "scripts": {
247
- "build": "pnpm run build:types && pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
248
- "build:types": "rm -rf dist/types && pnpm exec tsc -p tsconfig.types.json",
249
- "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
250
- "test": "playwright test",
251
- "test:ui": "playwright test --ui",
252
- "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-basename --project=hmr-prerender --no-deps --workers=1 && RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
253
- "test:hmr-routes-local": "RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
254
- "test:unit": "pnpm run build:types && vitest run",
255
- "test:unit:watch": "vitest",
256
- "test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
257
258
  }
258
- }
259
+ }
@@ -267,6 +267,45 @@ const router = createRouter({
267
267
  });
268
268
  ```
269
269
 
270
+ ### Search param key filtering (`cache.searchParams`)
271
+
272
+ By default every non-reserved query param keys the cache, so
273
+ `?utm_source=tw` and `?utm_source=ig` occupy separate slots in every tier.
274
+ The global `searchParams` option controls which params participate in default
275
+ cache-key generation:
276
+
277
+ ```typescript
278
+ import { createRouter, TRACKING_SEARCH_PARAMS } from "@rangojs/router";
279
+
280
+ const router = createRouter({
281
+ document: Document,
282
+ urls: urlpatterns,
283
+ cache: {
284
+ store,
285
+ // "all" (default) | "none" | { include: string[] } | { exclude: string[] }
286
+ searchParams: { exclude: TRACKING_SEARCH_PARAMS },
287
+ },
288
+ });
289
+ ```
290
+
291
+ - **Key-only**: `ctx.searchParams` and the request URL are untouched — handlers
292
+ and loaders still see the full query string.
293
+ - **Matching**: exact names plus a `*` SUFFIX wildcard (`"utm_*"`). No RegExp.
294
+ - **All tiers**: segment, document, response, PPR shell capture/lookup, the
295
+ baked-shell manifest gate (a URL whose only params are excluded ones now
296
+ matches the prerendered shell), and `"use cache"` ctx key normalization.
297
+ - **`cache({ key })` override wins**: a custom key bypasses the filter along
298
+ with the rest of default key generation.
299
+ - **The footgun**: excluding a param promises the rendered output does not
300
+ depend on it. If it does, the first variant is cached and served to everyone.
301
+ That is why the default is `"all"`.
302
+ - `TRACKING_SEARCH_PARAMS` (also exported from `@rangojs/router/cache`) covers
303
+ `utm_*`, `gclid`, `fbclid`, `msclkid`, `ttclid`, `mc_cid`/`mc_eid`, and the
304
+ other common click-id params.
305
+
306
+ Global-only by design — the per-route "this page varies only by `q`" case is
307
+ already expressible with `cache({ key })`.
308
+
270
309
  ## Cache Stores
271
310
 
272
311
  ### Memory Store
@@ -539,9 +539,16 @@ analog but operates on loader/query reload, not RSC-segment render.
539
539
 
540
540
  ### Prefetching: stability and control
541
541
 
542
- `<Link prefetch="hover|viewport|render|adaptive|none">` (default `"none"`), plus
542
+ `<Link prefetch="hover|viewport|render|adaptive|none">` (default: the router's
543
+ `defaultPrefetch`, `"none"` in development and `"viewport"` in production), plus
543
544
  `prefetchKey` (`":source"` scopes a prefetch to the originating page for routes
544
- whose response branches on `currentUrl`). The distinguishing part is the stability
545
+ whose response branches on `currentUrl`). Intercepted plain `<a href>` elements
546
+ inside the router basename follow the same default; common static-resource
547
+ extensions are excluded unless `data-prefetch="true"` identifies an application
548
+ route, and `data-prefetch="false"` or `"none"` opts out unsafe GET links. A
549
+ container with `data-prefetch-scope="false"` or `"none"` is a hard subtree boundary for both
550
+ Links and plain anchors, so one annotation suppresses speculative work for a
551
+ whole navigation section. The distinguishing part is the stability
545
552
  gating: a queued prefetch (`viewport`/`render`) will not fire until **both** the
546
553
  main thread is idle (`requestIdleCallback`, 200ms fallback) **and**
547
554
  `waitForViewportImages()` resolves — in-viewport images that are not `.complete`
@@ -423,6 +423,30 @@ Don't edit the file by hand — re-run codegen when patterns change.
423
423
  }
424
424
  ```
425
425
 
426
+ ## Prefetch boundaries
427
+
428
+ `Link` follows its `prefetch` prop or the router's `defaultPrefetch`; eligible
429
+ plain anchors follow the router default. When a whole DOM section must never
430
+ speculate, mark its container instead of repeating per-link opt-outs:
431
+
432
+ ```tsx
433
+ <section data-prefetch-scope="none">
434
+ <Link to="/activity" prefetch="viewport">
435
+ Activity
436
+ </Link>
437
+ <a href="/export">Export</a>
438
+ </section>
439
+ ```
440
+
441
+ The scope is a hard opt-out for every descendant Link and plain anchor; `"false"`
442
+ and `"none"` are equivalent. An explicit `prefetch` prop or
443
+ `data-prefetch="true"` cannot override it, and navigation still works normally.
444
+ Use `data-prefetch="false"` or `"none"` directly on a plain anchor when only that
445
+ anchor is unsafe. Dynamic scope changes re-evaluate only descendant anchors and
446
+ mounted viewport/render Links. Links share one document-level scope observer,
447
+ not one observer per Link or container. Adding a scope cannot recall work already
448
+ queued or in flight; removing it re-arms both Link types.
449
+
426
450
  ## When to use what
427
451
 
428
452
  | Context | API | Resolves | Use for |
@@ -175,12 +175,14 @@ On a document GET to a ppr route the router runs:
175
175
  point is after the chain, an unauthorized request NEVER sees shell bytes — put
176
176
  auth middleware anywhere (global or route DSL) and it guards PPR for free.
177
177
 
178
- ### Soft navigation reuses the captured segment shell
178
+ ### Soft navigation caches and reuses the handler layer
179
179
 
180
- A usable shell snapshot also accelerates ordinary partial RSC navigations to
181
- the same URL. The capture records the canonical document segment tree alongside
182
- the HTML prelude. On a partial request the server replays only that segment
183
- record through the normal `matchPartial()` pipeline, which then:
180
+ Ordinary partial RSC navigations to a `ppr` URL use the same handler-layer cache
181
+ contract even when no document request has captured an HTML shell yet. When a
182
+ shell snapshot exists, the server replays its canonical document segment record.
183
+ On a cold partial request, normal matching renders the response and schedules a
184
+ background navigation-only shell capture; later navigations and prefetches
185
+ replay its eligible snapshot. In both cases `matchPartial()`:
184
186
 
185
187
  - preserves client-owned shared layouts by segment id;
186
188
  - returns only new or revalidating destination segments;
@@ -190,10 +192,30 @@ record through the normal `matchPartial()` pipeline, which then:
190
192
  This is deliberately invisible to the browser: the response is the same
191
193
  `RscPayload` shape as any other partial navigation. Captured item/response values
192
194
  and loader-container pins are NOT replayed on this path, so loader reads stay
193
- live. A route's own `cache()` scope still resolves its normal store, key, TTL,
194
- SWR, tags, and condition; only the implicit document scope sees the replay
195
- overlay, and fresh segment writes there stay request-local rather than polluting
196
- the canonical `doc:` namespace. `transition({ when })` is evaluated from the
195
+ live.
196
+
197
+ A route's own `cache()` scope including one inherited from an ancestor, the
198
+ common app-wide storefront shape COMPOSES with replay instead of disabling
199
+ it. The explicit tier stays authoritative: its lookup runs first with its
200
+ normal store, key, TTL, SWR, tags, and condition, and when it supplies the
201
+ match the response reports `BYPASS; reason=explicit-cache-hit` (never a false
202
+ replay `HIT`). Only when the explicit tier misses does the shell snapshot's
203
+ canonical doc segment record supply the match and report `HIT`. To make that
204
+ possible, a capture of such a route records the doc segment record into the
205
+ shell snapshot IN ADDITION to the scope's normal store write; the record rides
206
+ only inside the shell entry, never the real store. Two opt-outs stay absolute:
207
+ `cache(false)` and a `condition()` returning false mean "do not serve this
208
+ request's segments from any cache" — no doc record is captured for them and
209
+ the seeded fallback never rescues a refused read. `cache(false)` is static, so
210
+ replay reports `BYPASS; reason=cache-disabled` before performing a single
211
+ shell read; a `condition()` refusal is request-time state, decided at the
212
+ lookup itself and reported as the same `cache-disabled` post-match (the gate
213
+ must not pre-decide a predicate that could flap between the two evaluations).
214
+ An errored explicit read — a throwing `key()`, or a built-in store's swallowed
215
+ backend failure (`CACHE_READ_ERROR`) — also never falls back: it renders
216
+ uncached, exactly as without ppr.
217
+
218
+ `transition({ when })` is evaluated from the
197
219
  matched manifest before route handlers on every PPR match, so it can vary by
198
220
  URL/params/action or middleware context without disabling replay; handler-set
199
221
  context is unavailable by design. Intercepts, handler-live holes, an active
@@ -201,23 +223,60 @@ nonce, and an absent/corrupt segment snapshot fall open to the ordinary partial
201
223
  path when encountered by the shell capture. A transition already replayed from
202
224
  an explicit cache tier remains frozen by that tier's normal semantics.
203
225
 
226
+ Two more decisions are made before any shell-store read, so probes and
227
+ prerendered routes never spend passive `getShell` I/O:
228
+
229
+ - A partial request carrying neither `X-RSC-Router-Client-Path` nor `Referer`
230
+ (a curl probe, a synthetic monitor) can never produce a partial match; it
231
+ reports `BYPASS; reason=no-navigation-context`. Alert on replay hit-rate
232
+ with this in mind — such probes are not cache misses.
233
+ - A `Prerender()` route's partial is served from the build-time prerender
234
+ store inside matching (a better-than-HIT outcome); it reports
235
+ `BYPASS; reason=prerender-store`. Its captures never record a doc segment
236
+ record (the prerender store short-circuits the cache write), so replay
237
+ seeding would be impossible anyway.
238
+
204
239
  Fresh and stale-within-SWR runtime shells replay. The stale read is passive: it
205
240
  uses `getShell(key, { claimRevalidation: false })`, does not claim SWR ownership,
206
241
  and cannot recapture HTML. A later document request owns the background
207
- recapture; hard-expired entries fall open. Production may also use a fresh local
208
- build manifest; development stays runtime-only because probing `/__rsc_shell`
209
- would block navigation on an on-demand capture. Custom `SegmentCacheStore`
242
+ recapture; hard-expired entries schedule a navigation-only capture. Production
243
+ may also use a fresh local build manifest. Development does not probe
244
+ `/__rsc_shell`; it schedules the same local background capture instead. Custom `SegmentCacheStore`
210
245
  implementations must set `supportsPassiveShellReads: true` and honor the
211
246
  non-claiming read option.
212
247
 
248
+ The first cold partial request reports `BYPASS; reason=no-entry` because no
249
+ artifact could supply that response; its successful render schedules
250
+ `scheduleShellCapture` with a navigation-only marker. The next request reports
251
+ `HIT` when the capture produced an eligible snapshot. Navigation snapshots use
252
+ a separate shell key, so they cannot overwrite or be served as a document shell.
253
+ The capture runs with the stripped target document URL and loads SSR support in
254
+ the bounded background queue, outside the triggering partial response's latency.
255
+ An `allReady` decision still declines capture.
256
+
213
257
  Partial responses expose the actual decision as `x-rango-ppr-replay`:
214
258
  `HIT; freshness=fresh|stale` or `BYPASS; reason=<bounded-token>`. With
215
259
  performance metrics enabled, the same decision appears as
216
260
  `ppr-navigation-replay` in `Server-Timing`. `HIT` means matching consumed the
217
261
  seeded segment record after it decoded successfully, not merely that a snapshot
218
262
  existed. An explicit `cache()` scope that supplies the match cannot produce a
219
- false HIT. There is still no Flight resume API; this is segment replay followed
220
- by normal Flight streaming, not reuse of the HTML `prelude`/`postponed` bytes.
263
+ false HIT — it reports `explicit-cache-hit`. There is still no Flight resume
264
+ API; this is segment replay followed by normal Flight streaming, not reuse of
265
+ the HTML `prelude`/`postponed` bytes.
266
+
267
+ The bounded bypass tokens, grouped by when they are decided:
268
+
269
+ | Token | Decided | Meaning |
270
+ | --------------------------------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
271
+ | `method`, `dynamic`, `nonce`, `store-unavailable`, `passive-read-unsupported` | pre-read | request/route/store ineligible for replay |
272
+ | `no-navigation-context` | pre-read | no `X-RSC-Router-Client-Path`/`Referer`; a partial match is impossible |
273
+ | `prerender-store` | pre-read or match | `Prerender()` route served by its baked artifact (pre-read probe of the normal variant; reclassified post-match when the store actually served, either variant) |
274
+ | `intercept` | match | the navigation resolved to an intercept — replay is never armed for intercepts (they keep their normal cache path); no heal capture |
275
+ | `cache-disabled` | pre-read or match | `cache(false)` (pre-read, static) or `condition()` false (decided at the lookup); consumer opt-out is absolute |
276
+ | `read-error`, `no-entry`, `invalid-version`, `corrupt-entry`, `stale-build-entry` | shell read | no usable shell entry (`no-entry`/`invalid-version`/`corrupt-entry`/`stale-build-entry` schedule the navigation-only heal capture) |
277
+ | `handler-live-holes`, `transition-when`, `no-segment-snapshot` | eligibility | entry exists but its snapshot cannot replay (no canonical doc record, or handler/transition liveness); on a route with an enabled `cache()` scope, `no-segment-snapshot` heals when the lookup did not refuse (a `condition()` false-at-capture entry becomes replayable) |
278
+ | `explicit-cache-hit` | match | the route's own `cache()` tier supplied the match |
279
+ | `snapshot-miss` | match | an eligible snapshot was seeded but matching did not consume it |
221
280
 
222
281
  ### Capture-generation invalidation
223
282
 
@@ -292,7 +351,9 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
292
351
  - Structured capture diagnostics: `createRouter({ debugShellCapture: true })`
293
352
  logs one line per capture attempt/skip (outcome, durations, prelude and
294
353
  snapshot bytes, backoff state); pass a function to receive each
295
- `ShellCaptureDebugEvent` instead. In dev, with `debugPerformance` on, the
354
+ `ShellCaptureDebugEvent` instead. `skip-capacity` means the isolate already
355
+ has 32 queued/running captures; the dropped best-effort capture can retry on a
356
+ later request. In dev, with `debugPerformance` on, the
296
357
  last capture outcome for a key also rides the next document GET's
297
358
  `Server-Timing` as `ppr-capture;desc="…"`.
298
359
 
@@ -750,7 +811,10 @@ cache"` value baked into the shell is PINNED at capture (the capture data
750
811
  it: call `cacheTag(...)` from the shell-material render code (the render-time
751
812
  lever), or add the tag to `ppr.tags` (operational tags the render cannot know —
752
813
  a tenant id, a deploy marker). Ring-1/ring-3 tag invalidation does NOT drop the
753
- shell.
814
+ shell. Tags are optional: if TTL/SWR is the complete freshness policy, leave
815
+ the shell untagged. Rango does not warn for that choice; with
816
+ `debugShellCapture` enabled, a stored event reports `untaggedBake: true` when
817
+ bake-lane loader material uses TTL/SWR-only invalidation.
754
818
  - **Uncached nondeterminism in the shell is a hydration hazard**: a raw
755
819
  `Date.now()` / `Math.random()` / uncached `fetch` rendered directly in shell
756
820
  material (outside any cache ring) drifts between capture and hit and the
@@ -120,6 +120,15 @@ interface RangoOptions<TEnv> {
120
120
  // Set to false to disable prefetch caching.
121
121
  prefetchCacheTTL?: number | false;
122
122
 
123
+ // Default prefetch strategy for Links without a `prefetch` prop and for
124
+ // intercepted plain anchors inside basename. false/none opts out; true allows
125
+ // an application route whose path has a common static-resource extension.
126
+ // data-prefetch-scope="false"/"none" on a container is a hard subtree
127
+ // opt-out for both Links and plain anchors, including explicit opt-ins.
128
+ // (dev: "none", production: "viewport").
129
+ // Per-Link props win over the router default, not a disabled container scope.
130
+ defaultPrefetch?: "hover" | "viewport" | "render" | "adaptive" | "none";
131
+
123
132
  // CSP nonce provider (for router.fetch)
124
133
  nonce?: (
125
134
  request: Request,
@@ -8,20 +8,21 @@ RTL-style stub (peer of React Router's `createRoutesStub` / Expo's `renderRouter
8
8
 
9
9
  ### Options — `RenderRouteOptions`
10
10
 
11
- | Field | Type | Meaning |
12
- | --------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
- | `request` | `Request \| string` | Initial location. Only the URL is read (client render — headers/method ignored). Defaults to the leaf spec's static prefix or `"/"`. |
14
- | `loaderData` | `Record<string, unknown>` | Loader data keyed by loader `$$id`. `useLoader(L)` reads `loaderData[L.$$id]`. |
15
- | `loaders` | `ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>` | Seed by REFERENCE: `[loader, data]` pairs. Robust for real `createLoader()` handles whose `$$id` is empty in a bare test. Prefer over `loaderData`. |
16
- | `params` | `Record<string, string>` | Explicit params, merged over (and overriding) params extracted from the `request` URL. |
17
- | `locationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | Seed `useLocationState(def)` by REFERENCE: `[def, value]` pairs; written to `history.state`. |
18
- | `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Seed `useHandle(handle)` by REFERENCE: `[handle, pushedValues[]]`. Accumulated GLOBALLY (not segment-scoped). |
19
- | `handle` | `HandleDataSeed` | Advanced: raw wire format `{ [handleId]: { [segmentId]: pushedValues[] } }`. Prefer `handles`. Merged with it. |
20
- | `routeMap` | `Record<string, string>` | Name -> pattern map (informational; client `useReverse` takes its map as an argument, so this is not consumed). |
21
- | `basename` | `string` | `createRouter({ basename })` value. Wired into `NavigationProvider` so `useRouter().basename`, `<Link>` prefixing, `useMount`/`useHref` resolve against the mount. Normalized like `createRouter`. Defaults to root. |
22
- | `mount` | `string` | `include()` mount prefix. Wraps the segment chain in a `MountContext` so `useMount()` returns the prefix. Normalized like a path prefix. Defaults to `"/"`. |
23
- | `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape) to wrap the tree in a `ThemeProvider`. Defaults to no provider. A component calling `useTheme()` REQUIRES one. |
24
- | `nonce` | `string` | CSP nonce to seed via `NonceContext`, so a component calling `useNonce()` (e.g. an analytics/GTM head script) sees it — mirroring SSR. Defaults to `undefined` (the browser default). |
11
+ | Field | Type | Meaning |
12
+ | ----------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
+ | `request` | `Request \| string` | Initial location. Only the URL is read (client render — headers/method ignored). Defaults to the leaf spec's static prefix or `"/"`. |
14
+ | `loaderData` | `Record<string, unknown>` | Loader data keyed by loader `$$id`. `useLoader(L)` reads `loaderData[L.$$id]`. |
15
+ | `loaders` | `ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>` | Seed by REFERENCE: `[loader, data]` pairs. Robust for real `createLoader()` handles whose `$$id` is empty in a bare test. Prefer over `loaderData`. |
16
+ | `params` | `Record<string, string>` | Explicit params, merged over (and overriding) params extracted from the `request` URL. |
17
+ | `locationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | Seed `useLocationState(def)` by REFERENCE: `[def, value]` pairs; written to `history.state`. |
18
+ | `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Seed `useHandle(handle)` by REFERENCE: `[handle, pushedValues[]]`. Accumulated GLOBALLY (not segment-scoped). |
19
+ | `handle` | `HandleDataSeed` | Advanced: raw wire format `{ [handleId]: { [segmentId]: pushedValues[] } }`. Prefer `handles`. Merged with it. |
20
+ | `routeMap` | `Record<string, string>` | Name -> pattern map (informational; client `useReverse` takes its map as an argument, so this is not consumed). |
21
+ | `basename` | `string` | `createRouter({ basename })` value. Wired into `NavigationProvider` so `useRouter().basename`, `<Link>` prefixing, `useMount`/`useHref` resolve against the mount. Normalized like `createRouter`. Defaults to root. |
22
+ | `mount` | `string` | `include()` mount prefix. Wraps the segment chain in a `MountContext` so `useMount()` returns the prefix. Normalized like a path prefix. Defaults to `"/"`. |
23
+ | `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape) to wrap the tree in a `ThemeProvider`. Defaults to no provider. A component calling `useTheme()` REQUIRES one. |
24
+ | `nonce` | `string` | CSP nonce to seed via `NonceContext`, so a component calling `useNonce()` (e.g. an analytics/GTM head script) sees it — mirroring SSR. Defaults to `undefined` (the browser default). |
25
+ | `defaultPrefetch` | `PrefetchStrategy` | Router default for `<Link>` and eligible plain anchors. `data-prefetch="false"`/`"none"` opts out one anchor; ancestor `data-prefetch-scope="false"`/`"none"` hard-disables the subtree; `"true"` permits routed resource suffixes elsewhere. |
25
26
 
26
27
  `RenderRouteSpec = { path, Component, layout?, loaderIds?, name? }` — one node of the route definition. The array is the layout chain root-to-leaf; the LAST entry is the leaf route (its pattern is matched against `request` to extract params; layout patterns are informational). `loaderIds` attaches seeded loaders to THIS node's segment; `layout` on the leaf wraps it; `name` is informational.
27
28
 
@@ -56,7 +56,7 @@ Per-function knobs go under `vercel` and are written into `.vc-config.json`:
56
56
  rango({
57
57
  preset: "vercel",
58
58
  vercel: {
59
- runtime: "nodejs22.x", // default
59
+ runtime: "nodejs24.x", // default
60
60
  maxDuration: 30, // seconds, default
61
61
  memory: 1024, // MB (platform default when omitted)
62
62
  regions: ["fra1"], // pin regions (platform default when omitted)