@rangojs/router 0.5.0 → 0.5.2

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 (65) hide show
  1. package/README.md +5 -1
  2. package/dist/types/browser/event-controller.d.ts +6 -0
  3. package/dist/types/browser/prefetch/cache.d.ts +31 -2
  4. package/dist/types/cache/cf/cf-cache-constants.d.ts +8 -1
  5. package/dist/types/cache/cf/cf-cache-store.d.ts +15 -1
  6. package/dist/types/cache/cf/cf-cache-types.d.ts +1 -1
  7. package/dist/types/cache/cf/cf-kv-utils.d.ts +21 -0
  8. package/dist/types/handles/is-thenable.d.ts +2 -4
  9. package/dist/types/route-definition/helpers-types.d.ts +5 -4
  10. package/dist/types/rsc/helpers.d.ts +3 -0
  11. package/dist/types/rsc/render-pipeline.d.ts +9 -0
  12. package/dist/types/rsc/routine-plan.d.ts +124 -0
  13. package/dist/types/rsc/shell-capture-constants.d.ts +17 -0
  14. package/dist/types/server/request-context.d.ts +4 -1
  15. package/dist/types/static-handler.d.ts +15 -1
  16. package/dist/types/urls/path-helper-types.d.ts +6 -7
  17. package/dist/types/vite/encryption-key.d.ts +2 -0
  18. package/dist/types/vite/plugins/expose-internal-ids.d.ts +10 -0
  19. package/dist/types/vite/plugins/server-ref-hashing.d.ts +24 -0
  20. package/dist/types/vite/plugins/server-reference-pattern.d.ts +1 -0
  21. package/dist/types/vite/utils/shared-utils.d.ts +12 -0
  22. package/dist/vite/index.js +154 -57
  23. package/package.json +3 -3
  24. package/skills/caching/SKILL.md +1 -1
  25. package/skills/mime-routes/SKILL.md +3 -1
  26. package/skills/parallel/SKILL.md +1 -1
  27. package/skills/response-routes/SKILL.md +4 -2
  28. package/skills/typesafety/generated-files-and-cli.md +16 -9
  29. package/skills/typesafety/route-types.md +5 -1
  30. package/skills/use-cache/SKILL.md +47 -0
  31. package/src/browser/event-controller.ts +40 -15
  32. package/src/browser/partial-update.ts +26 -11
  33. package/src/browser/prefetch/cache.ts +53 -9
  34. package/src/browser/prefetch/fetch.ts +83 -2
  35. package/src/browser/react/NavigationProvider.tsx +7 -0
  36. package/src/cache/cache-runtime.ts +89 -15
  37. package/src/cache/cf/cf-cache-constants.ts +8 -1
  38. package/src/cache/cf/cf-cache-store.ts +41 -64
  39. package/src/cache/cf/cf-cache-types.ts +1 -1
  40. package/src/cache/cf/cf-kv-utils.ts +38 -0
  41. package/src/cache/segment-codec.ts +21 -5
  42. package/src/handles/is-thenable.ts +2 -4
  43. package/src/route-definition/helpers-types.ts +5 -3
  44. package/src/router/match-middleware/cache-lookup.ts +24 -15
  45. package/src/rsc/handler.ts +26 -5
  46. package/src/rsc/helpers.ts +13 -0
  47. package/src/rsc/progressive-enhancement.ts +247 -70
  48. package/src/rsc/render-pipeline.ts +68 -24
  49. package/src/rsc/routine-plan.ts +359 -0
  50. package/src/rsc/rsc-rendering.ts +555 -302
  51. package/src/rsc/server-action.ts +180 -71
  52. package/src/rsc/shell-capture-constants.ts +18 -0
  53. package/src/rsc/shell-capture.ts +92 -21
  54. package/src/server/request-context.ts +6 -0
  55. package/src/ssr/ssr-root.tsx +1 -0
  56. package/src/static-handler.ts +18 -2
  57. package/src/urls/path-helper-types.ts +9 -10
  58. package/src/vite/encryption-key.ts +29 -0
  59. package/src/vite/plugins/expose-action-id.ts +2 -2
  60. package/src/vite/plugins/expose-internal-ids.ts +46 -0
  61. package/src/vite/plugins/server-ref-hashing.ts +74 -0
  62. package/src/vite/plugins/server-reference-pattern.ts +10 -0
  63. package/src/vite/rango.ts +9 -0
  64. package/src/vite/router-discovery.ts +21 -2
  65. package/src/vite/utils/shared-utils.ts +12 -7
