@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
@@ -1,26 +1,27 @@
1
1
  /**
2
2
  * Capture data snapshot: recording + seeding stores for PPR shell parity.
3
3
  *
4
- * The scar tissue this fixes: a PPR HIT serves frozen prelude bytes, then a
5
- * FULL FRESH Flight render for hydration. Any shell-baked (non-hole) content
6
- * that drifts between capture time and hit time — a cache() segment with a
7
- * shorter ttl than the shell, a tag-invalidated item — makes the fresh payload
8
- * disagree with the prelude, so React throws a hydration text mismatch and
9
- * regenerates the tree client-side (wiping the FOUC theme class, flashing
10
- * content). See docs/design/ppr-shell-resume.md.
4
+ * A PPR HIT serves frozen prelude bytes, then a Flight payload for hydration
5
+ * that must agree with them byte for byte. The handler layer's part of that
6
+ * payload is the capture's own doc segment record: the capture writes it
7
+ * first, renders its prelude from it, and every HIT replays it (no handler
8
+ * runs on a HIT). What still executes on a HIT is the loader layer: live
9
+ * loaders and promise-carrying bake-lane (`ssr: false`) loaders run, and the
10
+ * values the bake-lane ones read must match what the capture baked (a
11
+ * promise-free bake-lane loader is served from its pin and does not run). See docs/design/ppr-shell-resume.md.
11
12
  *
12
- * The fix (Next.js resume-data-cache analog, adapted to Rango's cache rings):
13
- * the CAPTURE render records every cache-store read-hit and write it performed
14
- * (the {@link RecordingShellStore}); the record rides inside the ShellCacheEntry
15
- * as its `snapshot`; on a HIT the tail render reads through a
16
- * {@link SeededShellStore} overlay that serves those recorded values AS FRESH,
17
- * so the shell region reproduces byte-identically while everything NOT recorded
18
- * (the holes — masked loaders were never executed at capture, so their reads
19
- * were never recorded) stays live.
13
+ * The mechanism (Next.js resume-data-cache analog, adapted to Rango's cache
14
+ * rings): the CAPTURE render records every cache-store read-hit and write it
15
+ * performed (the {@link RecordingShellStore}); the records ride inside the
16
+ * ShellCacheEntry as its `snapshot`; on a HIT the tail render reads through a
17
+ * {@link SeededShellStore} overlay that serves those recorded values AS FRESH.
18
+ * Everything NOT recorded (the holes — masked loaders were never executed at
19
+ * capture, so their reads were never recorded) stays live.
20
20
  *
21
- * The invariant, verbatim: the snapshot is exactly the set of cache-store reads
22
- * the capture render performed; replaying them on a HIT reproduces the shell
23
- * content byte-identically; everything not recorded stays live.
21
+ * The invariant, verbatim: the snapshot is the doc record plus the cache-store
22
+ * reads the capture's loaders performed; replaying them on a HIT reproduces
23
+ * the shell content byte-identically; everything not recorded stays live.
24
+ * {@link pruneShellSnapshot} drops the rest.
24
25
  */
25
26
 
