@rangojs/router 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/history-state.d.ts +16 -0
  4. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  5. package/dist/types/cache/cache-policy.d.ts +17 -0
  6. package/dist/types/cache/cache-scope.d.ts +98 -14
  7. package/dist/types/cache/cache-tag.d.ts +92 -7
  8. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  9. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  10. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  11. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  12. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  13. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  14. package/dist/types/cache/handle-snapshot.d.ts +4 -3
  15. package/dist/types/cache/index.d.ts +1 -0
  16. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  17. package/dist/types/cache/shell-memo.d.ts +178 -0
  18. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  19. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  20. package/dist/types/cache/types.d.ts +207 -43
  21. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  22. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  23. package/dist/types/router/instrument.d.ts +8 -0
  24. package/dist/types/router/middleware-types.d.ts +6 -1
  25. package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
  26. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
  27. package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
  28. package/dist/types/rsc/full-payload.d.ts +14 -0
  29. package/dist/types/rsc/handler.d.ts +1 -1
  30. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  31. package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
  32. package/dist/types/rsc/shell-capture.d.ts +146 -15
  33. package/dist/types/rsc/shell-serve.d.ts +140 -17
  34. package/dist/types/rsc/types.d.ts +7 -0
  35. package/dist/types/server/context.d.ts +109 -15
  36. package/dist/types/server/cookie-store.d.ts +21 -0
  37. package/dist/types/server/request-context.d.ts +159 -33
  38. package/dist/types/ssr/index.d.ts +9 -0
  39. package/dist/types/ssr/ssr-root.d.ts +9 -8
  40. package/dist/types/testing/flight.entry.d.ts +7 -0
  41. package/dist/types/testing/run-loader.d.ts +18 -3
  42. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  43. package/dist/types/testing/shell-status.d.ts +23 -10
  44. package/dist/types/types/handler-context.d.ts +14 -1
  45. package/dist/types/urls/pattern-types.d.ts +22 -18
  46. package/dist/types/vite/router-discovery.d.ts +29 -2
  47. package/dist/vite/index.js +46 -9
  48. package/package.json +1 -1
  49. package/skills/breadcrumbs/SKILL.md +17 -7
  50. package/skills/cache-guide/SKILL.md +123 -51
  51. package/skills/caching/SKILL.md +253 -23
  52. package/skills/catalog.json +1 -1
  53. package/skills/client-urls/SKILL.md +11 -6
  54. package/skills/cloudflare/SKILL.md +15 -0
  55. package/skills/comparison/references/framework-comparison.md +44 -32
  56. package/skills/defer-hydration/SKILL.md +50 -30
  57. package/skills/deployment-caching/SKILL.md +5 -2
  58. package/skills/document-cache/SKILL.md +8 -2
  59. package/skills/handler-use/SKILL.md +2 -0
  60. package/skills/hooks/handle-and-actions.md +3 -1
  61. package/skills/hooks/state.md +3 -1
  62. package/skills/loader/SKILL.md +161 -39
  63. package/skills/middleware/SKILL.md +16 -9
  64. package/skills/migrate-nextjs/SKILL.md +67 -36
  65. package/skills/migrate-react-router/SKILL.md +2 -1
  66. package/skills/migrate-react-router/component-migration.md +3 -1
  67. package/skills/migrate-react-router/data-and-actions.md +3 -1
  68. package/skills/migrate-react-router/route-mapping.md +7 -4
  69. package/skills/observability/SKILL.md +64 -0
  70. package/skills/parallel/SKILL.md +13 -8
  71. package/skills/ppr/SKILL.md +655 -363
  72. package/skills/prerender/SKILL.md +27 -14
  73. package/skills/rango/SKILL.md +52 -33
  74. package/skills/route/SKILL.md +12 -7
  75. package/skills/router-setup/SKILL.md +4 -2
  76. package/skills/scripts/SKILL.md +8 -1
  77. package/skills/server-actions/SKILL.md +11 -0
  78. package/skills/shell-manifest/SKILL.md +21 -12
  79. package/skills/testing/SKILL.md +25 -21
  80. package/skills/testing/cache-prerender.md +15 -14
  81. package/skills/testing/loader.md +18 -1
  82. package/skills/testing/serve-shell-request.md +188 -0
  83. package/skills/theme/SKILL.md +52 -4
  84. package/skills/typesafety/env-and-bindings.md +10 -2
  85. package/skills/use-cache/SKILL.md +39 -16
  86. package/skills/vercel/SKILL.md +14 -2
  87. package/skills/view-transitions/SKILL.md +1 -1
  88. package/src/browser/cookie-name.ts +23 -0
  89. package/src/browser/history-state.ts +23 -0
  90. package/src/browser/rsc-router.tsx +5 -0
  91. package/src/cache/cache-key-utils.ts +22 -2
  92. package/src/cache/cache-policy.ts +22 -0
  93. package/src/cache/cache-runtime.ts +34 -7
  94. package/src/cache/cache-scope.ts +345 -33
  95. package/src/cache/cache-tag.ts +375 -11
  96. package/src/cache/cf/cf-base64.ts +36 -4
  97. package/src/cache/cf/cf-cache-constants.ts +12 -0
  98. package/src/cache/cf/cf-cache-store.ts +990 -348
  99. package/src/cache/cf/cf-cache-types.ts +32 -4
  100. package/src/cache/cf/cf-shell-frame.ts +258 -0
  101. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  102. package/src/cache/handle-snapshot.ts +4 -3
  103. package/src/cache/index.ts +2 -0
  104. package/src/cache/isolate-tag-memo.ts +308 -0
  105. package/src/cache/shell-memo.ts +341 -0
  106. package/src/cache/shell-snapshot.ts +217 -27
  107. package/src/cache/tag-invalidation.ts +104 -19
  108. package/src/cache/types.ts +211 -43
  109. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  110. package/src/prerender/build-shell-capture.ts +65 -11
  111. package/src/route-definition/dsl-helpers.ts +4 -3
  112. package/src/router/handler-context.ts +38 -4
  113. package/src/router/instrument.ts +54 -2
  114. package/src/router/intercept-resolution.ts +19 -7
  115. package/src/router/loader-resolution.ts +63 -32
  116. package/src/router/match-api.ts +28 -9
  117. package/src/router/match-handlers.ts +5 -0
  118. package/src/router/match-middleware/cache-lookup.ts +17 -0
  119. package/src/router/match-middleware/cache-store.ts +31 -11
  120. package/src/router/middleware-types.ts +6 -1
  121. package/src/router/middleware.ts +23 -4
  122. package/src/router/prerender-match.ts +6 -0
  123. package/src/router/segment-resolution/fresh.ts +18 -9
  124. package/src/router/segment-resolution/helpers.ts +4 -2
  125. package/src/router/segment-resolution/loader-cache.ts +227 -56
  126. package/src/router/segment-resolution/loader-snapshot.ts +203 -51
  127. package/src/router/segment-resolution/mask-nested.ts +136 -42
  128. package/src/router/segment-resolution/revalidation.ts +27 -10
  129. package/src/rsc/full-payload.ts +25 -1
  130. package/src/rsc/handler.ts +14 -2
  131. package/src/rsc/progressive-enhancement.ts +3 -2
  132. package/src/rsc/response-cache-serve.ts +9 -6
  133. package/src/rsc/rsc-rendering.ts +601 -181
  134. package/src/rsc/shell-build-manifest.ts +27 -9
  135. package/src/rsc/shell-capture-constants.ts +60 -6
  136. package/src/rsc/shell-capture.ts +1001 -250
  137. package/src/rsc/shell-serve.ts +316 -34
  138. package/src/rsc/types.ts +7 -0
  139. package/src/server/context.ts +219 -28
  140. package/src/server/cookie-store.ts +87 -62
  141. package/src/server/request-context.ts +263 -38
  142. package/src/ssr/index.tsx +96 -37
  143. package/src/ssr/ssr-root.tsx +9 -8
  144. package/src/testing/flight.entry.ts +14 -0
  145. package/src/testing/run-loader.ts +63 -5
  146. package/src/testing/serve-shell-request.ts +378 -0
  147. package/src/testing/shell-status.ts +34 -12
  148. package/src/theme/ThemeProvider.tsx +7 -6
  149. package/src/types/handler-context.ts +14 -1
  150. package/src/urls/pattern-types.ts +22 -18
  151. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  152. package/src/vite/router-discovery.ts +14 -4