@@ -57,10 +57,13 @@ import { reportCacheError, reportingAsync } from "../cache-error.js";
57
57
  import type { CacheErrorCategory } from "../cache-error.js";
58
58
  import { bufferToBase64, base64ToBuffer } from "./cf-base64.js";
59
59
  import {
60
+ KV_KEY_PRESERVED_PREFIX_BYTES,
60
61
  KV_MAX_KEY_BYTES,
61
62
  KV_MIN_EXPIRATION_TTL,
62
63
  kvKeyByteLength,
64
+ kvKeyDigest,
63
65
  remainingCacheControl,
66
+ truncateToBytes,
64
67
  } from "./cf-kv-utils.js";
65
68
  import {
66
69
  TAG_MARKER_CACHE_PREFIX,
@@ -1325,7 +1328,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1325
1328
 
1326
1329
  // L2: delete from KV
1327
1330
  if (this.kv && this.waitUntil) {
1328
- const kvKey = this.toKVKey(key);
1331
+ const kvKey = await this.toKVKey(key);
1329
1332
  this.waitUntil(() =>
1330
1333
  reportingAsync(
1331
1334
  () => this.kv!.delete(kvKey),
@@ -1559,7 +1562,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1559
1562
 
1560
1563
  // L2: persist to KV (KV requires expirationTtl >= 60s)
1561
1564
  if (this.kv && this.waitUntil && totalTtl >= 60) {
1562
- const kvKey = this.toDocKVKey(key);
1565
+ const kvKey = await this.toDocKVKey(key);
1563
1566
  // Finding #3: never persist a per-client signal in the KV envelope.
1564
1567
  const headersArray: [string, string][] = [];
1565
1568
  response.headers.forEach((v, k) => {
@@ -1848,7 +1851,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1848
1851
  // JSON.stringify(KVItemEnvelope); field names differ from L1 so we assemble
1849
1852
  // from the pre-escaped value/handles pieces rather than re-stringifying.
1850
1853
  if (this.kv && this.waitUntil && totalTtl >= 60) {
1851
- const kvKey = this.toKVKey(`fn:${key}`);
1854
+ const kvKey = await this.toKVKey(`fn:${key}`);
1852
1855
  const expiresAt = staleAt + swrWindow * 1000;
1853
1856
  let envelopeJson = `{"v":${valueJson}`;
1854
1857
  if (handlesJson !== undefined) envelopeJson += `,"h":${handlesJson}`;
@@ -2073,20 +2076,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2073
2076
  const taggedAt =
2074
2077
  Array.isArray(tags) && tags.length > 0 ? entry.createdAt : undefined;
2075
2078
 
2076
- const kvKey = this.toKVKey(`shell:${key}`);
2077
- let writeKv = retentionTtl >= KV_MIN_EXPIRATION_TTL;
2078
- const kvKeyBytes = kvKeyByteLength(kvKey);
2079
- if (kvKeyBytes > KV_MAX_KEY_BYTES) {
2080
- reportCacheError(
2081
- new Error(
2082
- `shell cache key produces a ${kvKeyBytes}-byte KV key, over the ` +
2083
- `${KV_MAX_KEY_BYTES}-byte limit; the shell was not persisted to KV (L2).`,
2084
- ),
2085
- "cache-write",
2086
- "[CFCacheStore] putShell",
2087
- );
2088
- writeKv = false;
2089
- }
2079
+ const kvKey = await this.toKVKey(`shell:${key}`);
2080
+ const writeKv = retentionTtl >= KV_MIN_EXPIRATION_TTL;
2090
2081
 
2091
2082
  const write = (async (): Promise<"stored" | "invalidated" | void> => {
2092
2083
  if (
@@ -2219,7 +2210,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2219
2210
  if (!this.kv) return null;
2220
2211
  try {
2221
2212
  const readStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
2222
- const kvKey = this.toKVKey(`shell:${key}`);
2213
+ const kvKey = await this.toKVKey(`shell:${key}`);
2223
2214
  const { value: envelope, timedOut } =
2224
2215
  await this.kvGetOrEvict<CFShellEnvelope>(
2225
2216
  kvKey,
@@ -2330,11 +2321,27 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2330
2321
  /**
2331
2322
  * Convert string key to KV key string.
2332
2323
  * Uses same version prefix as Cache API for consistent invalidation.
2324
+ *
2325
+ * Single chokepoint for EVERY KV family (segments, items, shells, documents
2326
+ * via toDocKVKey, tag markers via tagMarkerKey): a composed key over
2327
+ * Cloudflare KV's 512-byte limit is normalized to a preserved readable
2328
+ * prefix plus a SHA-256-derived 128-bit digest of the FULL key. Without
2329
+ * this, kv.put/get reject with `414 ... exceeds key length limit of 512`
2330
+ * and the entry silently never reaches L2 (observed in production for
2331
+ * "use cache" items whose serialized args — e.g. a CMS query object — blow
2332
+ * the cap; autobarn pilot). Keys are opaque storage identifiers, so
2333
+ * normalization is semantics-preserving as long as distinct logical keys
2334
+ * stay distinct: colliding requires an identical 400-byte prefix AND a
2335
+ * 128-bit SHA-256 collision. Deterministic, so every family's read, write,
2336
+ * and delete paths agree on the stored key.
2333
2337
  * @internal
2334
2338
  */
2335
- private toKVKey(key: string): string {
2339
+ private async toKVKey(key: string): Promise<string> {
2336
2340
  const versionPath = this.version ? `v/${this.version}/` : "";
2337
- return `${versionPath}${key}`;
2341
+ const composed = `${versionPath}${key}`;
2342
+ if (kvKeyByteLength(composed) <= KV_MAX_KEY_BYTES) return composed;
2343
+ const prefix = truncateToBytes(composed, KV_KEY_PRESERVED_PREFIX_BYTES);
2344
+ return `${prefix}~${await kvKeyDigest(composed)}`;
2338
2345
  }
2339
2346
 
2340
2347
  /**
@@ -2364,7 +2371,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2364
2371
  * document tier is the one with a request-host context here.
2365
2372
  * @internal
2366
2373
  */
2367
- private toDocKVKey(key: string): string {
2374
+ private toDocKVKey(key: string): Promise<string> {
2368
2375
  return this.toKVKey(`h/${this.docKVHost()}/doc:${key}`);
2369
2376
  }
2370
2377
 
@@ -2480,7 +2487,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2480
2487
  // ============================================================================
2481
2488
 
2482
2489
  /** KV key for a tag's invalidation marker. */
2483
- private tagMarkerKey(tag: string): string {
2490
+ private tagMarkerKey(tag: string): Promise<string> {
2484
2491
  return this.toKVKey(`${TAG_MARKER_PREFIX}${tag}`);
2485
2492
  }
2486
2493
 
@@ -2805,7 +2812,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2805
2812
  // (reported cache-read), which also fails open. Either way one slow tag
2806
2813
  // never amplifies into a per-segment stall.
2807
2814
  const { value: raw, timedOut } = await this.readWithTimeout<string | null>(
2808
- () => this.kv!.get(this.tagMarkerKey(tag), { type: "text" }),
2815
+ async () => this.kv!.get(await this.tagMarkerKey(tag), { type: "text" }),
2809
2816
  this.kvReadTimeoutMs,
2810
2817
  "tag marker KV read",
2811
2818
  );
@@ -3012,7 +3019,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3012
3019
  *
3013
3020
  * Durable-write integrity: the in-memory write-through (memo + L1) for a tag
3014
3021
  * runs ONLY after that tag's KV marker write is confirmed. If any KV write
3015
- * fails (transient error, or an over-512-byte key), this rejects with the
3022
+ * fails (transient error; over-limit keys are normalized by toKVKey rather
3023
+ * than rejected), this rejects with the
3016
3024
  * failed tags so an awaiting updateTag() surfaces the failure instead of
3017
3025
  * silently reporting success while other requests/colos serve stale data. The
3018
3026
  * eager purge still fires for the whole batch first (it is additive).
@@ -3065,18 +3073,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3065
3073
  }
3066
3074
  await Promise.all(
3067
3075
  tags.map(async (tag) => {
3068
- const markerKey = this.tagMarkerKey(tag);
3069
- const markerKeyBytes = kvKeyByteLength(markerKey);
3070
- if (markerKeyBytes > KV_MAX_KEY_BYTES) {
3071
- failedTags.add(tag);
3072
- errors.push(
3073
- new Error(
3074
- `tag "${tag}" produces a ${markerKeyBytes}-byte KV ` +
3075
- `marker key, over the ${KV_MAX_KEY_BYTES}-byte limit`,
3076
- ),
3077
- );
3078
- return;
3079
- }
3076
+ // An over-limit tag no longer rejects: tagMarkerKey normalizes
3077
+ // through toKVKey, and the marker read path derives the key the same
3078
+ // way, so oversized tags invalidate correctly instead of erroring.
3079
+ const markerKey = await this.tagMarkerKey(tag);
3080
3080
  try {
3081
3081
  await this.kv!.put(markerKey, String(invalidatedAt), {
3082
3082
  ...(this.tagInvalidationTtl
@@ -3200,7 +3200,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3200
3200
  if (!this.kv) return null;
3201
3201
 
3202
3202
  try {
3203
- const kvKey = this.toKVKey(key);
3203
+ const kvKey = await this.toKVKey(key);
3204
3204
  const { value: envelope, timedOut } =
3205
3205
  await this.kvGetOrEvict<KVSegmentEnvelope>(
3206
3206
  kvKey,
@@ -3294,29 +3294,6 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3294
3294
  // KV requires expirationTtl >= 60s. Skip write for short-lived entries.
3295
3295
  if (!this.kv || !this.waitUntil || totalTtl < 60) return;
3296
3296
 
3297
- const kvKey = this.toKVKey(key);
3298
-
3299
- // Reject an oversized data-segment KV key the same way tag-marker keys are
3300
- // rejected in invalidateTags(). A key over KV_MAX_KEY_BYTES makes kv.put()
3301
- // fail, so the segment silently never lands in L2 (KV) and every cold-colo
3302
- // or TTL-expired read re-renders instead of serving stale. Segment keys can
3303
- // grow with user-controlled inputs (e.g. a route's search params), so report
3304
- // a clear, actionable error and skip the doomed write rather than letting it
3305
- // reject deep inside waitUntil as an opaque cache-write failure.
3306
- const kvKeyBytes = kvKeyByteLength(kvKey);
3307
- if (kvKeyBytes > KV_MAX_KEY_BYTES) {
3308
- reportCacheError(
3309
- new Error(
3310
- `cache segment key produces a ${kvKeyBytes}-byte KV key, over the ` +
3311
- `${KV_MAX_KEY_BYTES}-byte limit; the segment was not persisted to KV (L2). ` +
3312
- `Reduce the cache-key inputs (e.g. large search params on this route).`,
3313
- ),
3314
- "cache-write",
3315
- "[CFCacheStore] kvSetSegment",
3316
- );
3317
- return;
3318
- }
3319
-
3320
3297
  const expiresAt = staleAt + swrWindow * 1000;
3321
3298
  // Same wire shape as JSON.stringify({ d, s, e }) — dataJson is already
3322
3299
  // valid JSON for CachedEntryData, so embedding it avoids re-walking the tree.
@@ -3324,8 +3301,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3324
3301
 
3325
3302
  this.waitUntil(() =>
3326
3303
  reportingAsync(
3327
- () =>
3328
- this.kv!.put(kvKey, envelopeJson, {
3304
+ async () =>
3305
+ this.kv!.put(await this.toKVKey(key), envelopeJson, {
3329
3306
  expirationTtl: totalTtl,
3330
3307
  }),
3331
3308
  "cache-write",
@@ -3386,7 +3363,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3386
3363
  if (!this.kv) return null;
3387
3364
 
3388
3365
  try {
3389
- const kvKey = this.toKVKey(`fn:${key}`);
3366
+ const kvKey = await this.toKVKey(`fn:${key}`);
3390
3367
  const { value: envelope, timedOut } =
3391
3368
  await this.kvGetOrEvict<KVItemEnvelope>(
3392
3369
  kvKey,
@@ -3508,7 +3485,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3508
3485
  if (!this.kv) return null;
3509
3486
 
3510
3487
  try {
3511
- const kvKey = this.toDocKVKey(key);
3488
+ const kvKey = await this.toDocKVKey(key);
3512
3489
  // The document path is debug-silent (op is only get/getItem): a KV-read
3513
3490
  // timeout here is bounded for resilience parity (kvGetOrEvict applies the
3514
3491
  // budget) but emits no kv-timeout event, so its absence from the debug
@@ -333,7 +333,7 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
333
333
  * cannot stall the request; the read then falls through to its normal miss
334
334
  * path (L2/KV or render).
335
335
  *
336
- * Defaults to {@link EDGE_LOOKUP_TIMEOUT_MS} (10). Set to 0 (or any value
336
+ * Defaults to {@link EDGE_LOOKUP_TIMEOUT_MS} (25). Set to 0 (or any value
337
337
  * <= 0) to disable the budget and always await `match`.
338
338
  */
339
339
  edgeLookupTimeoutMs?: number;
@@ -26,6 +26,44 @@ export function kvKeyByteLength(key: string): number {
26
26
  return kvKeyEncoder.encode(key).length;
27
27
  }
28
28
 
29
+ /**
30
+ * Bytes of a composed key preserved verbatim when an over-limit key is
31
+ * normalized (toKVKey). 400 leaves room for the `~` separator and the 32-hex
32
+ * digest inside KV_MAX_KEY_BYTES while keeping the version prefix and most of
33
+ * the logical key readable (and prefix-listable) in the KV dashboard.
34
+ */
35
+ export const KV_KEY_PRESERVED_PREFIX_BYTES = 400;
36
+
37
+ /**
38
+ * Truncate to at most `maxBytes` of UTF-8. A multibyte sequence split at the
39
+ * boundary decodes to U+FFFD, which is stripped — the result is deterministic
40
+ * for a given input, which is all key normalization needs.
41
+ */
42
+ export function truncateToBytes(value: string, maxBytes: number): string {
43
+ const bytes = kvKeyEncoder.encode(value);
44
+ if (bytes.length <= maxBytes) return value;
45
+ return new TextDecoder()
46
+ .decode(bytes.subarray(0, maxBytes))
47
+ .replace(/�+$/, "");
48
+ }
49
+
50
+ /**
51
+ * 128-bit hex digest (SHA-256 truncated) of a composed KV key. WebCrypto is
52
+ * available on workerd and Node alike; SHA-256 (not a fast non-crypto hash)
53
+ * because cache keys embed user-influenced serialized args — an engineered
54
+ * collision between two normalized keys would serve one entry's payload for
55
+ * the other's.
56
+ */
57
+ export async function kvKeyDigest(value: string): Promise<string> {
58
+ const digest = await crypto.subtle.digest(
59
+ "SHA-256",
60
+ kvKeyEncoder.encode(value),
61
+ );
62
+ return Array.from(new Uint8Array(digest, 0, 16), (b) =>
63
+ b.toString(16).padStart(2, "0"),
64
+ ).join("");
65
+ }
66
+
29
67
  /**
30
68
  * Compute the Cache-Control directive for a stale-path REVALIDATING re-put from
31
69
  * the entry's stored hard-expiry deadline (CACHE_EXPIRES_AT_HEADER). Returns the
@@ -18,6 +18,12 @@ import {
18
18
  } from "@vitejs/plugin-rsc/rsc";
19
19
  import { createFromReadableStream } from "@vitejs/plugin-rsc/rsc";
20
20
 
21
+ // Preserve embedded server references on a cache/prerender HIT so they
22
+ // re-serialize to the client instead of resolving to a raw function React
23
+ // refuses to pass to a Client Component. Shared across the deserialize sites so
24
+ // the decode policy has a single owner.
25
+ const PRESERVE_SERVER_REFS = { preserveServerReferences: true } as const;
26
+
21
27
  /**
22
28
  * Convert a ReadableStream to a string.
23
29
  */
@@ -81,7 +87,11 @@ export async function rscDeserialize<T>(
81
87
 
82
88
  const temporaryReferences = createTemporaryReferenceSet();
83
89
  const stream = stringToStream(encoded);
84
- return createFromReadableStream<T>(stream, { temporaryReferences });
90
+ return createFromReadableStream<T>(
91
+ stream,
92
+ { temporaryReferences },
93
+ PRESERVE_SERVER_REFS,
94
+ );
85
95
  }
86
96
 
87
97
  /**
@@ -116,7 +126,11 @@ export async function serializeResult(value: unknown): Promise<string | null> {
116
126
  export async function deserializeResult<T>(encoded: string): Promise<T> {
117
127
  const temporaryReferences = createTemporaryReferenceSet();
118
128
  const stream = stringToStream(encoded);
119
- return createFromReadableStream<T>(stream, { temporaryReferences });
129
+ return createFromReadableStream<T>(
130
+ stream,
131
+ { temporaryReferences },
132
+ PRESERVE_SERVER_REFS,
133
+ );
120
134
  }
121
135
 
122
136
  /**
@@ -271,9 +285,11 @@ export async function deserializeSegments(
271
285
 
272
286
  const [component, layout, loaderData, loaderDataPromise, loadingData] =
273
287
  await Promise.all([
274
- createFromReadableStream(stringToStream(item.encoded), {
275
- temporaryReferences,
276
- }),
288
+ createFromReadableStream(
289
+ stringToStream(item.encoded),
290
+ { temporaryReferences },
291
+ PRESERVE_SERVER_REFS,
292
+ ),
277
293
  rscDeserialize(item.encodedLayout),
278
294
  rscDeserialize(item.encodedLoaderData),
279
295
  rscDeserialize(item.encodedLoaderDataPromise),
@@ -1,8 +1,6 @@
1
1
  /**
2
- * Single thenable predicate for the resolve-by-default handle machinery
3
- * (`handles/deferred-resolution.ts`, its sole owner) it decides which top-level
4
- * handle entries are deferred (`Promise`) values to await before any consumer
5
- * sees them, and which pass through verbatim.
2
+ * Shared thenable predicate for framework orchestration. It decides which values
3
+ * participate in promise lifecycles and which pass through synchronously.
6
4
  *
7
5
  * Requires a CALLABLE `then` (`typeof obj.then === "function"`), not merely a
8
6
  * `"then" in obj` membership check, so a non-promise carrying a `then` field
@@ -33,6 +33,7 @@ import type {
33
33
  TransitionItem,
34
34
  UseItems,
35
35
  } from "../route-types.js";
36
+ import type { StaticHandlerRef } from "../static-handler.js";
36
37
 
37
38
  // Re-export route item types for backward compatibility
38
39
  export type {
@@ -140,8 +141,8 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
140
141
  * },
141
142
  * })
142
143
  * ```
143
- * @param slots - Object with slot names (prefixed with @) mapped to handlers
144
- * or `{ handler, use? }` slot descriptors.
144
+ * @param slots - Object with slot names (prefixed with @) mapped to handlers,
145
+ * Static() definitions, or `{ handler, use? }` descriptors.
145
146
  * @param use - Optional callback for loaders, loading, revalidate, etc.
146
147
  * Items here apply to every slot in the call (broadcast).
147
148
  * For per-slot single-assignment items, use the slot descriptor's
@@ -157,8 +158,9 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
157
158
  `@${string}`,
158
159
  | Handler<any, any, TEnv>
159
160
  | ReactNode
161
+ | StaticHandlerRef
160
162
  | {
161
- handler: Handler<any, any, TEnv> | ReactNode;
163
+ handler: Handler<any, any, TEnv> | ReactNode | StaticHandlerRef;
162
164
  use?: () => UseItems<ParallelUseItem>;
163
165
  }
164
166
  >,
@@ -450,21 +450,30 @@ export function withCacheLookup<TEnv>(
450
450
  resolveLoadersOnly,
451
451
  } = getRouterContext<TEnv>();
452
452
 
453
- if (
454
- !ctx.isAction &&
455
- prerenderStoreShortCircuits(ctx.matched.pr, ctx.request)
456
- ) {
457
- await ensurePrerenderDeps();
458
- if (prerenderStoreInstance) {
459
- const served = yield* tryPrerenderLookup(
460
- ctx,
461
- state,
462
- pipelineStart,
463
- pipelineReqCtx,
464
- resolveLoadersOnly,
465
- resolveLoadersOnlyWithRevalidation,
466
- );
467
- if (served) return;
453
+ if (prerenderStoreShortCircuits(ctx.matched.pr, ctx.request)) {
454
+ // Actions normally re-render fresh and skip the prerender store. But a pure
455
+ // Prerender route's handler is evicted at build, so there is no fresh
456
+ // handler to run on an action re-render -- without the fallback the
457
+ // re-render falls through to the evicted handler and throws "No prerender
458
+ // data found". Serve the prerendered entry instead (the action ran already;
459
+ // its result is applied client-side via useActionState). Passthrough routes
460
+ // keep a liveHandler, so they still re-render fresh on actions.
461
+ const isPassthroughPrerenderRoute = ctx.entries.some(
462
+ (entry) => entry.type === "route" && entry.isPassthrough === true,
463
+ );
464
+ if (!ctx.isAction || !isPassthroughPrerenderRoute) {
465
+ await ensurePrerenderDeps();
466
+ if (prerenderStoreInstance) {
467
+ const served = yield* tryPrerenderLookup(
468
+ ctx,
469
+ state,
470
+ pipelineStart,
471
+ pipelineReqCtx,
472
+ resolveLoadersOnly,
473
+ resolveLoadersOnlyWithRevalidation,
474
+ );
475
+ if (served) return;
476
+ }
468
477
  }
469
478
  }
470
479
 
@@ -110,6 +110,7 @@ import {
110
110
  type RequestPlan,
111
111
  type ExecutableRequestPlan,
112
112
  } from "../router/request-classification.js";
113
+ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
113
114
 
114
115
  /**
115
116
  * Create an RSC request handler.
@@ -247,7 +248,8 @@ export function createRSCHandler<
247
248
  actionId?: string,
248
249
  ): Promise<Response> {
249
250
  const timeoutError = new RouterTimeoutError(phase, durationMs);
250
- const cursor = _getRequestContext<TEnv>()?._renderForeground;
251
+ const requestContext = _getRequestContext<TEnv>();
252
+ const cursor = requestContext?._renderForeground;
251
253
  const render: RenderTimeoutContext | undefined =
252
254
  phase === "render-start" && cursor
253
255
  ? {
@@ -261,11 +263,29 @@ export function createRSCHandler<
261
263
  }),
262
264
  }
263
265
  : undefined;
266
+ const trace =
267
+ phase === "render-start" ? requestContext?._activeRoutine : undefined;
268
+ const activeEntries = trace?.active() ?? [];
269
+ const activeStep = activeEntries.at(-1);
270
+ const activeAt = performance.now();
271
+ const routineSnapshot =
272
+ trace && activeStep
273
+ ? {
274
+ name: trace.name,
275
+ path: activeEntries.map((entry) => entry.name),
276
+ durationMs: activeAt - activeStep.startedAt,
277
+ }
278
+ : undefined;
279
+
280
+ if (INTERNAL_RANGO_DEBUG && trace && activeStep) {
281
+ console.log(
282
+ `[routine] TIMEOUT ${request.method} ${url.pathname} (${trace.name})\n${trace.formatActive(activeEntries, activeAt)}`,
283
+ );
284
+ }
264
285
 
265
- // Each surface gets its OWN shallow copy of the snapshot. onError, telemetry,
266
- // and onTimeout are independent consumers; a consumer that mutates its
267
- // `render` (e.g. an onError redacting metadata) must not corrupt what the
268
- // other two observe.
286
+ // Each surface gets its OWN shallow render snapshot. A consumer that mutates
287
+ // its copy must not corrupt what the other two observe. The internal-debug
288
+ // routine snapshot is bounded metadata sent only to onError.
269
289
  callOnError(timeoutError, phase === "action" ? "action" : "handler", {
270
290
  request,
271
291
  url,
@@ -278,6 +298,7 @@ export function createRSCHandler<
278
298
  phase,
279
299
  durationMs,
280
300
  ...(render && { render: { ...render } }),
301
+ ...(routineSnapshot && { routine: routineSnapshot }),
281
302
  },
282
303
  });
283
304
 
@@ -7,6 +7,7 @@
7
7
  import {
8
8
  _getRequestContext,
9
9
  getLocationState,
10
+ setRequestContextParams,
10
11
  } from "../server/request-context.js";
11
12
  import type { RequestContext } from "../server/request-context.js";
12
13
  import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
@@ -23,6 +24,7 @@ import {
23
24
  import type { MiddlewareEntry, MiddlewareFn } from "../router/middleware.js";
24
25
  import { formatCacheSignalHeader } from "../router/telemetry.js";
25
26
  import type { RscPayload } from "./types.js";
27
+ import type { HandlerContext } from "./handler-context.js";
26
28
 
27
29
  /**
28
30
  * DEVELOPMENT/TEST ONLY. When the debug cache signal gate is on,
@@ -381,3 +383,14 @@ export function finalizeResponse(response: Response): Response {
381
383
  if (!ctx) return response;
382
384
  return drainOnResponseCallbacks(ctx, response);
383
385
  }
386
+
387
+ /** Match and stamp params/routeName on the request context as one operation. */
388
+ export async function matchAndRecordParams<TEnv>(
389
+ ctx: HandlerContext<TEnv>,
390
+ request: Request,
391
+ env: TEnv,
392
+ ): Promise<Awaited<ReturnType<HandlerContext<TEnv>["router"]["match"]>>> {
393
+ const match = await ctx.router.match(request, { env });
394
+ setRequestContextParams(match.params, match.routeName);
395
+ return match;
396
+ }