@rangojs/router 0.0.0-experimental.144 → 0.0.0-experimental.146

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 (35) hide show
  1. package/dist/bin/rango.js +1 -40
  2. package/dist/vite/index.js +35 -9
  3. package/package.json +1 -1
  4. package/skills/ppr/SKILL.md +29 -23
  5. package/src/browser/logging.ts +18 -0
  6. package/src/browser/rsc-router.tsx +43 -0
  7. package/src/cache/cache-runtime.ts +41 -51
  8. package/src/cache/cache-scope.ts +30 -1
  9. package/src/cache/cf/cf-cache-store.ts +4 -0
  10. package/src/cache/handle-snapshot.ts +22 -1
  11. package/src/cache/shell-snapshot.ts +47 -0
  12. package/src/cache/types.ts +31 -4
  13. package/src/cache/vercel/vercel-cache-store.ts +6 -1
  14. package/src/deps/ssr.ts +4 -1
  15. package/src/router/loader-resolution.ts +16 -0
  16. package/src/router/match-api.ts +9 -2
  17. package/src/router/match-handlers.ts +13 -0
  18. package/src/router/segment-resolution/loader-cache.ts +19 -3
  19. package/src/router/segment-resolution/loader-mask.ts +4 -11
  20. package/src/router/segment-resolution/loader-snapshot.ts +14 -6
  21. package/src/router/segment-resolution/mask-nested.ts +99 -0
  22. package/src/rsc/rsc-rendering.ts +139 -16
  23. package/src/rsc/shell-capture.ts +122 -0
  24. package/src/rsc/shell-serve.ts +37 -6
  25. package/src/segment-loader-promise.ts +18 -0
  26. package/src/segment-system.tsx +123 -9
  27. package/src/server/request-context.ts +47 -0
  28. package/src/ssr/index.tsx +118 -18
  29. package/src/ssr/inject-rsc-eager.ts +167 -0
  30. package/src/ssr/preinit-client-references.ts +106 -0
  31. package/src/vite/index.ts +8 -0
  32. package/src/vite/plugin-types.ts +33 -0
  33. package/src/vite/plugins/virtual-entries.ts +37 -4
  34. package/src/vite/rango.ts +10 -2
  35. package/src/vite/utils/shared-utils.ts +4 -2
@@ -110,6 +110,19 @@ export class RecordingShellStore<
110
110
  this.writes.push(p);
111
111
  }
112
112
 
113
+ /**
114
+ * Record a segment-family write into the snapshot WITHOUT touching the inner
115
+ * store. The shell fast path's implicit doc-cache scope writes through this
116
+ * (via {@link SnapshotOnlySegmentStore}): the recorded doc entry must ride
117
+ * ONLY inside the shell entry — a passthrough write would leave a doc-keyed
118
+ * entry in the real store that the NEXT capture's lookup would hit, replaying
119
+ * the previous generation's segments instead of re-running handlers (breaking
120
+ * SWR recapture freshness).
121
+ */
122
+ recordSegmentWrite(key: string, data: CachedEntryData): void {
123
+ this.record("segment", key, data);
124
+ }
125
+
113
126
  /**
114
127
  * Await the tracked deferred writes so their records are present before drain.
115
128
  * Drains ITERATIVELY: a write task can schedule a NESTED write (the ring-3
@@ -245,6 +258,40 @@ export function getRecordingStore<TEnv>(
245
258
  return store instanceof RecordingShellStore ? store : undefined;
246
259
  }
247
260
 
261
+ /**
262
+ * The store the shell fast path's IMPLICIT doc-cache scope resolves during a
263
+ * capture: reads pass through the recording store (a real-store hit is
264
+ * recorded, exactly like any capture read), but segment WRITES are recorded
265
+ * into the snapshot only — see {@link RecordingShellStore.recordSegmentWrite}
266
+ * for why passthrough would break SWR recapture. Routes with their OWN
267
+ * cache() config never see this store (their scope resolves the app-level
268
+ * recording store and keeps today's record-and-write behavior).
269
+ */
270
+ export class SnapshotOnlySegmentStore<
271
+ TEnv = unknown,
272
+ > implements SegmentCacheStore<TEnv> {
273
+ constructor(private readonly recording: RecordingShellStore<TEnv>) {}
274
+
275
+ get defaults(): SegmentCacheStore<TEnv>["defaults"] {
276
+ return this.recording.defaults;
277
+ }
278
+ get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
279
+ return this.recording.keyGenerator;
280
+ }
281
+
282
+ async get(key: string): Promise<CacheGetResult | null> {
283
+ return this.recording.get(key);
284
+ }
285
+
286
+ async set(key: string, data: CachedEntryData): Promise<void> {
287
+ this.recording.recordSegmentWrite(key, data);
288
+ }
289
+
290
+ async delete(key: string): Promise<boolean> {
291
+ return this.recording.delete(key);
292
+ }
293
+ }
294
+
248
295
  /**
249
296
  * Materialize the loader-family seed from a shell snapshot for a HIT's tail
250
297
  * render: Flight-deserialize each recorded (promise-elided) bake-lane
@@ -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
@@ -266,6 +280,19 @@ export interface ShellCacheEntry {
266
280
  * heals it. See docs/design/ppr-shell-resume.md ("the capture data snapshot").
267
281
  */
