@rangojs/router 0.7.0 → 0.9.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 (59) hide show
  1. package/dist/types/cache/cf/cf-cache-store.d.ts +30 -16
  2. package/dist/types/cache/shell-snapshot.d.ts +2 -2
  3. package/dist/types/cache/types.d.ts +26 -7
  4. package/dist/types/client-urls/server-projection.d.ts +4 -4
  5. package/dist/types/client-urls/types.d.ts +13 -12
  6. package/dist/types/route-definition/helpers-types.d.ts +6 -5
  7. package/dist/types/router/segment-resolution/loader-mask.d.ts +1 -1
  8. package/dist/types/rsc/shell-capture.d.ts +14 -4
  9. package/dist/types/rsc/types.d.ts +8 -0
  10. package/dist/types/server/context.d.ts +1 -1
  11. package/dist/types/server/request-context.d.ts +1 -1
  12. package/dist/types/ssr/index.d.ts +16 -0
  13. package/dist/types/ssr/ssr-root.d.ts +5 -0
  14. package/dist/types/types/loader-types.d.ts +24 -22
  15. package/dist/types/urls/path-helper-types.d.ts +8 -7
  16. package/dist/vite/index.js +8 -5
  17. package/package.json +1 -1
  18. package/skills/breadcrumbs/SKILL.md +2 -2
  19. package/skills/catalog.json +2 -2
  20. package/skills/client-urls/SKILL.md +9 -8
  21. package/skills/cloudflare/SKILL.md +5 -3
  22. package/skills/comparison/references/framework-comparison.md +2 -2
  23. package/skills/hooks/data.md +1 -1
  24. package/skills/hooks/handle-and-actions.md +1 -1
  25. package/skills/loader/SKILL.md +29 -28
  26. package/skills/migrate-nextjs/SKILL.md +3 -3
  27. package/skills/migrate-react-router/component-migration.md +1 -1
  28. package/skills/migrate-react-router/data-and-actions.md +1 -1
  29. package/skills/migrate-react-router/route-mapping.md +1 -1
  30. package/skills/parallel/SKILL.md +1 -1
  31. package/skills/ppr/SKILL.md +12 -8
  32. package/skills/rango/SKILL.md +20 -20
  33. package/skills/router-setup/SKILL.md +1 -1
  34. package/skills/scripts/SKILL.md +1 -1
  35. package/skills/shell-manifest/SKILL.md +1 -1
  36. package/src/browser/react/Link.tsx +27 -4
  37. package/src/cache/cf/cf-cache-store.ts +118 -62
  38. package/src/cache/shell-snapshot.ts +2 -2
  39. package/src/cache/types.ts +27 -7
  40. package/src/client-urls/client-urls.ts +12 -9
  41. package/src/client-urls/server-projection.ts +6 -6
  42. package/src/client-urls/types.ts +13 -12
  43. package/src/route-definition/dsl-helpers.ts +7 -3
  44. package/src/route-definition/helpers-types.ts +6 -5
  45. package/src/router/loader-resolution.ts +3 -3
  46. package/src/router/segment-resolution/fresh.ts +2 -2
  47. package/src/router/segment-resolution/loader-cache.ts +1 -1
  48. package/src/router/segment-resolution/loader-mask.ts +1 -1
  49. package/src/rsc/rsc-rendering.ts +10 -0
  50. package/src/rsc/shell-build-manifest.ts +13 -6
  51. package/src/rsc/shell-capture.ts +32 -5
  52. package/src/rsc/ssr-setup.ts +4 -0
  53. package/src/rsc/types.ts +18 -2
  54. package/src/server/context.ts +1 -1
  55. package/src/server/request-context.ts +1 -1
  56. package/src/ssr/index.tsx +22 -2
  57. package/src/ssr/ssr-root.tsx +17 -1
  58. package/src/types/loader-types.ts +21 -19
  59. package/src/urls/path-helper-types.ts +8 -7
@@ -145,11 +145,12 @@ const warnedNoKvReadInvalidation = new Set<string>();
145
145
  const warnedTagInvalidationTtlFloor = new Set<string>();
146
146
 
147
147
  /**
148
- * Stores (by namespace) already warned about the shell family being inert
149
- * (getShell/putShell no-op without a KV namespace), so a ppr route hitting the
150
- * silent fail-open warns once per isolate instead of on every request.
148
+ * Stores (by namespace) already warned that a TAGGED shell was written on a
149
+ * KV-less store without tagPurge: no markers and no purge means updateTag()
150
+ * cannot reach the shell (freshness is ttl/swr only). Once per isolate, not
151
+ * per capture (CFCacheStore is constructed per request).
151
152
  */
152
- const warnedShellFamilyInert = new Set<string>();
153
+ const warnedShellTagsNoEviction = new Set<string>();
153
154
 
