@rangojs/router 0.17.0 → 0.18.0

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 (152) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/history-state.d.ts +16 -0
  4. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  5. package/dist/types/cache/cache-policy.d.ts +17 -0
  6. package/dist/types/cache/cache-scope.d.ts +98 -14
  7. package/dist/types/cache/cache-tag.d.ts +92 -7
  8. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  9. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  10. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  11. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  12. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  13. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  14. package/dist/types/cache/handle-snapshot.d.ts +4 -3
  15. package/dist/types/cache/index.d.ts +1 -0
  16. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  17. package/dist/types/cache/shell-memo.d.ts +178 -0
  18. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  19. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  20. package/dist/types/cache/types.d.ts +207 -43
  21. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  22. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  23. package/dist/types/router/instrument.d.ts +8 -0
  24. package/dist/types/router/middleware-types.d.ts +6 -1
  25. package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
  26. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
  27. package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
  28. package/dist/types/rsc/full-payload.d.ts +14 -0
  29. package/dist/types/rsc/handler.d.ts +1 -1
  30. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  31. package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
  32. package/dist/types/rsc/shell-capture.d.ts +146 -15
  33. package/dist/types/rsc/shell-serve.d.ts +140 -17
  34. package/dist/types/rsc/types.d.ts +7 -0
  35. package/dist/types/server/context.d.ts +109 -15
  36. package/dist/types/server/cookie-store.d.ts +21 -0
  37. package/dist/types/server/request-context.d.ts +159 -33
  38. package/dist/types/ssr/index.d.ts +9 -0
  39. package/dist/types/ssr/ssr-root.d.ts +9 -8
  40. package/dist/types/testing/flight.entry.d.ts +7 -0
  41. package/dist/types/testing/run-loader.d.ts +18 -3
  42. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  43. package/dist/types/testing/shell-status.d.ts +23 -10
  44. package/dist/types/types/handler-context.d.ts +14 -1
  45. package/dist/types/urls/pattern-types.d.ts +22 -18
  46. package/dist/types/vite/router-discovery.d.ts +29 -2
  47. package/dist/vite/index.js +46 -9
  48. package/package.json +1 -1
  49. package/skills/breadcrumbs/SKILL.md +17 -7
  50. package/skills/cache-guide/SKILL.md +123 -51
  51. package/skills/caching/SKILL.md +253 -23
  52. package/skills/catalog.json +1 -1
  53. package/skills/client-urls/SKILL.md +11 -6
  54. package/skills/cloudflare/SKILL.md +15 -0
  55. package/skills/comparison/references/framework-comparison.md +44 -32
  56. package/skills/defer-hydration/SKILL.md +50 -30
  57. package/skills/deployment-caching/SKILL.md +5 -2
  58. package/skills/document-cache/SKILL.md +8 -2
  59. package/skills/handler-use/SKILL.md +2 -0
  60. package/skills/hooks/handle-and-actions.md +3 -1
  61. package/skills/hooks/state.md +3 -1
  62. package/skills/loader/SKILL.md +161 -39
  63. package/skills/middleware/SKILL.md +16 -9
  64. package/skills/migrate-nextjs/SKILL.md +67 -36
  65. package/skills/migrate-react-router/SKILL.md +2 -1
  66. package/skills/migrate-react-router/component-migration.md +3 -1
  67. package/skills/migrate-react-router/data-and-actions.md +3 -1
  68. package/skills/migrate-react-router/route-mapping.md +7 -4
  69. package/skills/observability/SKILL.md +64 -0
  70. package/skills/parallel/SKILL.md +13 -8
  71. package/skills/ppr/SKILL.md +655 -363
  72. package/skills/prerender/SKILL.md +27 -14
  73. package/skills/rango/SKILL.md +52 -33
  74. package/skills/route/SKILL.md +12 -7
  75. package/skills/router-setup/SKILL.md +4 -2
  76. package/skills/scripts/SKILL.md +8 -1
  77. package/skills/server-actions/SKILL.md +11 -0
  78. package/skills/shell-manifest/SKILL.md +21 -12
  79. package/skills/testing/SKILL.md +25 -21
  80. package/skills/testing/cache-prerender.md +15 -14
  81. package/skills/testing/loader.md +18 -1
  82. package/skills/testing/serve-shell-request.md +188 -0
  83. package/skills/theme/SKILL.md +52 -4
  84. package/skills/typesafety/env-and-bindings.md +10 -2
  85. package/skills/use-cache/SKILL.md +39 -16
  86. package/skills/vercel/SKILL.md +14 -2
  87. package/skills/view-transitions/SKILL.md +1 -1
  88. package/src/browser/cookie-name.ts +23 -0
  89. package/src/browser/history-state.ts +23 -0
  90. package/src/browser/rsc-router.tsx +5 -0
  91. package/src/cache/cache-key-utils.ts +22 -2
  92. package/src/cache/cache-policy.ts +22 -0
  93. package/src/cache/cache-runtime.ts +34 -7
  94. package/src/cache/cache-scope.ts +345 -33
  95. package/src/cache/cache-tag.ts +375 -11
  96. package/src/cache/cf/cf-base64.ts +36 -4
  97. package/src/cache/cf/cf-cache-constants.ts +12 -0
  98. package/src/cache/cf/cf-cache-store.ts +990 -348
  99. package/src/cache/cf/cf-cache-types.ts +32 -4
  100. package/src/cache/cf/cf-shell-frame.ts +258 -0
  101. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  102. package/src/cache/handle-snapshot.ts +4 -3
  103. package/src/cache/index.ts +2 -0
  104. package/src/cache/isolate-tag-memo.ts +308 -0
  105. package/src/cache/shell-memo.ts +341 -0
  106. package/src/cache/shell-snapshot.ts +217 -27
  107. package/src/cache/tag-invalidation.ts +104 -19
  108. package/src/cache/types.ts +211 -43
  109. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  110. package/src/prerender/build-shell-capture.ts +65 -11
  111. package/src/route-definition/dsl-helpers.ts +4 -3
  112. package/src/router/handler-context.ts +38 -4
  113. package/src/router/instrument.ts +54 -2
  114. package/src/router/intercept-resolution.ts +19 -7
  115. package/src/router/loader-resolution.ts +63 -32
  116. package/src/router/match-api.ts +28 -9
  117. package/src/router/match-handlers.ts +5 -0
  118. package/src/router/match-middleware/cache-lookup.ts +17 -0
  119. package/src/router/match-middleware/cache-store.ts +31 -11
  120. package/src/router/middleware-types.ts +6 -1
  121. package/src/router/middleware.ts +23 -4
  122. package/src/router/prerender-match.ts +6 -0
  123. package/src/router/segment-resolution/fresh.ts +18 -9
  124. package/src/router/segment-resolution/helpers.ts +4 -2
  125. package/src/router/segment-resolution/loader-cache.ts +227 -56
  126. package/src/router/segment-resolution/loader-snapshot.ts +203 -51
  127. package/src/router/segment-resolution/mask-nested.ts +136 -42
  128. package/src/router/segment-resolution/revalidation.ts +27 -10
  129. package/src/rsc/full-payload.ts +25 -1
  130. package/src/rsc/handler.ts +14 -2
  131. package/src/rsc/progressive-enhancement.ts +3 -2
  132. package/src/rsc/response-cache-serve.ts +9 -6
  133. package/src/rsc/rsc-rendering.ts +601 -181
  134. package/src/rsc/shell-build-manifest.ts +27 -9
  135. package/src/rsc/shell-capture-constants.ts +60 -6
  136. package/src/rsc/shell-capture.ts +1001 -250
  137. package/src/rsc/shell-serve.ts +316 -34
  138. package/src/rsc/types.ts +7 -0
  139. package/src/server/context.ts +219 -28
  140. package/src/server/cookie-store.ts +87 -62
  141. package/src/server/request-context.ts +263 -38
  142. package/src/ssr/index.tsx +96 -37
  143. package/src/ssr/ssr-root.tsx +9 -8
  144. package/src/testing/flight.entry.ts +14 -0
  145. package/src/testing/run-loader.ts +63 -5
  146. package/src/testing/serve-shell-request.ts +378 -0
  147. package/src/testing/shell-status.ts +34 -12
  148. package/src/theme/ThemeProvider.tsx +7 -6
  149. package/src/types/handler-context.ts +14 -1
  150. package/src/urls/pattern-types.ts +22 -18
  151. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  152. package/src/vite/router-discovery.ts +14 -4
