@rangojs/router 0.1.0 → 0.1.1

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 (89) hide show
  1. package/dist/bin/rango.js +11 -1
  2. package/dist/types/browser/dev-discovery.d.ts +11 -0
  3. package/dist/types/browser/navigation-store.d.ts +3 -13
  4. package/dist/types/browser/prefetch/loader.d.ts +10 -0
  5. package/dist/types/browser/prefetch/runtime.d.ts +3 -0
  6. package/dist/types/browser/types.d.ts +4 -16
  7. package/dist/types/build/generate-manifest.d.ts +6 -0
  8. package/dist/types/cache/cache-scope.d.ts +7 -5
  9. package/dist/types/cache/cf/cf-cache-store.d.ts +10 -9
  10. package/dist/types/cache/memory-segment-store.d.ts +6 -6
  11. package/dist/types/cache/shell-snapshot.d.ts +21 -8
  12. package/dist/types/cache/types.d.ts +24 -7
  13. package/dist/types/cache/vercel/vercel-cache-store.d.ts +9 -8
  14. package/dist/types/dev-discovery-protocol.d.ts +5 -0
  15. package/dist/types/prerender/store.d.ts +1 -0
  16. package/dist/types/route-map-builder.d.ts +4 -29
  17. package/dist/types/router/prerender-match.d.ts +4 -1
  18. package/dist/types/router/router-interfaces.d.ts +3 -1
  19. package/dist/types/rsc/handler-context.d.ts +1 -0
  20. package/dist/types/rsc/render-pipeline.d.ts +3 -2
  21. package/dist/types/rsc/rsc-rendering.d.ts +5 -10
  22. package/dist/types/rsc/shell-capture.d.ts +4 -8
  23. package/dist/types/rsc/types.d.ts +2 -0
  24. package/dist/types/server/context.d.ts +16 -0
  25. package/dist/types/server/request-context.d.ts +18 -8
  26. package/dist/types/server.d.ts +1 -1
  27. package/dist/types/types/route-entry.d.ts +3 -0
  28. package/dist/types/vite/discovery/state.d.ts +3 -2
  29. package/dist/types/vite/utils/manifest-utils.d.ts +1 -1
  30. package/dist/vite/index.js +194 -107
  31. package/package.json +7 -2
  32. package/skills/caching/SKILL.md +1 -1
  33. package/skills/ppr/SKILL.md +48 -4
  34. package/src/browser/dev-discovery.ts +66 -0
  35. package/src/browser/navigation-client.ts +6 -0
  36. package/src/browser/navigation-store.ts +4 -271
  37. package/src/browser/partial-update.ts +7 -14
  38. package/src/browser/prefetch/cache.ts +1 -1
  39. package/src/browser/prefetch/loader.ts +110 -0
  40. package/src/browser/prefetch/runtime.ts +7 -0
  41. package/src/browser/react/Link.tsx +7 -10
  42. package/src/browser/react/NavigationProvider.tsx +1 -1
  43. package/src/browser/react/use-router.ts +1 -1
  44. package/src/browser/rsc-router.tsx +4 -2
  45. package/src/browser/types.ts +4 -27
  46. package/src/build/generate-manifest.ts +11 -0
  47. package/src/cache/cache-scope.ts +17 -6
  48. package/src/cache/cf/cf-cache-store.ts +63 -39
  49. package/src/cache/memory-segment-store.ts +17 -6
  50. package/src/cache/shell-snapshot.ts +45 -15
  51. package/src/cache/types.ts +23 -6
  52. package/src/cache/vercel/vercel-cache-store.ts +52 -22
  53. package/src/dev-discovery-protocol.ts +11 -0
  54. package/src/prerender/build-shell-capture.ts +7 -1
  55. package/src/prerender/store.ts +5 -0
  56. package/src/route-definition/dsl-helpers.ts +7 -2
  57. package/src/route-map-builder.ts +56 -49
  58. package/src/router/handler-context.ts +13 -5
  59. package/src/router/lazy-includes.ts +1 -0
  60. package/src/router/match-api.ts +8 -4
  61. package/src/router/prerender-match.ts +19 -2
  62. package/src/router/router-interfaces.ts +4 -0
  63. package/src/router/segment-resolution/static-store.ts +36 -10
  64. package/src/router.ts +34 -3
  65. package/src/rsc/handler-context.ts +1 -0
  66. package/src/rsc/handler.ts +3 -1
  67. package/src/rsc/loader-fetch.ts +2 -2
  68. package/src/rsc/manifest-init.ts +4 -11
  69. package/src/rsc/render-pipeline.ts +10 -2
  70. package/src/rsc/rsc-rendering.ts +207 -146
  71. package/src/rsc/shell-capture.ts +34 -11
  72. package/src/rsc/types.ts +2 -0
  73. package/src/server/context.ts +28 -0
  74. package/src/server/request-context.ts +32 -10
  75. package/src/server.ts +0 -2
  76. package/src/testing/dispatch.ts +1 -0
  77. package/src/types/route-entry.ts +3 -0
  78. package/src/urls/include-helper.ts +1 -0
  79. package/src/urls/path-helper.ts +8 -4
  80. package/src/vite/discovery/bundle-postprocess.ts +10 -7
  81. package/src/vite/discovery/discover-routers.ts +7 -66
  82. package/src/vite/discovery/prerender-collection.ts +13 -2
  83. package/src/vite/discovery/state.ts +4 -4
  84. package/src/vite/discovery/virtual-module-codegen.ts +75 -42
  85. package/src/vite/plugins/version-injector.ts +0 -1
  86. package/src/vite/plugins/virtual-entries.ts +11 -1
  87. package/src/vite/router-discovery.ts +132 -22
  88. package/src/vite/utils/manifest-utils.ts +1 -4
  89. package/src/vite/utils/shared-utils.ts +7 -0
