@rangojs/router 0.0.0-experimental.143 → 0.0.0-experimental.145

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 (51) hide show
  1. package/dist/vite/index.js +24 -6
  2. package/package.json +2 -2
  3. package/skills/cache-guide/SKILL.md +3 -1
  4. package/skills/caching/SKILL.md +23 -2
  5. package/skills/catalog.json +6 -0
  6. package/skills/defer-hydration/SKILL.md +235 -0
  7. package/skills/loader/SKILL.md +5 -0
  8. package/skills/migrate-nextjs/SKILL.md +4 -2
  9. package/skills/parallel/SKILL.md +2 -0
  10. package/skills/ppr/SKILL.md +63 -33
  11. package/skills/rango/SKILL.md +10 -0
  12. package/skills/use-cache/SKILL.md +12 -2
  13. package/src/browser/logging.ts +18 -0
  14. package/src/browser/partial-update.ts +7 -0
  15. package/src/browser/rsc-router.tsx +43 -0
  16. package/src/cache/cache-key-utils.ts +29 -0
  17. package/src/cache/cache-runtime.ts +41 -51
  18. package/src/cache/cache-scope.ts +2 -17
  19. package/src/cache/cache-tag.ts +60 -14
  20. package/src/cache/cf/cf-cache-store.ts +58 -20
  21. package/src/cache/document-cache.ts +17 -11
  22. package/src/cache/types.ts +18 -4
  23. package/src/cache/vercel/vercel-cache-store.ts +15 -20
  24. package/src/redirect-origin.ts +14 -0
  25. package/src/route-map-builder.ts +17 -3
  26. package/src/router/lazy-includes.ts +8 -2
  27. package/src/router/loader-resolution.ts +14 -2
  28. package/src/router/match-handlers.ts +11 -6
  29. package/src/router/middleware.ts +4 -1
  30. package/src/router/segment-resolution/loader-cache.ts +19 -3
  31. package/src/router/segment-resolution/loader-mask.ts +4 -11
  32. package/src/router/segment-resolution/loader-snapshot.ts +14 -6
  33. package/src/router/segment-resolution/mask-nested.ts +83 -0
  34. package/src/router/telemetry.ts +9 -1
  35. package/src/router.ts +7 -8
  36. package/src/rsc/handler.ts +9 -2
  37. package/src/rsc/redirect-guard.ts +2 -1
  38. package/src/rsc/rsc-rendering.ts +122 -18
  39. package/src/rsc/shell-capture.ts +125 -20
  40. package/src/rsc/shell-serve.ts +37 -6
  41. package/src/segment-loader-promise.ts +18 -0
  42. package/src/segment-system.tsx +90 -6
  43. package/src/server/context.ts +47 -9
  44. package/src/server/cookie-store.ts +26 -5
  45. package/src/server/request-context.ts +22 -0
  46. package/src/ssr/index.tsx +160 -113
  47. package/src/ssr/inject-rsc-eager.ts +167 -0
  48. package/src/testing/dispatch.ts +7 -0
  49. package/src/vite/index.ts +7 -0
  50. package/src/vite/inject-client-debug.ts +64 -12
  51. package/src/vite/router-discovery.ts +9 -1
