@rangojs/router 0.0.0-experimental.139 → 0.0.0-experimental.140

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 (45) hide show
  1. package/dist/bin/rango.js +27 -2
  2. package/dist/vite/index.js +147 -30
  3. package/package.json +1 -1
  4. package/skills/breadcrumbs/SKILL.md +1 -1
  5. package/skills/cache-guide/SKILL.md +1 -0
  6. package/skills/caching/SKILL.md +1 -1
  7. package/skills/migrate-nextjs/SKILL.md +15 -0
  8. package/skills/migrate-react-router/SKILL.md +15 -2
  9. package/skills/ppr/SKILL.md +426 -0
  10. package/skills/rango/SKILL.md +28 -25
  11. package/skills/route/SKILL.md +43 -0
  12. package/src/build/route-trie.ts +35 -7
  13. package/src/cache/cf/cf-cache-store.ts +155 -0
  14. package/src/cache/index.ts +6 -0
  15. package/src/cache/memory-segment-store.ts +57 -1
  16. package/src/cache/shell-cache.ts +386 -0
  17. package/src/cache/types.ts +58 -0
  18. package/src/cache/vercel/vercel-cache-store.ts +159 -5
  19. package/src/index.rsc.ts +5 -0
  20. package/src/index.ts +17 -0
  21. package/src/router/middleware.ts +14 -5
  22. package/src/router/parse-pattern.ts +115 -0
  23. package/src/router/pattern-matching.ts +53 -64
  24. package/src/router/segment-resolution/fresh.ts +12 -1
  25. package/src/router/segment-resolution/loader-cache.ts +14 -0
  26. package/src/router/segment-resolution/loader-mask.ts +44 -0
  27. package/src/router/substitute-pattern-params.ts +54 -35
  28. package/src/router/trie-matching.ts +19 -11
  29. package/src/router/url-params.ts +13 -0
  30. package/src/rsc/full-payload.ts +70 -0
  31. package/src/rsc/rsc-rendering.ts +105 -51
  32. package/src/rsc/shell-capture.ts +439 -0
  33. package/src/rsc/types.ts +26 -0
  34. package/src/server/cookie-store.ts +45 -0
  35. package/src/server/live.ts +130 -0
  36. package/src/server/request-context.ts +49 -0
  37. package/src/ssr/index.tsx +377 -180
  38. package/src/ssr/ssr-root.tsx +228 -0
  39. package/src/testing/render-route.tsx +7 -9
  40. package/src/types/route-config.ts +19 -7
  41. package/src/urls/type-extraction.ts +43 -18
  42. package/src/vite/discovery/discovery-errors.ts +61 -0
  43. package/src/vite/plugins/virtual-entries.ts +27 -2
  44. package/src/vite/router-discovery.ts +69 -15
  45. package/src/vite/utils/prerender-utils.ts +17 -4
@@ -174,6 +174,52 @@ export interface RequestContext<
174
174
  /** @internal Cache store for segment caching (optional, used by CacheScope) */
175
175
  _cacheStore?: SegmentCacheStore;
176
176
 
