@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
@@ -17,6 +17,7 @@ import type { CookieOptions } from "../router/middleware.js";
17
17
  import {
18
18
  KEEP_CACHE_HEADER,
19
19
  getRawCookieValue,
20
+ freshReadsCookieName,
20
21
  mintStateValue,
21
22
  serializeStateCookie,
22
23
  } from "../browser/cookie-name.js";
@@ -64,6 +65,7 @@ import {
64
65
  assertNonCacheableReadAllowed,
65
66
  clearPprHeaderScope,
66
67
  } from "./context.js";
68
+ import { assertThemeReadAllowed } from "./cookie-store.js";
67
69
  import {
68
70
  createReverseFunction,
69
71
  stripInternalParams,
@@ -165,6 +167,40 @@ export interface RequestContext<
165
167
  _rotateStateCookie(): void;
166
168
  /** @internal Set the keepClientCache() directive header on the response. */
167
169
  _setKeepCacheDirective(): void;
170
+ /**
171
+ * @internal This request carries the fresh-reads cookie: it follows an
172
+ * updateTag()/revalidateTag() by the same user, so the stores read tag
173
+ * markers and shells from the store, not from an isolate memo.
174
+ */
175
+ _freshReads?: boolean;
176
+ /**
177
+ * @internal Set the fresh-reads cookie on this response for `maxAgeMs`
178
+ * (rounded up to whole seconds; the longest stays). Called by
179
+ * updateTag()/revalidateTag().
180
+ */
181
+ _setFreshReadsCookie(maxAgeMs: number): void;
182
+ /**
183
+ * @internal The context createRequestContext() returned. A context derived
184
+ * from it with Object.create (a PPR HIT tail, a shell capture, a background
185
+ * revalidation render) inherits it, so per-request invalidation state
186
+ * (tag-invalidation.ts, the stores' masks) is keyed by the request, not by
187
+ * whichever derived object called or read (#973).
188
+ */
189
+ _requestRoot?: RequestContext<any, any>;
190
+ /**
191
+ * @internal The tags this request invalidated with
192
+ * updateTag()/revalidateTag(), each with its position in the invalidation
193
+ * order (tag-invalidation.ts currentCacheSeq). Kept on _requestRoot. A
194
+ * "use cache" execution that started before one of its own tags was
195
+ * invalidated is neither joined nor written (cache-runtime.ts, #973).
196
+ */
197
+ _tagInvalidations?: Map<string, number>;
198
+ /**
199
+ * @internal The handler has handed the response to the host (rsc/handler.ts
200
+ * `rango.response`): header and cookie writes after this are lost, as from
201
+ * a streaming loader or render.
202
+ */
203
+ _responseSent?: boolean;
168
204
 
169
205
  /**
170
206
  * Access loader data or push handle data.
@@ -264,6 +300,49 @@ export interface RequestContext<
264
300
  */
265
301
  _shellCaptureLoaderRecords?: Map<string, Promise<unknown>>;
266
302
 
303
+ /**
304
+ * @internal Set on a capture's derived context when a loader pushed a
305
+ * value the record cannot keep (a deferred push, masked nested promises,
306
+ * or a push outside a loader body): the loader records then carry
307
+ * `runs: 1`, so a HIT still runs their bodies for those pushes.
308
+ */
309
+ _shellCaptureUnrecordedLoaderPush?: true;
310
+
311
+ /**
312
+ * @internal Record keys resolved for this request (CacheScope
313
+ * resolveKeyFrom): by the scope's `key()` (one result) or its store's
314
+ * keyGenerator (one per default key). A shell capture's derived context
315
+ * reads it through the prototype chain, so the capture reuses the
316
+ * partition its request resolved instead of running `key()` again.
317
+ */
318
+ _resolvedCacheKeys?: Map<unknown, Map<string, Promise<string>>>;
319
+
320
+ /**
321
+ * @internal The shell store key the PPR serve path resolved for this
322
+ * document request (rsc-rendering.ts shellServePlan), request partition
323
+ * included. Read by the testing helper serveShellRequest.
324
+ */
325
+ _shellKey?: string;
326
+
327
+ /**
328
+ * @internal The request carried the forced-MISS reload marker
329
+ * (shell-serve.ts SHELL_MISS_PARAM). The handler strips the marker from the
330
+ * request on entry and sets this; the shell gate renders the request like a
331
+ * cache miss.
332
+ */
333
+ _shellForcedMiss?: true;
334
+
335
+ /**
336
+ * @internal The freshness window of the route cache() records this
337
+ * request read or wrote (cache-scope.ts noteRouteRecordWindow), as ms
338
+ * timestamps: `freshUntil` is the record's `expiresAt`, `staleUntil` adds
339
+ * its scope's swr. The narrowest record wins. A shell capture caps its
340
+ * entry's ttl and ttl+swr to it (shell-capture.ts capShellWindow), so a
341
+ * shell is never fresher, or served longer, than the route cache() entry
342
+ * it replayed or wrote.
343
+ */
344
+ _routeRecordWindow?: RouteRecordWindow;
345
+
267
346
  /**
268
347
  * @internal Loader-family snapshot seed for a shell HIT's tail render:
269
348
  * segment-key -> the capture's elided container (already Flight-deserialized
@@ -271,8 +350,10 @@ export interface RequestContext<
271
350
  * overlays it onto the fresh run's container (recorded paths pinned,
272
351
  * hole-marker paths keep the fresh nested promises) so the payload's baked
273
352
  * bytes match the frozen prelude; a hole-free entry resolves pin-first
274
- * without gating on the fresh run. Own property of the HIT tail's derived
275
- * context only.
353
+ * and does not run the loader. Two owners: serveShellHit sets it on the
354
+ * HIT tail's derived context, and matchPartialWithPprReplay sets it on the
355
+ * request context of a PPR partial replay once its doc record hits, and
356
+ * restores the previous value after the match.
276
357
  */
277
358
  _shellLoaderSeed?: Map<
278
359
  string,
@@ -280,16 +361,17 @@ export interface RequestContext<
280
361
  >;
281
362
 
282
363
  /**
283
- * @internal Shell fast-path marker: makes the next eligible match treat the
284
- * whole matched route as an implicit doc-level cache() boundary (see
364
+ * @internal The doc record marker: makes the next match treat the whole
365
+ * matched route as an implicit doc-level cache() boundary (see
285
366
  * resolveShellImplicitCacheScope in cache/cache-scope.ts). Set ONLY on
286
367
  * (a) the capture's derived context — with a record-only store so the
287
368
  * capture's cacheRoute write lands in the snapshot, never the real store —
288
- * (b) a HIT tail's seeded context, and (c) an eligible normal-route partial
289
- * replay, where `store` is a request-local segment overlay. Handler-live holes
290
- * and conditional transitions decline replay.
291
- * Routes with their own cache() config are never overridden — their scope's
292
- * store/key/ttl/swr/condition semantics stay authoritative. On the
369
+ * (b) a document HIT tail's seeded context (`docTail`), and (c) an eligible
370
+ * normal-route partial replay, where `store` is a request-local segment
371
+ * overlay.
372
+ * A document HIT tail replays the record even for a route with its own
373
+ * cache() config (the gate before the commit already evaluated that
374
+ * scope's opt-outs). Elsewhere the route's scope stays authoritative. On the
293
375
  * navigation-replay serve path (`onExplicitHit` set) the marker COMPOSES with
294
376
  * such a scope instead of being ignored: the seeded doc record supplies the match
295
377
  * only when the explicit tier misses (withCacheLookup). cache(false) and a
@@ -306,12 +388,17 @@ export interface RequestContext<
306
388
  * segment record even when the triggering request is partial.
307
389
  */
308
390
  keyPrefix?: "doc";
309
- /** @internal Called only after the implicit cache hit decodes successfully. */
310
- onHit?: () => void;
391
+ /**
392
+ * @internal Called only after the implicit cache hit decodes
393
+ * successfully; the lookup awaits it before returning the hit, so work
394
+ * it starts lands before the route's loaders resolve.
395
+ */
396
+ onHit?: () => void | Promise<void>;
311
397
  /**
312
398
  * @internal Called when the seeded document record fails server-side
313
399
  * deserialization. Partial replay uses it to replace the enclosing shell
314
- * snapshot after serving the fresh fallback.
400
+ * snapshot after serving the fresh fallback. A document HIT tail does not
401
+ * set it: withCacheLookup throws ShellRecordUnavailableError instead.
315
402
  */
316
403
  onCorrupt?: () => void;
317
404
  /**
@@ -322,6 +409,21 @@ export interface RequestContext<
322
409
  * require the exact consumable record instead of "any segment record".
323
410
  */
324
411
  docKey?: string;
412
+ /**
413
+ * @internal Set on a document HIT tail (serveShellHit). The implicit doc
414
+ * scope replaces any route-derived cache() scope for the tail's match
415
+ * (resolveShellImplicitCacheScope), and a lookup that does not hit throws
416
+ * ShellRecordUnavailableError instead of resolving segments
417
+ * (withCacheLookup): a HIT never runs a handler.
418
+ */
419
+ docTail?: true;
420
+ /**
421
+ * @internal The key a document HIT tail looks the doc record up by: the
422
+ * entry's own `docKey`. Recomputing it could name another key (a store
423
+ * keyGenerator folding request data, a build-time capture's synthetic
424
+ * host) and miss the record.
425
+ */
426
+ fixedDocKey?: string;
325
427
  /**
326
428
  * @internal Set ONLY by matchPartialWithPprReplay on the navigation-replay
327
429
  * serve path. Its presence arms the explicit-scope composition in
@@ -365,31 +467,16 @@ export interface RequestContext<
365
467
  _shellFragmentPayload?: boolean;
366
468
 
367
469
  /**
368
- * @internal Handler-layer liveness observed DURING a shell capture, from
369
- * three sources: (a) the capture handle-store push wrapper (shell-capture.ts)
370
- * when a push made OUTSIDE a DSL loader scope carries a nested thenable
371
- * (masked to a never-filling hole); (b) still-pending top-level handler
372
- * pushes (liveness unknowable at the barrier); (c) a handler-invoked loader
373
- * executing during the capture (loader-resolution.ts — its consumption-lane
374
- * value would freeze on a handler-free HIT). captureAndStoreShell folds it
375
- * into ShellCacheEntry.handlerLiveHoles at the putShell barrier. Own
376
- * property of the capture's derived context only.
377
- */
378
- _shellCaptureHandleLiveness?: {
379
- holes: boolean;
380
- pendingPushes: number;
381
- handlerInvokedLoader: boolean;
382
- };
383
-
384
- /**
385
- * @internal Set (to the offending fn name) by the cookies()/headers()
386
- * capture guard when it throws DURING a capture render. Load-bearing for the
470
+ * @internal Set by the capture guard (server/context.ts
471
+ * tripShellCaptureGuard) when it throws DURING a capture render: the read
472
+ * ("cookies()", "headers()", "ctx.get()", "ctx.theme") and the fix the
473
+ * refusal warning gives for it. Load-bearing for the
387
474
  * bake lane: a guard throw inside an executing loader is swallowed by
388
475
  * wrapLoaderPromise into per-loader error UI, which would otherwise bake
389
476
  * silently into the shared shell — the capture checks this flag after the
390
477
  * render and refuses instead. Deterministic, so the capture does not retry.
391
478
  */
392
- _shellCaptureGuardTripped?: string;
479
+ _shellCaptureGuardTripped?: { surface: string; fix: string };
393
480
 
394
481
  /**
395
482
  * @internal The loader $$id whose BODY was executing when the capture guard
@@ -420,6 +507,23 @@ export interface RequestContext<
420
507
  */
421
508
  _requestTags: Set<string>;
422
509
 
510
+ /**
511
+ * @internal This request can write a route cache() record: its match
512
+ * resolved a cache scope (match-api.ts). Arms the record tag owners
513
+ * (cache-tag.ts armRecordTagOwners); off, the segment scopes, loader links
514
+ * and owned tag sets are skipped entirely. A capture's or background
515
+ * re-render's derived context (Object.create) inherits it.
516
+ */
517
+ _recordTagOwners?: true;
518
+
519
+ /**
520
+ * @internal This request resolves a loader bound with its own cache()
521
+ * (match-api.ts, or the binding's funnel). Arms the per-execution loader
522
+ * tag sets (cache-tag.ts armLoaderTagSets); off, a loader execution
523
+ * allocates no set and a read links nothing. Derived contexts inherit it.
524
+ */
525
+ _recordLoaderTags?: true;
526
+
423
527
  /** @internal Cache profiles for "use cache" profile resolution (per-router) */
424
528
  _cacheProfiles?: Record<
425
529
  string,
@@ -467,6 +571,11 @@ export interface RequestContext<
467
571
  * Returns the theme value from the cookie, or the default theme if not set.
468
572
  * This is the user's preference ("light", "dark", or "system"), not the resolved value.
469
573
  *
574
+ * The visitor's cookie, so it is guarded like `cookies()`: reading it
575
+ * refuses a ppr shell capture and throws inside a `cache()` boundary or a
576
+ * `"use cache"` function (#971). A non-enumerable getter: a spread or
577
+ * `Object.assign` copy of the context does not carry it.
578
+ *
470
579
  * @example
471
580
  * ```typescript
472
581
  * route("settings", (ctx) => {
@@ -475,7 +584,7 @@ export interface RequestContext<
475
584
  * });
476
585
  * ```
477
586
  */
478
- theme?: Theme;
587
+ readonly theme?: Theme;
479
588
 
480
589
  /**
481
590
  * Set the theme (only available when theme is enabled in router config)
@@ -499,6 +608,15 @@ export interface RequestContext<
499
608
  /** @internal Theme configuration (null if theme not enabled) */
500
609
  _themeConfig?: ResolvedThemeConfig | null;
501
610
 
611
+ /**
612
+ * @internal The theme cookie (or the default) read with no identity guard.
613
+ * The guarded reads (`theme` here, the handler and middleware `ctx.theme`)
614
+ * call it after assertThemeReadAllowed; the router's own payload read goes
615
+ * through payloadInitialTheme (rsc/full-payload.ts). Undefined without
616
+ * theme config.
617
+ */
618
+ _readTheme(): Theme | undefined;
619
+
502
620
  /**
503
621
  * Attach location state entries to the current response.
504
622
  *
@@ -726,6 +844,21 @@ export interface RequestContext<
726
844
  _cacheSignal?: import("../router/telemetry.js").CacheSegmentSignal[];
727
845
  }
728
846
 
847
+ /**
848
+ * @internal The freshness window of a route cache() record, as ms
849
+ * timestamps: fresh until `freshUntil` (its `expiresAt`), stale until
850
+ * `staleUntil` (plus its scope's swr). `ttl`, `swr` and `written` describe
851
+ * the record that sets `staleUntil`: its cache() config, and whether this
852
+ * request wrote it (cacheRoute) rather than read it.
853
+ */
854
+ export interface RouteRecordWindow {
855
+ freshUntil: number;
856
+ staleUntil: number;
857
+ ttl: number;
858
+ swr: number;
859
+ written: boolean;
860
+ }
861
+
729
862
  /**
730
863
  * Public view of RequestContext, without internal methods and fields.
731
864
  *
@@ -751,16 +884,23 @@ export type PublicRequestContext<
751
884
  | "_shellImplicitCache"
752
885
  | "_shellLoaderSeed"
753
886
  | "_shellCaptureLoaderRecords"
754
- | "_shellCaptureHandleLiveness"
887
+ | "_shellCaptureUnrecordedLoaderPush"
888
+ | "_resolvedCacheKeys"
889
+ | "_shellKey"
890
+ | "_shellForcedMiss"
891
+ | "_routeRecordWindow"
755
892
  | "_shellFragmentPayload"
756
893
  | "_shellCaptureGuardTripped"
757
894
  | "_shellCaptureGuardTrippedLoaderId"
758
895
  | "_explicitTaggedStores"
759
896
  | "_requestTags"
897
+ | "_recordTagOwners"
898
+ | "_recordLoaderTags"
760
899
  | "_cacheProfiles"
761
900
  | "_onResponseCallbacks"
762
901
  | "_pendingBackgroundTasks"
763
902
  | "_themeConfig"
903
+ | "_readTheme"
764
904
  | "_locationState"
765
905
  | "_routeName"
766
906
  | "_prevRouteKey"
@@ -795,6 +935,11 @@ export type PublicRequestContext<
795
935
  | "_setStatus"
796
936
  | "_rotateStateCookie"
797
937
  | "_setKeepCacheDirective"
938
+ | "_freshReads"
939
+ | "_setFreshReadsCookie"
940
+ | "_requestRoot"
941
+ | "_tagInvalidations"
942
+ | "_responseSent"
798
943
  | "_variables"
799
944
  | "_classifiedRoute"
800
945
  | "_requestMode"
@@ -991,6 +1136,10 @@ export function createRequestContext<TEnv>(
991
1136
  } = options;
992
1137
  const cookieHeader = request.headers.get("Cookie");
993
1138
  let rangoStateRotated = false;
1139
+ const freshCookie = stateCookieName
1140
+ ? freshReadsCookieName(stateCookieName)
1141
+ : undefined;
1142
+ let freshCookieMaxAge = 0;
994
1143
  let parsedCookies: Record<string, string> | null = null;
995
1144
 
996
1145
  let stubResponse = initialResponse
@@ -1144,7 +1293,7 @@ export function createRequestContext<TEnv>(
1144
1293
  _dynamic: false,
1145
1294
  get: ((keyOrVar: any) => {
1146
1295
  if (isNonCacheable(variables, keyOrVar)) {
1147
- assertNonCacheableReadAllowed(keyOrVar);
1296
+ assertNonCacheableReadAllowed(keyOrVar, _getRequestContext());
1148
1297
  }
1149
1298
  return contextGet(variables, keyOrVar);
1150
1299
  }) as RequestContext<TEnv>["get"],
@@ -1254,6 +1403,48 @@ export function createRequestContext<TEnv>(
1254
1403
  rawStubHeaders.set(KEEP_CACHE_HEADER, "1");
1255
1404
  },
1256
1405
 
1406
+ // The fresh-reads cookie (browser/cookie-name.ts): one Set-Cookie per
1407
+ // response, the longest Max-Age any invalidation asked for. HttpOnly (the
1408
+ // client never reads it), SameSite=Lax, Path=/. rawStubHeaders: an
1409
+ // internal writer, allowed wherever updateTag()/revalidateTag() are. A
1410
+ // shared cache never stores it (Set-Cookie is a per-client signal).
1411
+ _setFreshReadsCookie(maxAgeMs: number): void {
1412
+ if (!freshCookie) return;
1413
+ const maxAge = Math.ceil(maxAgeMs / 1000);
1414
+ // Already set for at least this long (an earlier invalidation in this
1415
+ // request): nothing to add, sent or not.
1416
+ if (maxAge <= freshCookieMaxAge) return;
1417
+ if (ctx._responseSent) {
1418
+ // A streaming loader or render: the headers already left. The
1419
+ // invalidation itself still ran; only the mutating user's bypass of
1420
+ // the stores' isolate memos is lost.
1421
+ if (process.env.NODE_ENV !== "production") {
1422
+ console.warn(
1423
+ `[rango] updateTag()/revalidateTag() ran after the response ` +
1424
+ `headers were sent (from a streaming loader or render), so the ` +
1425
+ `${freshCookie} cookie was not set: this user's next requests ` +
1426
+ `can be served from a store's isolate memo for a few seconds. ` +
1427
+ `Invalidate in a server action, a route handler, or middleware.`,
1428
+ );
1429
+ }
1430
+ return;
1431
+ }
1432
+ const serialized = (age: number) =>
1433
+ `${freshCookie}=1; Max-Age=${age}; Path=/; HttpOnly; SameSite=Lax` +
1434
+ (url.protocol === "https:" ? "; Secure" : "");
1435
+ if (freshCookieMaxAge > 0) {
1436
+ const prior = serialized(freshCookieMaxAge);
1437
+ const kept = rawStubHeaders
1438
+ .getSetCookie()
1439
+ .filter((cookie) => cookie !== prior);
1440
+ rawStubHeaders.delete("Set-Cookie");
1441
+ for (const cookie of kept) rawStubHeaders.append("Set-Cookie", cookie);
1442
+ }
1443
+ freshCookieMaxAge = maxAge;
1444
+ rawStubHeaders.append("Set-Cookie", serialized(maxAge));
1445
+ invalidateResponseCookieCache();
1446
+ },
1447
+
1257
1448
  setStatus(status: number): void {
1258
1449
  assertNotInsideCacheExec(ctx, "setStatus");
1259
1450
  assertResponseWriteAllowed("setStatus");
@@ -1275,6 +1466,9 @@ export function createRequestContext<TEnv>(
1275
1466
  _explicitTaggedStores: explicitTaggedStores,
1276
1467
  _requestTags: new Set<string>(),
1277
1468
  _cacheProfiles: cacheProfiles,
1469
+ _freshReads:
1470
+ freshCookie !== undefined &&
1471
+ getRawCookieValue(cookieHeader, freshCookie) !== null,
1278
1472
 
1279
1473
  waitUntil(fn: () => Promise<void>): void {
1280
1474
  if (ctx.build) return;
@@ -1316,9 +1510,8 @@ export function createRequestContext<TEnv>(
1316
1510
  this._onResponseCallbacks.push(callback);
1317
1511
  },
1318
1512
 
1319
- get theme() {
1320
- return themeConfig ? getTheme() : undefined;
1321
- },
1513
+ // theme: a guarded, non-enumerable getter defined after this literal.
1514
+ _readTheme: getTheme,
1322
1515
  setTheme: themeConfig
1323
1516
  ? (theme: Theme) => {
1324
1517
  assertNotInsideCacheExec(ctx, "setTheme");
@@ -1359,7 +1552,39 @@ export function createRequestContext<TEnv>(
1359
1552
  getContext: () => ctx,
1360
1553
  });
1361
1554
 
1555
+ // getRequestContext().theme is the visitor's cookie, guarded like cookies()
1556
+ // (#971); before it was a plain read that a ppr shell, a cache() entry or a
1557
+ // "use cache" entry stored for every visitor. Non-enumerable like the
1558
+ // handler ctx.theme (handler-context.ts THEME_DESCRIPTOR): serializing the
1559
+ // context (React dev debug info) must not count as a read. The guard reads
1560
+ // the ambient context, as cookies() does, and falls back to the receiver.
1561
+ // _readTheme is the unguarded read: non-enumerable too, so a spread copy of
1562
+ // the context (the fetchable-loader ctx, loader-fetch.ts) cannot carry it.
1563
+ Object.defineProperties(ctx, {
1564
+ theme: {
1565
+ get(this: RequestContext<TEnv>): Theme | undefined {
1566
+ if (!themeConfig) return undefined;
1567
+ assertThemeReadAllowed(
1568
+ _getRequestContext() ?? this,
1569
+ "getRequestContext().theme",
1570
+ );
1571
+ return getTheme();
1572
+ },
1573
+ enumerable: false,
1574
+ configurable: true,
1575
+ },
1576
+ _readTheme: { enumerable: false },
1577
+ });
1578
+
1362
1579
  (ctx as any)[NOCACHE_SYMBOL] = true;
1580
+ // Non-enumerable: a self-reference would make the context circular for
1581
+ // JSON.stringify, spreads and React's dev debug-info serialization of props.
1582
+ Object.defineProperty(ctx, "_requestRoot", {
1583
+ value: ctx,
1584
+ enumerable: false,
1585
+ configurable: true,
1586
+ writable: true,
1587
+ });
1363
1588
  return ctx;
1364
1589
  }
1365
1590
 
package/src/ssr/index.tsx CHANGED
@@ -2,7 +2,11 @@ import React from "react";
2
2
  import { createSsrRootComponent, deserializeSsrPayload } from "./ssr-root.js";
3
3
  import { injectRSCPayloadEager } from "./inject-rsc-eager.js";
4
4
  import { runWithPreinitNonce } from "./preinit-client-references.js";
5
- import { SHELL_CAPTURE_MAX_WAIT_MS } from "../rsc/shell-capture-constants.js";
5
+ import {
6
+ POST_QUIESCE_TASK_HOPS,
7
+ SHELL_CAPTURE_MAX_WAIT_MS,
8
+ } from "../rsc/shell-capture-constants.js";
9
+ import { isThenable } from "../handles/is-thenable.js";
6
10
  import type { ErrorPhase } from "../types.js";
7
11
  import type { HeadScriptsOption } from "../vite/plugin-types.js";
8
12
 
@@ -237,28 +241,64 @@ export interface SSRDependencies<TEnv = unknown> {
237
241
  }
238
242
 
239
243
  /**
240
- * Fixed number of macrotask hops between `quiesce` resolving and the abort. These
241
- * give React's fizz worker turns to flush the settled shell into the prelude and
242
- * mark still-pending boundaries as POSTPONED (rather than errored) before
243
- * controller.abort() lands. Not a wall-clock wait.
244
- *
245
- * Why 16 and not the original 2: under the REPLAY-ONLY capture model
246
- * (docs/design/ppr-shell-resume.md), the capture Flight render serializes ring-3
247
- * cached segments that are ALREADY serialized, so it emits the whole shell payload
248
- * in the first tick and the gate declares quiesce almost immediately (~a few ms).
249
- * On the old fresh-execution path the Flight dribbled out as handlers ran, so
250
- * Flight-quiet effectively meant "the shell has rendered" and 2 hops sufficed.
251
- *
252
- * Hops alone are NOT render-readiness: fizz cannot emit even <html> until the
253
- * payload root settles, which waits on every referenced client-module LOAD —
254
- * real module-runner I/O in dev (100ms+ cold), which no fixed count of near-
255
- * zero-cost task hops can buy. captureShellHTML therefore awaits the payload-
256
- * settled signal (SsrRootOptions.onPayloadSettled, deadline-bounded) between
257
- * quiesce and these hops; the hops then only flush the settled tree and mark
258
- * pending boundaries POSTPONED. Still task-based (masked loaders never emit,
259
- * so more hops never lets a hole settle). Bounded by maxWaitMs end to end.
244
+ * One set per running capture, collecting the client-reference module loads
245
+ * requested while it runs (captureClientLoads). The Flight client resolves a
246
+ * client component used as an element type lazily, so the payload settles
247
+ * while its module is still loading, and a shell that renders it outside any
248
+ * Suspense boundary cannot complete until the load does. On the
249
+ * cloudflare-basic build a cold load (the module, its imports, its CSS
250
+ * virtual) settled at +890.1ms, after the payload (+873.4ms) and 1ms before
251
+ * the abort, and uninstrumented builds lost that race (issue #949). The
252
+ * Flight client asks the loader again for every import row, even for a
253
+ * module another render is already loading, so a capture sees each load it
254
+ * needs; one a concurrent render requests meanwhile is collected too and is
255
+ * the same bounded I/O. The sets are per capture, their contents isolate-wide:
256
+ * a load that never settles can only hold the captures running when it was
257
+ * requested, each until its deadline.
260
258
  */
261
- const POST_QUIESCE_TASK_HOPS = 16;
259
+ const activeCaptureClientLoads = new Set<Set<PromiseLike<unknown>>>();
260
+
261
+ /** The loader wrapper {@link captureClientLoads} last installed. */
262
+ let recordingClientReferenceLoader: ((id: string) => unknown) | undefined;
263
+
264
+ /**
265
+ * Start collecting the client-reference loads requested during a capture;
266
+ * call the returned `stop` when the capture ends. Wraps plugin-rsc's SSR
267
+ * loader (`globalThis.__vite_rsc_client_require__`, which
268
+ * `__vite_rsc_require__` reads per call) on the first capture, so an isolate
269
+ * that never captures keeps the bare loader. Wraps again when another wrapper
270
+ * replaced this one (the build shell phase bridges hashed ids the same way,
271
+ * vite/discovery/shell-prerender-phase.ts). Collects nothing without the
272
+ * loader.
273
+ */
274
+ function captureClientLoads(): {
275
+ loads: Set<PromiseLike<unknown>>;
276
+ stop: () => void;
277
+ } {
278
+ const g = globalThis as {
279
+ __vite_rsc_client_require__?: (id: string) => unknown;
280
+ };
281
+ const load = g.__vite_rsc_client_require__;
282
+ if (typeof load === "function" && load !== recordingClientReferenceLoader) {
283
+ recordingClientReferenceLoader = (id) => {
284
+ const loading = load(id);
285
+ // plugin-rsc memoizes one promise per id and the Flight client stamps
286
+ // status "fulfilled" on it once loaded: a warm module is not collected.
287
+ if (
288
+ activeCaptureClientLoads.size > 0 &&
289
+ isThenable(loading) &&
290
+ (loading as { status?: string }).status !== "fulfilled"
291
+ ) {
292
+ for (const loads of activeCaptureClientLoads) loads.add(loading);
293
+ }
294
+ return loading;
295
+ };
296
+ g.__vite_rsc_client_require__ = recordingClientReferenceLoader;
297
+ }
298
+ const loads = new Set<PromiseLike<unknown>>();
299
+ activeCaptureClientLoads.add(loads);
300
+ return { loads, stop: () => activeCaptureClientLoads.delete(loads) };
301
+ }
262
302
 
263
303
  /**
264
304
  * Route an SSR error through the deps.onError notification callback with the
@@ -421,6 +461,13 @@ interface ShellCaptureOptions {
421
461
  * refuses to store the shell (issue #915).
422
462
  */
423
463
  onError?: (error: unknown) => void;
464
+ /**
465
+ * Called with React's errorInfo for each task still pending when the
466
+ * capture's own abort froze the prelude; its componentStack is computed
467
+ * when read. The dev no-shell warning prints the stacks: one of them is what
468
+ * suspended above <body> (issue #941).
469
+ */
470
+ onAbortedTask?: (errorInfo: { componentStack?: string } | undefined) => void;
424
471
  }
425
472
 
426
473
  /**
@@ -761,12 +808,13 @@ export function createShellCaptureHandler<TEnv = unknown>(
761
808
  const deadline = (await isDebuggerAttached())
762
809
  ? { promise: new Promise<void>(() => {}), cancel: () => {} }
763
810
  : createCancelableTimeout(maxWaitMs);
811
+ const clientLoads = captureClientLoads();
764
812
  try {
765
813
  // No nonce (nonce'd requests never reach capture); no formState.
766
- // payloadSettled: fires when the Flight payload root settles — i.e.
767
- // every client-module load the payload references completed and fizz
768
- // can actually emit the tree. The abort below gates on it (bounded by
769
- // the same deadline): Flight byte-quiet alone is NOT render-readiness.
814
+ // payloadSettled: fires when the Flight payload root settles and fizz
815
+ // can emit the tree. The abort below gates on it and on the
816
+ // client-reference loads in flight in the isolate (bounded by the same
817
+ // deadline): Flight byte-quiet alone is NOT render-readiness.
770
818
  let settlePayload!: () => void;
771
819
  const payloadSettled = new Promise<void>((resolve) => {
772
820
  settlePayload = resolve;
@@ -823,8 +871,9 @@ export function createShellCaptureHandler<TEnv = unknown>(
823
871
  // render errors are NOT our sentinel and still surface through
824
872
  // deps.onError, the same channel renderHTML uses. See
825
873
  // docs/design/ppr-shell-resume.md.
826
- onError: (error: unknown) => {
874
+ onError: (error: unknown, errorInfo?: { componentStack?: string }) => {
827
875
  if (error === abortReason) {
876
+ opts.onAbortedTask?.(errorInfo);
828
877
  return;
829
878
  }
830
879
  opts.onError?.(error);
@@ -846,25 +895,34 @@ export function createShellCaptureHandler<TEnv = unknown>(
846
895
  // shell that never goes quiet (a root postpone / hung handle).
847
896
  await Promise.race([opts.quiesce, deadline.promise]);
848
897
  // Then wait for fizz RENDER-READINESS, bounded by the same deadline:
849
- // the payload root settles only after every client-module load the
850
- // payload references completed (real module-runner I/O in dev; 100ms+
851
- // on a cold graph). Flight byte-quiet does NOT imply this — a fully
898
+ // the payload root settles, then the client-reference module loads the
899
+ // capture started settle — a client component used as an element type
900
+ // resolves lazily, after the payload (real module-runner I/O in dev;
901
+ // 100ms+ on a cold graph). Flight byte-quiet does NOT imply this — a fully
852
902
  // REPLAYED (prerendered) route's Flight stream finishes in ~1-3ms and
853
903
  // an abort taken on quiet-plus-task-hops alone landed BEFORE fizz could
854
904
  // emit <html>, freezing a zero-byte prelude: the eternal-MISS shape
855
905
  // this route class showed on every cold graph (dev cold boots, GH
856
906
  // runners) while ordinary routes — whose live handler execution keeps
857
- // Flight noisy long enough — never hit it. Masked-loader holes do not
858
- // block payload settlement (they postpone below the root), so this
859
- // await costs a genuinely hole-y shell nothing; a payload that NEVER
860
- // settles (hung handles) degrades at the deadline exactly as before.
861
- // A prerender that SETTLES first (early success or a hard rejection)
862
- // ends the wait immediately — fizz is already done either way.
907
+ // Flight noisy long enough — never hit it. Holes the payload carries
908
+ // block neither wait (masked live loaders and loader-nested promises postpone
909
+ // below the root and load no module; the Flight input stays frozen),
910
+ // so this await costs a genuinely hole-y shell nothing; a payload that
911
+ // NEVER settles (hung handles) degrades at the deadline exactly as
912
+ // before. A prerender that SETTLES first (early success or a hard
913
+ // rejection) ends the wait immediately — fizz is already done either
914
+ // way.
863
915
  const prerenderSettled = prerenderPromise.then(
864
916
  () => {},
865
917
  () => {},
866
918
  );
867
- await Promise.race([payloadSettled, prerenderSettled, deadline.promise]);
919
+ // The capture's loads all started when their import rows were parsed,
920
+ // before the Flight input froze.
921
+ await Promise.race([
922
+ payloadSettled.then(() => Promise.allSettled(clientLoads.loads)),
923
+ prerenderSettled,
924
+ deadline.promise,
925
+ ]);
868
926
  // Fixed task hops before the abort: give React's fizz worker turns to flush
869
927
  // the now-complete shell and mark the still-pending boundaries as POSTPONED
870
928
  // rather than errored. Deterministic (the byte set is already frozen and
@@ -923,6 +981,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
923
981
  };
924
982
  } finally {
925
983
  deadline.cancel();
984
+ clientLoads.stop();
926
985
  }
927
986
  };
928
987
  }