154
155
  /**
155
156
  * Stores (by namespace) already warned that tag invalidation is writing KV
@@ -412,8 +413,6 @@ interface KVResponseEnvelope {
412
413
 
413
414
  export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
414
415
  readonly supportsPassiveShellReads: true = true;
415
- /** True when constructed without KV: the shell family no-ops (see ctor). */
416
- readonly shellFamilyInert?: boolean;
417
416
  readonly defaults?: CacheDefaults;
418
417
  readonly keyGenerator?: (
419
418
  ctx: RequestContext<TEnv>,
@@ -429,6 +428,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
429
428
  private readonly kvReadTimeoutMs: number;
430
429
  private readonly debug?: (event: CFCacheReadDebugEvent) => void;
431
430
  private readonly kv?: KVNamespace;
431
+ /** True when constructed without KV: no durable tag history (see ctor). */
432
+ readonly tagHistoryInert?: boolean;
432
433
  private readonly onRevalidateTag?: (tags: string[]) => Promise<void>;
433
434
  private readonly tagPurge?: (cacheTags: string[]) => Promise<void>;
434
435
  private readonly tagInvalidationTtl?: number;
@@ -485,11 +486,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
485
486
  this.keyGenerator = options.keyGenerator;
486
487
  this.waitUntil = (fn) => options.ctx.waitUntil(fn());
487
488
  this.kv = options.kv;
488
- // The shell family requires KV (getShell/putShell no-op without it — see
489
- // warnShellFamilyInertOnce). Declaring it lets scheduleShellCapture skip
490
- // captures whose write could only no-op instead of burning a background
491
- // render per MISS that still occupies the serialized capture queue.
492
- this.shellFamilyInert = options.kv ? undefined : true;
489
+ // Without KV, isTagsInvalidatedSince has no durable history it answers
490
+ // from the per-request memo at best. Runtime shells tolerate that (purge
491
+ // eviction + ttl/swr bound the staleness), but an immutable TAGGED
492
+ // build-manifest shell must not serve on such a store: nothing could ever
493
+ // evict it (purge cannot delete a build asset), so the manifest gate
494
+ // declines on this flag (shell-build-manifest.ts).
495
+ this.tagHistoryInert = options.kv ? undefined : true;
493
496
  this.onRevalidateTag = options.onRevalidateTag;
494
497
  // tagPurge accepts a ready purge function or a credentials object; the
495
498
  // object form is normalized through the built-in zone purge client, which
@@ -506,9 +509,12 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
506
509
  // marker write throw and break ALL invalidation. Floor it (and warn once);
507
510
  // a non-finite/non-positive value falls back to the no-expiry default
508
511
  // (markers persist) rather than silently sailing a NaN into expirationTtl.
509
- this.tagInvalidationTtl = this.sanitizeTagInvalidationTtl(
510
- options.tagInvalidationTtl,
511
- );
512
+ // KV-less the option is dead config — no markers to expire, and the
513
+ // retention cap it used to imply is KV-conditional (putShell) — so it is
514
+ // dropped without the KV-floor validation/warning, which would misdirect.
515
+ this.tagInvalidationTtl = options.kv
516
+ ? this.sanitizeTagInvalidationTtl(options.tagInvalidationTtl)
517
+ : undefined;
512
518
  // tagCacheTtl gates the L1 marker cache via `> 0`. A non-finite value (NaN
513
519
  // from `Number(env.UNSET)`) is not null/undefined, so `?? 0` would let it
514
520
  // through and silently disable the cache while reading as "configured".
@@ -1876,40 +1882,75 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1876
1882
  }
1877
1883
 
1878
1884
  // ============================================================================
1879
- // Shell Cache Methods (PPR shell resume) — Cache API L1 + KV L2
1885
+ // Shell Cache Methods (PPR shell resume) — Cache API L1 + optional KV L2
1880
1886
  // ============================================================================
1881
1887
  //
1882
- // KV remains the durable, cross-colo shell tier. Cache API is a per-colo
1883
- // read-through accelerator: writes populate both tiers, and a valid KV hit
1884
- // promotes the same coupled envelope into L1. The family still requires KV;
1885
- // without it, getShell/putShell no-op and PPR fails open to a full HTML render.
1888
+ // With KV it is the durable, cross-colo shell tier: writes populate both
1889
+ // tiers, a valid KV hit promotes the same coupled envelope into L1, and
1890
+ // shell L1 hits deliberately keep the KV generation-marker check even in
1891
+ // purge mode. A shell's taggedAt is its CAPTURE START, not its write time:
1892
+ // an invalidation can purge while an older capture is still running, then
1893
+ // that capture can land after the purge. The marker check rejects that
1894
+ // resurrection.
1886
1895
  //