@@ -15,9 +15,197 @@ import {
15
15
  _getRequestContext,
16
16
  type RequestContext,
17
17
  } from "../server/request-context.js";
18
+ import {
19
+ getCurrentLoaderBodyId,
20
+ getLoaderBodyTags,
21
+ isInsideLoaderScope,
22
+ type LoaderIdentityRead,
23
+ type LoaderIdentityReadVerb,
24
+ } from "../server/context.js";
18
25
 
19
26
  const cacheTagStorage = new AsyncLocalStorage<Set<string>>();
20
27
 
28
+ /**
29
+ * Tag owners (#957). A route cache() record replays its segments without
30
+ * running their handlers or re-rendering their server components, so the
31
+ * tags that content recorded must ride on the record, or a HIT (and the PPR
32
+ * capture that replays it) loses them.
33
+ *
34
+ * Every tag records onto its owner: the running loader body
35
+ * (getCurrentLoaderBodyId, server/context.ts), else the segment whose handler
36
+ * or record serialization is running (runInSegmentTagScope). No segment scope
37
+ * opens inside a loader body, so this is innermost-wins. A loader's value
38
+ * reaches a record only through the handler (or loader) that reads it, so
39
+ * ctx.use() links the reader to the loader (linkLoaderTags) whoever started
40
+ * it, and getSegmentTags follows those links. A loader nobody reads
41
+ * server-side (useLoader under loading()) stays off every record.
42
+ *
43
+ * The sets are keyed by the request context a tag records on, so a shell
44
+ * capture's derived context (Object.create(reqCtx)) never reads or feeds the
45
+ * foreground's.
46
+ *
47
+ * All of it runs only for a request that can write a record
48
+ * (RequestContext._recordTagOwners, armed by armRecordTagOwners at match
49
+ * time): elsewhere a handler call takes no scope, closure or allocation.
50
+ */
51
+ const segmentOwnerStorage = new AsyncLocalStorage<string>();
52
+
53
+ /**
54
+ * Arm the record tag owners for `ctx`: its match resolved a cache scope, so
55
+ * it can write a route cache() record (match-api.ts). A capture's or
56
+ * background re-render's derived context inherits the flag.
57
+ */
58
+ export function armRecordTagOwners(
59
+ ctx: RequestContext | undefined = _getRequestContext(),
60
+ ): void {
61
+ if (ctx) ctx._recordTagOwners = true;
62
+ }
63
+
64
+ /** Whether `ctx` records tag owners (see armRecordTagOwners). */
65
+ export function recordTagOwnersArmed(
66
+ ctx: RequestContext | undefined = _getRequestContext(),
67
+ ): boolean {
68
+ return ctx?._recordTagOwners === true;
69
+ }
70
+
71
+ interface RequestTagOwners {
72
+ /** owner key -> tags recorded while it ran */
73
+ tags: Map<string, Set<string>>;
74
+ /** owner key -> loader owner keys whose values it consumed */
75
+ uses: Map<string, Set<string>>;
76
+ }
77
+ const tagOwnersByRequest = new WeakMap<RequestContext, RequestTagOwners>();
78
+
79
+ const segmentOwner = (segmentId: string): string => `s:${segmentId}`;
80
+ const loaderOwner = (loaderId: string): string => `l:${loaderId}`;
81
+
82
+ function ownersFor(ctx: RequestContext): RequestTagOwners {
83
+ let owners = tagOwnersByRequest.get(ctx);
84
+ if (!owners) {
85
+ owners = { tags: new Map(), uses: new Map() };
86
+ tagOwnersByRequest.set(ctx, owners);
87
+ }
88
+ return owners;
89
+ }
90
+
91
+ function setFor(map: Map<string, Set<string>>, key: string): Set<string> {
92
+ let set = map.get(key);
93
+ if (!set) {
94
+ set = new Set();
95
+ map.set(key, set);
96
+ }
97
+ return set;
98
+ }
99
+
100
+ function currentOwner(): string | undefined {
101
+ const loaderId = getCurrentLoaderBodyId();
102
+ return loaderId !== undefined
103
+ ? loaderOwner(loaderId)
104
+ : segmentOwnerStorage.getStore();
105
+ }
106
+
107
+ /**
108
+ * Call `fn(arg)` inside segment `segmentId`'s tag scope: tags recorded while
109
+ * it or its async continuations run (outside a loader body) are owned by that
110
+ * segment. Unarmed, it is a plain call.
111
+ */
112
+ export function runInSegmentTagScope<R, A = undefined>(
113
+ segmentId: string,
114
+ fn: (arg: A) => R,
115
+ arg?: A,
116
+ ): R {
117
+ if (!recordTagOwnersArmed()) return fn(arg as A);
118
+ return segmentOwnerStorage.run(segmentOwner(segmentId), fn, arg as A);
119
+ }
120
+
121
+ /**
122
+ * Record that the current owner reads loader `loaderId`'s value (ctx.use),
123
+ * so the loader's tags follow the reader onto a record. The DSL funnel
124
+ * starting a loader (resolveLoaderData, inside the DSL loader scope but no
125
+ * loader body) is not a reader.
126
+ */
127
+ export function linkLoaderTags(
128
+ loaderId: string,
129
+ ctx: RequestContext | undefined = _getRequestContext(),
130
+ ): void {
131
+ if (!ctx || !recordTagOwnersArmed(ctx)) return;
132
+ const owner = currentOwner();
133
+ if (owner === undefined) return;
134
+ if (owner.startsWith("s:") && isInsideLoaderScope()) return;
135
+ setFor(ownersFor(ctx).uses, owner).add(loaderOwner(loaderId));
136
+ }
137
+
138
+ /**
139
+ * Record `tags` as loader `loaderId`'s own, read outside its body (its
140
+ * cache() config tags, or a loader-cache HIT's stored tags):
141
+ * recordRequestTags with an explicit owner.
142
+ */
143
+ export function recordLoaderTags(
144
+ loaderId: string,
145
+ tags: Iterable<string> | undefined,
146
+ ctx: RequestContext | undefined = _getRequestContext(),
147
+ ): void {
148
+ recordOwnedTags(
149
+ tags,
150
+ ctx,
151
+ recordTagOwnersArmed(ctx) ? loaderOwner(loaderId) : undefined,
152
+ );
153
+ }
154
+
155
+ /**
156
+ * The tags segment `segmentId`'s content recorded in `ctx`: its own, plus
157
+ * those of every loader it consumed, transitively.
158
+ */
159
+ export function getSegmentTags(
160
+ ctx: RequestContext,
161
+ segmentId: string,
162
+ ): ReadonlySet<string> {
163
+ const owners = tagOwnersByRequest.get(ctx);
164
+ const out = new Set<string>();
165
+ if (!owners) return out;
166
+ const seen = new Set<string>();
167
+ const pending = [segmentOwner(segmentId)];
168
+ for (let owner = pending.pop(); owner !== undefined; owner = pending.pop()) {
169
+ if (seen.has(owner)) continue;
170
+ seen.add(owner);
171
+ for (const tag of owners.tags.get(owner) ?? []) out.add(tag);
172
+ for (const used of owners.uses.get(owner) ?? []) pending.push(used);
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * Record `tags` onto each of `segmentIds` (a route cache() HIT replaying
179
+ * those segments): a record written from the replay, such as a shell
180
+ * capture's doc record (match-middleware/cache-store.ts), then carries them
181
+ * as a fresh render's record would. Unarmed, a no-op.
182
+ */
183
+ export function recordSegmentTags(
184
+ segmentIds: readonly string[],
185
+ tags: Iterable<string> | undefined,
186
+ ctx: RequestContext | undefined = _getRequestContext(),
187
+ ): void {
188
+ if (!tags || !ctx || !recordTagOwnersArmed(ctx)) return;
189
+ for (const id of segmentIds) recordOwnedTags(tags, ctx, segmentOwner(id));
190
+ }
191
+
192
+ /**
193
+ * The owner of the loaders a shell capture bakes (loader-cache.ts bake
194
+ * lane): their data is shell material that no handler reads, so their tags
195
+ * reach the shell through this owner (shell-capture.ts), not a record.
196
+ */
197
+ export const SHELL_BAKE_TAG_OWNER: string = "#shell-bake";
198
+
199
+ /** Link loader `loaderId`'s tags to `ownerId` (see SHELL_BAKE_TAG_OWNER). */
200
+ export function linkLoaderTagsTo(
201
+ ownerId: string,
202
+ loaderId: string,
203
+ ctx: RequestContext | undefined = _getRequestContext(),
204
+ ): void {
205
+ if (!ctx || !recordTagOwnersArmed(ctx)) return;
206
+ setFor(ownersFor(ctx).uses, segmentOwner(ownerId)).add(loaderOwner(loaderId));
207
+ }
208
+
21
209
  export function normalizeTag(tag: string): string | null {
22
210
  // Trim and return the canonical (trimmed) form, not the raw tag. Both the
23
211
  // write path (cacheTag) and the invalidate path (updateTag/revalidateTag)
@@ -57,14 +245,15 @@ export function normalizeTags(tags: Iterable<string>): string[] {
57
245
  * cache" in its tree. This is PPR's DERIVATIVE invalidation: PPR is
58
246
  * execution-PRESERVING (everything still runs underneath; only document bytes
59
247
  * are shortcut), so its tags ride this existing instrument rather than a
60
- * first-class ppr key/tag API. The shell-expiry invariant holds by
61
- * construction — baked ⇒ evicts (bake-lane loaders execute during capture and
62
- * record here), hole ⇒ fresh (masked loaders behind a renderable loading()
63
- * never execute during capture, so nothing under a hole can tag the shell).
64
- *
65
- * Inside a cache() DSL segment the render-callable form records at the DOCUMENT
66
- * level, not the segment (only the "use cache" runtime enters the tag scope) — a
67
- * documented semantic, not a filtered one. An empty/whitespace-only tag is
248
+ * first-class ppr key/tag API. Whatever renders into the shell records here,
249
+ * at capture or on the route's cache() record the capture replays. A masked
250
+ * live-lane loader never executes at capture, so its own tags reach a shell
251
+ * only through a handler that reads it (ctx.use).
252
+ *
253
+ * Inside a route cache() boundary the render-callable form also tags that
254
+ * route's cache() record, which re-records it on every HIT (#957). A loader's
255
+ * tags reach the record only when a handler consumes its value (ctx.use);
256
+ * see runInSegmentTagScope / linkLoaderTags. An empty/whitespace-only tag is
68
257
  * dropped in both forms (the render-callable form silently, via normalizeTags in
69
258
  * recordRequestTags; the scope form with a dev warning).
70
259
  *
@@ -120,12 +309,187 @@ export function recordRequestTags(
120
309
  tags: Iterable<string> | undefined,
121
310
  ctx: RequestContext | undefined = _getRequestContext(),
122
311
  ): void {
312
+ recordOwnedTags(
313
+ tags,
314
+ ctx,
315
+ recordTagOwnersArmed(ctx) ? currentOwner() : undefined,
316
+ );
317
+ }
318
+
319
+ /**
320
+ * Recorded-tag sets (#964). A loader with its own cache() skips its body on a
321
+ * HIT, so its entry stores what its execution recorded, including the tags of
322
+ * every loader value it read, whoever ran that loader.
323
+ *
324
+ * Each loader execution records into its own set, held on its body scope
325
+ * (runInsideLoaderBodyScope); a loader-cache MISS opens one around its
326
+ * execution and value encodes (captureRecordedTags). The innermost wins. A
327
+ * read of a loader's value links the reader's set to the value's
328
+ * (readValueTags), and the entry write flattens the links
329
+ * (flattenRecordedTags) after its value settled.
330
+ *
331
+ * The per-execution sets and read links run only for a request that resolves
332
+ * a loader-cache binding (armLoaderTagSets); elsewhere a loader execution
333
+ * allocates no set and a read links nothing.
334
+ *
335
+ * The same sets carry the execution's request-identity reads (#972,
336
+ * recordLoaderIdentityRead in server/context.ts): an entry with no declared
337
+ * key refuses an execution whose links reach one (recordedIdentityRead).
338
+ */
339
+ interface TagCapture {
340
+ into: Set<string>;
341
+ /** The loader body set active when the capture opened. */
342
+ outer: Set<string> | undefined;
343
+ }
344
+ // On globalThis like the loader scopes (server/context.ts): the identity-read
345
+ // recorder is installed there too, so a second evaluated copy of this module
346
+ // (a duplicated package, a dev re-evaluation) must share the capture scope and
347
+ // the recorded reads, or its recorder writes where no fill looks.
348
+ const recordedTagCapture: AsyncLocalStorage<TagCapture> = ((globalThis as any)[
349
+ Symbol.for("rangojs-router:recorded-tag-capture")
350
+ ] ??= new AsyncLocalStorage<TagCapture>());
351
+ const tagLinks = new WeakMap<Set<string>, Set<Set<string>>>();
352
+ const loaderValueTags = new WeakMap<object, Set<string>>();
353
+ const identityReads: WeakMap<Set<string>, LoaderIdentityRead> = ((
354
+ globalThis as any
355
+ )[Symbol.for("rangojs-router:identity-reads")] ??= new WeakMap());
356
+
357
+ /**
358
+ * Arm the per-execution loader tag sets for `ctx`: its match resolved a
359
+ * loader bound with its own cache() (match-api.ts, bindsLoaderCache), or such
360
+ * a binding started (loader-cache.ts). A loader-cache MISS captures its own
361
+ * execution and encodes either way.
362
+ */
363
+ export function armLoaderTagSets(
364
+ ctx: RequestContext | undefined = _getRequestContext(),
365
+ ): void {
366
+ if (ctx) ctx._recordLoaderTags = true;
367
+ }
368
+
369
+ /** Whether `ctx` records per-execution loader tag sets (armLoaderTagSets). */
370
+ export function loaderTagSetsArmed(ctx: RequestContext | undefined): boolean {
371
+ return ctx?._recordLoaderTags === true;
372
+ }
373
+
374
+ function activeTagSet(): Set<string> | undefined {
375
+ const capture = recordedTagCapture.getStore();
376
+ const body = getLoaderBodyTags();
377
+ if (capture && capture.outer === body) return capture.into;
378
+ return body ?? capture?.into;
379
+ }
380
+
381
+ /**
382
+ * Call `fn`, adding every tag recorded in its async chain
383
+ * (recordRequestTags, recordLoaderTags) to `into`, until a loader body
384
+ * started inside it takes over with its own set.
385
+ */
386
+ export function captureRecordedTags<T>(into: Set<string>, fn: () => T): T {
387
+ return recordedTagCapture.run({ into, outer: getLoaderBodyTags() }, fn);
388
+ }
389
+
390
+ /** `into` also answers for every tag `from` records. */
391
+ export function linkRecordedTags(into: Set<string>, from: Set<string>): void {
392
+ if (into === from) return;
393
+ let links = tagLinks.get(into);
394
+ if (!links) tagLinks.set(into, (links = new Set()));
395
+ links.add(from);
396
+ }
397
+
398
+ /** Mark a loader's value promise as carrying `tags` (see readValueTags). */
399
+ export function tagLoaderValue(value: object, tags: Set<string>): void {
400
+ loaderValueTags.set(value, tags);
401
+ }
402
+
403
+ /** A loader value's tags also answer for `tags` (tagLoaderValue). */
404
+ export function linkValueTags(value: object, tags: Set<string>): void {
405
+ const own = loaderValueTags.get(value);
406
+ if (own) linkRecordedTags(own, tags);
407
+ }
408
+
409
+ /** The current execution reads a loader's value: it takes on its tags. */
410
+ export function readValueTags(value: object): void {
411
+ const tags = loaderValueTags.get(value);
123
412
  if (!tags) return;
124
- const set = ctx?._requestTags;
125
- if (!set) return;
413
+ const into = activeTagSet();
414
+ if (into) linkRecordedTags(into, tags);
415
+ }
416
+
417
+ // recordLoaderIdentityRead (server/context.ts) calls this through the shared
418
+ // key, not an import: a test mocking context.ts would break module init. First
419
+ // read per execution: the fill's error names it.
420
+ (globalThis as Record<symbol, unknown>)[
421
+ Symbol.for("rangojs-router:identity-read-recorder")
422
+ ] = (surface: string, verb: LoaderIdentityReadVerb): void => {
423
+ const set = activeTagSet();
424
+ if (set === undefined || identityReads.has(set)) return;
425
+ identityReads.set(set, { surface, verb, bodyId: getCurrentLoaderBodyId() });
426
+ };
427
+
428
+ /**
429
+ * A loader-cache HIT restores the read its entry's MISS recorded onto the
430
+ * value's set, so an unkeyed reader of the value refuses it as it would on
431
+ * the MISS (loader-cache.ts identity mark).
432
+ */
433
+ export function markIdentityRead(
434
+ set: Set<string>,
435
+ read: LoaderIdentityRead,
436
+ ): void {
437
+ if (!identityReads.has(set)) identityReads.set(set, read);
438
+ }
439
+
440
+ /**
441
+ * The identity read `root`'s execution recorded, or one a set it links to
442
+ * recorded, nearest first (breadth-first, so the execution's own read wins).
443
+ */
444
+ export function recordedIdentityRead(
445
+ root: Set<string>,
446
+ ): LoaderIdentityRead | undefined {
447
+ const seen = new Set<Set<string>>();
448
+ const queue = [root];
449
+ for (let i = 0; i < queue.length; i++) {
450
+ const set = queue[i]!;
451
+ if (seen.has(set)) continue;
452
+ seen.add(set);
453
+ const read = identityReads.get(set);
454
+ if (read) return read;
455
+ for (const linked of tagLinks.get(set) ?? []) queue.push(linked);
456
+ }
457
+ return undefined;
458
+ }
459
+
460
+ /** `root`'s tags plus those of every set it links to, transitively. */
461
+ export function flattenRecordedTags(root: Set<string>): Set<string> {
462
+ const out = new Set<string>();
463
+ const seen = new Set<Set<string>>();
464
+ const pending = [root];
465
+ for (let set = pending.pop(); set !== undefined; set = pending.pop()) {
466
+ if (seen.has(set)) continue;
467
+ seen.add(set);
468
+ for (const tag of set) out.add(tag);
469
+ for (const linked of tagLinks.get(set) ?? []) pending.push(linked);
470
+ }
471
+ return out;
472
+ }
473
+
474
+ /** recordRequestTags, also recording onto `owner`'s set (#957). */
475
+ function recordOwnedTags(
476
+ tags: Iterable<string> | undefined,
477
+ ctx: RequestContext | undefined,
478
+ owner: string | undefined,
479
+ ): void {
480
+ if (!tags || !ctx?._requestTags) return;
481
+ const set = ctx._requestTags;
482
+ const captured = activeTagSet();
483
+ let owned: Set<string> | undefined;
126
484
  for (const tag of tags) {
127
485
  const normalized = normalizeTag(tag);
128
- if (normalized !== null) set.add(normalized);
486
+ if (normalized === null) continue;
487
+ set.add(normalized);
488
+ captured?.add(normalized);
489
+ if (owner !== undefined) {
490
+ owned ??= setFor(ownersFor(ctx).tags, owner);
491
+ owned.add(normalized);
492
+ }
129
493
  }
130
494
  }
131
495
 
@@ -7,9 +7,28 @@
7
7
  // (~65k). 8192 turns a per-byte concat loop into O(n/8192) apply calls.
8
8
  const FROM_CHARCODE_CHUNK = 8192;
9
9
 
10
+ /**
11
+ * TC39 Uint8Array base64 methods (workerd, V8 >= 14; not Node 22/24). Same
12
+ * alphabet, padding, and invalid-character throw as btoa/atob, and about 15x
13
+ * faster than the per-byte loop on a 629 KB PPR prelude in workerd (0.05 ms vs
14
+ * 0.75 ms, issue #941). Looked up per call, not captured at import, so a
15
+ * polyfill or test spy installed later is the one that runs.
16
+ */
17
+ type NativeUint8ArrayBase64 = {
18
+ fromBase64?: (b64: string) => Uint8Array;
19
+ prototype: { toBase64?: (this: Uint8Array) => string };
20
+ };
21
+ const NativeUint8Array = Uint8Array as unknown as NativeUint8ArrayBase64;
22
+
10
23
  /** Encode ArrayBuffer to base64 string. */
11
24
  export function bufferToBase64(buffer: ArrayBuffer): string {
12
- const bytes = new Uint8Array(buffer);
25
+ return bytesToBase64(new Uint8Array(buffer));
26
+ }
27
+
28
+ /** Encode bytes (a view's own region only) to a base64 string. */
29
+ export function bytesToBase64(bytes: Uint8Array): string {
30
+ const nativeToBase64 = NativeUint8Array.prototype.toBase64;
31
+ if (nativeToBase64) return nativeToBase64.call(bytes);
13
32
  // Build the binary (latin1) string in fixed-size chunks instead of one
14
33
  // String.fromCharCode per byte. Identical output to the per-byte loop (each
15
34
  // byte maps to the same code unit); just far fewer string concatenations for
@@ -22,12 +41,25 @@ export function bufferToBase64(buffer: ArrayBuffer): string {
22
41
  return btoa(binary);
23
42
  }
24
43
 
25
- /** Decode base64 string to ArrayBuffer. */
26
- export function base64ToBuffer(base64: string): ArrayBuffer {
44
+ /** Decode a base64 string to bytes. Throws on a character outside the alphabet. */
45
+ export function base64ToBytes(base64: string): Uint8Array {
46
+ if (NativeUint8Array.fromBase64) return NativeUint8Array.fromBase64(base64);
27
47
  const binary = atob(base64);
28
48
  const bytes = new Uint8Array(binary.length);
29
49
  for (let i = 0; i < binary.length; i++) {
30
50
  bytes[i] = binary.charCodeAt(i);
31
51
  }
32
- return bytes.buffer;
52
+ return bytes;
53
+ }
54
+
55
+ /** The byte length `base64` decodes to (padded or not), without decoding. */
56
+ export function base64ByteLength(base64: string): number {
57
+ let length = base64.length;
58
+ while (length > 0 && base64.charCodeAt(length - 1) === 61 /* = */) length--;
59
+ return Math.floor((length * 3) / 4);
60
+ }
61
+
62
+ /** Decode base64 string to ArrayBuffer. */
63
+ export function base64ToBuffer(base64: string): ArrayBuffer {
64
+ return base64ToBytes(base64).buffer as ArrayBuffer;
33
65
  }
@@ -132,3 +132,15 @@ export const EDGE_READ_TIMEOUT_MS = 20;
132
132
  * Override per store via `CFCacheStoreOptions.kvReadTimeoutMs` (<= 0 disables).
133
133
  */
134
134
  export const KV_READ_TIMEOUT_MS = 170;
135
+
136
+ /**
137
+ * Floor (ms) on the budget for reading a PPR shell's snapshot, the rest of a
138
+ * frame whose head and prelude were already read. A document HIT has sent
139
+ * its prelude by then and cannot render the rest without the snapshot's doc
140
+ * record, so a timed-out read degrades the HIT (the entry is replaced, the
141
+ * page reloads once into a MISS; rsc-rendering.ts degradeUnreplayableShell).
142
+ * That costs far more than waiting, so a slow body read gets at least this
143
+ * long; `kvReadTimeoutMs` above it wins, and `kvReadTimeoutMs <= 0` still
144
+ * disables the bound.
145
+ */
146
+ export const SHELL_SNAPSHOT_READ_MIN_TIMEOUT_MS = 1000;