268
282
  snapshot?: ShellSnapshotRecord[];
283
+ /**
284
+ * True when the capture's HANDLER layer declared per-request liveness: a
285
+ * handle pushed OUTSIDE a DSL loader scope carried a nested thenable (the
286
+ * capture mask turns it into a never-filling hole), such a push was still
287
+ * pending when the entry was written, or a handler-invoked loader
288
+ * (ctx.use(loader) from a handler body — the consumption lane, #672)
289
+ * executed during the capture. The serve tail then must NOT take the
290
+ * handler-free fast path (the implicit doc-cache hit): only a handler
291
+ * re-run can mint that hole's live promise or refresh that consumed value.
292
+ * DSL-loader pushes never set this — loaders re-run fresh on every HIT, so
293
+ * their holes always fill.
294
+ */
295
+ handlerLiveHoles?: boolean;
269
296
  /** Epoch ms when the shell was captured. */
270
297
  createdAt: number;
271
298
  }
@@ -177,6 +177,8 @@ interface VercelShellEnvelope {
177
177
  po: string | null;
178
178
  /** React.version at capture. */
179
179
  rv: string;
180
+ /** Build version at capture (ShellCacheEntry.buildVersion). */
181
+ bv?: string;
180
182
  /** createdAt (ms since epoch). */
181
183
  c: number;
182
184
  /** staleAt (ms since epoch). */
@@ -776,6 +778,7 @@ export class VercelCacheStore<
776
778
  prelude: env.p,
777
779
  postponed: env.po,
778
780
  reactVersion: env.rv,
781
+ buildVersion: env.bv,
779
782
  initialTheme: env.i,
780
783
  snapshot: env.sn,
781
784
  createdAt: env.c,
@@ -805,6 +808,7 @@ export class VercelCacheStore<
805
808
  p: entry.prelude,
806
809
  po: entry.postponed,
807
810
  rv: entry.reactVersion,
811
+ bv: entry.buildVersion,
808
812
  c: entry.createdAt,
809
813
  s: staleAt,
810
814
  e: expiresAt,
@@ -1091,7 +1095,7 @@ export class VercelCacheStore<
1091
1095
 
1092
1096
  private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
1093
1097
  if (!isRecord(raw)) return null;
1094
- const { p, po, rv, c, s, e, t, i, sn } = raw;
1098
+ const { p, po, rv, bv, c, s, e, t, i, sn } = raw;
1095
1099
  if (typeof p !== "string" || typeof rv !== "string") return null;
1096
1100
  if (po !== null && typeof po !== "string") return null;
1097
1101
  if (typeof c !== "number") return null;
@@ -1100,6 +1104,7 @@ export class VercelCacheStore<
1100
1104
  p,
1101
1105
  po: po as string | null,
1102
1106
  rv,
1107
+ bv: typeof bv === "string" ? bv : undefined,
1103
1108
  c,
1104
1109
  s,
1105
1110
  e,
package/src/deps/ssr.ts CHANGED
@@ -1 +1,4 @@
1
- export { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
1
+ export {
2
+ createFromReadableStream,
3
+ setOnClientReference,
4
+ } from "@vitejs/plugin-rsc/ssr";
@@ -430,6 +430,22 @@ function createLoaderExecutor<TEnv>(
430
430
  // nested deps inherit isDslLoader=false only when the CHAIN started in a
431
431
  // handler; a chain started by the segment funnel stays DSL (the loader
432
432
  // scope ALS survives the body's awaits).
433
+ // Shell fast path eligibility: a HANDLER-invoked loader executing during a
434
+ // capture is handler-layer dynamism — on a handler-free (replayed) HIT it
435
+ // would never re-run, freezing its consumption-lane value (#672's "fresh
436
+ // per serve" slot shape). Mark the capture; the entry declines the fast
437
+ // path and keeps the full tail. DSL loaders re-run on every HIT and never
438
+ // set this.
439
+ if (!isDslLoader) {
440
+ const captureCtx = _getRequestContext();
441
+ if (
442
+ captureCtx?._shellCaptureRun &&
443
+ captureCtx._shellCaptureHandleLiveness
444
+ ) {
445
+ captureCtx._shellCaptureHandleLiveness.handlerInvokedLoader = true;
446
+ }
447
+ }
448
+
433
449
  const promise = observePhase(PHASES.loader(loader.$$id), () =>
434
450
  Promise.resolve(
435
451
  runInsideLoaderBodyScope(
@@ -1,4 +1,8 @@
1
- import { CacheScope, createCacheScope } from "../cache/cache-scope.js";
1
+ import {
2
+ CacheScope,
3
+ createCacheScope,
4
+ resolveShellImplicitCacheScope,
5
+ } from "../cache/cache-scope.js";
2
6
  import { RouteNotFoundError } from "../errors";
3
7
  import {
4
8
  createErrorInfo,
@@ -180,7 +184,10 @@ export async function createMatchContextForFull<TEnv>(
180
184
  },
181
185
  isSameRouteNavigation: false,
182
186
  interceptResult: null,
183
- cacheScope: snapshot.cacheScope,
187
+ // Shell fast path: a capture or an eligible HIT tail may substitute an
188
+ // implicit doc-level scope (marker-gated; a route-derived scope wins).
189
+ // Full matches only — partial navigations never serve a shell.
190
+ cacheScope: resolveShellImplicitCacheScope(snapshot.cacheScope),
184
191
  isIntercept: false,
185
192
  actionContext: undefined,
186
193
  isAction: false,
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
2
3
  import { sanitizeError } from "../errors";
3
4
  import type { ErrorInfo, ErrorPhase, MatchResult } from "../types";
4
5
  import type {
@@ -291,7 +292,13 @@ export function createMatchHandlers<TEnv = any>(
291
292
  });
292
293
  emitter.start();
293
294
 
295
+ const ctxBuildStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
294
296
  const result = await createMatchContextForFull(request, env);
297
+ if (INTERNAL_RANGO_DEBUG) {
298
+ console.log(
299
+ `[Server][match] context built +${Math.round(performance.now() - ctxBuildStart)}ms (abs ${Math.round(performance.now())})`,
300
+ );
301
+ }
295
302
 
296
303
  if ("type" in result && result.type === "redirect") {
297
304
  emitter.end(0, false);
@@ -310,7 +317,13 @@ export function createMatchHandlers<TEnv = any>(
310
317
  try {
311
318
  const state = createPipelineState();
312
319
  const pipeline = createMatchPartialPipeline(ctx, state);
320
+ const pipeStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
313
321
  const matchResult = await collectMatchResult(pipeline, ctx, state);
322
+ if (INTERNAL_RANGO_DEBUG) {
323
+ console.log(
324
+ `[Server][match] pipeline collected +${Math.round(performance.now() - pipeStart)}ms (abs ${Math.round(performance.now())})`,
325
+ );
326
+ }
314
327
  if (hasTelemetry || cacheSignalEnabled) {
315
328
  const signalSegments = buildSignal(ctx.routeKey, state);
316
329
  recordSignalIfEnabled(signalSegments);
@@ -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,99 @@
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
+ /**
55
+ * Optional single-walk report for maskNestedContainerThenables: `thenable`
56
+ * flips true when the walk masked at least one thenable. The shell fast path
57
+ * reads it at handle-push time — a pushed container with a nested thenable
58
+ * declares per-request data, and a shell entry whose HANDLER layer made such
59
+ * a declaration cannot be served handler-free (the hole would never fill).
60
+ * Same single-pass shape as elideLoaderContainer's `hasHole`.
61
+ */
62
+ export interface MaskReport {
63
+ thenable: boolean;
64
+ }
65
+
66
+ export function maskNestedContainerThenables(
67
+ value: unknown,
68
+ seen: Map<object, unknown> = new Map(),
69
+ report?: MaskReport,
70
+ ): unknown {
71
+ if (isThenable(value)) {
72
+ if (report) report.thenable = true;
73
+ return createMaskedLoaderPromise();
74
+ }
75
+
76
+ if (Array.isArray(value)) {
77
+ const cached = seen.get(value);
78
+ if (cached !== undefined) return cached;
79
+ const out: unknown[] = new Array(value.length);
80
+ seen.set(value, out);
81
+ for (let i = 0; i < value.length; i++) {
82
+ out[i] = maskNestedContainerThenables(value[i], seen, report);
83
+ }
84
+ return out;
85
+ }
86
+
87
+ if (isPlainObject(value)) {
88
+ const cached = seen.get(value);
89
+ if (cached !== undefined) return cached;
90
+ const out: Record<string, unknown> = {};
91
+ seen.set(value, out);
92
+ for (const key of Object.keys(value)) {
93
+ out[key] = maskNestedContainerThenables(value[key], seen, report);
94
+ }
95
+ return out;
96
+ }
97
+
98
+ return value;
99
+ }