@rangojs/router 0.0.0-experimental.145 → 0.0.0-experimental.147

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 (46) hide show
  1. package/dist/bin/rango.js +8 -40
  2. package/dist/vite/index.js +840 -243
  3. package/package.json +6 -1
  4. package/src/browser/event-controller.ts +16 -2
  5. package/src/browser/rsc-router.tsx +11 -0
  6. package/src/cache/cache-scope.ts +41 -3
  7. package/src/cache/cf/cf-cache-store.ts +23 -0
  8. package/src/cache/handle-snapshot.ts +22 -1
  9. package/src/cache/memory-segment-store.ts +32 -0
  10. package/src/cache/segment-codec.ts +47 -0
  11. package/src/cache/shell-snapshot.ts +47 -0
  12. package/src/cache/types.ts +27 -0
  13. package/src/cache/vercel/vercel-cache-store.ts +71 -2
  14. package/src/deps/ssr.ts +4 -1
  15. package/src/prerender/build-shell-capture.ts +237 -0
  16. package/src/prerender/shell-manifest-key.ts +20 -0
  17. package/src/prerender/store.ts +10 -1
  18. package/src/router/loader-resolution.ts +16 -0
  19. package/src/router/match-api.ts +9 -2
  20. package/src/router/match-handlers.ts +13 -0
  21. package/src/router/match-middleware/cache-lookup.ts +12 -1
  22. package/src/router/prerender-match.ts +21 -0
  23. package/src/router/segment-resolution/mask-nested.ts +19 -3
  24. package/src/rsc/capture-queue.ts +67 -0
  25. package/src/rsc/rsc-rendering.ts +136 -25
  26. package/src/rsc/shell-build-manifest.ts +244 -0
  27. package/src/rsc/shell-capture.ts +194 -43
  28. package/src/segment-fragments.ts +124 -0
  29. package/src/segment-system.tsx +49 -19
  30. package/src/server/request-context.ts +112 -11
  31. package/src/ssr/index.tsx +151 -22
  32. package/src/ssr/inject-rsc-eager.ts +2 -2
  33. package/src/ssr/preinit-client-references.ts +106 -0
  34. package/src/ssr/ssr-root.tsx +35 -2
  35. package/src/vite/discovery/discover-routers.ts +27 -0
  36. package/src/vite/discovery/prerender-collection.ts +16 -0
  37. package/src/vite/discovery/shell-prerender-phase.ts +395 -0
  38. package/src/vite/discovery/state.ts +42 -0
  39. package/src/vite/index.ts +1 -0
  40. package/src/vite/plugin-types.ts +33 -0
  41. package/src/vite/plugins/version-plugin.ts +8 -0
  42. package/src/vite/plugins/virtual-entries.ts +37 -4
  43. package/src/vite/rango.ts +11 -2
  44. package/src/vite/router-discovery.ts +292 -8
  45. package/src/vite/utils/prerender-utils.ts +25 -6
  46. package/src/vite/utils/shared-utils.ts +4 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.145",
3
+ "version": "0.0.0-experimental.147",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -128,6 +128,11 @@
128
128
  "types": "./src/build/index.ts",
129
129
  "import": "./src/build/index.ts"
130
130
  },