@@ -65,6 +65,8 @@ export interface RscMetadata {
65
65
  * Used to detect version mismatches after HMR/deployment.
66
66
  */
67
67
  version?: string;
68
+ /** Cloudflare dev worker generation used for stale-document convergence. */
69
+ devDiscoveryEpoch?: number;
68
70
  /**
69
71
  * TTL in milliseconds for the client-side in-memory prefetch cache.
70
72
  * Sent on initial render so the browser can configure its cache duration.
@@ -430,24 +432,10 @@ export type StateListener = () => void;
430
432
  /**
431
433
  * Navigation store interface
432
434
  *
433
- * Manages both:
434
- * - NavigationState: Public state exposed via useNavigation hook
435
- * - SegmentState: Internal segment management for partial updates
435
+ * Owns segment state, history snapshots, and partial-update notifications.
436
+ * EventController owns the public navigation lifecycle exposed by hooks.
436
437
  */
437
438
  export interface NavigationStore {
438
- // Public state (for useNavigation hook)
439
- getState(): NavigationState;
440
- setState(partial: Partial<NavigationState>): void;
441
- subscribe(listener: StateListener): () => void;
442
-
443
- // Inflight action management
444
- addInflightAction(action: InflightAction): void;
445
- removeInflightAction(id: string): void;
446
-
447
- // Action state (for controlling update behavior during server actions)
448
- isActionInProgress(): boolean;
449
- setActionInProgress(value: boolean): void;
450
-
451
439
  // Internal segment state (for bridges)
452
440
  getSegmentState(): SegmentState;
453
441
  setPath(path: string): void;
@@ -459,8 +447,6 @@ export interface NavigationStore {
459
447
  setHistoryKey(key: string): void;
460
448
  /** Monotonic token of the most recently committed navigation. */
461
449
  getNavInstance(): number;
462
- /** Nav-instance token recorded on a cache entry (undefined if absent). */
463
- getCacheEntryInstance(historyKey: string): number | undefined;
464
450
  cacheSegmentsForHistory(
465
451
  historyKey: string,
466
452
  segments: ResolvedSegment[],
@@ -506,7 +492,6 @@ export interface NavigationStore {
506
492
  stale?: boolean,
507
493
  handlesPending?: boolean,
508
494
  ): void;
509
- markCacheAsStale(): void;
510
495
  markHistoryCacheStale(): void;
511
496
  markCacheAsStaleAndBroadcast(): void;
512
497
  clearHistoryCache(): void;
@@ -525,14 +510,6 @@ export interface NavigationStore {
525
510
  // UI update notifications
526
511
  onUpdate(callback: UpdateSubscriber): () => void;
527
512
  emitUpdate(update: NavigationUpdate): void;
528
-
529
- // Action state tracking (for useAction hook)
530
- getActionState(actionId: string): TrackedActionState;
531
- setActionState(actionId: string, state: Partial<TrackedActionState>): void;
532
- subscribeToAction(
533
- actionId: string,
534
- listener: ActionStateListener,
535
- ): () => void;
536
513
  }
537
514
 
538
515
  // ============================================================================
@@ -115,6 +115,7 @@ async function buildPrefixTreeNode(
115
115
  passthroughRoutes?: string[],
116
116
  responseTypeRoutes?: Record<string, string>,
117
117
  routeSearchSchemas?: Record<string, Record<string, string>>,
118
+ routerId?: string,
118
119
  ): Promise<PrefixTreeNode> {
119
120
  // Resolve an async include provider (`() => import("./routes")`) so its routes
120
121
  // are walked into the build-time manifest/types/href. Runtime matching still
@@ -156,6 +157,7 @@ async function buildPrefixTreeNode(
156
157
  parent: null,
157
158
  counters: {},
158
159
  mountIndex,
160
+ ...(routerId ? { routerId } : {}),
159
161
  trackedIncludes, // Enable nested include tracking
160
162
  },
161
163
  () => {
@@ -227,6 +229,7 @@ async function buildPrefixTreeNode(
227
229
  passthroughRoutes,
228
230
  responseTypeRoutes,
229
231
  routeSearchSchemas,
232
+ routerId,
230
233
  ),
231
234
  );
232
235
 
@@ -296,6 +299,12 @@ export async function generateManifestFull<TEnv>(
296
299
  mountIndex: number = 0,
297
300
  options?: {
298
301
  urlPrefix?: string;
302
+ /**
303
+ * Owning router id. Threaded into the evaluation store so path() scopes
304
+ * its search-schema/root-scope registrations per router — same-named
305
+ * routes in different routers must not clobber each other.
306
+ */
307
+ routerId?: string;
299
308
  /**
300
309
  * Called once per `"use client"` component registered as an
301
310
  * errorBoundary/notFoundBoundary fallback, with its client-reference key
@@ -328,6 +337,7 @@ export async function generateManifestFull<TEnv>(
328
337
  parent: null,
329
338
  counters: {},
330
339
  mountIndex,
340
+ ...(options?.routerId ? { routerId: options.routerId } : {}),
331
341
  trackedIncludes, // Enable include tracking
332
342
  // basename sets the initial URL prefix for all path() registrations
333
343
  ...(options?.urlPrefix ? { urlPrefix: options.urlPrefix } : {}),
@@ -402,6 +412,7 @@ export async function generateManifestFull<TEnv>(
402
412
  passthroughRoutes,
403
413
  responseTypeRoutes,
404
414
  routeSearchSchemas,
415
+ options?.routerId,
405
416
  ),
406
417
  );
407
418
 
@@ -89,6 +89,7 @@ function getDefaultRouteCacheKey(
89
89
  pathname: string,
90
90
  params?: Record<string, string>,
91
91
  isIntercept?: boolean,
92
+ prefixOverride?: "doc",
92
93
  ): string {
93
94
  const ctx = getRequestContext();
94
95
  const isPartial = ctx?.originalUrl?.searchParams.has("_rsc_partial") ?? false;
@@ -96,7 +97,9 @@ function getDefaultRouteCacheKey(
96
97
  const host = ctx?.url.host ?? "localhost";
97
98
 
98
99
  // Intercept navigations get their own cache namespace
99
- const prefix = isIntercept ? "intercept" : isPartial ? "partial" : "doc";
100
+ const prefix = isIntercept
101
+ ? "intercept"
102
+ : (prefixOverride ?? (isPartial ? "partial" : "doc"));
100
103
 
101
104
  return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params)}`;
102
105
  }
@@ -131,6 +134,7 @@ export class CacheScope {
131
134
  constructor(
132
135
  config: PartialCacheOptions | false,
133
136
  parent: CacheScope | null = null,
137
+ private readonly defaultKeyPrefix?: "doc",
134
138
  ) {
135
139
  this.config = config;
136
140
  this.parent = parent;
@@ -209,7 +213,12 @@ export class CacheScope {
209
213
  params: Record<string, string>,
210
214
  isIntercept?: boolean,
211
215
  ): Promise<string> {
212
- const defaultKey = getDefaultRouteCacheKey(pathname, params, isIntercept);
216
+ const defaultKey = getDefaultRouteCacheKey(
217
+ pathname,
218
+ params,
219
+ isIntercept,
220
+ this.defaultKeyPrefix,
221
+ );
213
222
  const keyFn = this.config !== false ? this.config.key : undefined;
214
223
  return resolveCacheKey(keyFn, this.getStore(), defaultKey, "CacheScope");
215
224
  }
@@ -529,10 +538,11 @@ export function createCacheScope(
529
538
  /**
530
539
  * Shell fast path: when the route tree derived NO cache scope and the current
531
540
  * 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).
541
+ * an eligible HIT tail, or a normal partial navigation replay), substitute an
542
+ * implicit doc-level scope so withCacheLookup/withCacheStore treat the WHOLE
543
+ * matched route as a cache() boundary — the shell entry IS a cache() of the
544
+ * handler layer, with loaders as the live carve-outs
545
+ * (resolveFreshLoadersAndYield).
536
546
  *
537
547
  * An existing scope — including an explicit cache(false) opt-out — always
538
548
  * wins: the consumer's cache() semantics (their ttl/swr/store/condition) are
@@ -548,5 +558,6 @@ export function resolveShellImplicitCacheScope(
548
558
  return new CacheScope(
549
559
  { ttl: marker.ttl, swr: marker.swr, store: marker.store },
550
560
  null,
561
+ marker.keyPrefix,
551
562
  );
552
563
  }
@@ -272,7 +272,7 @@ interface KVShellEnvelope {
272
272
  rv: string;
273
273
  /** Build version captured at prerender time (ShellCacheEntry.buildVersion) */
274
274
  bv?: string;
275
- /** createdAt (ms epoch) */
275
+ /** Capture-generation start time (ms epoch), used by tag marker checks. */
276
276
  c: number;
277
277
  /** When entry becomes stale (ms epoch) */
278
278
  s: number;
@@ -293,6 +293,8 @@ interface KVShellEnvelope {
293
293
  * their holes only a handler re-run can fill.
294
294
  */
295
295
  lh?: boolean;
296
+ /** ShellCacheEntry.transitionWhen; conditional transitions must re-run. */
297
+ tw?: true;
296
298
  }
297
299
 
298
300
  /**
@@ -323,6 +325,7 @@ interface KVResponseEnvelope {
323
325
  // ============================================================================
324
326
 
325
327
  export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
328
+ readonly supportsPassiveShellReads: true = true;
326
329
  readonly defaults?: CacheDefaults;
327
330
  readonly keyGenerator?: (
328
331
  ctx: RequestContext<TEnv>,
@@ -1743,7 +1746,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1743
1746
  // per-colo L1 for them is a deliberate follow-up (see the PPR shell-resume
1744
1747
  // design doc). Shell entries live only in KV (the global tier), so the family
1745
1748
  // requires a configured KV namespace; without one, getShell/putShell no-op and
1746
- // the shell-cache middleware fails open to a full HTML render. Tag invalidation
1749
+ // the integrated PPR serve path fails open to a full HTML render. Tag invalidation
1747
1750
  // still applies: shell entries carry tags/taggedAt and are checked against the
1748
1751
  // same KV markers isGloballyInvalidated() reads for every other tier.
1749
1752
 
@@ -1772,7 +1775,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1772
1775
  * Get a cached PPR shell entry by key from KV (no L1). Applies the KV read
1773
1776
  * budget, corrupt-entry eviction, hard-expiry, and tag invalidation exactly
1774
1777
  * like kvGetItem, minus the L1 promote. SWR is a plain staleness flag — KV has
1775
- * no REVALIDATING herd guard, so the shell-cache middleware's module-level
1778
+ * no REVALIDATING herd guard, so the capture scheduler's module-level
1776
1779
  * in-flight set is the recapture stampede guard.
1777
1780
  */
1778
1781
  async getShell(
@@ -1815,6 +1818,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1815
1818
  initialTheme: envelope.i,
1816
1819
  snapshot: envelope.sn,
1817
1820
  handlerLiveHoles: envelope.lh,
1821
+ transitionWhen: envelope.tw,
1818
1822
  createdAt: envelope.c,
1819
1823
  },
1820
1824
  shouldRevalidate,
@@ -1826,8 +1830,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1826
1830
  }
1827
1831
 
1828
1832
  /**
1829
- * Store a PPR shell entry in KV with TTL and optional SWR window. Non-blocking
1830
- * (waitUntil) like the other KV writes. The tags/taggedAt ride in the envelope
1833
+ * Store a PPR shell entry in KV with TTL and optional SWR window. The write is
1834
+ * registered with waitUntil and awaited so invalidation rejection can be
1835
+ * acknowledged to the capture scheduler. The tags/taggedAt ride in the envelope
1831
1836
  * so isGloballyInvalidated() can invalidate the shell via the shared KV markers.
1832
1837
  */
1833
1838
  async putShell(
@@ -1836,8 +1841,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1836
1841
  ttlSeconds?: number,
1837
1842
  swrSeconds?: number,
1838
1843
  tags?: string[],
1839
- ): Promise<void> {
1840
- // KV-only tier: needs a KV namespace and waitUntil (writes are non-blocking).
1844
+ ): Promise<"stored" | "invalidated" | void> {
1845
+ // KV-only tier: needs a KV namespace and waitUntil. The same write promise is
1846
+ // registered for isolate lifetime and awaited so invalidation rejection can
1847
+ // be acknowledged to the capture scheduler.
1841
1848
  if (!this.kv) {
1842
1849
  this.warnShellFamilyInertOnce();
1843
1850
  return;
@@ -1851,9 +1858,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1851
1858
  // letting kv.put reject inside waitUntil (mirrors setItem/kvSetSegment).
1852
1859
  if (totalTtl < 60) return;
1853
1860
 
1854
- const staleAt = Date.now() + ttl * 1000;
1861
+ const retentionTtl =
1862
+ tags && tags.length > 0 && this.tagInvalidationTtl
1863
+ ? Math.min(totalTtl, this.tagInvalidationTtl)
1864
+ : totalTtl;
1865
+ const now = Date.now();
1866
+ const staleAt = now + ttl * 1000;
1867
+ const expiresAt = now + retentionTtl * 1000;
1855
1868
  const taggedAt =
1856
- Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
1869
+ Array.isArray(tags) && tags.length > 0 ? entry.createdAt : undefined;
1857
1870
 
1858
1871
  const kvKey = this.toKVKey(`shell:${key}`);
1859
1872
  // A key over the KV limit makes kv.put reject deep inside waitUntil; report
@@ -1871,31 +1884,43 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1871
1884
  return;
1872
1885
  }
1873
1886
 
1874
- this.waitUntil(() =>
1875
- reportingAsync(
1876
- () => {
1877
- const envelope: KVShellEnvelope = {
1878
- p: entry.prelude,
1879
- po: entry.postponed,
1880
- rv: entry.reactVersion,
1881
- bv: entry.buildVersion,
1882
- c: entry.createdAt,
1883
- s: staleAt,
1884
- e: staleAt + swrWindow * 1000,
1885
- t: tags,
1886
- ta: taggedAt,
1887
- i: entry.initialTheme,
1888
- sn: entry.snapshot,
1889
- lh: entry.handlerLiveHoles,
1890
- };
1891
- return this.kv!.put(kvKey, JSON.stringify(envelope), {
1892
- expirationTtl: totalTtl,
1893
- });
1894
- },
1895
- "cache-write",
1896
- "[CFCacheStore] putShell",
1897
- ),
1898
- );
1887
+ const write = (async (): Promise<"stored" | "invalidated" | void> => {
1888
+ try {
1889
+ if (
1890
+ tags &&
1891
+ tags.length > 0 &&
1892
+ (await this.isGloballyInvalidated(tags, entry.createdAt))
1893
+ ) {
1894
+ return "invalidated";
1895
+ }
1896
+ const envelope: KVShellEnvelope = {
1897
+ p: entry.prelude,
1898
+ po: entry.postponed,
1899
+ rv: entry.reactVersion,
1900
+ bv: entry.buildVersion,
1901
+ c: entry.createdAt,
1902
+ s: staleAt,
1903
+ e: expiresAt,
1904
+ t: tags,
1905
+ ta: taggedAt,
1906
+ i: entry.initialTheme,
1907
+ sn: entry.snapshot,
1908
+ lh: entry.handlerLiveHoles,
1909
+ tw: entry.transitionWhen,
1910
+ };
1911
+ await this.kv!.put(kvKey, JSON.stringify(envelope), {
1912
+ expirationTtl: retentionTtl,
1913
+ });
1914
+ return "stored";
1915
+ } catch (error) {
1916
+ reportCacheError(error, "cache-write", "[CFCacheStore] putShell");
1917
+ return undefined;
1918
+ }
1919
+ })();
1920
+ this.waitUntil(async () => {
1921
+ await write;
1922
+ });
1923
+ return await write;
1899
1924
  } catch (error) {
1900
1925
  reportCacheError(error, "cache-write", "[CFCacheStore] putShell");
1901
1926
  }
@@ -2610,11 +2635,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2610
2635
  * eager purge still fires for the whole batch first (it is additive).
2611
2636
  */
2612
2637
  /**
2613
- * Build-shell read-through gate (SegmentCacheStore.isTagsInvalidatedSince):
2614
- * a baked shell entry is immutable in the build manifest, so eviction is
2615
- * answered by the SAME KV tag markers updateTag() writes, compared against
2616
- * the entry's build-time createdAt. Thin public wrapper over the private
2617
- * envelope check (identical semantics: marker >= since, fail open).
2638
+ * Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince): the
2639
+ * SAME KV markers used by runtime envelopes also evict immutable build shells
2640
+ * and captures whose write races updateTag(). Thin public wrapper over the
2641
+ * private envelope check (marker >= since, fail open).
2618
2642
  */
2619
2643
  async isTagsInvalidatedSince(
2620
2644
  tags: string[],
@@ -188,6 +188,7 @@ const DEFAULT_MAX_ENTRIES = 1000;
188
188
  export class MemorySegmentCacheStore<
189
189
  TEnv = unknown,
190
190
  > implements SegmentCacheStore<TEnv> {
191
+ readonly supportsPassiveShellReads: true = true;
191
192
  private cache: Map<string, CachedEntryData>;
192
193
  private responseCache: Map<string, CachedResponseEntry>;
193
194
  private itemCache: Map<string, CachedItemEntry>;
@@ -197,11 +198,9 @@ export class MemorySegmentCacheStore<
197
198
  /** prefixed cache key -> set of tags (reverse index for O(tags) unregister) */
198
199
  private keyTags: Map<string, Set<string>>;
199
200
  /**
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.
201
+ * tag -> epoch ms of its latest invalidateTags() call. Runtime capture races
202
+ * and immutable build shells are gated by comparing these markers with the
203
+ * shell generation start. Per-isolate, like every other map here.
205
204
  */
206
205
  private tagInvalidatedAt: Map<string, number>;
207
206
  readonly defaults?: CacheDefaults;
@@ -459,7 +458,14 @@ export class MemorySegmentCacheStore<
459
458
  ttlSeconds?: number,
460
459
  swrSeconds?: number,
461
460
  tags?: string[],
462
- ): Promise<void> {
461
+ ): Promise<"stored" | "invalidated"> {
462
+ if (
463
+ tags &&
464
+ tags.length > 0 &&
465
+ this.tagsInvalidatedSince(tags, entry.createdAt)
466
+ ) {
467
+ return "invalidated";
468
+ }
463
469
  const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
464
470
  const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
465
471
  const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
@@ -470,12 +476,17 @@ export class MemorySegmentCacheStore<
470
476
  if (tags && tags.length > 0) {
471
477
  this.registerTags(tags, prefixedKey);
472
478
  }
479
+ return "stored";
473
480
  }
474
481
 
475
482
  async isTagsInvalidatedSince(
476
483
  tags: string[],
477
484
  sinceMs: number,
478
485
  ): Promise<boolean> {
486
+ return this.tagsInvalidatedSince(tags, sinceMs);
487
+ }
488
+
489
+ private tagsInvalidatedSince(tags: string[], sinceMs: number): boolean {
479
490
  for (const tag of tags) {
480
491
  const at = this.tagInvalidatedAt.get(tag);
481
492
  // >= so a same-millisecond invalidation wins (freshness over staleness),
@@ -96,6 +96,9 @@ export class RecordingShellStore<
96
96
  get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
97
97
  return this.inner.keyGenerator;
98
98
  }
99
+ get supportsPassiveShellReads(): true | undefined {
100
+ return this.inner.supportsPassiveShellReads;
101
+ }
99
102
 
100
103
  private record(
101
104
  family: ShellSnapshotRecord["family"],
@@ -232,8 +235,9 @@ export class RecordingShellStore<
232
235
 
233
236
  async getShell(
234
237
  key: string,
238
+ options?: { claimRevalidation?: boolean },
235
239
  ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
236
- return this.inner.getShell ? this.inner.getShell(key) : null;
240
+ return this.inner.getShell ? this.inner.getShell(key, options) : null;
237
241
  }
238
242
 
239
243
  async putShell(
@@ -242,7 +246,7 @@ export class RecordingShellStore<
242
246
  ttlSeconds?: number,
243
247
  swrSeconds?: number,
244
248
  tags?: string[],
245
- ): Promise<void> {
249
+ ): Promise<"stored" | "invalidated" | void> {
246
250
  return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
247
251
  }
248
252
 
@@ -360,28 +364,42 @@ export async function buildShellLoaderSeed(
360
364
  * the snapshot it serves the recorded value AS FRESH (shouldRevalidate: false —
361
365
  * a pinned key must NOT kick SWR background revalidation) so the tail's payload
362
366
  * matches the frozen prelude. Every other read falls through to the real store
363
- * (the holes — masked loaders were never recorded — stay live). ALL writes pass
364
- * through unchanged: a live hole's loader may legitimately write. The shell
365
- * family always passes through.
367
+ * (the holes — masked loaders were never recorded — stay live). Writes pass
368
+ * through so a live hole may legitimately write, except that `segmentsOnly`
369
+ * fully isolates the segment family: misses do not fall through, and writes or
370
+ * deletes stay local to the navigation overlay. The shell family always passes
371
+ * through. With `segmentsOnly`, item and response reads also pass through so a
372
+ * partial navigation cannot freeze captured data or write a partial result into
373
+ * the canonical document namespace.
366
374
  */
367
375
  export class SeededShellStore<
368
376
  TEnv = unknown,
369
377
  > implements SegmentCacheStore<TEnv> {
370
- private readonly items = new Map<string, ShellSnapshotItemValue>();
378
+ private readonly items: Map<string, ShellSnapshotItemValue> | undefined;
371
379
  private readonly segments = new Map<string, CachedEntryData>();
372
- private readonly responses = new Map<string, ShellSnapshotResponseValue>();
380
+ private readonly responses:
381
+ | Map<string, ShellSnapshotResponseValue>
382
+ | undefined;
383
+ private readonly segmentsOnly: boolean;
373
384
 
374
385
  constructor(
375
386
  private readonly inner: SegmentCacheStore<TEnv>,
376
387
  snapshot: ShellSnapshotRecord[],
388
+ options?: { segmentsOnly?: boolean },
377
389
  ) {
390
+ this.segmentsOnly = options?.segmentsOnly === true;
391
+ this.items = this.segmentsOnly ? undefined : new Map();
392
+ this.responses = this.segmentsOnly ? undefined : new Map();
378
393
  for (const rec of snapshot) {
379
- if (rec.family === "item") {
380
- this.items.set(rec.key, rec.value as ShellSnapshotItemValue);
381
- } else if (rec.family === "segment") {
394
+ if (!rec || typeof rec !== "object") continue;
395
+ if (rec.family === "segment") {
382
396
  this.segments.set(rec.key, rec.value as CachedEntryData);
397
+ } else if (this.segmentsOnly) {
398
+ continue;
399
+ } else if (rec.family === "item") {
400
+ this.items?.set(rec.key, rec.value as ShellSnapshotItemValue);
383
401
  } else if (rec.family === "response") {
384
- this.responses.set(rec.key, rec.value as ShellSnapshotResponseValue);
402
+ this.responses?.set(rec.key, rec.value as ShellSnapshotResponseValue);
385
403
  }
386
404
  // "loader" family records are not store reads — serveShellHit seeds them
387
405
  // onto the tail context (_shellLoaderSeed) for the resolveLoaderData
@@ -395,10 +413,14 @@ export class SeededShellStore<
395
413
  get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
396
414
  return this.inner.keyGenerator;
397
415
  }
416
+ get supportsPassiveShellReads(): true | undefined {
417
+ return this.inner.supportsPassiveShellReads;
418
+ }
398
419
 
399
420
  async get(key: string): Promise<CacheGetResult | null> {
400
421
  const seeded = this.segments.get(key);
401
422
  if (seeded) return { data: seeded, shouldRevalidate: false };
423
+ if (this.segmentsOnly) return null;
402
424
  return this.inner.get(key);
403
425
  }
404
426
 
@@ -408,10 +430,17 @@ export class SeededShellStore<
408
430
  ttl: number,
409
431
  swr?: number,
410
432
  ): Promise<void> {
433
+ if (this.segmentsOnly) {
434
+ this.segments.set(key, data);
435
+ return;
436
+ }
411
437
  return this.inner.set(key, data, ttl, swr);
412
438
  }
413
439
 
414
440
  async delete(key: string): Promise<boolean> {
441
+ if (this.segmentsOnly) {
442
+ return this.segments.delete(key);
443
+ }
415
444
  return this.inner.delete(key);
416
445
  }
417
446
 
@@ -422,7 +451,7 @@ export class SeededShellStore<
422
451
  async getResponse(
423
452
  key: string,
424
453
  ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
425
- const seeded = this.responses.get(key);
454
+ const seeded = this.responses?.get(key);
426
455
  if (seeded) {
427
456
  return { response: deserializeResponse(seeded), shouldRevalidate: false };
428
457
  }
@@ -440,7 +469,7 @@ export class SeededShellStore<
440
469
  }
441
470
 
442
471
  async getItem(key: string): Promise<CacheItemResult | null> {
443
- const seeded = this.items.get(key);
472
+ const seeded = this.items?.get(key);
444
473
  if (seeded) {
445
474
  return {
446
475
  value: seeded.value,
@@ -462,8 +491,9 @@ export class SeededShellStore<
462
491
 
463
492
  async getShell(
464
493
  key: string,
494
+ options?: { claimRevalidation?: boolean },
465
495
  ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
466
- return this.inner.getShell ? this.inner.getShell(key) : null;
496
+ return this.inner.getShell ? this.inner.getShell(key, options) : null;
467
497
  }
468
498
 
469
499
  async putShell(
@@ -472,7 +502,7 @@ export class SeededShellStore<
472
502
  ttlSeconds?: number,
473
503
  swrSeconds?: number,
474
504
  tags?: string[],
475
- ): Promise<void> {
505
+ ): Promise<"stored" | "invalidated" | void> {
476
506
  return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
477
507
  }
478
508
 
@@ -36,6 +36,13 @@ export interface CacheGetResult {
36
36
  * @typeParam TEnv - Platform bindings type (e.g., Cloudflare env)
37
37
  */
38
38
  export interface SegmentCacheStore<TEnv = unknown> {
39
+ /**
40
+ * The store honors getShell(..., { claimRevalidation: false }) without
41
+ * claiming an SWR lock. Navigation replay requires this opt-in because it
42
+ * cannot recapture an HTML shell after observing a stale entry.
43
+ */
44
+ readonly supportsPassiveShellReads?: true;
45
+
39
46
  /**
40
47
  * Default cache options for this store.
41
48
  * Used by cache() boundaries when ttl/swr are not explicitly specified.
@@ -144,9 +151,12 @@ export interface SegmentCacheStore<TEnv = unknown> {
144
151
  *
145
152
  * Optional: a store that does not implement the shell family disables the
146
153
  * shell-cache middleware (it fails open to the normal HTML render path).
154
+ * A passive read reports a stale entry as `shouldRevalidate: true` without
155
+ * claiming store-specific revalidation ownership.
147
156
  */
148
157
  getShell?(
149
158
  key: string,
159
+ options?: { claimRevalidation?: boolean },
150
160
  ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null>;
151
161
 
152
162
  /**
@@ -160,6 +170,8 @@ export interface SegmentCacheStore<TEnv = unknown> {
160
170
  * @param swrSeconds - Optional stale-while-revalidate window in seconds
161
171
  * @param tags - Optional cache tags for invalidation (participates in
162
172
  * invalidateTags via the same tag machinery as the item family)
173
+ * @returns `invalidated` when a generation marker rejected the write,
174
+ * `stored` when acknowledged, or void for stores without acknowledgements.
163
175
  */
164
176
  putShell?(
165
177
  key: string,
@@ -167,7 +179,7 @@ export interface SegmentCacheStore<TEnv = unknown> {
167
179
  ttlSeconds?: number,
168
180
  swrSeconds?: number,
169
181
  tags?: string[],
170
- ): Promise<void>;
182
+ ): Promise<"stored" | "invalidated" | void>;
171
183
 
172
184
  /**
173
185
  * Get a cached function result by key.
@@ -200,10 +212,9 @@ export interface SegmentCacheStore<TEnv = unknown> {
200
212
  /**
201
213
  * True when ANY of `tags` was invalidated (invalidateTags/updateTag) at or
202
214
  * 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.
215
+ * freshness). Consulted by build-shell read-through and runtime shell stores:
216
+ * "was it evicted" is answered by tag markers against the entry's createdAt,
217
+ * including when invalidation races a capture that has not been written yet.
207
218
  * Optional: without it, TAGGED build entries are not served (untagged ones
208
219
  * are unaffected — they are evictable only by deploy/buildVersion anyway).
209
220
  * Fail open to `false` on marker-read errors: a transient store fault must
@@ -307,7 +318,13 @@ export interface ShellCacheEntry {
307
318
  * their holes always fill.
308
319
  */
309
320
  handlerLiveHoles?: boolean;
310
- /** Epoch ms when the shell was captured. */
321
+ /**
322
+ * The capture encountered transition({ when }). Its effective hold policy is
323
+ * request-dependent, so handler-free document and navigation replay must
324
+ * re-run resolution to collect and evaluate the predicate.
325
+ */
326
+ transitionWhen?: true;
327
+ /** Capture-generation start time; tag invalidations at or after it win. */
311
328
  createdAt: number;
312
329
  }
313
330