177
+ /**
178
+ * @internal PPR shell-resume signal. Set by the shell-cache middleware on a
179
+ * validated shell HIT before it awaits next(); read in the render orchestration
180
+ * (rsc-rendering) to call the resume strategy instead of a full fizz render. The
181
+ * middleware sets it optimistically — the render layer is the final authority
182
+ * (nonce/formState/allReady bypass) and marks the response only when it actually
183
+ * resumed. Single-request; the middleware clears it in a finally.
184
+ */
185
+ _shellResume?: { postponed: string | null };
186
+
187
+ /**
188
+ * @internal PPR shell-capture DESCRIPTOR ("a capture of this document is
189
+ * wanted"). Set by the shell-cache middleware BEFORE its single foreground
190
+ * next() on a MISS or SWR stale hit, and read by the render orchestration
191
+ * (rsc-rendering) AFTER the response is built to schedule a background capture
192
+ * task. Its mere presence must NOT change the foreground render — loader
193
+ * masking and the cookie/header capture guard key off `_shellCaptureRun`, not
194
+ * this descriptor. `store` carries the SAME store the middleware resolved for
195
+ * its getShell read (options.store ?? _cacheStore), so a store-attached
196
+ * middleware writes captures where it reads them; without it an explicit
197
+ * options.store distinct from the app-level _cacheStore would read one store
198
+ * and write another and the shell would never HIT. `tags` is left unset here —
199
+ * the background capture collects the shell's own (non-loader) request tags
200
+ * from its derived render. Single-request; the middleware clears it in a
201
+ * finally after next() settles.
202
+ */
203
+ _shellCapture?: {
204
+ key: string;
205
+ ttl?: number;
206
+ swr?: number;
207
+ tags?: string[];
208
+ store?: SegmentCacheStore<any>;
209
+ };
210
+
211
+ /**
212
+ * @internal PPR shell-capture ACTIVE marker. True ONLY inside the background
213
+ * capture task's derived request context (built by shell-capture.ts). This is
214
+ * the switch every capture-specific behavior reads: loader masking
215
+ * (loader-mask.ts isShellCaptureActive / fresh.ts emitStreaming) and the
216
+ * cookies()/headers() capture guard (cookie-store.ts
217
+ * assertNotInsideShellCapture). The foreground render never sets it — even when
218
+ * `_shellCapture` (the "capture wanted" descriptor) is present — so the served
219
+ * response is byte-identical to axis 1.
220
+ */
221
+ _shellCaptureRun?: boolean;
222
+
177
223
  /**
178
224
  * @internal Handler-owned registry of explicit per-scope stores from
179
225
  * cache({ store }). Created once per createRSCHandler() and threaded into
@@ -449,6 +495,9 @@ export type PublicRequestContext<
449
495
  | "_handleStore"
450
496
  | "_transitionWhen"
451
497
  | "_cacheStore"
498
+ | "_shellResume"
499
+ | "_shellCapture"
500
+ | "_shellCaptureRun"
452
501
  | "_explicitTaggedStores"
453
502
  | "_requestTags"
454
503
  | "_cacheProfiles"
package/src/ssr/index.tsx CHANGED
@@ -1,21 +1,6 @@
1
1
  import React from "react";
2
- import { renderSegments } from "../segment-system.js";
3
- import {
4
- filterSegmentOrder,
5
- filterRouteSegmentIds,
6
- } from "../browser/react/filter-segment-order.js";
7
- import { ThemeProvider } from "../theme/ThemeProvider.js";
8
- import { NonceContext } from "../browser/react/nonce-context.js";
9
- import { NavigationStoreContext } from "../browser/react/context.js";
10
- import type { NavigationStoreContextValue } from "../browser/react/context.js";
11
- import type { HandleData } from "../browser/types.js";
2
+ import { createSsrRootComponent } from "./ssr-root.js";
12
3
  import type { ErrorPhase } from "../types.js";
13
- import type { ResolvedSegment } from "../types.js";
14
- import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
15
- import type {
16
- EventController,
17
- DerivedNavigationState,
18
- } from "../browser/event-controller.js";
19
4
 
20
5
  /**
21
6
  * Options for injectRSCPayload
@@ -43,6 +28,51 @@ interface ReactDOMReadableStream extends ReadableStream<Uint8Array> {
43
28
  allReady: Promise<void>;
44
29
  }
45
30
 
31
+ /**
32
+ * Options for prerender from react-dom/static.edge
33
+ */
34
+ interface PrerenderOptions {
35
+ signal?: AbortSignal;
36
+ bootstrapScriptContent?: string;
37
+ onError?: (error: unknown) => void;
38
+ }
39
+
40
+ /**
41
+ * Result of prerender from react-dom/static.edge. `postponed` is React's
42
+ * opaque resume state — non-null when the render was aborted with pending
43
+ * holes, null when the shell completed with nothing left to stream.
44
+ */
45
+ interface PrerenderResult {
46
+ prelude: ReadableStream<Uint8Array>;
47
+ postponed: unknown;
48
+ }
49
+
50
+ /**
51
+ * prerender from react-dom/static.edge
52
+ */
53
+ type PrerenderFn = (
54
+ element: React.ReactNode,
55
+ options?: PrerenderOptions,
56
+ ) => Promise<PrerenderResult>;
57
+
58
+ /**
59
+ * Options for resume from react-dom/server.edge
60
+ */
61
+ interface ResumeOptions {
62
+ onError?: (error: unknown) => void;
63
+ nonce?: string;
64
+ }
65
+
66
+ /**
67
+ * resume from react-dom/server.edge — continues a prerendered render, emitting
68
+ * only the postponed holes.
69
+ */
70
+ type ResumeFn = (
71
+ element: React.ReactNode,
72
+ postponedState: unknown,
73
+ options?: ResumeOptions,
74
+ ) => Promise<ReactDOMReadableStream>;
75
+
46
76
  /**
47
77
  * Options for the renderHTML function
48
78
  */