131
+ "./build/shell-capture": {
132
+ "types": "./src/prerender/build-shell-capture.ts",
133
+ "react-server": "./src/prerender/build-shell-capture.ts",
134
+ "import": "./src/prerender/build-shell-capture.ts"
135
+ },
131
136
  "./host": {
132
137
  "types": "./src/host/index.ts",
133
138
  "react-server": "./src/host/index.ts",
@@ -615,8 +615,22 @@ export function createEventController(
615
615
  const arbitration = arb;
616
616
  arbitration.inflight++;
617
617
 
618
- // Track if this action started while others were pending (concurrent)
619
- const hadConcurrent = inflightActions.size > 0;
618
+ // Track if this action started while another was genuinely in-flight.
619
+ // Completed entries don't count: complete()/fail() ran, so the prior
620
+ // action's response was fully processed and applied — a request dispatched
621
+ // after that point is strictly ordered behind the prior action's server
622
+ // execution, and there is no skipped render or order uncertainty for
623
+ // consolidation to repair. Completed entries still linger in the map for
624
+ // the 100ms doSettle window (useAction reads) and, on a slow connection,
625
+ // while the Flight stream drains its EOF after complete(). Counting them
626
+ // latched hadAnyConcurrentActions for back-to-back sequential actions,
627
+ // which made the LAST action classify as consolidation-needed; the
628
+ // consolidation refetch omits every concurrently-revalidated segment id
629
+ // from _rsc_segments, so the server re-ran gated loaders as "new-segment"
630
+ // — bypassing revalidate(({ isAction }) => ...) on a plain GET (#675).
631
+ const hadConcurrent = [...inflightActions.values()].some(
632
+ (a) => !a.completed,
633
+ );
620
634
  if (hadConcurrent) {
621
635
  hadAnyConcurrentActions = true;
622
636
  }
@@ -22,6 +22,7 @@ import type {
22
22
  } from "./types.js";
23
23
  import type { EventController } from "./event-controller.js";
24
24
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
25
+ import { expandSegmentFragments } from "../segment-fragments.js";
25
26
  import { initRangoState } from "./rango-state.js";
26
27
  import { registerNavigationStore } from "./navigation-store-handle.js";
27
28
  import { initPrefetchCache } from "./prefetch/cache.js";
@@ -162,6 +163,16 @@ export async function initBrowserApp(
162
163
  const initialPayload =
163
164
  await deps.createFromReadableStream<RscPayload>(rscStream);
164
165
 
166
+ // Shell-HIT documents carry replayed segments as VERBATIM stored fragments
167
+ // (segment-fragments.ts, issue #700); expand them through the browser
168
+ // deserializer BEFORE any consumer reads the segments (store seed,
169
+ // renderSegments, history cache). Non-HIT payloads have no envelopes and pay
170
+ // one field scan. The SSR resume pass ran the same expansion (ssr-root.tsx),
171
+ // so the hydrated tree matches the server-rendered one by construction.
172
+ await expandSegmentFragments(initialPayload.metadata?.segments, (stream) =>
173
+ deps.createFromReadableStream(stream),
174
+ );
175
+
165
176
  // Extract themeConfig and initialTheme from payload if not explicitly provided
166
177
  // This allows virtual entries to work without importing the router
167
178
  const effectiveThemeConfig =
@@ -285,10 +285,19 @@ export class CacheScope {
285
285
  // partial: evict the entry (self-heal - the re-render re-caches under the
286
286
  // same key) and report it as corruption, distinct from a transient infra
287
287
  // error (handled by the outer catch).
288
+ //
289
+ // Shell-HIT tail (_shellFragmentPayload, issue #700): skip the decode and
290
+ // carry the stored fragment strings verbatim — the payload consumers
291
+ // expand them (segment-fragments.ts). Read off the ambient context at the
292
+ // same point the cache key was resolved (getDefaultRouteCacheKey), so the
293
+ // flag shares fate with the key: a disrupted ALS already missed the
294
+ // seeded record and degraded to the full tail.
288
295
  let segments: ResolvedSegment[];
289
296
  try {
290
- const { deserializeSegments } = await import("./segment-codec.js");
291
- segments = await deserializeSegments(cached.segments);
297
+ const codec = await import("./segment-codec.js");
298
+ segments = _getRequestContext()?._shellFragmentPayload
299
+ ? await codec.fragmentSegments(cached.segments)
300
+ : await codec.deserializeSegments(cached.segments);
292
301
  } catch (error) {
293
302
  reportCacheError(
294
303
  error,
@@ -452,7 +461,11 @@ export class CacheScope {
452
461
  }
453
462
 
454
463
  // Collect handle data for non-loader segments only
455
- const handles = captureHandles(nonLoaderSegments, handleStore);
464
+ const handles = captureHandles(
465
+ nonLoaderSegments,
466
+ handleStore,
467
+ requestCtx._shellCaptureLoaderHandleValues,
468
+ );
456
469
 
457
470
  try {
458
471
  if (INTERNAL_RANGO_DEBUG) {
@@ -512,3 +525,28 @@ export function createCacheScope(
512
525
  if (!config) return parent; // No config, inherit parent
513
526
  return new CacheScope(config.options, parent);
514
527
  }
528
+
529
+ /**
530
+ * Shell fast path: when the route tree derived NO cache scope and the current
531
+ * request context carries the `_shellImplicitCache` marker (a shell capture,
532
+ * or a HIT tail serving an eligible entry), substitute an implicit doc-level
533
+ * scope so withCacheLookup/withCacheStore treat the WHOLE matched route as a
534
+ * cache() boundary — the shell entry IS a cache() of the handler layer, with
535
+ * loaders as the live carve-outs (resolveFreshLoadersAndYield).
536
+ *
537
+ * An existing scope — including an explicit cache(false) opt-out — always
538
+ * wins: the consumer's cache() semantics (their ttl/swr/store/condition) are
539
+ * never overridden, and cache(false) keeps the tail on the full handler
540
+ * re-run path.
541
+ */
542
+ export function resolveShellImplicitCacheScope(
543
+ scope: CacheScope | null,
544
+ ): CacheScope | null {
545
+ if (scope) return scope;
546
+ const marker = getRequestContext()?._shellImplicitCache;
547
+ if (!marker) return null;
548
+ return new CacheScope(
549
+ { ttl: marker.ttl, swr: marker.swr, store: marker.store },
550
+ null,
551
+ );
552
+ }
@@ -227,6 +227,13 @@ interface KVShellEnvelope {
227
227
  i?: string;
228
228
  /** Capture data snapshot: recorded cache-store hits/writes for HIT parity */
229
229
  sn?: import("../types.js").ShellSnapshotRecord[];
230
+ /**
231
+ * ShellCacheEntry.handlerLiveHoles. Must round-trip: the serve side arms the
232
+ * handler-free fast path on `!entry.handlerLiveHoles`, so dropping the flag
233
+ * here silently fast-pathed handler-live entries after a KV round trip —
234
+ * their holes only a handler re-run can fill.
235
+ */
236
+ lh?: boolean;
230
237
  }
231
238
 
232
239
  /**
@@ -1678,6 +1685,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1678
1685
  buildVersion: envelope.bv,
1679
1686
  initialTheme: envelope.i,
1680
1687
  snapshot: envelope.sn,
1688
+ handlerLiveHoles: envelope.lh,
1681
1689
  createdAt: envelope.c,
1682
1690
  },
1683
1691
  shouldRevalidate,
@@ -1745,6 +1753,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1745
1753
  ta: taggedAt,
1746
1754
  i: entry.initialTheme,
1747
1755
  sn: entry.snapshot,
1756
+ lh: entry.handlerLiveHoles,
1748
1757
  };
1749
1758
  return this.kv!.put(kvKey, JSON.stringify(envelope), {
1750
1759
  expirationTtl: totalTtl,
@@ -2260,6 +2269,20 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2260
2269
  * silently reporting success while other requests/colos serve stale data. The
2261
2270
  * eager purge still fires for the whole batch first (it is additive).
2262
2271
  */
2272
+ /**
2273
+ * Build-shell read-through gate (SegmentCacheStore.isTagsInvalidatedSince):
2274
+ * a baked shell entry is immutable in the build manifest, so eviction is
2275
+ * answered by the SAME KV tag markers updateTag() writes, compared against
2276
+ * the entry's build-time createdAt. Thin public wrapper over the private
2277
+ * envelope check (identical semantics: marker >= since, fail open).
2278
+ */
2279
+ async isTagsInvalidatedSince(
2280
+ tags: string[],
2281
+ sinceMs: number,
2282
+ ): Promise<boolean> {
2283
+ return this.isGloballyInvalidated(tags, sinceMs);
2284
+ }
2285
+
2263
2286
  async invalidateTags(tags: string[]): Promise<void> {
2264
2287
  if (tags.length === 0) return;
2265
2288
  const invalidatedAt = Date.now();
@@ -83,14 +83,35 @@ export async function decodeHandleValue<T>(encoded: string): Promise<T | null> {
83
83
  /**
84
84
  * Capture handle data for a set of segments from the handle store.
85
85
  * Used when caching segments to preserve their handle data.
86
+ *
87
+ * `exclude` (shell captures: RequestContext._shellCaptureLoaderHandleValues)
88
+ * drops DSL-loader-scoped push values from the CACHE WRITE only: loaders
89
+ * re-run fresh on every HIT, so replaying their captured values would
90
+ * duplicate the fresh push — and their masked nested promises would stall the
91
+ * Flight handle encode to its timeout. Threaded as an explicit argument so
92
+ * every other getDataForSegment consumer (the render-barrier snapshot,
93
+ * prerender) provably sees every push.
86
94
  */
87
95
  export function captureHandles(
88
96
  segments: ResolvedSegment[],
89
97
  handleStore: HandleStore,
98
+ exclude?: WeakSet<object>,
90
99
  ): Record<string, SegmentHandleData> {
91
100
  const handles: Record<string, SegmentHandleData> = {};
92
101
  for (const seg of segments) {
93
- handles[seg.id] = handleStore.getDataForSegment(seg.id);
102
+ const data = handleStore.getDataForSegment(seg.id);
103
+ if (!exclude) {
104
+ handles[seg.id] = data;
105
+ continue;
106
+ }
107
+ const filtered: SegmentHandleData = {};
108
+ for (const [handleName, values] of Object.entries(data)) {
109
+ const kept = values.filter(
110
+ (v) => typeof v !== "object" || v === null || !exclude.has(v),
111
+ );
112
+ if (kept.length > 0) filtered[handleName] = kept;
113
+ }
114
+ handles[seg.id] = filtered;
94
115
  }
95
116
  return handles;
96
117
  }
@@ -30,6 +30,8 @@ const ITEM_CACHE_REGISTRY_KEY = "__rsc_router_item_cache_registry__";
30
30
  const SHELL_CACHE_REGISTRY_KEY = "__rsc_router_shell_cache_registry__";
31
31
  const TAG_INDEX_REGISTRY_KEY = "__rsc_router_tag_index_registry__";
32
32
  const KEY_TAGS_REGISTRY_KEY = "__rsc_router_key_tags_registry__";
33
+ const TAG_INVALIDATED_AT_REGISTRY_KEY =
34
+ "__rsc_router_tag_invalidated_at_registry__";
33
35
 
34
36
  /**
35
37
  * Get or create a named Map from a globalThis-backed registry.
@@ -194,6 +196,14 @@ export class MemorySegmentCacheStore<
194
196
  private tagIndex: Map<string, Set<string>>;
195
197
  /** prefixed cache key -> set of tags (reverse index for O(tags) unregister) */
196
198
  private keyTags: Map<string, Set<string>>;
199
+ /**
200
+ * tag -> epoch ms of its latest invalidateTags() call. The build-shell
201
+ * read-through's isTagsInvalidatedSince gate: baked shell entries are
202
+ * immutable in the build manifest, so eviction is answered by comparing
203
+ * these markers against the entry's build-time createdAt. Per-isolate,
204
+ * like every other map here — matching this store's tag semantics.
205
+ */
206
+ private tagInvalidatedAt: Map<string, number>;
197
207
  readonly defaults?: CacheDefaults;
198
208
  readonly keyGenerator?: (
199
209
  ctx: RequestContext<TEnv>,
@@ -228,6 +238,10 @@ export class MemorySegmentCacheStore<
228
238
  KEY_TAGS_REGISTRY_KEY,
229
239
  options.name,
230
240
  );
241
+ this.tagInvalidatedAt = getNamedMap<number>(
242
+ TAG_INVALIDATED_AT_REGISTRY_KEY,
243
+ options.name,
244
+ );
231
245
  } else {
232
246
  this.cache = new Map<string, CachedEntryData>();
233
247
  this.responseCache = new Map<string, CachedResponseEntry>();
@@ -235,6 +249,7 @@ export class MemorySegmentCacheStore<
235
249
  this.shellCache = new Map<string, CachedShellEntry>();
236
250
  this.tagIndex = new Map<string, Set<string>>();
237
251
  this.keyTags = new Map<string, Set<string>>();
252
+ this.tagInvalidatedAt = new Map<string, number>();
238
253
  }
239
254
  this.defaults = options?.defaults;
240
255
  this.keyGenerator = options?.keyGenerator;
@@ -457,8 +472,25 @@ export class MemorySegmentCacheStore<
457
472
  }
458
473
  }
459
474
 
475
+ async isTagsInvalidatedSince(
476
+ tags: string[],
477
+ sinceMs: number,
478
+ ): Promise<boolean> {
479
+ for (const tag of tags) {
480
+ const at = this.tagInvalidatedAt.get(tag);
481
+ // >= so a same-millisecond invalidation wins (freshness over staleness),
482
+ // matching the CF marker comparison.
483
+ if (at !== undefined && at >= sinceMs) return true;
484
+ }
485
+ return false;
486
+ }
487
+
460
488
  async invalidateTags(tags: string[]): Promise<void> {
489
+ const invalidatedAt = Date.now();
461
490
  for (const tag of tags) {
491
+ // Marker first: build-shell entries evict by marker comparison even when
492
+ // no runtime entry currently carries the tag (tagIndex miss below).
493
+ this.tagInvalidatedAt.set(tag, invalidatedAt);
462
494
  const keys = this.tagIndex.get(tag);
463
495
  if (!keys || keys.size === 0) continue;
464
496
 
@@ -11,6 +11,7 @@
11
11
  import type { ResolvedSegment } from "../types.js";
12
12
  import type { SerializedSegmentData } from "./types.js";
13
13
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
14
+ import { segmentFragment } from "../segment-fragments.js";
14
15
  import {
15
16
  renderToReadableStream,
16
17
  createTemporaryReferenceSet,
@@ -206,6 +207,52 @@ export async function serializeSegments(
206
207
  );
207
208
  }
208
209
 
210
+ /**
211
+ * Build ResolvedSegments that carry the STORED fragment strings verbatim
212
+ * instead of decoding them (PPR fast-path payload splice, issue #700; see
213
+ * segment-fragments.ts). The ReactNode fields (component/layout/loading)
214
+ * become {@link segmentFragment} envelopes the outer Flight render serializes
215
+ * as a string copy; the CONSUMER (SSR resume + browser hydration) expands them
216
+ * through its own deserializer. Loader data fields are NOT enveloped — they
217
+ * are consumer data of any shape (a marker there could collide) and decode
218
+ * server-side exactly as deserializeSegments does; on the doc/prerender
219
+ * records this path serves they are absent in practice (loaders are never
220
+ * cached with the route record).
221
+ *
222
+ * The loading "null" sentinel decodes here (not on the consumer): loading:null
223
+ * must survive as null, and shipping the raw sentinel would change the
224
+ * deduplicateLoaderSegments loading-presence check.
225
+ */
226
+ export async function fragmentSegments(
227
+ data: SerializedSegmentData[],
228
+ ): Promise<ResolvedSegment[]> {
229
+ return Promise.all(
230
+ data.map(async (item): Promise<ResolvedSegment> => {
231
+ const loadingIsNullSentinel = item.encodedLoading === "null";
232
+ const [loaderData, loaderDataPromise] = await Promise.all([
233
+ rscDeserialize(item.encodedLoaderData),
234
+ rscDeserialize(item.encodedLoaderDataPromise),
235
+ ]);
236
+ return {
237
+ ...item.metadata,
238
+ // Envelopes ride ReactNode-typed fields; the consumer expansion pass
239
+ // (segment-fragments.ts) replaces them before any render reads them.
240
+ component: segmentFragment(item.encoded) as unknown,
241
+ layout: item.encodedLayout
242
+ ? (segmentFragment(item.encodedLayout) as unknown)
243
+ : undefined,
244
+ loading: loadingIsNullSentinel
245
+ ? null
246
+ : item.encodedLoading !== undefined
247
+ ? (segmentFragment(item.encodedLoading) as unknown)
248
+ : undefined,
249
+ loaderData,
250
+ loaderDataPromise,
251
+ } as ResolvedSegment;
252
+ }),
253
+ );
254
+ }
255
+
209
256
  /**
210
257
  * Deserialize segments from storage.
211
258
  * Reconstructs ResolvedSegment objects from RSC-serialized data.
@@ -110,6 +110,19 @@ export class RecordingShellStore<
110
110
  this.writes.push(p);
111
111
  }
112
112
 
113
+ /**
114
+ * Record a segment-family write into the snapshot WITHOUT touching the inner
115
+ * store. The shell fast path's implicit doc-cache scope writes through this
116
+ * (via {@link SnapshotOnlySegmentStore}): the recorded doc entry must ride
117
+ * ONLY inside the shell entry — a passthrough write would leave a doc-keyed
118
+ * entry in the real store that the NEXT capture's lookup would hit, replaying
119
+ * the previous generation's segments instead of re-running handlers (breaking
120
+ * SWR recapture freshness).
121
+ */
122
+ recordSegmentWrite(key: string, data: CachedEntryData): void {
123
+ this.record("segment", key, data);
124
+ }
125
+
113
126
  /**
114
127
  * Await the tracked deferred writes so their records are present before drain.
115
128
  * Drains ITERATIVELY: a write task can schedule a NESTED write (the ring-3
@@ -245,6 +258,40 @@ export function getRecordingStore<TEnv>(
245
258
  return store instanceof RecordingShellStore ? store : undefined;
246
259
  }
247
260
 
261
+ /**
262
+ * The store the shell fast path's IMPLICIT doc-cache scope resolves during a
263
+ * capture: reads pass through the recording store (a real-store hit is
264
+ * recorded, exactly like any capture read), but segment WRITES are recorded
265
+ * into the snapshot only — see {@link RecordingShellStore.recordSegmentWrite}
266
+ * for why passthrough would break SWR recapture. Routes with their OWN
267
+ * cache() config never see this store (their scope resolves the app-level
268
+ * recording store and keeps today's record-and-write behavior).
269
+ */
270
+ export class SnapshotOnlySegmentStore<
271
+ TEnv = unknown,
272
+ > implements SegmentCacheStore<TEnv> {
273
+ constructor(private readonly recording: RecordingShellStore<TEnv>) {}
274
+
275
+ get defaults(): SegmentCacheStore<TEnv>["defaults"] {
276
+ return this.recording.defaults;
277
+ }
278
+ get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
279
+ return this.recording.keyGenerator;
280
+ }
281
+
282
+ async get(key: string): Promise<CacheGetResult | null> {
283
+ return this.recording.get(key);
284
+ }
285
+
286
+ async set(key: string, data: CachedEntryData): Promise<void> {
287
+ this.recording.recordSegmentWrite(key, data);
288
+ }
289
+
290
+ async delete(key: string): Promise<boolean> {
291
+ return this.recording.delete(key);
292
+ }
293
+ }
294
+
248
295
  /**
249
296
  * Materialize the loader-family seed from a shell snapshot for a HIT's tail
250
297
  * render: Flight-deserialize each recorded (promise-elided) bake-lane
@@ -196,6 +196,20 @@ export interface SegmentCacheStore<TEnv = unknown> {
196
196
  * @param tags - The cache tags to invalidate
197
197
  */
198
198
  invalidateTags?(tags: string[]): Promise<void>;
199
+
200
+ /**
201
+ * True when ANY of `tags` was invalidated (invalidateTags/updateTag) at or
202
+ * after `sinceMs` (>= so a same-millisecond invalidation wins, favouring
203
+ * freshness). Consulted by the build-time shell read-through
204
+ * (rsc/shell-build-manifest.ts): a baked shell entry is immutable in the
205
+ * build manifest, so "was it evicted" is answered by the store's tag
206
+ * markers against the entry's createdAt rather than by deleting anything.
207
+ * Optional: without it, TAGGED build entries are not served (untagged ones
208
+ * are unaffected — they are evictable only by deploy/buildVersion anyway).
209
+ * Fail open to `false` on marker-read errors: a transient store fault must
210
+ * degrade to "still valid", the same posture as the envelope tag checks.
211
+ */
212
+ isTagsInvalidatedSince?(tags: string[], sinceMs: number): Promise<boolean>;
199
213
  }
200
214
 
201
215
  /**
@@ -280,6 +294,19 @@ export interface ShellCacheEntry {
280
294
  * heals it. See docs/design/ppr-shell-resume.md ("the capture data snapshot").
281
295
  */
282
296
  snapshot?: ShellSnapshotRecord[];
297
+ /**
298
+ * True when the capture's HANDLER layer declared per-request liveness: a
299
+ * handle pushed OUTSIDE a DSL loader scope carried a nested thenable (the
300
+ * capture mask turns it into a never-filling hole), such a push was still
301
+ * pending when the entry was written, or a handler-invoked loader
302
+ * (ctx.use(loader) from a handler body — the consumption lane, #672)
303
+ * executed during the capture. The serve tail then must NOT take the
304
+ * handler-free fast path (the implicit doc-cache hit): only a handler
305
+ * re-run can mint that hole's live promise or refresh that consumed value.
306
+ * DSL-loader pushes never set this — loaders re-run fresh on every HIT, so
307
+ * their holes always fill.
308
+ */
309
+ handlerLiveHoles?: boolean;
283
310
  /** Epoch ms when the shell was captured. */
284
311
  createdAt: number;
285
312
  }
@@ -127,7 +127,18 @@ const REVALIDATION_LOCK_MS = 30_000;
127
127
  /** Family prefixes that keep the value tiers from colliding in the single Vercel
128
128
  * keyspace. The router's own semantic prefixes (doc:/partial:/use-cache:) become
129
129
  * the suffix; `rg:` namespaces every Rango entry. `h` is the PPR shell tier. */
130
- type CacheFamily = "s" | "i" | "r" | "h";
130
+ type CacheFamily = "s" | "i" | "r" | "h" | "tm";
131
+
132
+ /**
133
+ * TTL for tag-invalidation marker entries ("tm" family), written by
134
+ * invalidateTags for the build-shell read-through's isTagsInvalidatedSince
135
+ * gate. The platform's expireTag() DELETES tagged entries (no queryable
136
+ * history), so the markers are rango's own record of "tag X was invalidated
137
+ * at T". One year: markers must outlive any build's shell entries (which the
138
+ * buildVersion gate retires on the next deploy anyway); an expired marker
139
+ * would silently resurrect an updateTag()-evicted build shell.
140
+ */
141
+ const TAG_MARKER_TTL_SECONDS = 365 * 24 * 60 * 60;
131
142
 
132
143
  /** Stored envelope for a segment-tree entry (get/set). */
133
144
  interface VercelSegmentEnvelope {
@@ -191,6 +202,13 @@ interface VercelShellEnvelope {
191
202
  i?: string;
192
203
  /** Capture data snapshot: recorded cache-store hits/writes for HIT parity. */
193
204
  sn?: ShellSnapshotRecord[];
205
+ /**
206
+ * ShellCacheEntry.handlerLiveHoles. Must round-trip: the serve side arms the
207
+ * handler-free fast path on `!entry.handlerLiveHoles`, so dropping the flag
208
+ * here silently fast-pathed handler-live entries after a store round trip —
209
+ * their holes only a handler re-run can fill.
210
+ */
211
+ lh?: boolean;
194
212
  }
195
213
 
196
214
  /** Read-path outcome for the debug sink. */
@@ -781,6 +799,7 @@ export class VercelCacheStore<
781
799
  buildVersion: env.bv,
782
800
  initialTheme: env.i,
783
801
  snapshot: env.sn,
802
+ handlerLiveHoles: env.lh,
784
803
  createdAt: env.c,
785
804
  },
786
805
  shouldRevalidate,
@@ -815,6 +834,7 @@ export class VercelCacheStore<
815
834
  t: safeTags.length > 0 ? safeTags : undefined,
816
835
  i: entry.initialTheme,
817
836
  sn: entry.snapshot,
837
+ lh: entry.handlerLiveHoles,
818
838
  };
819
839
  // write() enforces the 2 MB per-item ceiling (withinSizeLimit): an
820
840
  // oversized shell prelude is reported and skipped (fail-open to a full
@@ -833,6 +853,40 @@ export class VercelCacheStore<
833
853
 
834
854
  // --- Tags ---
835
855
 
856
+ /**
857
+ * Build-shell read-through gate (SegmentCacheStore.isTagsInvalidatedSince).
858
+ * The platform's expireTag() DELETES tagged entries and keeps no queryable
859
+ * history, so invalidateTags() below writes its own "tm" marker entries and
860
+ * this compares them against the baked entry's build-time createdAt
861
+ * (>= so a same-millisecond invalidation wins). Fails open to `false` on
862
+ * read errors — the same posture as the CF marker check.
863
+ */
864
+ async isTagsInvalidatedSince(
865
+ tags: string[],
866
+ sinceMs: number,
867
+ ): Promise<boolean> {
868
+ try {
869
+ const markers = await Promise.all(
870
+ tags.map((tag) => this.cache.get(this.toStoreKey(tag, "tm"))),
871
+ );
872
+ for (const raw of markers) {
873
+ if (raw == null) continue;
874
+ const decoded = this.decodeRaw(raw) as { at?: unknown } | null;
875
+ const at =
876
+ decoded && typeof decoded.at === "number" ? decoded.at : null;
877
+ if (at !== null && at >= sinceMs) return true;
878
+ }
879
+ return false;
880
+ } catch (error) {
881
+ reportCacheError(
882
+ error,
883
+ "cache-read",
884
+ "[VercelCacheStore] tag invalidation check",
885
+ );
886
+ return false;
887
+ }
888
+ }
889
+
836
890
  async invalidateTags(tags: string[]): Promise<void> {
837
891
  if (!tags || tags.length === 0) return;
838
892
  // No per-item cap here: an invalidation must reach every requested tag.
@@ -842,6 +896,20 @@ export class VercelCacheStore<
842
896
  "cache-invalidate",
843
897
  );
844
898
  if (safe.length === 0) return;
899
+ // Marker writes FIRST, and strict: isTagsInvalidatedSince() is how an
900
+ // updateTag() reaches a build-time shell entry (expireTag cannot delete
901
+ // what lives in the build manifest), so a failed marker write must reject
902
+ // like a failed expireTag — silently resolving would report success while
903
+ // the baked shell keeps serving.
904
+ await Promise.all(
905
+ safe.map((tag) =>
906
+ this.cache.set(
907
+ this.toStoreKey(tag, "tm"),
908
+ JSON.stringify({ at: Date.now() }),
909
+ { ttl: TAG_MARKER_TTL_SECONDS },
910
+ ),
911
+ ),
912
+ );
845
913
  try {
846
914
  await this.cache.expireTag(safe);
847
915
  } catch (error) {
@@ -1095,7 +1163,7 @@ export class VercelCacheStore<
1095
1163
 
1096
1164
  private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
1097
1165
  if (!isRecord(raw)) return null;
1098
- const { p, po, rv, bv, c, s, e, t, i, sn } = raw;
1166
+ const { p, po, rv, bv, c, s, e, t, i, sn, lh } = raw;
1099
1167
  if (typeof p !== "string" || typeof rv !== "string") return null;
1100
1168
  if (po !== null && typeof po !== "string") return null;
1101
1169
  if (typeof c !== "number") return null;
@@ -1111,6 +1179,7 @@ export class VercelCacheStore<
1111
1179
  t: Array.isArray(t) ? (t as string[]) : undefined,
1112
1180
  i: typeof i === "string" ? i : undefined,
1113
1181
  sn: Array.isArray(sn) ? (sn as ShellSnapshotRecord[]) : undefined,
1182
+ lh: lh === true ? true : undefined,
1114
1183
  };
1115
1184
  }
1116
1185
 
package/src/deps/ssr.ts CHANGED
@@ -1 +1,4 @@
1
- export { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
1
+ export {
2
+ createFromReadableStream,
3
+ setOnClientReference,
4
+ } from "@vitejs/plugin-rsc/ssr";