@@ -189,14 +189,15 @@ export interface SsrRootOptions {
189
189
  nonce?: string;
190
190
  /**
191
191
  * Fires once when the Flight payload root settles (resolve OR reject) — the
192
- * signal that every client-module load the payload references completed and
193
- * fizz can start emitting the tree. The capture pass gates its abort on
194
- * this: a fully REPLAYED (prerendered) route's Flight stream goes
195
- * byte-quiet in ~1-3ms, but fizz cannot render even <html> until the
196
- * module loads finish (real module-runner I/O in dev; 100ms+ on a cold
197
- * graph), so an abort gated on Flight quiet alone fires first and freezes
198
- * a zero-byte prelude. Masked-loader holes do NOT block this signal —
199
- * they postpone below the root.
192
+ * signal that fizz can start emitting the tree. A client component used as
193
+ * an element type resolves lazily, so its module load can still be in
194
+ * flight (captureShellHTML waits for those separately, issue #949). The
195
+ * capture pass gates its abort on this: a fully REPLAYED (prerendered)
196
+ * route's Flight stream goes byte-quiet in ~1-3ms, but fizz cannot render
197
+ * even <html> until the module loads finish (real module-runner I/O in dev;
198
+ * 100ms+ on a cold graph), so an abort gated on Flight quiet alone fires
199
+ * first and freezes a zero-byte prelude. Masked-loader holes do NOT block
200
+ * this signal — they postpone below the root.
200
201
  */
201
202
  onPayloadSettled?: () => void;
202
203
  /**
@@ -18,6 +18,11 @@
18
18
  * element tree, so you can assert typed prop fidelity across the client boundary
19
19
  * (a `Date` comes back a `Date`) and detect inlined-vs-island. Serialize +
20
20
  * deserialize only — no hydration/interaction (that is the e2e tier).
21
+ *
22
+ * `serveShellRequest` serves one request through the router's production
23
+ * handler, so a `ppr` route's MISS runs the real shell capture and a later
24
+ * request is a real HIT; only the HTML step is stubbed. `resetShellTestState`
25
+ * clears the per-isolate PPR state between tests.
21
26
  */
22
27
 
23
28
  export {
@@ -50,3 +55,12 @@ export type {
50
55
  RenderHandlerResult,
51
56
  StateCookieSeed,
52
57
  } from "./render-handler.js";
58
+
59
+ export {
60
+ serveShellRequest,
61
+ resetShellTestState,
62
+ } from "./serve-shell-request.js";
63
+ export type {
64
+ ServeShellRequestOptions,
65
+ ServeShellRequestResult,
66
+ } from "./serve-shell-request.js";
@@ -48,6 +48,9 @@ import { collectHandle } from "./collect-handle.js";
48
48
  import type { ThemeConfig } from "../theme/types.js";
49
49
  import type { SegmentCacheStore } from "../cache/types.js";
50
50
  import type { CacheProfile } from "../cache/profile-registry.js";
51
+ import type { PartialCacheOptions } from "../types/cache-types.js";
52
+ import type { HandlerContext } from "../types/handler-context.js";
53
+ import { resolveLoaderData } from "../router/segment-resolution/loader-cache.js";
51
54
  import {
52
55
  createTestRequestContext,
53
56
  buildRunSnapshot,
@@ -111,9 +114,11 @@ export interface RunLoaderOptions<TEnv = any> {
111
114
  basename?: string;
112
115
  /**
113
116
  * Theme config in the same shape `createRouter({ theme })` takes (e.g. `true`
114
- * or `{ themes: [...] }`). Seeds the request's theme config so nested handler
115
- * or cache contexts created from this loader observe it. Loaders themselves do
116
- * not expose `ctx.theme`/`ctx.setTheme` (those are handler/middleware-only).
117
+ * or `{ themes: [...] }`). Seeds the request's theme config, so the loader
118
+ * body reads the visitor's theme (the Cookie on `request`, or the default)
119
+ * with `getRequestContext().theme`. That read is guarded like `cookies()`: it
120
+ * throws inside a `"use cache"` function (#971). The loader ctx has no
121
+ * `theme`/`setTheme` (those are handler/middleware-only).
117
122
  */
118
123
  theme?: ThemeConfig | true;
119
124
  /** Environment bindings surfaced as `ctx.env`. */
@@ -151,6 +156,18 @@ export interface RunLoaderOptions<TEnv = any> {
151
156
  cacheStore?: SegmentCacheStore;
152
157
  /** Cache profiles (the `createRouter({ cacheProfiles })` shape). */
153
158
  cacheProfiles?: Record<string, CacheProfile>;
159
+ /**
160
+ * Run the loader through the `cache()` a route binds it with — the options
161
+ * `loader(Loader, () => [cache({...})])` takes — using the production
162
+ * read-through against `cache.store`, else `cacheStore`. A HIT returns the
163
+ * stored value without running the body and records the tags the body
164
+ * recorded when the entry was written, so `updateTag()` of any of them drops
165
+ * the entry. The write is a background task the call does not await (spy
166
+ * on the store's `setItem` to wait for it). A raw loader body is keyed by
167
+ * its function reference. `handlePushes` lists the body's pushes, so a HIT
168
+ * reports none: the replay into the page's handle store is not modeled.
169
+ */
170
+ cache?: PartialCacheOptions;
154
171
  /**
155
172
  * Customize the rango state cookie a loader that calls
156
173
  * `invalidateClientCache()` rotates (the name is always seeded — default
@@ -352,6 +369,47 @@ function runWithLoaderContext<R>(
352
369
  });
353
370
  }
354
371
 
372
+ const rawLoaderIds = new WeakMap<object, string>();
373
+ let rawLoaderSeq = 0;
374
+
375
+ function cacheLoaderId(loader: RunnableLoader<unknown>): string {
376
+ if (typeof loader !== "function") return loader.$$id;
377
+ let id = rawLoaderIds.get(loader);
378
+ if (id === undefined) {
379
+ id = `runLoader#${++rawLoaderSeq}`;
380
+ rawLoaderIds.set(loader, id);
381
+ }
382
+ return id;
383
+ }
384
+
385
+ /**
386
+ * Call the loader: directly, or through its `cache()` binding (the funnel a
387
+ * route's DSL loaders take, loader-cache.ts resolveLoaderData), whose MISS
388
+ * reads the binding through the handler ctx's use().
389
+ */
390
+ function invokeLoader<T>(
391
+ loader: RunnableLoader<T>,
392
+ loaderFn: (ctx: TestLoaderContext) => Promise<T> | T,
393
+ opts: RunLoaderOptions,
394
+ loaderCtx: TestLoaderContext,
395
+ ): Promise<T> {
396
+ const run = () => Promise.resolve(loaderFn(loaderCtx));
397
+ if (!opts.cache) return run();
398
+ const handlerCtx = {
399
+ params: loaderCtx.params,
400
+ use: run,
401
+ } as unknown as HandlerContext<any, any>;
402
+ return resolveLoaderData(
403
+ {
404
+ loader: { __brand: "loader", $$id: cacheLoaderId(loader) },
405
+ revalidate: [],
406
+ cache: { options: opts.cache },
407
+ },
408
+ handlerCtx,
409
+ loaderCtx.pathname,
410
+ );
411
+ }
412
+
355
413
  export async function runLoader<T>(
356
414
  loader: RunnableLoader<T>,
357
415
  opts: RunLoaderOptions = {},
@@ -359,7 +417,7 @@ export async function runLoader<T>(
359
417
  const loaderFn = resolveLoaderFn(loader);
360
418
  const { ctx } = createTestRequestContext(buildLoaderCtxOpts(opts));
361
419
  return runWithLoaderContext(ctx as RequestContext<any>, opts, (loaderCtx) =>
362
- Promise.resolve(loaderFn(loaderCtx)),
420
+ invokeLoader(loader, loaderFn, opts, loaderCtx),
363
421
  );
364
422
  }
365
423
 
@@ -413,7 +471,7 @@ export async function runLoaderResult<T>(
413
471
  result = await runWithLoaderContext(
414
472
  reqCtx,
415
473
  opts,
416
- (loaderCtx) => Promise.resolve(loaderFn(loaderCtx)),
474
+ (loaderCtx) => invokeLoader(loader, loaderFn, opts, loaderCtx),
417
475
  handlePushes,
418
476
  );
419
477
  } catch (error) {
@@ -0,0 +1,378 @@
1
+ /**
2
+ * serveShellRequest — one request through the production PPR serve path.
3
+ *
4
+ * Builds the router's request handler as `router.fetch` does
5
+ * (`createRSCHandler` with the router's `nonce`, `version` and `cache`
6
+ * config) and serves one GET through it, so middleware, the shell serve path
7
+ * and a MISS's background capture all run production code, on real Flight
8
+ * (the vendored react-server-dom serializer). The call settles every
9
+ * background task the request scheduled before it returns, so a MISS's
10
+ * `putShell` has landed.
11
+ *
12
+ * The one stub is the SSR module (the HTML step): `react-dom/server` refuses
13
+ * to load under the react-server condition this project runs with.
14
+ * - `renderHTML` passes the Flight stream through: a document body is its
15
+ * Flight payload.
16
+ * - `captureShellHTML` returns, as the prelude, the Flight text the capture
17
+ * rendered before `quiesce` (plus the post-quiesce task hops the real
18
+ * capture takes). `postponed` is always null. A capture that does not
19
+ * quiesce within what `ppr.captureTimeout` leaves after the handler output
20
+ * settled returns no shell; the real one goes on to its abort and returns
21
+ * whatever prelude rendered by then.
22
+ * - `resumeShellHTML` passes the tail's Flight stream through: a HIT body is
23
+ * the stored prelude followed by the tail's Flight payload.
24
+ * So there is no HTML: the prelude's `<body` sanity gate, SSR render errors
25
+ * (production refuses the capture on one), fizz resume of the holes,
26
+ * bootstrap scripts and nonce injection stay e2e-only. A route whose real
27
+ * capture refuses for a root postpone (a live loader read with no boundary
28
+ * above it) or an SSR render error is captured here.
29
+ *
30
+ * Must run under the `react-server` condition (the rsc Vitest project), with
31
+ * `rangoTestAliases()` resolving `@vitejs/plugin-rsc/rsc/server` to the stub
32
+ * that carries the Flight runtime.
33
+ */
34
+
35
+ import { AsyncLocalStorage } from "node:async_hooks";
36
+ import { toInternal, type Rango } from "../router/router-interfaces.js";
37
+ import { ensureRouterManifest } from "../route-map-builder.js";
38
+ import type { SegmentCacheStore, ShellCacheEntry } from "../cache/types.js";
39
+ import type { ExecutionContext } from "../types/request-scope.js";
40
+ import type { HandlerCacheConfig, SSRModule } from "../rsc/types.js";
41
+ import type { createRSCHandler } from "../rsc/handler.js";
42
+ import {
43
+ POST_QUIESCE_TASK_HOPS,
44
+ SHELL_CAPTURE_MAX_WAIT_MS,
45
+ } from "../rsc/shell-capture-constants.js";
46
+ import { SEGMENT_FRAGMENT_CAPABILITY_HEADER } from "../segment-fragments.js";
47
+ import { _getRequestContext } from "../server/request-context.js";
48
+ import {
49
+ parsePprReplayStatus,
50
+ parseShellStatus,
51
+ shellCacheKey,
52
+ type PprReplayStatus,
53
+ type ShellStatus,
54
+ } from "./shell-status.js";
55
+
56
+ /** Options for {@link serveShellRequest}. */
57
+ export interface ServeShellRequestOptions<TEnv = any> {
58
+ /**
59
+ * The shell and segment store for this request, in place of the router's
60
+ * `createRouter({ cache })` store. The rest of that config (`enabled`,
61
+ * `searchParams`) still applies. Omit it to serve through the router's own
62
+ * store.
63
+ */
64
+ cacheStore?: SegmentCacheStore;
65
+ /** Environment bindings, as `router.fetch(request, { env })` passes them. */
66
+ env?: TEnv;
67
+ /**
68
+ * Request headers. A document request defaults `accept` to `text/html`.
69
+ */
70
+ headers?: HeadersInit;
71
+ /**
72
+ * Serve the client navigation the browser sends (`?_rsc_partial`) instead
73
+ * of a document GET: from `from` (default `/`) with `segments` mounted
74
+ * (default none), advertising fragment passthrough. The replay decision is
75
+ * `result.replayStatus`.
76
+ */
77
+ partial?: true | { from?: string; segments?: readonly string[] };
78
+ }
79
+
80
+ /** Result of {@link serveShellRequest}. */
81
+ export interface ServeShellRequestResult {
82
+ /** The handler's Response. Its body is already read: see `body`. */
83
+ response: Response;
84
+ /** The response body text. */
85
+ body: string;
86
+ /** `x-rango-shell` (`HIT` | `MISS`), or null when the header is absent. */
87
+ shellStatus: ShellStatus | null;
88
+ /** `x-rango-ppr-replay` on a partial request, or null. */
89
+ replayStatus: PprReplayStatus | null;
90
+ /**
91
+ * The prelude a HIT served: the capture's Flight text at quiesce (the
92
+ * shell as captured). Undefined unless `shellStatus` is `HIT`.
93
+ */
94
+ prelude: string | undefined;
95
+ /**
96
+ * The Flight payload this request rendered: a HIT's tail, a document's
97
+ * render, or a Flight response. Undefined when no Flight was rendered (a
98
+ * redirect or a middleware response).
99
+ */
100
+ flight: string | undefined;
101
+ /**
102
+ * The shell key production resolved for this request: for a document the
103
+ * serve path read and captured (a MISS or a HIT), the key it used, request
104
+ * partition included (a route partitioned by `cache({ key })` or the
105
+ * store's `keyGenerator`). Otherwise (a route without `ppr`, a request
106
+ * passed to axis 1 before its key was resolved, a partial request) the
107
+ * document URL's key without a partition, as `shellCacheKey` builds it.
108
+ */
109
+ key: string;
110
+ /**
111
+ * Read the document shell entry under `key` from the request's store
112
+ * (a passive `getShell`), or null. A read, so on a store with a shell memo
113
+ * (CFCacheStore, VercelCacheStore) it warms the memo like any other read.
114
+ */
115
+ readEntry(): Promise<ShellCacheEntry | null>;
116
+ }
117
+
118
+ type ShellHandler = ReturnType<typeof createRSCHandler>;
119
+
120
+ /** What one call's cache resolution and SSR step saw. */
121
+ interface Recorder {
122
+ cache?: HandlerCacheConfig;
123
+ /** The shell key the serve path resolved (RequestContext._shellKey). */
124
+ shellKey?: string;
125
+ /** A document render reached the HTML step. */
126
+ rendered?: boolean;
127
+ /** The HIT tail's Flight text. */
128
+ tail?: string;
129
+ }
130
+
131
+ const recorders = new AsyncLocalStorage<Recorder>();
132
+
133
+ function macrotask(): Promise<void> {
134
+ return new Promise((resolve) => setTimeout(resolve, 0));
135
+ }
136
+
137
+ /**
138
+ * Record the shell key the serve path resolved: every SSR step of a document
139
+ * the serve path read runs inside its request context (a capture's derived
140
+ * context inherits it).
141
+ */
142
+ function recordShellKey(recorder: Recorder | undefined): void {
143
+ const shellKey = _getRequestContext()?._shellKey;
144
+ if (recorder && shellKey !== undefined) recorder.shellKey ??= shellKey;
145
+ }
146
+
147
+ const SSR_STUB: SSRModule = {
148
+ async renderHTML(rscStream) {
149
+ const recorder = recorders.getStore();
150
+ if (recorder) recorder.rendered = true;
151
+ recordShellKey(recorder);
152
+ return rscStream;
153
+ },
154
+ async captureShellHTML(rscStream, options) {
155
+ const reader = rscStream.getReader();
156
+ const decoder = new TextDecoder();
157
+ let frozen = "";
158
+ // The capture gate freezes the stream at quiesce without closing it.
159
+ void (async () => {
160
+ for (;;) {
161
+ const { done, value } = await reader.read();
162
+ if (done) return;
163
+ frozen += decoder.decode(value, { stream: true });
164
+ }
165
+ })().catch(() => {});
166
+ let timer: ReturnType<typeof setTimeout> | undefined;
167
+ const deadline = new Promise<false>((resolve) => {
168
+ timer = setTimeout(
169
+ () => resolve(false),
170
+ options.maxWaitMs ?? SHELL_CAPTURE_MAX_WAIT_MS,
171
+ );
172
+ });
173
+ try {
174
+ const quiet = await Promise.race([
175
+ options.quiesce.then(() => true as const),
176
+ deadline,
177
+ ]);
178
+ if (!quiet) return null;
179
+ } finally {
180
+ clearTimeout(timer);
181
+ }
182
+ for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) await macrotask();
183
+ return { prelude: new TextEncoder().encode(frozen), postponed: null };
184
+ },
185
+ async resumeShellHTML(rscStream) {
186
+ const recorder = recorders.getStore();
187
+ recordShellKey(recorder);
188
+ // The served HIT's tail only, not a background re-render's.
189
+ if (!recorder || recorder.tail !== undefined) return rscStream;
190
+ recorder.tail = "";
191
+ const decoder = new TextDecoder();
192
+ return rscStream.pipeThrough(
193
+ new TransformStream<Uint8Array, Uint8Array>({
194
+ transform(chunk, controller) {
195
+ recorder.tail += decoder.decode(chunk, { stream: true });
196
+ controller.enqueue(chunk);
197
+ },
198
+ flush() {
199
+ recorder.tail += decoder.decode();
200
+ },
201
+ }),
202
+ );
203
+ },
204
+ };
205
+
206
+ /** Handlers per router, keyed by the `cacheStore` override or the router. */
207
+ const handlers = new WeakMap<object, WeakMap<object, ShellHandler>>();
208
+
209
+ async function getHandler(
210
+ router: Rango<any, any>,
211
+ cacheStore: SegmentCacheStore | undefined,
212
+ ): Promise<ShellHandler> {
213
+ let byStore = handlers.get(router);
214
+ if (!byStore) handlers.set(router, (byStore = new WeakMap()));
215
+ const cached = byStore.get(cacheStore ?? router);
216
+ if (cached) return cached;
217
+
218
+ const internal = toInternal(router);
219
+ const routerCache = internal.cache;
220
+ // Resolved per request, as the handler resolves the router's own config.
221
+ const cache = (env: unknown, ctx?: ExecutionContext): HandlerCacheConfig => {
222
+ const base =
223
+ typeof routerCache === "function" ? routerCache(env, ctx) : routerCache;
224
+ const config = cacheStore ? { ...base, store: cacheStore } : base!;
225
+ const recorder = recorders.getStore();
226
+ if (recorder) recorder.cache = config;
227
+ return config;
228
+ };
229
+ const { createRSCHandler } = await import("../rsc/handler.js");
230
+ const handler = createRSCHandler({
231
+ router: internal,
232
+ nonce: internal.nonce,
233
+ version: internal.version,
234
+ loadSSRModule: async () => SSR_STUB,
235
+ cache: routerCache || cacheStore ? cache : undefined,
236
+ });
237
+ byStore.set(cacheStore ?? router, handler);
238
+ return handler;
239
+ }
240
+
241
+ function buildRequest(url: URL, options: ServeShellRequestOptions): Request {
242
+ const headers = new Headers(options.headers);
243
+ const partial = options.partial;
244
+ if (!partial) {
245
+ if (!headers.has("accept")) headers.set("accept", "text/html");
246
+ return new Request(url, { headers });
247
+ }
248
+ const { from = "/", segments = [] } = partial === true ? {} : partial;
249
+ const target = new URL(url);
250
+ target.searchParams.set("_rsc_partial", "true");
251
+ target.searchParams.set("_rsc_segments", segments.join(","));
252
+ if (!headers.has("X-RSC-Router-Client-Path")) {
253
+ headers.set("X-RSC-Router-Client-Path", new URL(from, url).href);
254
+ }
255
+ if (!headers.has(SEGMENT_FRAGMENT_CAPABILITY_HEADER)) {
256
+ headers.set(SEGMENT_FRAGMENT_CAPABILITY_HEADER, "1");
257
+ }
258
+ return new Request(target, { headers });
259
+ }
260
+
261
+ /**
262
+ * Reset the per-isolate PPR state requests leave behind, so the next test
263
+ * starts as a fresh worker would: the capture's stampede guard, its backoff
264
+ * (a refused capture backs its URL off for later tests too), the capture's
265
+ * and the serve path's once-per-key warnings, the build-shell manifest memo,
266
+ * and CFCacheStore's isolate memos (shells, tag markers, tag hints), which
267
+ * every CFCacheStore in the process shares by namespace and URL. Call it in
268
+ * `beforeEach`, never while a request is in flight. VercelCacheStore's memos
269
+ * live on the `cache` handle it is given: a new handle starts empty.
270
+ */
271
+ export async function resetShellTestState(): Promise<void> {
272
+ const [capture, serve, buildShells, cf] = await Promise.all([
273
+ import("../rsc/shell-capture.js"),
274
+ import("../rsc/shell-serve.js"),
275
+ import("../rsc/shell-build-manifest.js"),
276
+ import("../cache/cf/cf-cache-store.js"),
277
+ ]);
278
+ capture.resetShellCaptureStateForTests();
279
+ serve.resetShellServeStateForTests();
280
+ buildShells.resetBuildShellManifestForTests();
281
+ cf.resetCFShellMemoForTests();
282
+ }
283
+
284
+ /** Settle background tasks, including ones scheduled while settling. */
285
+ async function settle(tasks: Promise<unknown>[]): Promise<void> {
286
+ do {
287
+ while (tasks.length > 0) await Promise.allSettled(tasks.splice(0));
288
+ await macrotask();
289
+ } while (tasks.length > 0);
290
+ }
291
+
292
+ /**
293
+ * Serve one GET for `url` through the router's production request handler
294
+ * and settle the background tasks it scheduled (a MISS's capture and
295
+ * `putShell` included). The first request for a `ppr` route is a MISS; the
296
+ * next one with the same store is a HIT from the stored shell. The HTML step
297
+ * is stubbed (see the module header), so `prelude` and `flight` are Flight
298
+ * text. The request starts in a later millisecond than the call: a store
299
+ * refuses a capture that starts in the millisecond of a tag invalidation, so
300
+ * an `updateTag()` made just before the call never refuses its recapture.
301
+ * Rejects when the handler throws or the response stream errors (a failing
302
+ * HIT tail), after settling the tasks scheduled so far.
303
+ *
304
+ * @example
305
+ * const store = new MemorySegmentCacheStore();
306
+ * const miss = await serveShellRequest(router, "/product/1", { cacheStore: store });
307
+ * expect(miss.shellStatus).toBe("MISS");
308
+ * const hit = await serveShellRequest(router, "/product/1", { cacheStore: store });
309
+ * expect(hit.shellStatus).toBe("HIT");
310
+ * expect(hit.prelude).toContain("Widget"); // captured shell
311
+ * expect(hit.flight).toContain("in stock"); // live hole, rendered now
312
+ */
313
+ export async function serveShellRequest<TEnv = any>(
314
+ router: Rango<TEnv, any>,
315
+ url: string | URL,
316
+ options: ServeShellRequestOptions<TEnv> = {},
317
+ ): Promise<ServeShellRequestResult> {
318
+ const called = Date.now();
319
+ const target = new URL(url, "http://localhost");
320
+ await ensureRouterManifest(router.id);
321
+ const handler = await getHandler(router, options.cacheStore);
322
+ const request = buildRequest(target, options);
323
+ const tasks: Promise<unknown>[] = [];
324
+ const executionContext: ExecutionContext = {
325
+ waitUntil(promise) {
326
+ tasks.push(
327
+ Promise.resolve(promise).catch((error) =>
328
+ console.error("[waitUntil] Background task failed:", error),
329
+ ),
330
+ );
331
+ },
332
+ passThroughOnException() {},
333
+ };
334
+ const recorder: Recorder = {};
335
+ while (Date.now() === called) await macrotask();
336
+
337
+ const { response, body } = await recorders.run(recorder, async () => {
338
+ try {
339
+ const response = await handler(request, {
340
+ env: options.env,
341
+ ctx: executionContext,
342
+ });
343
+ return { response, body: await response.text() };
344
+ } finally {
345
+ await settle(tasks);
346
+ }
347
+ });
348
+
349
+ const config =
350
+ recorder.cache && recorder.cache.enabled !== false
351
+ ? recorder.cache
352
+ : undefined;
353
+ const key = recorder.shellKey ?? shellCacheKey(target, config?.searchParams);
354
+ const tail = recorder.tail;
355
+ const isFlight =
356
+ recorder.rendered ||
357
+ response.headers.get("content-type")?.includes("text/x-component");
358
+ return {
359
+ response,
360
+ body,
361
+ shellStatus: parseShellStatus(response),
362
+ replayStatus: parsePprReplayStatus(response),
363
+ prelude:
364
+ tail !== undefined && body.endsWith(tail)
365
+ ? body.slice(0, body.length - tail.length)
366
+ : undefined,
367
+ flight: tail ?? (isFlight && body ? body : undefined),
368
+ key,
369
+ async readEntry() {
370
+ // Passive, like the serve path's own reads: a stale entry's SWR
371
+ // revalidation stays unclaimed for the next request.
372
+ const read = await config?.store.getShell?.(key, {
373
+ claimRevalidation: false,
374
+ });
375
+ return read?.entry ?? null;
376
+ },
377
+ };
378
+ }
@@ -18,17 +18,25 @@
18
18
  * Secondary unit signal: `store.getShell(shellCacheKey(url))` after a real
19
19
  * capture flush (background `putShell`). There is no Flight flag for shell HIT.
20
20
  *
21
- * 3. **Stays e2e-only:** live MISS → background capture → HIT (needs real
22
- * `captureShellHTML` / React `prerender` + resume fizz), browser resume of
23
- * holes, Cloudflare/Vercel live stores, nonce/eligibility fall-open, and
24
- * build-time producer B bake. Unit layer dogfoods the **store family + key
25
- * identity + header assert**; it must never stub a shell HIT response.
21
+ * 3. **A real MISS → capture → HIT in unit tests:** `serveShellRequest`
22
+ * (`@rangojs/router/testing/flight`, serve-shell-request.ts) serves one
23
+ * request through the router's production handler and settles its
24
+ * background capture; a later request with the same store is a real HIT or
25
+ * partial replay. Its only stub is the HTML step (the SSR module).
26
+ * **Stays e2e-only:** real HTML (the prelude's `<body` gate, fizz resume of
27
+ * holes), browser resume of holes, live Cloudflare/Vercel runtimes (store
28
+ * test doubles work in unit), and build-time producer B bake. The unit layer
29
+ * must never stub a shell HIT response.
26
30
  *
27
31
  * Import from `@rangojs/router/testing` (Vitest) or `@rangojs/router/testing/e2e`
28
32
  * (Playwright — same pure helpers, no Vite virtuals).
29
33
  */
30
34
 
31
- import { sortedSearchString } from "../cache/cache-key-utils.js";
35
+ import {
36
+ composeCacheKeys,
37
+ sortedSearchString,
38
+ } from "../cache/cache-key-utils.js";
39
+ import { partitionShellKey } from "../rsc/shell-capture-constants.js";
32
40
  import {
33
41
  compileSearchParamsFilter,
34
42
  type CacheSearchParams,
@@ -57,8 +65,6 @@ const PPR_REPLAY_BYPASS_REASONS = [
57
65
  "no-entry",
58
66
  "invalid-version",
59
67
  "corrupt-entry",
60
- "handler-live-holes",
61
- "transition-when",
62
68
  "no-segment-snapshot",
63
69
  "snapshot-miss",
64
70
  "explicit-cache-hit",
@@ -79,17 +85,27 @@ export type ShellStatusTarget = Response | { headers: Headers };
79
85
  /**
80
86
  * Shell store key for a document URL — same formula as production
81
87
  * `buildShellKey` in `rsc/shell-serve.ts` (host + pathname + sorted search +
82
- * `:shell`). Kept here so the testing barrel never imports the shell-serve
83
- * module (it pulls React). A parity test pins the two implementations.
88
+ * `:shell`), then the production `partitionShellKey` itself (the leaf
89
+ * `rsc/shell-capture-constants.ts`). The base is kept here so the testing
90
+ * barrel never imports the shell-serve module (it pulls React). A parity test
91
+ * pins the two implementations.
84
92
  *
85
93
  * Accepts a `URL` or an absolute/relative request URL string (relative strings
86
94
  * resolve against `http://localhost`). Pass the router's `cache.searchParams`
87
95
  * config as `searchParams` when the app under test sets one — the production
88
- * key applies it, so the expected key must too.
96
+ * key applies it, so the expected key must too. When the route's record is
97
+ * partitioned by the request — its `cache({ key })`, or the store's
98
+ * `keyGenerator` — its shell is too: pass the key that function returns for
99
+ * the request as `partition` (appended URI-encoded; a result equal to the
100
+ * default key partitions nothing, so pass none). Under nested keyed
101
+ * `cache()` scopes, pass their `key()` results as an array, outermost first:
102
+ * they compose as the production record key composes them
103
+ * (`composeCacheKeys`).
89
104
  */
90
105
  export function shellCacheKey(
91
106
  url: URL | string,
92
107
  searchParams?: CacheSearchParams,
108
+ partition?: string | readonly string[],
93
109
  ): string {
94
110
  const resolved =
95
111
  typeof url === "string" ? new URL(url, "http://localhost") : url;
@@ -98,7 +114,13 @@ export function shellCacheKey(
98
114
  compileSearchParamsFilter(searchParams),
99
115
  );
100
116
  const searchSuffix = sorted ? `?${sorted}` : "";
101
- return `${resolved.host}${resolved.pathname}${searchSuffix}:shell`;
117
+ const key = `${resolved.host}${resolved.pathname}${searchSuffix}:shell`;
118
+ if (partition === undefined) return key;
119
+ if (typeof partition === "string") return partitionShellKey(key, partition);
120
+ // No key() results: no partition, as when `partition` is omitted.
121
+ return partition.length === 0
122
+ ? key
123
+ : partitionShellKey(key, composeCacheKeys(partition));
102
124
  }
103
125
 
104
126
  function getHeaders(target: ShellStatusTarget): Headers {
@@ -133,12 +133,13 @@ export function ThemeProvider({
133
133
  setSystemTheme(getSystemTheme());
134
134
  // Re-sync state from an EXPLICITLY stored theme after mount. initialTheme
135
135
  // comes from the payload and can legitimately differ from the visitor's
136
- // stored theme — on a PPR shell HIT it is deliberately the CAPTURE's theme
137
- // (the resume tree must match the frozen prelude; see
138
- // ShellCacheEntry.initialTheme). The FOUC script already applied the stored
139
- // theme to the document pre-paint; this brings the provider state (toggles,
140
- // useTheme readers) in line with it, and is the ONLY place the provider
141
- // reads storage (the initializer must not — see the parity note above).
136
+ // stored theme — on a PPR shell HIT it is deliberately the CAPTURE's
137
+ // initialTheme, the no-cookie default (the resume tree must match the
138
+ // frozen prelude; see ShellCacheEntry.initialTheme). The FOUC script
139
+ // already applied the stored theme to the document pre-paint; this brings
140
+ // the provider state (toggles, useTheme readers) in line with it, and is
141
+ // the ONLY place the provider reads storage (the initializer must not —
142
+ // see the parity note above).
142
143
  // Only an explicit cookie/localStorage value re-syncs — a defaultTheme
143
144
  // fallback must not override a server-provided initialTheme when the
144
145
  // visitor never chose a theme.