@@ -102,6 +132,18 @@ export interface SSRDependencies<TEnv = unknown> {
102
132
  */
103
133
  loadBootstrapScriptContent: () => Promise<string>;
104
134
 
135
+ /**
136
+ * prerender from react-dom/static.edge. Optional; required only by
137
+ * {@link createShellCaptureHandler} for PPR shell capture.
138
+ */
139
+ prerender?: PrerenderFn;
140
+
141
+ /**
142
+ * resume from react-dom/server.edge. Optional; required only by
143
+ * {@link createShellResumeHandler} for resuming a postponed shell.
144
+ */
145
+ resume?: ResumeFn;
146
+
105
147
  /**
106
148
  * Optional callback invoked when an error occurs during SSR rendering.
107
149
  *
@@ -122,97 +164,142 @@ export interface SSRDependencies<TEnv = unknown> {
122
164
  }
123
165
 
124
166
  /**
125
- * RSC payload type (minimal interface for SSR)
167
+ * Default guard for how long capture waits on the caller's `quiesce` signal
168
+ * before forcing the abort that freezes the shell. This is the ONLY wall-clock
169
+ * on the capture path and it is a pathological guard — it should never fire once
170
+ * the caller's `quiesce` is a task-quantized, frozen-byte signal (the capture
171
+ * gate in shell-capture.ts). See docs/design/ppr-shell-resume.md.
126
172
  */
127
- interface RscPayload {
128
- metadata?: {
129
- segments?: ResolvedSegment[];
130
- rootLayout?: React.ComponentType<{ children: React.ReactNode }>;
131
- handles?: AsyncGenerator<HandleData, void, unknown>;
132
- matched?: string[];
133
- pathname?: string;
134
- params?: Record<string, string>;
135
- basename?: string;
136
- themeConfig?: ResolvedThemeConfig | null;
137
- initialTheme?: Theme;
138
- version?: string;
139
- };
140
- }
173
+ const DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS = 5000;
174
+
175
+ /**
176
+ * Fixed number of macrotask hops between `quiesce` resolving and the abort. By
177
+ * the time `quiesce` resolves the Flight input is byte-quiet and frozen, so
178
+ * these hops are deterministic: they only give React's fizz worker turns to
179
+ * flush the settled shell and mark still-pending boundaries as POSTPONED (rather
180
+ * than errored) before controller.abort() lands. Not a wall-clock wait.
181
+ */
182
+ const POST_QUIESCE_TASK_HOPS = 2;
141
183
 
142
184
  /**
143
- * Consume an async generator and return a Promise that resolves with the final value.
144
- * Used for SSR where we need to await all handle data before rendering.
185
+ * Route an SSR error through the deps.onError notification callback with the
186
+ * "rendering" phase. Swallows callback failures so a broken reporter never
187
+ * masks the original error. Shared by renderHTML, capture, and resume so the
188
+ * onError contract is identical across all three handlers.
145
189
  */
146
- async function consumeAsyncGenerator(
147
- generator: AsyncGenerator<HandleData, void, unknown>,
148
- ): Promise<HandleData> {
149
- let lastData: HandleData = {};
150
- for await (const data of generator) {
151
- lastData = data;
190
+ function reportRenderError(
191
+ onError: SSRDependencies["onError"],
192
+ error: unknown,
193
+ ): void {
194
+ if (onError) {
195
+ const errorObj = error instanceof Error ? error : new Error(String(error));
196
+ try {
197
+ onError(errorObj, { phase: "rendering" });
198
+ } catch (callbackError) {
199
+ console.error("[SSRHandler.onError] Callback error:", callbackError);
200
+ }
152
201
  }
153
- return lastData;
154
202
  }
155
203
 
156
204
  /**
157
- * Create a minimal event controller for SSR.
158
- * This provides the correct pathname so useNavigation returns the right value during SSR.
205
+ * Yield one macrotask. Used by capture to let React's fizz worker flush the
206
+ * shell and mark still-pending boundaries as postponed before the abort.
159
207
  */
160
- function createSsrEventController(opts: {
161
- pathname: string;
162
- params?: Record<string, string>;
163
- handleData?: HandleData;
164
- matched?: string[];
165
- }): EventController {
166
- const location = new URL(opts.pathname, "http://localhost");
167
- let params = opts.params ?? {};
168
- const rawMatched = opts.matched ?? [];
169
- const handleState = {
170
- data: opts.handleData ?? {},
171
- segmentOrder: filterSegmentOrder(rawMatched),
172
- routeSegmentIds: filterRouteSegmentIds(rawMatched),
173
- };
174
- const state: DerivedNavigationState = {
175
- state: "idle",
176
- isStreaming: false,
177
- isNavigating: false,
178
- location,
179
- pendingUrl: null,
180
- inflightActions: [],
181
- };
208
+ function macrotask(): Promise<void> {
209
+ return new Promise((resolve) => setTimeout(resolve, 0));
210
+ }
182
211
 
183
- return {
184
- getState: () => state,
185
- getLocation: () => location,
186
- subscribe: () => () => {},
187
- getActionState: () => ({
188
- state: "idle",
189
- actionId: null,
190
- payload: null,
191
- error: null,
192
- result: null,
193
- }),
194
- subscribeToAction: () => () => {},
195
- subscribeToHandles: () => () => {},
196
- setHandleData: () => {},
197
- getHandleState: () => handleState,
198
- setRouteSegmentIds: () => {},
199
- setParams: (nextParams) => {
200
- params = nextParams;
201
- },
202
- getParams: () => params,
203
- setLocation: () => {},
204
- startNavigation: () => {
205
- throw new Error("Navigation not supported during SSR");
206
- },
207
- abortNavigation: () => {},
208
- startAction: () => {
209
- throw new Error("Actions not supported during SSR");
212
+ /**
213
+ * A timeout promise paired with a cancel() so the pending timer is cleared once
214
+ * the race is decided — otherwise the maxWait timer keeps the event loop alive
215
+ * for the full duration even after `quiesce` won.
216
+ */
217
+ function createCancelableTimeout(ms: number): {
218
+ promise: Promise<void>;
219
+ cancel: () => void;
220
+ } {
221
+ let id: ReturnType<typeof setTimeout> | undefined;
222
+ const promise = new Promise<void>((resolve) => {
223
+ id = setTimeout(resolve, ms);
224
+ });
225
+ return { promise, cancel: () => clearTimeout(id) };
226
+ }
227
+
228
+ /**
229
+ * Drain a ReadableStream fully into a single Uint8Array. Capture buffers the
230
+ * whole prelude so it can be stored and later prepended byte-for-byte.
231
+ */
232
+ async function readStreamToUint8Array(
233
+ stream: ReadableStream<Uint8Array>,
234
+ ): Promise<Uint8Array> {
235
+ const reader = stream.getReader();
236
+ const chunks: Uint8Array[] = [];
237
+ let total = 0;
238
+ while (true) {
239
+ const { done, value } = await reader.read();
240
+ if (done) break;
241
+ chunks.push(value);
242
+ total += value.length;
243
+ }
244
+ const out = new Uint8Array(total);
245
+ let offset = 0;
246
+ for (const chunk of chunks) {
247
+ out.set(chunk, offset);
248
+ offset += chunk.length;
249
+ }
250
+ return out;
251
+ }
252
+
253
+ /**
254
+ * A minimal HTML stream for the resume DATA variant: one empty chunk, then
255
+ * close.
256
+ *
257
+ * injectRSCPayload only resolves its internal flight-data promise (and thus
258
+ * only writes the Flight payload <script> pushes) from inside transform()'s
259
+ * scheduled callback. A stream that closes without ever emitting a chunk never
260
+ * runs transform, so its flush() awaits a promise that is never resolved and
261
+ * the output deadlocks. Emitting a single empty chunk runs transform once,
262
+ * which is enough for the payload to be written and the trailer appended.
263
+ */
264
+ function createDataVariantHtmlStream(): ReadableStream<Uint8Array> {
265
+ return new ReadableStream({
266
+ start(controller) {
267
+ controller.enqueue(new Uint8Array(0));
268
+ controller.close();
210
269
  },
211
- abortAllActions: () => {},
212
- getCurrentNavigation: () => null,
213
- getInflightActions: () => new Map(),
214
- hadAnyConcurrentActions: () => false,
215
- };
270
+ });
271
+ }
272
+
273
+ /**
274
+ * Options for the captureShellHTML function returned by
275
+ * {@link createShellCaptureHandler}.
276
+ */
277
+ interface ShellCaptureOptions {
278
+ /** Caller-provided promise that resolves once the cached content settled. */
279
+ quiesce: Promise<void>;
280
+ /** Upper bound on how long to wait for `quiesce`. Default 5000ms. */
281
+ maxWaitMs?: number;
282
+ }
283
+
284
+ /**
285
+ * Result of a successful shell capture. `prelude` is the raw prelude bytes;
286
+ * `postponed` is React's resume state serialized to JSON, or null when the
287
+ * shell completed with no holes (the DATA variant).
288
+ */
289
+ interface ShellCaptureResult {
290
+ prelude: Uint8Array;
291
+ postponed: string | null;
292
+ }
293
+
294
+ /**
295
+ * Options for the resumeShellHTML function returned by
296
+ * {@link createShellResumeHandler}.
297
+ */
298
+ interface ShellResumeOptions {
299
+ /** JSON from capture; null selects the DATA variant (no fizz). */
300
+ postponed: string | null;
301
+ /** Nonce for CSP. */
302
+ nonce?: string;
216
303
  }
217
304
 
218
305
  /**
@@ -261,81 +348,11 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
261
348
  // - rscStream2: For browser hydration (inject as __FLIGHT_DATA__)
262
349
  const [rscStream1, rscStream2] = rscStream.tee();
263
350
 
264
- // Deserialize RSC stream to React tree
265
- let payload: Promise<RscPayload> | undefined;
266
- let handlesPromise: Promise<HandleData> | undefined;
267
- let ssrContextValue: NavigationStoreContextValue | undefined;
268
- let rootPromise: Promise<React.ReactNode> | undefined;
269
- function SsrRoot() {
270
- payload ??= createFromReadableStream<RscPayload>(rscStream1);
271
- const resolved = React.use(payload);
272
-
273
- const themeConfig = resolved.metadata?.themeConfig ?? null;
274
- const pathname = resolved.metadata?.pathname ?? "/";
275
-
276
- // Await handles before creating SSR event controller so hooks can
277
- // read request-local handle data via NavigationStoreContext.
278
- // The handles property is an async generator that yields on each push
279
- // Memoize the promise since async generators can only be iterated once
280
- let handleData: HandleData = {};
281
- if (resolved.metadata?.handles) {
282
- handlesPromise ??= consumeAsyncGenerator(resolved.metadata.handles);
283
- handleData = React.use(handlesPromise);
284
- }
285
-
286
- // Create SSR context with request-local pathname/params/handles.
287
- ssrContextValue ??= {
288
- store: null as any,
289
- eventController: createSsrEventController({
290
- pathname,
291
- params: resolved.metadata?.params,
292
- handleData,
293
- matched: resolved.metadata?.matched,
294
- }),
295
- navigate: async () => {},
296
- refresh: async () => {},
297
- version: resolved.metadata?.version,
298
- basename: resolved.metadata?.basename,
299
- };
300
-
301
- // Build content tree from segments.
302
- // Order must match NavigationProvider: NavigationStoreContext > NonceContext > ThemeProvider > content
303
- // Memoize like payload/handles above: renderSegments is async, so
304
- // React.use() on a fresh promise suspends and replays SsrRoot, which
305
- // would re-run the entire segment-tree build on every initial render.
306
- rootPromise ??= Promise.resolve(
307
- renderSegments(resolved.metadata?.segments ?? [], {
308
- rootLayout: resolved.metadata?.rootLayout,
309
- }),
310
- );
311
- let content: React.ReactNode = React.use(rootPromise);
312
-
313
- // Wrap content with ThemeProvider if theme is enabled
314
- if (themeConfig) {
315
- content = (
316
- <ThemeProvider
317
- config={themeConfig}
318
- initialTheme={resolved.metadata?.initialTheme}
319
- >
320
- {content}
321
- </ThemeProvider>
322
- );
323
- }
324
-
325
- // Wrap with NonceContext so client components (e.g. MetaTags) can
326
- // apply CSP nonces to inline scripts during SSR. Always present to
327
- // match the browser-side NavigationProvider tree shape for hydration.
328
- content = (
329
- <NonceContext.Provider value={nonce}>{content}</NonceContext.Provider>
330
- );
331
-
332
- // Wrap with NavigationStoreContext for useNavigation hook
333
- return (
334
- <NavigationStoreContext.Provider value={ssrContextValue!}>
335
- {content}
336
- </NavigationStoreContext.Provider>
337
- );
338
- }
351
+ const SsrRoot = createSsrRootComponent({
352
+ createFromReadableStream,
353
+ rscStream: rscStream1,
354
+ nonce,
355
+ });
339
356
 
340
357
  // Get bootstrap script content
341
358
  const bootstrapScriptContent = await loadBootstrapScriptContent();
@@ -359,17 +376,197 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
359
376
  // Inject RSC payload into HTML as <script nonce="...">__FLIGHT_DATA__</script>
360
377
  return htmlStream.pipeThrough(injectRSCPayload(rscStream2, { nonce }));
361
378
  } catch (error) {
362
- // Invoke onError callback if provided
363
- if (onError) {
364
- const errorObj =
365
- error instanceof Error ? error : new Error(String(error));
366
- try {
367
- onError(errorObj, { phase: "rendering" });
368
- } catch (callbackError) {
369
- console.error("[SSRHandler.onError] Callback error:", callbackError);
370
- }
379
+ reportRenderError(onError, error);
380
+ throw error;
381
+ }
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Create the PPR shell capture handler.
387
+ *
388
+ * captureShellHTML prerenders the shell over the (cached, loader-masked) Flight
389
+ * stream, aborts once the shell settles, and returns the prelude bytes plus the
390
+ * postponed resume state for storage. The stored pair is later served by
391
+ * {@link createShellResumeHandler}. See docs/design/ppr-shell-resume.md.
392
+ *
393
+ * Throws at creation if `deps.prerender` is missing — capture cannot run
394
+ * without react-dom/static.edge's prerender.
395
+ */
396
+ export function createShellCaptureHandler<TEnv = unknown>(
397
+ deps: SSRDependencies<TEnv>,
398
+ ) {
399
+ const { createFromReadableStream, loadBootstrapScriptContent, prerender } =
400
+ deps;
401
+
402
+ if (!prerender) {
403
+ throw new Error(
404
+ "[createShellCaptureHandler] Missing `prerender` dependency (react-dom/static.edge). " +
405
+ "PPR shell capture requires the prerender export; wire it in the SSR virtual entry.",
406
+ );
407
+ }
408
+
409
+ /**
410
+ * Prerender the shell and return the stored artifacts, or null when the
411
+ * shell degraded (root postpone / hung handles) and must not be cached.
412
+ *
413
+ * @param rscStream - Flight stream to render the shell over. Not teed and not
414
+ * piped through injectRSCPayload: the hydration payload is produced fresh
415
+ * per request by the resume/serve pass.
416
+ * @param opts - quiesce signal and maxWait guard.
417
+ */
418
+ return async function captureShellHTML(
419
+ rscStream: ReadableStream<Uint8Array>,
420
+ opts: ShellCaptureOptions,
421
+ ): Promise<ShellCaptureResult | null> {
422
+ const maxWaitMs = opts.maxWaitMs ?? DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS;
423
+
424
+ // No nonce (nonce'd requests never reach capture); no formState.
425
+ const SsrRoot = createSsrRootComponent({
426
+ createFromReadableStream,
427
+ rscStream,
428
+ });
429
+
430
+ const bootstrapScriptContent = await loadBootstrapScriptContent();
431
+
432
+ // Start prerender first, then run the abort schedule concurrently. When
433
+ // holes are pending, prerender's promise settles only after abort(); when
434
+ // the shell completes with no holes it settles on its own and the later
435
+ // abort() is a harmless no-op (the DATA variant).
436
+ const controller = new AbortController();
437
+ const prerenderPromise = prerender(<SsrRoot />, {
438
+ signal: controller.signal,
439
+ bootstrapScriptContent,
440
+ });
441
+
442
+ // Wait for the caller's quiesce signal. By the time it resolves the Flight
443
+ // input is byte-quiet and FROZEN by the capture gate (shell-capture.ts
444
+ // gateFlightForCapture), so there is no wall-clock debounce here — maxWaitMs
445
+ // is only the pathological guard for a shell that never goes quiet (a root
446
+ // postpone / hung handle), and should never fire in tests.
447
+ const timer = createCancelableTimeout(maxWaitMs);
448
+ try {
449
+ await Promise.race([opts.quiesce, timer.promise]);
450
+ } finally {
451
+ timer.cancel();
452
+ }
453
+ // Fixed task hops before the abort: give React's fizz worker turns to flush
454
+ // the now-complete shell and mark the still-pending boundaries as POSTPONED
455
+ // rather than errored. Deterministic (the byte set is already frozen), so a
456
+ // fixed count of turns suffices — no wall-clock.
457
+ for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) {
458
+ await macrotask();
459
+ }
460
+ controller.abort();
461
+
462
+ // A hard prerender rejection (fatal shell error) propagates. Expected
463
+ // degradation surfaces three ways and all return null: a trivial prelude
464
+ // (sanity gate below), the prerender REJECTING with an AbortError, or the
465
+ // prelude STREAM erroring with the abort reason mid-read — both abort
466
+ // shapes happen when our own abort lands before the shell completed (seen
467
+ // on dev cold paths, where module transform / first-render latency
468
+ // outlasts flight quiesce; a later request re-captures against warm
469
+ // modules and succeeds).
470
+ let prelude: Uint8Array;
471
+ let postponed: unknown;
472
+ try {
473
+ const result = await prerenderPromise;
474
+ prelude = await readStreamToUint8Array(result.prelude);
475
+ postponed = result.postponed;
476
+ } catch (error) {
477
+ // Name-based match: the rejection is a DOMException on workerd/Node,
478
+ // which is not an Error subclass there, so instanceof Error would let
479
+ // the abort escape as a spurious reported error.
480
+ if (
481
+ controller.signal.aborted &&
482
+ (error as { name?: string } | null)?.name === "AbortError"
483
+ ) {
484
+ return null;
371
485
  }
372
486
  throw error;
373
487
  }
488
+
489
+ // Sanity gate: a prelude with no `<body` is the no-shell failure mode.
490
+ // Return null and store nothing; the request falls back to axis 1 and a
491
+ // later request re-captures. The dominant real-world cause is a loader
492
+ // route WITHOUT a route-level loading() boundary: renderSegments' loading-
493
+ // less branch awaits loader data at TREE-BUILD, so the masked loader pins
494
+ // the whole tree above <body> (root postpone). Root-postponing layouts and
495
+ // hung handles degrade the same way. shell-capture.ts logs a once-per-key
496
+ // warning so the eternal-MISS shape is diagnosable.
497
+ if (!new TextDecoder().decode(prelude).includes("<body")) {
498
+ return null;
499
+ }
500
+
501
+ return {
502
+ prelude,
503
+ postponed: postponed == null ? null : JSON.stringify(postponed),
504
+ };
505
+ };
506
+ }
507
+
508
+ /**
509
+ * Create the PPR shell resume handler.
510
+ *
511
+ * resumeShellHTML produces the per-request live portion of the document: for a
512
+ * postponed shell it resumes fizz over a fresh SsrRoot to emit only the holes;
513
+ * for the DATA variant it emits only the fresh Flight payload scripts. The
514
+ * caller (shell-cache middleware) prepends the stored prelude bytes to form the
515
+ * composite response. See docs/design/ppr-shell-resume.md.
516
+ */
517
+ export function createShellResumeHandler<TEnv = unknown>(
518
+ deps: SSRDependencies<TEnv>,
519
+ ) {
520
+ const { createFromReadableStream, injectRSCPayload, resume, onError } = deps;
521
+
522
+ /**
523
+ * @param rscStream - Fresh full Flight stream for this request.
524
+ * @param opts - postponed state (null = DATA variant) and optional nonce.
525
+ */
526
+ return async function resumeShellHTML(
527
+ rscStream: ReadableStream<Uint8Array>,
528
+ opts: ShellResumeOptions,
529
+ ): Promise<ReadableStream<Uint8Array>> {
530
+ const { postponed, nonce } = opts;
531
+
532
+ try {
533
+ if (postponed === null) {
534
+ // DATA variant: the stored prelude is the complete shell. No fizz runs;
535
+ // feed injectRSCPayload a minimal HTML stream so its flush appends the
536
+ // fresh Flight payload scripts after the shell. The stream must emit at
537
+ // least one chunk — see createDataVariantHtmlStream.
538
+ return createDataVariantHtmlStream().pipeThrough(
539
+ injectRSCPayload(rscStream, { nonce }),
540
+ );
541
+ }
542
+
543
+ if (!resume) {
544
+ throw new Error(
545
+ "[createShellResumeHandler] Missing `resume` dependency (react-dom/server.edge). " +
546
+ "Resuming a postponed shell requires the resume export; wire it in the SSR virtual entry.",
547
+ );
548
+ }
549
+
550
+ // Tee: one branch deserializes into the SsrRoot VDOM that resume() replays
551
+ // (a fresh instance is fine — replay matches structure, not identity), the
552
+ // other feeds the fresh hydration payload to injectRSCPayload.
553
+ const [rscStream1, rscStream2] = rscStream.tee();
554
+
555
+ const SsrRoot = createSsrRootComponent({
556
+ createFromReadableStream,
557
+ rscStream: rscStream1,
558
+ nonce,
559
+ });
560
+
561
+ const resumed = await resume(<SsrRoot />, JSON.parse(postponed), {
562
+ onError: (error) => reportRenderError(onError, error),
563
+ nonce,
564
+ });
565
+
566
+ return resumed.pipeThrough(injectRSCPayload(rscStream2, { nonce }));
567
+ } catch (error) {
568
+ reportRenderError(onError, error);
569
+ throw error;
570
+ }
374
571
  };
375
572
  }