26
27
  import type {
@@ -39,6 +40,7 @@ import type {
39
40
  import { CACHE_READ_ERROR } from "./types.js";
40
41
  import { bufferToBase64, base64ToBuffer } from "./cf/cf-base64.js";
41
42
  import { isPerClientSignalHeader } from "../browser/cookie-name.js";
43
+ import { isInsideAnyLoaderScope } from "../server/context.js";
42
44
 
43
45
  /** Compose the last-write-wins map key. NUL (`\u0000`) cannot appear in a cache key. */
44
46
  function recordKey(family: ShellSnapshotRecord["family"], key: string): string {
@@ -83,12 +85,21 @@ function deserializeResponse(value: ShellSnapshotResponseValue): Response {
83
85
  * — may land after the shell has quiesced. The capture collects those write
84
86
  * promises via {@link trackWrite} and awaits them ({@link settleWrites}) before
85
87
  * draining, so a MISS-at-capture value is still pinned.
88
+ *
89
+ * Loader attribution: every item/response access made inside a loader scope
90
+ * (hit, miss or write) marks its key in {@link loaderKeys}. A loader re-runs
91
+ * on every HIT, so those records stay pinned when the snapshot is pruned. A
92
+ * miss counts too: a loader that joins a handler's in-flight "use cache" call
93
+ * reads the store first (cache-runtime.ts) and finds nothing, and the record
94
+ * then comes from the handler's write.
86
95
  */
87
96
  export class RecordingShellStore<
88
97
  TEnv = unknown,
89
98
  > implements SegmentCacheStore<TEnv> {
90
99
  private readonly records = new Map<string, ShellSnapshotRecord>();
91
100
  private readonly writes: Promise<unknown>[] = [];
101
+ /** Record keys ({@link recordKey}) a loader read or wrote during the capture. */
102
+ readonly loaderKeys: Set<string> = new Set();
92
103
 
93
104
  constructor(private readonly inner: SegmentCacheStore<TEnv>) {}
94
105
 
@@ -110,6 +121,13 @@ export class RecordingShellStore<
110
121
  this.records.set(recordKey(family, key), { family, key, value });
111
122
  }
112
123
 
124
+ private noteLoaderAccess(
125
+ family: ShellSnapshotRecord["family"],
126
+ key: string,
127
+ ): void {
128
+ if (isInsideAnyLoaderScope()) this.loaderKeys.add(recordKey(family, key));
129
+ }
130
+
113
131
  /** Track a deferred cache-write promise so the capture can await it pre-drain. */
114
132
  trackWrite(p: Promise<unknown>): void {
115
133
  this.writes.push(p);
@@ -134,25 +152,35 @@ export class RecordingShellStore<
134
152
  * cacheRoute path schedules its actual store.set in a second waitUntil while the
135
153
  * first is running), so each awaited batch may enqueue more. Loop until the
136
154
  * queue empties or the deadline passes. Bounded: a pathologically slow write
137
- * must never stall the capture task, so a key that does not settle in time is
138
- * left unpinned (it drifts, the pre-snapshot behavior) rather than hanging.
155
+ * must never stall the capture task. Resolves true when every tracked write
156
+ * settled, false at the deadline (settleCaptureRecord then fails the capture
157
+ * when the doc record is among the writes still pending).
139
158
  */
140
- async settleWrites(timeoutMs: number): Promise<void> {
159
+ async settleWrites(timeoutMs: number): Promise<boolean> {
141
160
  const deadline = Date.now() + timeoutMs;
142
161
  while (this.writes.length > 0) {
143
162
  const remaining = deadline - Date.now();
144
- if (remaining <= 0) return;
163
+ if (remaining <= 0) return false;
145
164
  // Take the current batch; new writes scheduled while awaiting accumulate in
146
165
  // this.writes and are drained on the next iteration.
147
166
  const batch = this.writes.splice(0);
148
167
  let timer: ReturnType<typeof setTimeout> | undefined;
149
- const guard = new Promise<void>((resolve) => {
150
- timer = setTimeout(resolve, remaining);
168
+ const guard = new Promise<false>((resolve) => {
169
+ timer = setTimeout(() => resolve(false), remaining);
151
170
  (timer as { unref?: () => void }).unref?.();
152
171
  });
153
- await Promise.race([Promise.allSettled(batch).then(() => {}), guard]);
172
+ const settled = await Promise.race([
173
+ Promise.allSettled(batch).then(() => true as const),
174
+ guard,
175
+ ]);
154
176
  if (timer) clearTimeout(timer);
177
+ if (!settled) {
178
+ // Keep tracking the batch: a later settleWrites still waits for it.
179
+ this.writes.unshift(...batch);
180
+ return false;
181
+ }
155
182
  }
183
+ return true;
156
184
  }
157
185
 
158
186
  /** The recorded snapshot (last-write-wins per family+key), or undefined if empty. */
@@ -160,6 +188,14 @@ export class RecordingShellStore<
160
188
  return this.records.size > 0 ? [...this.records.values()] : undefined;
161
189
  }
162
190
 
191
+ /** One recorded value, by family and key. */
192
+ getRecord(
193
+ family: ShellSnapshotRecord["family"],
194
+ key: string,
195
+ ): ShellSnapshotRecord["value"] | undefined {
196
+ return this.records.get(recordKey(family, key))?.value;
197
+ }
198
+
163
199
  async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
164
200
  const result = await this.inner.get(key);
165
201
  if (result && result !== CACHE_READ_ERROR) {
@@ -190,6 +226,7 @@ export class RecordingShellStore<
190
226
  key: string,
191
227
  ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
192
228
  if (!this.inner.getResponse) return null;
229
+ this.noteLoaderAccess("response", key);
193
230
  const result = await this.inner.getResponse(key);
194
231
  if (result)
195
232
  this.record("response", key, await serializeResponse(result.response));
@@ -204,12 +241,14 @@ export class RecordingShellStore<
204
241
  tags?: string[],
205
242
  ): Promise<void> {
206
243
  if (!this.inner.putResponse) return;
244
+ this.noteLoaderAccess("response", key);
207
245
  this.record("response", key, await serializeResponse(response));
208
246
  return this.inner.putResponse(key, response, ttl, swr, tags);
209
247
  }
210
248
 
211
249
  async getItem(key: string): Promise<CacheItemResult | null> {
212
250
  if (!this.inner.getItem) return null;
251
+ this.noteLoaderAccess("item", key);
213
252
  const result = await this.inner.getItem(key);
214
253
  if (result) {
215
254
  const value: ShellSnapshotItemValue = {
@@ -228,6 +267,7 @@ export class RecordingShellStore<
228
267
  options?: CacheItemOptions,
229
268
  ): Promise<void> {
230
269
  if (!this.inner.setItem) return;
270
+ this.noteLoaderAccess("item", key);
231
271
  const stored: ShellSnapshotItemValue = {
232
272
  value,
233
273
  handles: options?.handles,
@@ -266,6 +306,130 @@ export function getRecordingStore<TEnv>(
266
306
  return store instanceof RecordingShellStore ? store : undefined;
267
307
  }
268
308
 
309
+ /**
310
+ * True when `snapshot` carries the canonical doc segment record `docKey`
311
+ * names, with at least one segment: the record partial replay and a
312
+ * fast-path HIT tail consume.
313
+ */
314
+ export function hasDocRecord(
315
+ snapshot: readonly ShellSnapshotRecord[] | undefined,
316
+ docKey: string | undefined,
317
+ ): boolean {
318
+ if (docKey === undefined || !snapshot) return false;
319
+ return snapshot.some((record) => {
320
+ if (
321
+ !record ||
322
+ typeof record !== "object" ||
323
+ record.family !== "segment" ||
324
+ record.key !== docKey ||
325
+ typeof record.value !== "object" ||
326
+ record.value === null
327
+ ) {
328
+ return false;
329
+ }
330
+ const segments = (record.value as { segments?: unknown }).segments;
331
+ return Array.isArray(segments) && segments.length > 0;
332
+ });
333
+ }
334
+
335
+ /**
336
+ * Who reads a stored snapshot besides the doc record
337
+ * (docs/design/shell-entry-layout.md §2):
338
+ * - "segments": nobody. A navigation-only entry is read only by partial
339
+ * replay, which seeds the doc record alone (SeededShellStore
340
+ * `segmentsOnly`, implicit doc scope only).
341
+ * - "loaders": the loaders a document HIT re-runs. Every HIT tail replays
342
+ * the handler layer from the doc record and never runs a handler, so the
343
+ * records only handler code read are dead weight.
344
+ */
345
+ export type ShellSnapshotReaders = "segments" | "loaders";
346
+
347
+ /**
348
+ * Split a capture's snapshot into what its readers consume and the rest: the
349
+ * doc record (`docKey`) always; with "loaders", the loader-family records and
350
+ * the item/response records a loader touched during the capture
351
+ * (RecordingShellStore.loaderKeys). Every other segment record goes: an
352
+ * explicit cache() tier's reads and writes are keyed where no HIT tail and no
353
+ * partial replay looks (both consult only the doc record). The doc record
354
+ * stays a copy inside the entry: a reference to a separately stored record
355
+ * could be evicted on its own, and the HIT would have nothing to replay.
356
+ */
357
+ export function pruneShellSnapshot(
358
+ snapshot: readonly ShellSnapshotRecord[],
359
+ readers: ShellSnapshotReaders,
360
+ loaderKeys: ReadonlySet<string>,
361
+ docKey: string,
362
+ ): { kept: ShellSnapshotRecord[]; pruned: ShellSnapshotRecord[] } {
363
+ const kept: ShellSnapshotRecord[] = [];
364
+ const pruned: ShellSnapshotRecord[] = [];
365
+ for (const record of snapshot) {
366
+ const keep =
367
+ (record.family === "segment" && record.key === docKey) ||
368
+ (readers === "loaders" &&
369
+ (record.family === "loader" ||
370
+ (record.family !== "segment" &&
371
+ loaderKeys.has(recordKey(record.family, record.key)))));
372
+ (keep ? kept : pruned).push(record);
373
+ }
374
+ return { kept, pruned };
375
+ }
376
+
377
+ /**
378
+ * The shell entry head's fields estimateShellEntryBytes does not measure
379
+ * (versions, timestamps, theme, lengths, flags; about 200 bytes in a
380
+ * CFCacheStore frame) and the frame's 12-byte prefix, with room to spare.
381
+ */
382
+ export const SHELL_ENTRY_HEAD_ALLOWANCE_BYTES: number = 512;
383
+
384
+ const ENTRY_BYTE_ENCODER = new TextEncoder();
385
+
386
+ /**
387
+ * Bytes (UTF-8) a stored shell entry takes, as the built-in stores write it:
388
+ * the prelude, the snapshot's JSON, and a JSON head that carries the
389
+ * postponed state as an escaped string next to the tags and doc key
390
+ * (CFCacheStore's frame head, VercelCacheStore's envelope), plus
391
+ * SHELL_ENTRY_HEAD_ALLOWANCE_BYTES for the head's fixed fields and the
392
+ * frame prefix. Measuring the raw postponed bytes undercounted: every quote
393
+ * in it is escaped in the head (a /ppr-large/holes entry measured 1,098
394
+ * bytes under its KV value). The capture's whole-entry guard compares this
395
+ * with the store's value limit.
396
+ */
397
+ export function estimateShellEntryBytes(parts: {
398
+ preludeBytes: number;
399
+ postponed: string | null | undefined;
400
+ snapshot: readonly ShellSnapshotRecord[] | undefined;
401
+ tags: readonly string[] | undefined;
402
+ docKey: string | undefined;
403
+ prunedRecords: string | undefined;
404
+ }): number {
405
+ const head = ENTRY_BYTE_ENCODER.encode(
406
+ JSON.stringify({
407
+ po: parts.postponed,
408
+ t: parts.tags,
409
+ dk: parts.docKey,
410
+ pr: parts.prunedRecords,
411
+ }),
412
+ ).length;
413
+ const snapshot =
414
+ parts.snapshot && parts.snapshot.length > 0
415
+ ? ENTRY_BYTE_ENCODER.encode(JSON.stringify(parts.snapshot)).length
416
+ : 0;
417
+ return (
418
+ parts.preludeBytes + head + snapshot + SHELL_ENTRY_HEAD_ALLOWANCE_BYTES
419
+ );
420
+ }
421
+
422
+ /** Snapshot records by family, e.g. `segment:1/item:5` (no commas: it rides a Server-Timing desc). */
423
+ export function countSnapshotFamilies(
424
+ snapshot: readonly ShellSnapshotRecord[],
425
+ ): string {
426
+ const counts = new Map<string, number>();
427
+ for (const record of snapshot) {
428
+ counts.set(record.family, (counts.get(record.family) ?? 0) + 1);
429
+ }
430
+ return [...counts].map(([family, n]) => `${family}:${n}`).join("/");
431
+ }
432
+
269
433
  /**
270
434
  * The store the shell fast path's IMPLICIT doc-cache scope resolves during a
271
435
  * capture: reads pass through the recording store (a real-store hit is
@@ -324,6 +488,12 @@ export interface ShellLoaderSeedEntry {
324
488
  * from the pin (loader-cache.ts pin-first path).
325
489
  */
326
490
  holes: boolean;
491
+ /**
492
+ * The record needs its loader body to run on a HIT for pushes the capture
493
+ * could not record (ShellSnapshotLoaderValue.runs). Only read for a
494
+ * hole-free record: a hole-carrying one always runs.
495
+ */
496
+ runs: boolean;
327
497
  }
328
498
 
329
499
  export async function buildShellLoaderSeed(
@@ -345,9 +515,12 @@ export async function buildShellLoaderSeed(
345
515
  rec.key,
346
516
  {
347
517
  container: await deserializeResult(stored.value),
348
- // A record without the bit (pre-bit snapshot) reads as
349
- // hole-carrying: unknown hole-ness must keep the gated path.
518
+ // A record without a bit predates it: without the hole bit it
519
+ // reads as hole-carrying (unknown hole-ness keeps the gated
520
+ // path), and without the runs bit as runs (its snapshot lacks
521
+ // the loader-owned pushes, so the body supplies them).
350
522
  holes: stored.holes !== 0,
523
+ runs: stored.runs !== 0,
351
524
  },
352
525
  ];
353
526
  } catch {
@@ -514,3 +687,20 @@ export class SeededShellStore<
514
687
  return this.inner.invalidateTags?.(tags);
515
688
  }
516
689
  }
690
+
691
+ /**
692
+ * Thrown by a document HIT tail's match when its doc record cannot supply the
693
+ * handler layer (it failed to decode, or the entry lost it). The tail must not
694
+ * run handlers after the prelude committed, so withCacheLookup throws this
695
+ * instead of resolving segments, and serveShellHit degrades the response:
696
+ * the entry is replaced with a tombstone, the memo is dropped, a recapture is
697
+ * scheduled, and the browser reloads into a MISS.
698
+ */
699
+ export class ShellRecordUnavailableError extends Error {
700
+ constructor(docKey: string | undefined) {
701
+ super(
702
+ `PPR shell HIT: the doc record${docKey ? ` "${docKey}"` : ""} could not supply the handler layer`,
703
+ );
704
+ this.name = "ShellRecordUnavailableError";
705
+ }
706
+ }
@@ -21,9 +21,28 @@
21
21
  * primitive for each tag. A single configured store (the common case) owns its
22
22
  * own tag index and distributed invalidation - there is no separate
23
23
  * tag-invalidation store.
24
+ *
25
+ * Both call invalidateTags() synchronously, inside the request (#973). A store
26
+ * masks the tags for the rest of the request before its first await (the
27
+ * built-in stores write their per-request marker state there), so the request
28
+ * that ran either verb reads its own writes; revalidateTag() leaves only the
29
+ * durable remainder (KV marker put, tag purge, expireTag) to waitUntil.
30
+ * Invariant: the mask only turns the invalidating request's hits into misses.
31
+ * If the durable write then fails, that request paid extra misses, never a
32
+ * stale read, and other requests read the durable state. Both also record
33
+ * the tags on the request (markInvalidated), so a "use cache" execution or
34
+ * stale refresh this request started before one of its own tags was
35
+ * invalidated is neither joined nor written (cache-runtime.ts). Its own tags
36
+ * only: a nested "use cache" function's tags do not reach the enclosing
37
+ * entry (#980), for this gate as for eviction. Not covered (#977): such an
38
+ * execution in another request, or a loader cache() write, still lands, and
39
+ * the store accepts it (docs/design/caching.md "Read-your-own-writes").
24
40
  */
25
41
 
26
- import { _getRequestContext } from "../server/request-context.js";
42
+ import {
43
+ _getRequestContext,
44
+ type RequestContext,
45
+ } from "../server/request-context.js";
27
46
  import { reportingAsync } from "./cache-error.js";
28
47
  import { normalizeTags } from "./cache-tag.js";
29
48
  import type { SegmentCacheStore } from "./types.js";
@@ -104,6 +123,66 @@ function warnPartialTagStore(fn: string, incapable: number): void {
104
123
  );
105
124
  }
106
125
 
126
+ /**
127
+ * Set the fresh-reads cookie on this request's response for as long as the
128
+ * invalidated stores' isolate memos can stay stale (isolate-tag-memo.ts), so
129
+ * the same user's next requests read past them on every isolate.
130
+ */
131
+ function markFreshReads(stores: SegmentCacheStore[]): void {
132
+ const ctx = _getRequestContext();
133
+ if (!ctx?._setFreshReadsCookie) return;
134
+ let windowMs = 0;
135
+ for (const store of stores) {
136
+ windowMs = Math.max(windowMs, store.freshReadsWindowMs ?? 0);
137
+ }
138
+ if (windowMs > 0) ctx._setFreshReadsCookie(windowMs);
139
+ }
140
+
141
+ /**
142
+ * The isolate's order of tag invalidations and "use cache" execution starts
143
+ * (#973). A counter, not Date.now(): Workers advance the clock only on I/O,
144
+ * so an execution started right after an invalidation would share its
145
+ * millisecond and read as older.
146
+ */
147
+ let cacheSeq = 0;
148
+
149
+ /** The current position in that order; an execution records it at start. */
150
+ export function currentCacheSeq(): number {
151
+ return cacheSeq;
152
+ }
153
+
154
+ /**
155
+ * Whether the request invalidated any of `tags` after `since` (a
156
+ * currentCacheSeq() value an execution recorded when it started): its value
157
+ * may predate the invalidation, so the request neither joins nor writes it.
158
+ */
159
+ export function invalidatedSince(
160
+ ctx: RequestContext<any, any> | undefined,
161
+ tags: readonly string[] | undefined,
162
+ since: number,
163
+ ): boolean {
164
+ const record = (ctx?._requestRoot ?? ctx)?._tagInvalidations;
165
+ if (!record || !tags) return false;
166
+ for (const tag of tags) {
167
+ const at = record.get(tag);
168
+ if (at !== undefined && at > since) return true;
169
+ }
170
+ return false;
171
+ }
172
+
173
+ /**
174
+ * Record the invalidation on the request (RequestContext._tagInvalidations,
175
+ * on the root so derived contexts share it), before any store sees it.
176
+ */
177
+ function markInvalidated(tags: readonly string[]): void {
178
+ const ctx = _getRequestContext();
179
+ if (!ctx) return;
180
+ const root = ctx._requestRoot ?? ctx;
181
+ const at = ++cacheSeq;
182
+ const record = (root._tagInvalidations ??= new Map());
183
+ for (const tag of tags) record.set(tag, at);
184
+ }
185
+
107
186
  async function invalidateAcross(
108
187
  stores: SegmentCacheStore[],
109
188
  tags: string[],
@@ -163,6 +242,8 @@ export async function updateTag(...tags: string[]): Promise<void> {
163
242
  }
164
243
  if (incapable > 0) warnPartialTagStore("updateTag", incapable);
165
244
 
245
+ markFreshReads(capable);
246
+ markInvalidated(valid);
166
247
  await invalidateAcross(capable, valid);
167
248
  }
168
249
 
@@ -173,10 +254,12 @@ export async function updateTag(...tags: string[]): Promise<void> {
173
254
  * This is NOT stale-while-revalidate: like updateTag() it hard-purges, so the
174
255
  * next read after the invalidation lands is a miss that re-renders fresh. The
175
256
  * only difference from updateTag() is awaitability - revalidateTag() defers the
176
- * purge off the response path and is not awaited.
257
+ * durable write off the response path and is not awaited.
177
258
  *
178
- * Use in Route Handlers / webhooks. For read-your-own-writes inside a Server
179
- * Action, use updateTag() instead so the action's own response is fresh.
259
+ * Read-your-own-writes (#973): each built-in store masks the tags for the rest
260
+ * of this request before revalidateTag() returns, so a Server Action's own
261
+ * re-render after it is fresh, as after `await updateTag()`. Other requests see
262
+ * the invalidation once the durable write lands.
180
263
  *
181
264
  * Fire-and-forget: because this returns void and runs in the background, a
182
265
  * failed durable marker write (e.g. a transient KV outage) is NOT surfaced to
@@ -209,23 +292,25 @@ export function revalidateTag(...tags: string[]): void {
209
292
  }
210
293
  if (incapable > 0) warnPartialTagStore("revalidateTag", incapable);
211
294
 
295
+ markFreshReads(capable);
212
296
  const ctx = _getRequestContext();
297
+ // A build-time render drops background work (RequestContext.waitUntil), so
298
+ // it never ran this invalidation; keep it that way.
299
+ if (ctx?.build) return;
300
+ markInvalidated(valid);
301
+ // Started here, not in the waitUntil task, so each store masks the tags
302
+ // before this returns (#973, header above).
303
+ //
213
304
  // reportingAsync never rejects: it catches a failed durable write and routes
214
305
  // it through reportCacheError (loud log + onError). This is the only place a
215
306
  // revalidateTag failure can be observed, since it is not awaitable. Pass ctx
216
- // explicitly - the run executes in a detached waitUntil where the ALS context
217
- // is gone, so onError fires only if we hand it the captured context.
218
- const run = () =>
219
- reportingAsync(
220
- () => invalidateAcross(capable, valid),
221
- "cache-invalidate",
222
- "[revalidateTag] background invalidation",
223
- ctx,
224
- );
225
- if (ctx?.waitUntil) {
226
- ctx.waitUntil(run);
227
- } else {
228
- // No request context (e.g. called outside ALS): best-effort background run.
229
- void run();
230
- }
307
+ // explicitly: the write can settle after the response, outside the
308
+ // request's ALS scope, and onError fires only with the captured context.
309
+ const pending = reportingAsync(
310
+ () => invalidateAcross(capable, valid),
311
+ "cache-invalidate",
312
+ "[revalidateTag] background invalidation",
313
+ ctx,
314
+ );
315
+ ctx?.waitUntil(() => pending);
231
316
  }