1887
- // Shell L1 hits deliberately keep the KV generation-marker check even in
1888
- // purge mode. A shell's taggedAt is its CAPTURE START, not its write time: an
1889
- // invalidation can purge while an older capture is still running, then that
1890
- // capture can land after the purge. The marker check rejects that resurrection.
1896
+ // Without KV the family is L1-only (edge-only ppr): every colo captures and
1897
+ // serves its own shell from the Cache API. Tag eviction then needs purge
1898
+ // mode (tagPurge) shell L1 entries carry the same namespaced Cache-Tag
1899
+ // tokens as the data families, read-your-own-writes comes from the
1900
+ // per-request marker memo, and the capture resurrection race narrows to
1901
+ // cross-request timing bounded by ttl+swr (the data families' documented
1902
+ // purge-mode stance). KV-less WITHOUT tagPurge still caches: freshness is
1903
+ // ttl/swr only, and a tagged write warns once that invalidation cannot
1904
+ // reach it (see warnShellTagsNoEvictionOnce).
1891
1905
 
1892
1906
  /**
1893
- * Warn once per isolate that the shell family is inert: getShell/putShell
1894
- * are ONLY called for routes that declared the `ppr` path option, so firing
1895
- * here (not in the constructor) scopes the warning to apps that actually
1896
- * use PPR a KV-less CFCacheStore is a perfectly fine config otherwise.
1897
- * Without it, the correctness-first fail-open (issue #651) is invisible:
1898
- * every ppr route is a permanent MISS with zero diagnostics.
1907
+ * Warn once per isolate that a TAGGED shell landed on a store with no
1908
+ * eviction path for it: no KV (markers) and no tagPurge (purge-by-tag).
1909
+ * The shell still caches and expires by ttl+swr, but updateTag()/
1910
+ * revalidateTag() cannot reach it silent staleness a consumer who tagged
1911
+ * the route clearly did not intend. Fired from putShell (not the
1912
+ * constructor) so an untagged edge-only ppr config stays warning-free.
1899
1913
  * @internal
1900
1914
  */