@@ -225,10 +225,13 @@ export interface CacheItemResult {
225
225
  * One entry carries BOTH artifacts a resume needs — the rendered HTML prelude
226
226
  * and React's postponed state — because the pair is version- and
227
227
  * generation-coupled and must never be mixed across a React upgrade or a build
228
- * change. The reactVersion field is the read-time gate that enforces that: the
229
- * shell-cache middleware treats an entry whose reactVersion differs from the
230
- * running React as a miss (the postponed blob is build-coupled and cannot be
231
- * resumed by a different React).
228
+ * change. The reactVersion and buildVersion fields are the read-time gates that
229
+ * enforce both halves: isValidShellHit (rsc/shell-serve.ts) treats an entry
230
+ * whose reactVersion differs from the running React, or whose buildVersion
231
+ * differs from the running build, as a miss (the postponed blob encodes hole
232
+ * positions against one exact tree; resuming it against a different React or a
233
+ * different app build tree-mismatches inside resume(), AFTER the 200 + prelude
234
+ * are committed — an unrecoverable broken serve).
232
235
  */
233
236
  export interface ShellCacheEntry {
234
237
  /** Rendered HTML prelude bytes, base64-encoded (stores are JSON-serializing). */
@@ -240,6 +243,17 @@ export interface ShellCacheEntry {
240
243
  postponed: string | null;
241
244
  /** React.version captured at prerender time; the read-time invalidation gate. */
242
245
  reactVersion: string;
246
+ /**
247
+ * Build version captured at prerender time (the RSC handler's `version` —
248
+ * the `@rangojs/router:version` build stamp by default, bumped per build and
249
+ * on dev RSC-module edits). The second read-time gate: a persistent shared
250
+ * store (KV/runtime-cache) survives deploys, and an app-code change that
251
+ * keeps the same React version would otherwise leave a stale-build
252
+ * prelude+postponed live under the same key. Optional only for entries
253
+ * stored before the field existed — those are treated as a miss and the
254
+ * recapture re-stamps them (pre-release, no compat shim).
255
+ */
256
+ buildVersion?: string;
243
257
  /**
244
258
  * The initialTheme the CAPTURE render was built with (the derived context's
245
259
  * reqCtx.theme). The resume tail must render ThemeProvider with the SAME
@@ -51,6 +51,15 @@ import {
51
51
  } from "../cache-policy.js";
52
52
  import { reportCacheError, reportingAsync } from "../cache-error.js";
53
53
  import type { CacheErrorCategory } from "../cache-error.js";
54
+ // Reuse the CF store's binary-safe base64 helpers. bufferToBase64 caps each
55
+ // String.fromCharCode batch at 8192 and uses .apply (never a spread), so a large
56
+ // Response/PPR-shell body cannot blow the JS argument-count ceiling (~65k) and
57
+ // throw RangeError inside putResponse/putShell - which the outer try/catch would
58
+ // swallow as a cache-write degrade, silently never caching the entry. Output is
59
+ // byte-identical to a per-byte encoder (chunk size does not affect base64), so
60
+ // this is a robustness fix, not a format change. Do NOT reintroduce a local
61
+ // spread-based encoder or raise the chunk here; cf-base64.ts is import-pure.
62
+ import { bufferToBase64, base64ToBuffer } from "../cf/cf-base64.js";
54
63
 
55
64
  /**
56
65
  * Minimal structural shape of the Vercel Runtime Cache returned by `getCache()`
@@ -168,6 +177,8 @@ interface VercelShellEnvelope {
168
177
  po: string | null;
169
178
  /** React.version at capture. */
170
179
  rv: string;
180
+ /** Build version at capture (ShellCacheEntry.buildVersion). */
181
+ bv?: string;
171
182
  /** createdAt (ms since epoch). */
172
183
  c: number;
173
184
  /** staleAt (ms since epoch). */
@@ -277,25 +288,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
277
288
  return typeof value === "object" && value !== null;
278
289
  }
279
290
 
280
- /** Encode binary body bytes to base64 in chunks (avoids call-stack blowups). */
281
- function bufferToBase64(buffer: ArrayBuffer): string {
282
- const bytes = new Uint8Array(buffer);
283
- let binary = "";
284
- const CHUNK = 0x8000;
285
- for (let i = 0; i < bytes.length; i += CHUNK) {
286
- binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
287
- }
288
- return btoa(binary);
289
- }
290
-
291
- /** Decode a base64 body back into bytes. */
292
- function base64ToBuffer(b64: string): ArrayBuffer {
293
- const binary = atob(b64);
294
- const bytes = new Uint8Array(binary.length);
295
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
296
- return bytes.buffer;
297
- }
298
-
299
291
  /**
300
292
  * Vercel Runtime Cache-backed segment cache store.
301
293
  *
@@ -786,6 +778,7 @@ export class VercelCacheStore<
786
778
  prelude: env.p,
787
779
  postponed: env.po,
788
780
  reactVersion: env.rv,
781
+ buildVersion: env.bv,
789
782
  initialTheme: env.i,
790
783
  snapshot: env.sn,
791
784
  createdAt: env.c,
@@ -815,6 +808,7 @@ export class VercelCacheStore<
815
808
  p: entry.prelude,
816
809
  po: entry.postponed,
817
810
  rv: entry.reactVersion,
811
+ bv: entry.buildVersion,
818
812
  c: entry.createdAt,
819
813
  s: staleAt,
820
814
  e: expiresAt,
@@ -1101,7 +1095,7 @@ export class VercelCacheStore<
1101
1095
 
1102
1096
  private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
1103
1097
  if (!isRecord(raw)) return null;
1104
- const { p, po, rv, c, s, e, t, i, sn } = raw;
1098
+ const { p, po, rv, bv, c, s, e, t, i, sn } = raw;
1105
1099
  if (typeof p !== "string" || typeof rv !== "string") return null;
1106
1100
  if (po !== null && typeof po !== "string") return null;
1107
1101
  if (typeof c !== "number") return null;
@@ -1110,6 +1104,7 @@ export class VercelCacheStore<
1110
1104
  p,
1111
1105
  po: po as string | null,
1112
1106
  rv,
1107
+ bv: typeof bv === "string" ? bv : undefined,
1113
1108
  c,
1114
1109
  s,
1115
1110
  e,
@@ -60,6 +60,20 @@ export function resolveExternalRedirect(
60
60
  }
61
61
  }
62
62
 
63
+ /**
64
+ * The safe same-origin landing for a blocked redirect.
65
+ *
66
+ * Every guard that neutralizes a cross-origin/unsafe redirect target sends the
67
+ * browser here instead: the app's basename root, or `"/"` when unset. Kept
68
+ * beside the resolvers so the "where does a blocked redirect go" answer lives
69
+ * in ONE place -- the server 3xx guard (`rsc/redirect-guard.ts`) and the
70
+ * shell-HIT degradation path (`rsc/rsc-rendering.ts`) must agree, or a blocked
71
+ * redirect lands differently depending on which exit it took.
72
+ */
73
+ export function safeSameOriginLanding(basename: string | undefined): string {
74
+ return basename && basename !== "/" ? basename : "/";
75
+ }
76
+
63
77
  /**
64
78
  * Out-of-band brand for `redirect(url, { external: true })`.
65
79
  *
@@ -20,11 +20,25 @@ let cachedPrecomputedEntries: Array<{
20
20
  /**
21
21
  * Register routes into the global route map.
22
22
  * Routes are merged with any existing registered routes.
23
- * Called by createRouter() during module evaluation.
23
+ * Called by createRouter() during module evaluation, and by lazy-include
24
+ * expansion (src/router/lazy-includes.ts) with each expansion's route delta.
25
+ *
26
+ * Merges IN PLACE — O(|map|), not O(total routes). The previous
27
+ * `globalRouteMap = { ...globalRouteMap, ...map }` copy made every
28
+ * lazy-include first hit O(total routes) on the request path: with a 26k-route
29
+ * manifest the spread measured 8.9ms/call (M4, node), paid once per level of a
30
+ * nested async-include chain (3 calls on a 3-level chain — the 464ms edge
31
+ * cold-hit in issue #666).
32
+ *
33
+ * In-place mutation is safe because every getGlobalRouteMap() consumer reads
34
+ * it fresh per call (server/request-context.ts, rsc/loader-fetch.ts,
35
+ * router/intercept-resolution.ts, testing/generated-routes.ts,
36
+ * rsc/manifest-init.ts) — none memoizes the returned reference. If you add a
37
+ * consumer that caches the map object, it will now observe later
38
+ * registrations; snapshot it yourself if you need frozen contents.
24
39
  */
25
40
  export function registerRouteMap(map: Record<string, string>): void {
26
- // Always merge with existing map (don't replace)
27
- globalRouteMap = { ...globalRouteMap, ...map };
41
+ Object.assign(globalRouteMap, map);
28
42
  }
29
43
 
30
44
  /**
@@ -91,7 +91,11 @@ export function evaluateLazyEntry<TEnv = any>(
91
91
  for (const [name, pattern] of Object.entries(routes)) {
92
92
  deps.mergedRouteMap[name] = pattern;
93
93
  }
94
- registerRouteMap(deps.mergedRouteMap);
94
+ // Register only this entry's routes (the delta): the full
95
+ // mergedRouteMap is seeded from the generated manifest at
96
+ // createRouter() time and already registered there — re-passing it
97
+ // made this request-path call O(total routes) (issue #666).
98
+ registerRouteMap(routes);
95
99
  return;
96
100
  }
97
101
  }
@@ -245,7 +249,9 @@ function runExpansion<TEnv = any>(
245
249
  deps.routesEntries.splice(insertIndex, 0, nestedEntry);
246
250
  }
247
251
 
248
- registerRouteMap(deps.mergedRouteMap);
252
+ // Delta only — see the matching comment on the precomputed branch above and
253
+ // the WHY block on registerRouteMap (issue #666).
254
+ registerRouteMap(routesObject);
249
255
 
250
256
  // Expansion fully succeeded (handler ran, routes + nested includes spliced) —
251
257
  // mark done now so a mid-expansion throw above leaves lazyEvaluated=false and
@@ -420,10 +420,22 @@ function createLoaderExecutor<TEnv>(
420
420
  // throw. rendered() gating uses the captured isDslLoader (above), so this
421
421
  // does not grant rendered() to handler-invoked loaders. Uses a body-only
422
422
  // scope, so isInsideLoaderScope() / barrier / deadlock gating is unchanged.
423
+ //
424
+ // `handlerInvoked` (!isDslLoader) rides on the scope for the CONSUMPTION-
425
+ // LANE RULE: a handler-consumed loader's value is a BAKED copy in every
426
+ // shared artifact (cache(), "use cache", the PPR shell), so its identity
427
+ // reads are exempt from the shell-capture guard — same allowance the
428
+ // cache-purity guards give it. DSL segment loaders keep their lane
429
+ // machinery (live = masked at capture, bake = guarded). A DSL loader's
430
+ // nested deps inherit isDslLoader=false only when the CHAIN started in a
431
+ // handler; a chain started by the segment funnel stays DSL (the loader
432
+ // scope ALS survives the body's awaits).
423
433
  const promise = observePhase(PHASES.loader(loader.$$id), () =>
424
434
  Promise.resolve(
425
- runInsideLoaderBodyScope(() =>
426
- loaderFn(loaderCtx as LoaderContext<any, TEnv>),
435
+ runInsideLoaderBodyScope(
436
+ () => loaderFn(loaderCtx as LoaderContext<any, TEnv>),
437
+ loader.$$id,
438
+ !isDslLoader,
427
439
  ),
428
440
  ).finally(() => {
429
441
  pendingLoaders.delete(loader.$$id);
@@ -52,7 +52,7 @@ import { _getRequestContext } from "../server/request-context.js";
52
52
  */
53
53
  interface LifecycleEmitter {
54
54
  start(): void;
55
- end(segmentCount: number, cacheHit: boolean): void;
55
+ end(segmentCount: number, cacheHit: boolean, status?: number): void;
56
56
  cacheDecision(
57
57
  routeKey: string,
58
58
  state: {
@@ -88,7 +88,7 @@ function createLifecycleEmitter(args: {
88
88
  isPartial: args.isPartial,
89
89
  });
90
90
  },
91
- end(segmentCount: number, cacheHit: boolean): void {
91
+ end(segmentCount: number, cacheHit: boolean, status?: number): void {
92
92
  if (!args.enabled) return;
93
93
  safeEmit(args.sink, {
94
94
  type: "request.end",
@@ -100,6 +100,9 @@ function createLifecycleEmitter(args: {
100
100
  durationMs: performance.now() - args.matchStart,
101
101
  segmentCount,
102
102
  cacheHit,
103
+ // Only a thrown-Response short-circuit passes a status; a normal render
104
+ // completion omits it (the Response is built after match()).
105
+ ...(status !== undefined && { status }),
103
106
  });
104
107
  },
105
108
  cacheDecision(
@@ -322,8 +325,9 @@ export function createMatchHandlers<TEnv = any>(
322
325
  // error: emit request.end (the same shape the non-thrown redirect
323
326
  // result above already emits), never request.error with a
324
327
  // synthetic "[object Response]" error. Rethrow so the caller
325
- // drives the redirect.
326
- emitter.end(0, false);
328
+ // drives the redirect. Carry the Response's status so a sink can
329
+ // tell a 3xx short-circuit from a 2xx completion.
330
+ emitter.end(0, false, error.status);
327
331
  throw error;
328
332
  }
329
333
  emitter.error(
@@ -441,8 +445,9 @@ export function createMatchHandlers<TEnv = any>(
441
445
  // A thrown Response (middleware short-circuit — redirect / auth
442
446
  // gate) is a COMPLETED request, not an error: emit request.end
443
447
  // (parity with match()), never request.error. Rethrow so the
444
- // caller drives the redirect.
445
- emitter.end(0, false);
448
+ // caller drives the redirect. Carry the Response's status so a
449
+ // sink can tell a 3xx short-circuit from a 2xx completion.
450
+ emitter.end(0, false, error.status);
446
451
  throw error;
447
452
  }
448
453
  emitter.error(
@@ -312,7 +312,10 @@ export function createMiddlewareContext<TEnv>(
312
312
  const reqCtx = _getRequestContext();
313
313
  if (reqCtx) {
314
314
  reqCtx._debugPerformance = true;
315
- reqCtx._metricsStore ??= createMetricsStore(true);
315
+ // Anchor to the true request entry (reqCtx._handlerStart) so phases that
316
+ // began before this opt-in report non-negative offsets; undefined falls
317
+ // back to performance.now() in createMetricsStore.
318
+ reqCtx._metricsStore ??= createMetricsStore(true, reqCtx._handlerStart);
316
319
  }
317
320
  },
318
321
  };
@@ -36,7 +36,10 @@ import {
36
36
  DEFAULT_ROUTE_TTL,
37
37
  } from "../../cache/cache-policy.js";
38
38
  import { readThroughItem } from "../../cache/read-through-swr.js";
39
- import { overlayLoaderContainer } from "./loader-snapshot.js";
39
+ import {
40
+ maskNestedContainerThenables,
41
+ overlayLoaderContainer,
42
+ } from "./loader-snapshot.js";
40
43
  import { recordRequestTags } from "../../cache/cache-tag.js";
41
44
  import {
42
45
  isShellCaptureActive,
@@ -162,8 +165,21 @@ export function resolveLoaderData<TEnv>(
162
165
  // never as an unhandled rejection that can kill the worker before the
163
166
  // drain probes this record.
164
167
  containerPromise.catch(() => {});
165
- reqCtx?._shellCaptureLoaderRecords?.set(bakeSegmentKey, containerPromise);
166
- return containerPromise;
168
+ // Nested-promise SHAPE is the liveness declaration: mask nested thenables
169
+ // in the capture's copy of the container so the consuming subtree
170
+ // postpones as a hole no matter when the promise settles, elide records a
171
+ // HOLE marker, and every HIT streams the fresh value. Without this, a
172
+ // nested promise that settled before the quiet window baked its value into
173
+ // the SHARED shell and the snapshot pinned it for every visitor
174
+ // (per-request basket data served cross-session, found live). The raw
175
+ // container is untouched: handler-side ctx.use consumption (the
176
+ // consumption-lane rule, semantic-matrix PPR3) keeps real values.
177
+ const maskedPromise = containerPromise.then((container: unknown) =>
178
+ maskNestedContainerThenables(container),
179
+ );
180
+ maskedPromise.catch(() => {});
181
+ reqCtx?._shellCaptureLoaderRecords?.set(bakeSegmentKey, maskedPromise);
182
+ return maskedPromise;
167
183
  }
168
184
 
169
185
  if (bakeSegmentKey) {
@@ -36,17 +36,10 @@ export function isShellCaptureActive(
36
36
  return reqCtx?._shellCaptureRun === true;
37
37
  }
38
38
 
39
- /**
40
- * A promise that never settles — the masked stand-in for a loader's value during
41
- * shell capture. The consuming Suspense subtree suspends forever, so the static
42
- * prerender postpones it as a hole instead of baking a per-request value into the
43
- * shared shell. The capture abort (`maxWaitMs` in captureShellHTML) bounds how
44
- * long the prerender waits before it freezes the prelude, so this never hangs the
45
- * request.
46
- */
47
- export function createMaskedLoaderPromise<T = unknown>(): Promise<T> {
48
- return new Promise<T>(() => {});
49
- }
39
+ // createMaskedLoaderPromise moved to the leaf module mask-nested.ts (shared
40
+ // with the handle-push funnel in request-context, which cannot import THIS
41
+ // module without a cycle). Re-exported to keep the mask API in one place.
42
+ export { createMaskedLoaderPromise } from "./mask-nested.js";
50
43
 
51
44
  /**
52
45
  * Lane decision for an entry's loaders under PPR (the loading() value decides;
@@ -8,12 +8,14 @@
8
8
  * payload byte-identical to that frozen prelude, the capture pins the container
9
9
  * in the shell snapshot:
10
10
  *
11
- * - elide: deep-walk the settled container; a SETTLED nested promise baked
12
- * its value (physics: it won the quiet window), so it is pinned as
13
- * that value wrapped in a SETTLED marker — the wrapper remembers
14
- * "this was a promise" so the overlay can rehydrate the shape; a
15
- * PENDING nested promise is a hole, replaced by
16
- * {@link LOADER_HOLE_KEY}. The result is promise-free and
11
+ * - elide: deep-walk the settled container; a PENDING nested promise is a
12
+ * hole, replaced by {@link LOADER_HOLE_KEY} — and since
13
+ * loader-cache masks every nested thenable at capture
14
+ * ({@link maskNestedContainerThenables}), nested promises are
15
+ * ALWAYS pending here for new captures. The SETTLED-marker branch
16
+ * below is legacy: it fires only if a settled thenable reaches
17
+ * elide anyway, and the overlay keeps decoding SETTLED markers
18
+ * from pre-mask snapshots. The result is promise-free and
17
19
  * Flight-serializable.
18
20
  * - overlay: on a HIT the loader runs fresh (only the loader body can mint
19
21
  * the live nested promises), then the recorded container is laid
@@ -28,6 +30,12 @@
28
30
 
29
31
  import { isThenable } from "../../handles/is-thenable.js";
30
32
 
33
+ // Capture-side nested-thenable masking lives in the LEAF module mask-nested.ts
34
+ // (request-context also needs it for handle pushes and cannot import through
35
+ // loader-mask without a cycle). Re-exported here so loader-cache and the unit
36
+ // tests keep one import site for the snapshot family.
37
+ export { maskNestedContainerThenables } from "./mask-nested.js";
38
+
31
39
  /**
32
40
  * Marker object standing in for a pending nested promise in a recorded loader
33
41
  * container. Shape-checked (not identity-checked) because the record round-trips
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Capture-side nested-thenable masking — the mechanism behind "nested-promise
3
+ * shape is the liveness declaration" for BOTH bake-lane loader containers
4
+ * (loader-cache.ts) and pushed handle containers (request-context.ts
5
+ * createUseFunction).
6
+ *
7
+ * Deliberately a LEAF module: request-context needs the mask for handle
8
+ * pushes, and loader-mask (the other natural home) imports request-context —
9
+ * importing from there would cycle. This module imports only is-thenable.
10
+ */
11
+
12
+ import { isThenable } from "../../handles/is-thenable.js";
13
+
14
+ /**
15
+ * A promise that never settles — the masked stand-in for a per-request value
16
+ * during shell capture. The consuming Suspense subtree suspends forever, so
17
+ * the static prerender postpones it as a hole instead of baking a per-request
18
+ * value into the shared shell. The capture abort (`maxWaitMs` in
19
+ * captureShellHTML) bounds how long the prerender waits before it freezes the
20
+ * prelude, so this never hangs the request.
21
+ */
22
+ export function createMaskedLoaderPromise<T = unknown>(): Promise<T> {
23
+ return new Promise<T>(() => {});
24
+ }
25
+
26
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
27
+ if (typeof value !== "object" || value === null) return false;
28
+ const proto = Object.getPrototypeOf(value);
29
+ return proto === Object.prototype || proto === null;
30
+ }
31
+
32
+ /**
33
+ * Deep-copy a container with every NESTED thenable replaced by a masked
34
+ * (never-resolving) promise. Applied during shell capture to (a) bake-lane
35
+ * loader containers (loader-cache.ts) and (b) pushed handle containers
36
+ * (createUseFunction) — the two rango-owned funnels where consumers declare
37
+ * per-request data by promise SHAPE.
38
+ *
39
+ * Why: a nested promise that happened to SETTLE before the capture's quiet
40
+ * window closed used to bake its value into the SHARED shell (and, for
41
+ * loaders, the snapshot pinned it for every HIT) — per-request data frozen
42
+ * and served cross-session (found live: a storefront basket, carrying the
43
+ * capturing session's basketId/customer identifiers, served to anonymous
44
+ * visitors). The window waits for the slowest shared material on the page, so
45
+ * any real data source (a 5ms SQL read, a 200ms basket API) lost the race.
46
+ * Masking makes the consuming subtree postpone as a hole no matter when the
47
+ * promise settles: liveness by declaration, not by racing the window.
48
+ *
49
+ * Only plain objects/arrays are traversed; other values are leaves. The INPUT
50
+ * IS NEVER MUTATED — handler-side loader consumption (the consumption-lane
51
+ * rule, semantic-matrix PPR3) shares the raw container and must keep real
52
+ * values. Cycles are preserved as cycles in the copy.
53
+ */
54
+ export function maskNestedContainerThenables(
55
+ value: unknown,
56
+ seen: Map<object, unknown> = new Map(),
57
+ ): unknown {
58
+ if (isThenable(value)) return createMaskedLoaderPromise();
59
+
60
+ if (Array.isArray(value)) {
61
+ const cached = seen.get(value);
62
+ if (cached !== undefined) return cached;
63
+ const out: unknown[] = new Array(value.length);
64
+ seen.set(value, out);
65
+ for (let i = 0; i < value.length; i++) {
66
+ out[i] = maskNestedContainerThenables(value[i], seen);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ if (isPlainObject(value)) {
72
+ const cached = seen.get(value);
73
+ if (cached !== undefined) return cached;
74
+ const out: Record<string, unknown> = {};
75
+ seen.set(value, out);
76
+ for (const key of Object.keys(value)) {
77
+ out[key] = maskNestedContainerThenables(value[key], seen);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ return value;
83
+ }
@@ -38,6 +38,14 @@ export interface RequestEndEvent extends BaseEvent {
38
38
  durationMs: number;
39
39
  segmentCount: number;
40
40
  cacheHit: boolean;
41
+ /**
42
+ * HTTP status when a Response ended the transaction — a thrown-Response
43
+ * short-circuit (redirect / auth gate carries the Response's status, e.g. 302),
44
+ * or dispatch()'s final response status. Absent for a normal render completion:
45
+ * the Response is built after match(), so match()/matchPartial() have no status
46
+ * to stamp there. Lets a sink split 3xx short-circuits from 2xx completions.
47
+ */
48
+ status?: number;
41
49
  }
42
50
 
43
51
  export interface RequestErrorEvent extends BaseEvent {
@@ -320,7 +328,7 @@ export function createConsoleSink(): TelemetrySink {
320
328
  break;
321
329
  case "request.end":
322
330
  console.log(
323
- `[telemetry] ${event.type} ${event.method} ${event.pathname} ${event.durationMs.toFixed(1)}ms segments=${event.segmentCount} cache=${event.cacheHit}`,
331
+ `[telemetry] ${event.type} ${event.method} ${event.pathname} ${event.durationMs.toFixed(1)}ms segments=${event.segmentCount} cache=${event.cacheHit}${event.status !== undefined ? ` status=${event.status}` : ""}`,
324
332
  );
325
333
  break;
326
334
  case "request.error":
package/src/router.ts CHANGED
@@ -427,7 +427,10 @@ export function createRouter<TEnv = any>(
427
427
  const reqCtx = _getRequestContext();
428
428
  const enabled = debugPerformance || !!reqCtx?._debugPerformance;
429
429
  if (!enabled || !reqCtx) return undefined;
430
- reqCtx._metricsStore ??= createMetricsStore(true);
430
+ // Anchor a mid-request store to the true request entry (reqCtx._handlerStart),
431
+ // not this call's performance.now(); undefined falls back to now() inside
432
+ // createMetricsStore (metrics.ts).
433
+ reqCtx._metricsStore ??= createMetricsStore(true, reqCtx._handlerStart);
431
434
  return reqCtx._metricsStore;
432
435
  };
433
436
 
@@ -438,11 +441,6 @@ export function createRouter<TEnv = any>(
438
441
  const findNearestNotFoundBoundary = (entry: EntryData | null) =>
439
442
  findNotFoundBoundary(entry, defaultNotFoundBoundary);
440
443
 
441
- // Helper to get handleStore from request context
442
- const getHandleStore = (): HandleStore | undefined => {
443
- return _getRequestContext()?._handleStore;
444
- };
445
-
446
444
  // Track a pending handler promise (non-blocking).
447
445
  // Attaches a side-effect .catch() to report streaming handler errors to onError
448
446
  // without altering the rejection chain (React's streaming error boundary still handles it).
@@ -453,13 +451,14 @@ export function createRouter<TEnv = any>(
453
451
  segmentType?: string;
454
452
  },
455
453
  ): Promise<T> => {
456
- const store = getHandleStore();
454
+ // One ALS read serves both the store lookup and the onError closure.
455
+ const reqCtx = _getRequestContext();
456
+ const store = reqCtx?._handleStore;
457
457
  const tracked = store ? store.track(promise) : promise;
458
458
 
459
459
  // Report streaming handler errors to onError as a side-effect.
460
460
  // The rejection still propagates to the RSC stream for client error boundaries.
461
461
  // Captures request context eagerly (closure) so the catch handler has full context.
462
- const reqCtx = _getRequestContext();
463
462
  if (reqCtx && onError) {
464
463
  tracked.catch((error) => {
465
464
  callOnError(error, "handler", {
@@ -466,6 +466,11 @@ export function createRSCHandler<
466
466
  stateCookieName: router.resolvedStateCookieName,
467
467
  version,
468
468
  });
469
+ // Thread the true request entry timestamp onto the context so a metrics
470
+ // store created MID-request (ctx.debugPerformance() / getMetricsStore) anchors
471
+ // to the real start, not the opt-in moment — phases that began earlier then
472
+ // report non-negative offsets. Set unconditionally: debug may be enabled later.
473
+ requestContext._handlerStart = handlerStart;
469
474
  if (earlyMetricsStore) {
470
475
  requestContext._debugPerformance = true;
471
476
  requestContext._metricsStore = earlyMetricsStore;
@@ -573,8 +578,10 @@ export function createRSCHandler<
573
578
  if (metricsStore) {
574
579
  // When the store was created at handler start (earlyMetricsStore),
575
580
  // handler:total covers the full request. When ctx.debugPerformance()
576
- // created the store mid-request, use its requestStart to avoid a
577
- // negative startTime offset.
581
+ // created the store mid-request its requestStart is now the threaded
582
+ // _handlerStart (== handlerStart), so both branches yield the true
583
+ // request entry; reading the store's own anchor keeps this correct even
584
+ // if a store ever lands without the threading (falls back to its start).
578
585
  const totalStart = earlyMetricsStore
579
586
  ? handlerStart
580
587
  : metricsStore.requestStart;
@@ -38,6 +38,7 @@ import {
38
38
  resolveSameOriginRedirect,
39
39
  resolveExternalRedirect,
40
40
  isExternalRedirect,
41
+ safeSameOriginLanding,
41
42
  EXTERNAL_REDIRECT_MARKER,
42
43
  } from "../redirect-origin.js";
43
44
  import { carryOverRedirectHeaders } from "./helpers.js";
@@ -79,7 +80,7 @@ export function guardOutgoingRedirect(
79
80
 
80
81
  // Cross-origin (or unsafe-scheme external): neutralize to a safe same-origin
81
82
  // landing.
82
- const safeTarget = basename && basename !== "/" ? basename : "/";
83
+ const safeTarget = safeSameOriginLanding(basename);
83
84
  if (process.env.NODE_ENV !== "production") {
84
85
  console.error(
85
86
  `[rango] Blocked cross-origin redirect to "${location}"; sent to ` +