1901
- private warnShellFamilyInertOnce(): void {
1915
+ private warnShellTagsNoEvictionOnce(): void {
1902
1916
  this.warnOncePerNamespace(
1903
- warnedShellFamilyInert,
1904
- `[CFCacheStore] a ppr route resolved to this store, but no KV namespace ` +
1905
- `is configured, so the shell family (getShell/putShell) is a no-op: ` +
1906
- `every ppr route stays a permanent shell MISS (the page still serves ` +
1907
- `via a full render). Bind a KV namespace and pass it ` +
1908
- `new CFCacheStore({ ctx, kv: env.CACHE_KV }) or use a shell-capable ` +
1909
- `store via createRouter({ cache }).`,
1917
+ warnedShellTagsNoEviction,
1918
+ `[CFCacheStore] a ppr shell with tags was stored on a KV-less store ` +
1919
+ `without tagPurge: tag invalidation cannot evict it (no KV markers, ` +
1920
+ `no purge-by-tag), so updateTag()/revalidateTag() will not reach ` +
1921
+ `this shell it serves until ttl+swr expiry. Configure { kv } for ` +
1922
+ `marker invalidation or { tagPurge } for purge-by-tag eviction; ` +
1923
+ `untagged ppr routes (ttl/swr freshness) are unaffected.`,
1910
1924
  );
1911
1925
  }
1912
1926
 
1927
+ /**
1928
+ * Generation gate for shell writes and the capture scheduler
1929
+ * (isTagsInvalidatedSince). With KV it is the durable marker cascade.
1930
+ * Without KV there are no markers: in purge mode the per-request memo is
1931
+ * the only signal — a capture racing THIS request's updateTag() is still
1932
+ * rejected (read-your-own-writes), while cross-request races are bounded
1933
+ * by ttl+swr exactly like the data families' purge-mode writes. Without
1934
+ * either, fail open (ttl/swr-only semantics, warned at putShell).
1935
+ * @internal
1936
+ */
1937
+ private async isShellGenerationInvalidated(
1938
+ tags: string[] | undefined,
1939
+ since: number | undefined,
1940
+ ): Promise<boolean> {
1941
+ if (this.kv) return this.isGloballyInvalidated(tags, since);
1942
+ if (!this.tagPurge || !Array.isArray(tags) || tags.length === 0 || !since)
1943
+ return false;
1944
+ const ctx = _getRequestContext();
1945
+ if (!ctx) return false;
1946
+ const memo = getTagMarkerMemo(ctx, this);
1947
+ for (const tag of tags) {
1948
+ const marker = memo.get(tag);
1949
+ if (marker != null && marker >= since) return true;
1950
+ }
1951
+ return false;
1952
+ }
1953
+
1913
1954
  /**
1914
1955
  * Get a cached PPR shell entry from Cache API, falling through to KV and
1915
1956
  * promoting a valid KV hit. Both tiers store one envelope so the prelude,
@@ -1920,10 +1961,6 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1920
1961
  async getShell(
1921
1962
  key: string,
1922
1963
  ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
1923
- if (!this.kv) {
1924
- this.warnShellFamilyInertOnce();
1925
- return null;
1926
- }
1927
1964
  try {
1928
1965
  const cache = await this.getCache();
1929
1966
  const request = this.keyToRequest(`shell:${key}`);
@@ -2005,10 +2042,17 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2005
2042
  return this.kvGetShell(key);
2006
2043
  }
2007
2044
 
2008
- // Unlike other L1 families, shells always check the durable generation
2009
- // marker. See the capture-start/purge race documented above.
2045
+ // Unlike other L1 families, shells with KV always check the durable
2046
+ // generation marker see the capture-start/purge race documented
2047
+ // above. Without KV there are no markers: L1-only shells adopt the
2048
+ // data families' purge-mode read semantics (a hit that survived the
2049
+ // purge is trusted; the per-request memo masks this request's own
2050
+ // updateTag() writes; entries a purge cannot reach fall back to the
2051
+ // marker check, which fails open KV-less).
2010
2052
  const markerStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
2011
- const invalidated = await this.isGloballyInvalidated(value.t, value.ta);
2053
+ const invalidated = this.kv
2054
+ ? await this.isGloballyInvalidated(value.t, value.ta)
2055
+ : await this.isL1Invalidated(value.t, value.ta, response.headers);
2012
2056
  const markerMs = INTERNAL_RANGO_DEBUG
2013
2057
  ? Date.now() - markerStartedAt
2014
2058
  : undefined;
@@ -2042,11 +2086,12 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2042
2086
  }
2043
2087
 
2044
2088
  /**
2045
- * Store a PPR shell envelope in Cache API and, when its retention meets KV's
2046
- * 60-second floor, KV. The shared write is registered with waitUntil and
2047
- * awaited so invalidation rejection can be acknowledged to the capture
2048
- * scheduler. Short-lived shells remain useful in L1 even though KV rejects
2049
- * them.
2089
+ * Store a PPR shell envelope in Cache API and, when KV is configured and
2090
+ * the retention meets its 60-second floor, KV. The shared write is
2091
+ * registered with waitUntil and awaited so invalidation rejection can be
2092
+ * acknowledged to the capture scheduler. Short-lived shells remain useful
2093
+ * in L1 even though KV rejects them; a KV-less store is L1-only by design
2094
+ * (edge-only ppr — see the section comment).
2050
2095
  */
2051
2096
  async putShell(
2052
2097
  key: string,
@@ -2054,20 +2099,29 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2054
2099
  ttlSeconds?: number,
2055
2100
  swrSeconds?: number,
2056
2101
  tags?: string[],
2057
- ): Promise<"stored" | "invalidated" | void> {
2058
- // KV remains required for durable generation markers and cross-colo reads.
2059
- if (!this.kv) {
2060
- this.warnShellFamilyInertOnce();
2061
- return;
2062
- }
2102
+ ): Promise<"stored" | "invalidated" | "uncacheable" | void> {
2063
2103
  if (!this.waitUntil) return;
2104
+ // Same write gate as the data families: in KV-less purge mode an
2105
+ // over-limit tag set has NO eviction path (no tokens, no markers), so the
2106
+ // shell is not cached rather than becoming un-invalidatable. Unlike the
2107
+ // void-returning data puts, this is ACKNOWLEDGED — the capture scheduler
2108
+ // must back the key off (every write would refuse identically), not
2109
+ // treat the capture as stored and re-render on every MISS.
2110
+ if (this.skipUncacheableTagSet(tags)) return "uncacheable";
2111
+ if (!this.kv && !this.tagPurge && Array.isArray(tags) && tags.length > 0) {
2112
+ this.warnShellTagsNoEvictionOnce();
2113
+ }
2064
2114
  try {
2065
2115
  const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
2066
2116
  const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
2067
2117
  const totalTtl = ttl + swrWindow;
2068
2118
 
2119
+ // The tagInvalidationTtl cap exists so a tagged entry can never outlive
2120
+ // its KV markers (an expired marker would resurrect it). Without KV
2121
+ // there are no markers to outlive — capping would just hard-expire the
2122
+ // shell below its declared ttl+swr — so the cap is KV-conditional.
2069
2123
  const retentionTtl =
2070
- tags && tags.length > 0 && this.tagInvalidationTtl
2124
+ tags && tags.length > 0 && this.kv && this.tagInvalidationTtl
2071
2125
  ? Math.min(totalTtl, this.tagInvalidationTtl)
2072
2126
  : totalTtl;
2073
2127
  const now = Date.now();
@@ -2076,14 +2130,14 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2076
2130
  const taggedAt =
2077
2131
  Array.isArray(tags) && tags.length > 0 ? entry.createdAt : undefined;
2078
2132
 
2079
- const kvKey = await this.toKVKey(`shell:${key}`);
2080
- const writeKv = retentionTtl >= KV_MIN_EXPIRATION_TTL;
2133
+ const writeKv = !!this.kv && retentionTtl >= KV_MIN_EXPIRATION_TTL;
2134
+ const kvKey = writeKv ? await this.toKVKey(`shell:${key}`) : null;
2081
2135
 
2082
2136
  const write = (async (): Promise<"stored" | "invalidated" | void> => {
2083
2137
  if (
2084
2138
  tags &&
2085
2139
  tags.length > 0 &&
2086
- (await this.isGloballyInvalidated(tags, entry.createdAt))
2140
+ (await this.isShellGenerationInvalidated(tags, entry.createdAt))
2087
2141
  ) {
2088
2142
  this.debugShell(key, "write-invalidated");
2089
2143
  return "invalidated";
@@ -2132,7 +2186,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2132
2186
  }
2133
2187
  })(),
2134
2188
  ];
2135
- if (writeKv) {
2189
+ if (writeKv && kvKey !== null) {
2136
2190
  writes.push(
2137
2191
  (async () => {
2138
2192
  try {
@@ -3029,13 +3083,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3029
3083
  * Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince): the
3030
3084
  * SAME KV markers used by runtime envelopes also evict immutable build shells
3031
3085
  * and captures whose write races updateTag(). Thin public wrapper over the
3032
- * private envelope check (marker >= since, fail open).
3086
+ * shell generation check (marker >= since, fail open); KV-less it degrades
3087
+ * to the per-request memo in purge mode and to false otherwise — see
3088
+ * isShellGenerationInvalidated.
3033
3089
  */
3034
3090
  async isTagsInvalidatedSince(
3035
3091
  tags: string[],
3036
3092
  sinceMs: number,
3037
3093
  ): Promise<boolean> {
3038
- return this.isGloballyInvalidated(tags, sinceMs);
3094
+ return this.isShellGenerationInvalidated(tags, sinceMs);
3039
3095
  }
3040
3096
 
3041
3097
  async invalidateTags(tags: string[]): Promise<void> {
@@ -250,7 +250,7 @@ export class RecordingShellStore<
250
250
  ttlSeconds?: number,
251
251
  swrSeconds?: number,
252
252
  tags?: string[],
253
- ): Promise<"stored" | "invalidated" | void> {
253
+ ): Promise<"stored" | "invalidated" | "uncacheable" | void> {
254
254
  return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
255
255
  }
256
256
 
@@ -506,7 +506,7 @@ export class SeededShellStore<
506
506
  ttlSeconds?: number,
507
507
  swrSeconds?: number,
508
508
  tags?: string[],
509
- ): Promise<"stored" | "invalidated" | void> {
509
+ ): Promise<"stored" | "invalidated" | "uncacheable" | void> {
510
510
  return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
511
511
  }
512
512
 
@@ -188,7 +188,12 @@ export interface SegmentCacheStore<TEnv = unknown> {
188
188
  * @param tags - Optional cache tags for invalidation (participates in
189
189
  * invalidateTags via the same tag machinery as the item family)
190
190
  * @returns `invalidated` when a generation marker rejected the write,
191
- * `stored` when acknowledged, or void for stores without acknowledgements.
191
+ * `stored` when acknowledged, `uncacheable` when the entry can NEVER be
192
+ * stored under the current configuration (every retry would refuse
193
+ * identically — the capture scheduler backs the key off instead of
194
+ * recapturing per MISS; CFCacheStore returns it for a tag set whose
195
+ * Cache-Tag header overflows in KV-less purge mode), or void for stores
196
+ * without acknowledgements.
192
197
  */
193
198
  putShell?(
194
199
  key: string,
@@ -196,18 +201,33 @@ export interface SegmentCacheStore<TEnv = unknown> {
196
201
  ttlSeconds?: number,
197
202
  swrSeconds?: number,
198
203
  tags?: string[],
199
- ): Promise<"stored" | "invalidated" | void>;
204
+ ): Promise<"stored" | "invalidated" | "uncacheable" | void>;
200
205
 
201
206
  /**
202
207
  * Declares the shell family present-but-inert: getShell/putShell exist but
203
- * no-op (CFCacheStore without a KV namespace). scheduleShellCapture skips
204
- * captures whose only write target is inert — the background render would be
205
- * dead work that still occupies the per-isolate serialized capture queue
206
- * (a promise-heavy route bakes for seconds per MISS with nothing stored).
207
- * Absent/false means the family, when present, actually stores.
208
+ * no-op (a custom store whose backing tier is conditionally unavailable).
209
+ * scheduleShellCapture skips captures whose only write target is inert —
210
+ * the background render would be dead work that still occupies the
211
+ * per-isolate serialized capture queue (a promise-heavy route bakes for
212
+ * seconds per MISS with nothing stored). Absent/false means the family,
213
+ * when present, actually stores. The built-in stores never declare it:
214
+ * CFCacheStore is L1-only without KV (edge-only ppr), not inert.
208
215
  */
209
216
  shellFamilyInert?: boolean;
210
217
 
218
+ /**
219
+ * Declares isTagsInvalidatedSince present-but-inert: the store implements
220
+ * the method but has no DURABLE invalidation history behind it (a KV-less
221
+ * CFCacheStore answers from the per-request memo at best). Runtime shells
222
+ * tolerate that — purge eviction plus ttl/swr bound their staleness — but
223
+ * a TAGGED build-manifest shell is immutable with no ttl of its own, so
224
+ * serving it on such a store would make updateTag() a permanent no-op for
225
+ * it. The build-shell read-through declines tagged entries on this flag
226
+ * (same declared-intent-cannot-be-honored doctrine as a store missing the
227
+ * method entirely). Absent/false means answers are durably backed.
228
+ */
229
+ tagHistoryInert?: boolean;
230
+
211
231
  /**
212
232
  * Get a cached function result by key.
213
233
  * Returns the serialized value, optional handle data, and staleness flag.
@@ -137,7 +137,7 @@ interface LoaderItem extends ItemBase {
137
137
  readonly type: "loader";
138
138
  readonly definition: LoaderDefinition<any, any>;
139
139
  readonly revalidate: readonly ClientRevalidateFn[];
140
- readonly stream?: "navigation";
140
+ readonly ssr?: false;
141
141
  }
142
142
 
143
143
  interface RevalidateItem extends ItemBase {
@@ -380,9 +380,14 @@ function createHelpers(): ClientUrlHelpers {
380
380
  const options =
381
381
  typeof optionsOrUse === "function" ? undefined : optionsOrUse;
382
382
  const use = typeof optionsOrUse === "function" ? optionsOrUse : maybeUse;
383
- if (options?.stream !== undefined && options.stream !== "navigation") {
383
+ if ((options as { stream?: unknown } | undefined)?.stream !== undefined) {
384
384
  throw new Error(
385
- `clientUrls() loader() stream must be "navigation" (got ${JSON.stringify(options.stream)}). Omit it to stream on every render.`,
385
+ "clientUrls() loader() stream was replaced: use loader(Def, { ssr: false }) — the same knob as loading(fallback, { ssr: false }) to await the loader before first flush on document requests.",
386
+ );
387
+ }
388
+ if (options?.ssr !== undefined && typeof options.ssr !== "boolean") {
389
+ throw new Error(
390
+ `clientUrls() loader() ssr must be a boolean (got ${JSON.stringify(options.ssr)}). Omit it (or pass true) to stream on every render.`,
386
391
  );
387
392
  }
388
393
  const items = use ? runUse(use, "loader use") : [];
@@ -397,9 +402,7 @@ function createHelpers(): ClientUrlHelpers {
397
402
  item.type === "revalidate",
398
403
  )
399
404
  .map((item) => item.fn),
400
- ...(options?.stream === "navigation"
401
- ? { stream: "navigation" as const }
402
- : {}),
405
+ ...(options?.ssr === false ? { ssr: false as const } : {}),
403
406
  });
404
407
  };
405
408
 
@@ -505,9 +508,9 @@ function createHelpers(): ClientUrlHelpers {
505
508
  for (const item of items) {
506
509
  // Intercept loaders run on soft navigations only — a document-render
507
510
  // await can never apply, so accepting the flag would be silently inert.
508
- if (item.type === "loader" && item.stream !== undefined) {
511
+ if (item.type === "loader" && item.ssr !== undefined) {
509
512
  throw new Error(
510
- 'clientUrls() intercept() loaders cannot use stream: "navigation" — intercepts render on client navigations only',
513
+ "clientUrls() intercept() loaders cannot use ssr: false — intercepts render on client navigations only",
511
514
  );
512
515
  }
513
516
  }
@@ -557,7 +560,7 @@ function applyConfig(
557
560
  Object.freeze({
558
561
  loader: item.definition,
559
562
  revalidate: Object.freeze([...item.revalidate]),
560
- ...(item.stream ? { stream: item.stream } : {}),
563
+ ...(item.ssr === false ? { ssr: false as const } : {}),
561
564
  }),
562
565
  );
563
566
  } else if (item.type === "loading") {
@@ -89,10 +89,10 @@ export interface ClientUrlProjectionRoute {
89
89
  readonly options: ClientUrlProjectionOptions;
90
90
  readonly loaderIds: readonly string[];
91
91
  readonly hasLoading: boolean;
92
- /** Indices into loaderIds of loaders declared loader(Def, { stream:
93
- * "navigation" }); materialization passes the option through to the server
94
- * loader() so document renders await them before first flush. Absent (=
95
- * none) in projections serialized before stream support. */
92
+ /** Indices into loaderIds of loaders declared loader(Def, { ssr: false });
93
+ * materialization passes the option through to the server loader() so
94
+ * document renders await them before first flush. Absent (= none) in
95
+ * projections serialized before the option existed. */
96
96
  readonly awaitedLoaderIndices?: readonly number[];
97
97
  /** Data-only transition config (no `when` — server-tree only); absent in
98
98
  * projections serialized before transition support. */
@@ -274,7 +274,7 @@ function serializeRoute(route: ClientUrlRouteRecord): ClientUrlProjectionRoute {
274
274
  });
275
275
 
276
276
  const awaitedLoaderIndices = route.loaders
277
- .map(({ stream }, index) => (stream === "navigation" ? index : -1))
277
+ .map(({ ssr }, index) => (ssr === false ? index : -1))
278
278
  .filter((index) => index >= 0);
279
279
 
280
280
  const transition = serializeTransition(route);
@@ -526,7 +526,7 @@ function materializeRouteItems(
526
526
  loader(
527
527
  createLoaderStub(id),
528
528
  route.awaitedLoaderIndices?.includes(loaderIndex)
529
- ? { stream: "navigation" }
529
+ ? { ssr: false }
530
530
  : undefined,
531
531
  () => [revalidate(makeClientDecisionRevalidate(id))],
532
532
  ),
@@ -87,12 +87,12 @@ export interface ClientUrlLoaderRecord {
87
87
  /** Client-run per-loader revalidation predicates; empty = locked defaults. */
88
88
  readonly revalidate: readonly ClientRevalidateFn[];
89
89
  /**
90
- * loader(Def, { stream: "navigation" }): document renders await this loader
91
- * before first flush (see {@link LoaderOptions}). Projected into the server
92
- * tree, where the per-isSSR entry stamping applies — client navigations
93
- * stream regardless.
90
+ * loader(Def, { ssr: false }): document renders await this loader before
91
+ * first flush (see {@link LoaderOptions}). Projected into the server tree,
92
+ * where the per-isSSR entry stamping applies — client navigations stream
93
+ * regardless.
94
94
  */
95
- readonly stream?: "navigation";
95
+ readonly ssr?: false;
96
96
  }
97
97
 
98
98
  /**
@@ -141,13 +141,14 @@ export interface ClientUrlHelpers {
141
141
  * revalidate() only — a CLIENT-RUN per-loader predicate; its decision (not
142
142
  * the function) is sent with the revalidation request.
143
143
  *
144
- * Pass `{ stream: "navigation" }` to await this loader before first flush
145
- * on DOCUMENT requests (see {@link LoaderOptions}) the opt-in for loaders
146
- * whose data, handle pushes, or thrown notFound()/redirect() must be in the
147
- * SSR'd HTML. Per-loader: a dynamic sibling keeps streaming. Under a
148
- * `ppr` group route the flag BAKES: the loader executes at shell capture
149
- * and its settled return freezes into the shell (nested promises stay
150
- * live holes).
144
+ * Pass `{ ssr: false }` the same knob as loading(fallback, { ssr:
145
+ * false }) to await this loader before first flush on DOCUMENT requests
146
+ * (see {@link LoaderOptions}): the opt-in for loaders whose data, handle
147
+ * pushes, or thrown notFound()/redirect() must be in the SSR'd HTML.
148
+ * Per-loader: a streaming sibling keeps streaming. Under a `ppr` group
149
+ * route the flag BAKES: the loader executes at shell capture and its
150
+ * settled return freezes into the shell (nested promises stay live
151
+ * holes).
151
152
  */
152
153
  readonly loader: <TData>(
153
154
  definition: LoaderDefinition<TData>,
@@ -807,8 +807,12 @@ const loader: RouteHelpers<any, any>["loader"] = (
807
807
  "loader() received two use() callbacks. Pass loader(Def, options, use) or loader(Def, use).",
808
808
  );
809
809
  invariant(
810
- optionsGiven?.stream === undefined || optionsGiven.stream === "navigation",
811
- `loader() stream must be "navigation" (got ${JSON.stringify(optionsGiven?.stream)}). Omit it to stream on every render.`,
810
+ (optionsGiven as { stream?: unknown } | undefined)?.stream === undefined,
811
+ "loader() stream was replaced: use loader(Def, { ssr: false }) — the same knob as loading(fallback, { ssr: false }) to await the loader before first flush on document requests.",
812
+ );
813
+ invariant(
814
+ optionsGiven?.ssr === undefined || typeof optionsGiven.ssr === "boolean",
815
+ `loader() ssr must be a boolean (got ${JSON.stringify(optionsGiven?.ssr)}). Omit it (or pass true) to stream on every render.`,
812
816
  );
813
817
 
814
818
  const name = `${ctx.namespace}.$${store.getNextIndex("loader")}`;
@@ -820,7 +824,7 @@ const loader: RouteHelpers<any, any>["loader"] = (
820
824
  const loaderEntry: LoaderEntry = {
821
825
  loader: loaderDef,
822
826
  revalidate: [] as ShouldRevalidateFn<any, any>[],
823
- ...(optionsGiven?.stream === "navigation" && ctx.isSSR
827
+ ...(optionsGiven?.ssr === false && ctx.isSSR
824
828
  ? { awaitBeforeFlush: true as const }
825
829
  : {}),
826
830
  };
@@ -310,13 +310,14 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
310
310
  * return <div>{data.name}</div>;
311
311
  * }
312
312
  * ```
313
- * Pass `{ stream: "navigation" }` to await this loader before first flush on
314
- * DOCUMENT requests (see {@link LoaderOptions}) — the opt-in for loaders whose
315
- * data, handle pushes, or thrown notFound()/redirect() must be in the SSR'd
316
- * HTML. Per-loader: a dynamic sibling in the same segment keeps streaming.
313
+ * Pass `{ ssr: false }` the same knob as loading(fallback, { ssr:
314
+ * false }) — to await this loader before first flush on DOCUMENT requests
315
+ * (see {@link LoaderOptions}): the opt-in for loaders whose data, handle
316
+ * pushes, or thrown notFound()/redirect() must be in the SSR'd HTML.
317
+ * Per-loader: a streaming sibling in the same segment keeps streaming.
317
318
  *
318
319
  * ```typescript
319
- * loader(ProductLoader, { stream: "navigation" }, () => [cache()]),
320
+ * loader(ProductLoader, { ssr: false }, () => [cache()]),
320
321
  * loader(RecommendationsLoader), // still streams behind loading()
321
322
  * ```
322
323
  *
@@ -572,7 +572,7 @@ function createLoaderExecutor<TEnv>(
572
572
  }
573
573
 
574
574
  // awaitBeforeFlush cycle: segment resolution awaits this loader
575
- // (loader(Def, { stream: "navigation" })), the barrier awaits segment
575
+ // (loader(Def, { ssr: false })), the barrier awaits segment
576
576
  // resolution, and rendered() awaits the barrier — waiting here can
577
577
  // never complete. Fail fast with the cause instead of hanging the
578
578
  // document render. Only document renders populate the set (the flag is
@@ -581,10 +581,10 @@ function createLoaderExecutor<TEnv>(
581
581
  if (reqCtx._awaitBeforeFlushLoaderIds?.has(currentLoaderId)) {
582
582
  throw new Error(
583
583
  `Deadlock: loader "${currentLoaderId}" is registered with ` +
584
- `stream: "navigation", so the document render awaits it before ` +
584
+ `ssr: false, so the document render awaits it before ` +
585
585
  `the render barrier resolves — ctx.rendered() (and the ` +
586
586
  `ctx.get(handle) read it gates) can never settle here. Drop ` +
587
- `stream: "navigation" on this loader or move the handle read to ` +
587
+ `ssr: false on this loader or move the handle read to ` +
588
588
  `a component.`,
589
589
  );
590
590
  }
@@ -104,7 +104,7 @@ export async function resolveLoaders<TEnv>(
104
104
  const errorContext = buildLoaderErrorContext(ctx);
105
105
 
106
106
  if (emitStreaming) {
107
- // awaitBeforeFlush (loader(Def, { stream: "navigation" })): document
107
+ // awaitBeforeFlush (loader(Def, { ssr: false })): document
108
108
  // renders await these loaders before returning, so their data is settled,
109
109
  // their handle pushes beat the barrier snapshot, and a thrown notFound()'s
110
110
  // status write deterministically precedes Response construction. The ids
@@ -148,7 +148,7 @@ export async function resolveLoaders<TEnv>(
148
148
  loaderEntry,
149
149
  ctx,
150
150
  ctx.pathname,
151
- // The bake key rides for flagged loaders too: stream:"navigation"
151
+ // The bake key rides for flagged loaders too: ssr:false
152
152
  // bakes at capture regardless of the entry's loading() lane
153
153
  // (loader-cache.ts capture branch) and its HIT-tail seed
154
154
  // overlay needs the same key.
@@ -159,7 +159,7 @@ export function resolveLoaderData<TEnv>(
159
159
  if (isShellCaptureActive(reqCtx)) {
160
160
  // Capture lane, per LOADER (not per entry):
161
161
  //
162
- // - `stream: "navigation"` (awaitBeforeFlush) — the BAKE lane. The flag's
162
+ // - `ssr: false` (awaitBeforeFlush) — the BAKE lane. The flag's
163
163
  // document promise is "this loader's data is in the HTML before first
164
164
  // flush"; under ppr the pre-flush HTML IS the frozen prelude, so the
165
165
  // loader executes at capture and its SETTLED return bakes into the
@@ -44,7 +44,7 @@ export { createMaskedLoaderPromise } from "./mask-nested.js";
44
44
  /**
45
45
  * Entry-level lane input for an entry's loaders under PPR (the loading()
46
46
  * value; docs/design/loader-container-bake.md). The CAPTURE decision itself
47
- * is per LOADER in loader-cache.ts: a `stream: "navigation"`
47
+ * is per LOADER in loader-cache.ts: an `ssr: false`
48
48
  * (awaitBeforeFlush) loader BAKES at capture regardless of this value — the
49
49
  * flag's document promise ("data in the HTML before first flush") maps to
50
50
  * the frozen prelude — while every other loader is LIVE (masked at capture,