@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
@@ -17,18 +17,36 @@
17
17
  * On stale hit (SWR): returns stale data, schedules background revalidation.
18
18
  * On miss: executes loader, schedules non-blocking cache write.
19
19
  *
20
+ * Identity (#972): without a key() or a store keyGenerator the entry is
21
+ * shared across users, so a fill whose execution recorded a request-identity
22
+ * read fails and is not stored, whoever started that execution
23
+ * (server/context.ts recordLoaderIdentityRead).
24
+ *
20
25
  * Handle pushes (`ctx.use(Handle)(...)` in the loader body) are a side effect
21
26
  * the HIT must reproduce: the MISS records the pushes of the body and of the
22
27
  * loaders it awaits via ctx.use into the entry's `handles` blob (the
23
28
  * "use cache" capture/encode), and every HIT — stale included — replays
24
29
  * them, each loader's pushes at most once per request (a dependency another
25
30
  * reader already ran keeps its live pushes). See replayLoaderHandles.
31
+ *
32
+ * Tags too (#964): the entry stores its cache() tags plus every tag its
33
+ * execution recorded, those of the loader values it read included
34
+ * (cache-tag.ts "Recorded-tag sets"), so updateTag() of any of them drops it.
35
+ * Every HIT records the stored tags through the loader's tag owner
36
+ * (recordLoaderTags), so a route cache() record, PPR shell or document built
37
+ * over the HIT carries them, and a loader that reads this one takes them on.
38
+ * Once the binding started, a loader body's ctx.use of it gets the same
39
+ * dataPromise as a handler read (createLoaderExecutor's useLoader); a stale
40
+ * refresh does not (setupLoaderAccess _runLoaderIsolated).
26
41
  */
27
42
 
28
43
  import {
29
44
  getCurrentLoaderBodyId,
30
45
  isInsideLoaderBody,
46
+ loaderCacheIdentityError,
47
+ type EntryData,
31
48
  type LoaderEntry,
49
+ type LoaderIdentityRead,
32
50
  } from "../../server/context.js";
33
51
  import type { HandlerContext, InternalHandlerContext } from "../../types.js";
34
52
  import type { HandleStore } from "../../server/handle-store.js";
@@ -59,7 +77,20 @@ import {
59
77
  maskNestedContainerThenables,
60
78
  overlayLoaderContainer,
61
79
  } from "./loader-snapshot.js";
62
- import { recordRequestTags } from "../../cache/cache-tag.js";
80
+ import {
81
+ SHELL_BAKE_TAG_OWNER,
82
+ armLoaderTagSets,
83
+ captureRecordedTags,
84
+ flattenRecordedTags,
85
+ linkLoaderTags,
86
+ linkLoaderTagsTo,
87
+ linkRecordedTags,
88
+ markIdentityRead,
89
+ readValueTags,
90
+ recordedIdentityRead,
91
+ recordLoaderTags,
92
+ tagLoaderValue,
93
+ } from "../../cache/cache-tag.js";
63
94
  import {
64
95
  isShellCaptureActive,
65
96
  createMaskedLoaderPromise,
@@ -98,7 +129,8 @@ function getDefaultLoaderCacheKey(
98
129
  }
99
130
 
100
131
  /**
101
- * Resolve cache key using the shared 3-tier priority.
132
+ * Resolve cache key using the shared 3-tier priority. `declared`: a key() or
133
+ * store keyGenerator produced it, so it can carry request identity (#972).
102
134
  */
103
135
  async function resolveLoaderKey(
104
136
  loaderEntry: LoaderEntry,
@@ -106,7 +138,7 @@ async function resolveLoaderKey(
106
138
  loaderId: string,
107
139
  pathname: string,
108
140
  params: Record<string, string>,
109
- ): Promise<string> {
141
+ ): Promise<{ key: string; declared: boolean }> {
110
142
  const options = loaderEntry.cache!.options;
111
143
  // The host is part of the loader cache identity, matching the route-level
112
144
  // cache (cache-scope getCacheKeyBase: `${host}${pathname}`) and "use cache"
@@ -115,8 +147,11 @@ async function resolveLoaderKey(
115
147
  // loader data to another.
116
148
  const host = getRequestContext()?.url?.host ?? "localhost";
117
149
  const defaultKey = getDefaultLoaderCacheKey(loaderId, host, pathname, params);
118
- if (options === false) return defaultKey;
119
- return resolveCacheKey(options.key, store, defaultKey, "LoaderCache");
150
+ if (options === false) return { key: defaultKey, declared: false };
151
+ return {
152
+ key: await resolveCacheKey(options.key, store, defaultKey, "LoaderCache"),
153
+ declared: Boolean(options.key || store.keyGenerator),
154
+ };
120
155
  }
121
156
 
122
157
  /**
@@ -131,6 +166,39 @@ function resolveTags(loaderEntry: LoaderEntry): string[] | undefined {
131
166
  return resolveTagsOption(options.tags, getRequestContext(), "LoaderCache");
132
167
  }
133
168
 
169
+ const bindsLoaderCacheMemo = new WeakMap<EntryData, boolean>();
170
+
171
+ const hasLoaderCache = (loaders: readonly LoaderEntry[] | undefined) =>
172
+ loaders?.some((l) => l.cache !== undefined && l.cache.options !== false) ??
173
+ false;
174
+
175
+ function entryBindsLoaderCache(entry: EntryData): boolean {
176
+ let binds = bindsLoaderCacheMemo.get(entry);
177
+ if (binds === undefined) {
178
+ binds =
179
+ hasLoaderCache(entry.loader) ||
180
+ (entry.intercept?.some((i) => hasLoaderCache(i.loader)) ?? false) ||
181
+ (entry.layout?.some(entryBindsLoaderCache) ?? false) ||
182
+ Object.values(entry.parallel ?? {}).some(
183
+ (p) => p !== undefined && entryBindsLoaderCache(p),
184
+ );
185
+ bindsLoaderCacheMemo.set(entry, binds);
186
+ }
187
+ return binds;
188
+ }
189
+
190
+ /**
191
+ * Whether a request over the matched chain `entries` resolves a loader bound
192
+ * with its own cache(): on a chain entry, or on its parallel slots, orphan
193
+ * layouts or intercepts. The match arms the per-execution loader tag sets on
194
+ * it (cache-tag.ts armLoaderTagSets), before any loader starts, so a
195
+ * dependency the handler or another binding starts first still records.
196
+ * Memoized per entry.
197
+ */
198
+ export function bindsLoaderCache(entries: readonly EntryData[]): boolean {
199
+ return entries.some(entryBindsLoaderCache);
200
+ }
201
+
134
202
  function getLoaderStore(
135
203
  loaderEntry: LoaderEntry,
136
204
  ): import("../../cache/types.js").SegmentCacheStore | null {
@@ -158,9 +226,10 @@ function getLoaderStore(
158
226
  * keyed on that body scope (executeLoaderData), which would swallow the
159
227
  * replay; and the store reads the body scope to tell a live push from a
160
228
  * replayed one.
161
- * Outside it, a PPR shell capture classifies replayed pushes as
162
- * loader-scoped and unbaked (shell-capture.ts), so a shell HIT gets them
163
- * from the loader's own re-run, not twice.
229
+ * A PPR shell capture records the replayed pushes under this loader (its
230
+ * push funnel reads the pushReplayed owner, shell-capture.ts), so a shell
231
+ * HIT restores them owned: the claim above then skips this replay when the
232
+ * loader runs on that HIT, and its live pushes replace the restored ones.
164
233
  */
165
234
  async function replayLoaderHandles(
166
235
  encoded: string,
@@ -187,6 +256,36 @@ async function replayLoaderHandles(
187
256
  }
188
257
  }
189
258
 
259
+ /**
260
+ * Identity mark (#972): a declared-key entry whose MISS recorded a
261
+ * request-identity read stores that read ahead of its payload. A HIT skips the
262
+ * body, so without the mark an unkeyed loader reading this value on a HIT
263
+ * would store another user's data. The value string is this module's own
264
+ * (serialize/deserialize below), so the mark needs no store support; Flight
265
+ * and JSON payloads never start with "~".
266
+ */
267
+ const IDENTITY_MARK = "~identity:";
268
+
269
+ function markIdentity(
270
+ value: string,
271
+ read: LoaderIdentityRead | undefined,
272
+ ): string {
273
+ return read ? `${IDENTITY_MARK}${JSON.stringify(read)}\n${value}` : value;
274
+ }
275
+
276
+ function unmarkIdentity(value: string): {
277
+ read: LoaderIdentityRead | undefined;
278
+ payload: string;
279
+ } {
280
+ if (!value.startsWith(IDENTITY_MARK))
281
+ return { read: undefined, payload: value };
282
+ const end = value.indexOf("\n");
283
+ return {
284
+ read: JSON.parse(value.slice(IDENTITY_MARK.length, end)),
285
+ payload: value.slice(end + 1),
286
+ };
287
+ }
288
+
190
289
  /**
191
290
  * Record key for the MISS capture: run-length groups by the innermost loader
192
291
  * body, `${seq}:${loaderId}` — the cached loader or a dependency it awaits
@@ -226,18 +325,23 @@ function recordOwnerKey(cachedLoaderId: string): () => string {
226
325
  * never run under a shell capture), so the lane is decided here, per LOADER,
227
326
  * never by the entry's loading():
228
327
  *
229
- * - BAKE lane: `loader(Def, { ssr: false })` (awaitBeforeFlush) with a
230
- * `bakeSegmentKey` from the caller. The loader EXECUTES at capture (the
328
+ * - BAKE lane: `loader(Def, { ssr: false })` (LoaderEntry `bake`: the
329
+ * caller passes a `bakeSegmentKey` for it on document and navigation
330
+ * evaluations alike; a capture, a document evaluation, also has
331
+ * awaitBeforeFlush). The loader EXECUTES at capture (the
231
332
  * flag's "data in the HTML before first flush" maps to the frozen prelude)
232
333
  * and its settled non-promise data bakes into the shell. Promises nested in
233
- * plain objects/arrays are masked (mask-nested.ts) and stay live holes at
234
- * the consumer's own Suspense — per-request material must be promise-shaped
235
- * (the #692 cross-session scar). The masked container registers on
236
- * `_shellCaptureLoaderRecords`: it holds the capture gate (bounded by
237
- * `ppr.captureTimeout`) and pins into the snapshot's loader family, and on a
238
- * HIT the recorded container is overlaid onto the fresh run (below) so the
239
- * payload matches the frozen prelude byte-for-byte. cookies()/headers() in
240
- * its body (nested promise bodies included) trip the capture guard.
334
+ * plain objects, arrays and JSX props are masked (mask-nested.ts) and stay
335
+ * live holes at the consumer's own Suspense — per-request material must be
336
+ * promise-shaped (the #692 cross-session scar). The masked container
337
+ * registers on `_shellCaptureLoaderRecords`: it holds the capture gate
338
+ * (bounded by `ppr.captureTimeout`) and pins into the snapshot's loader
339
+ * family, and on a HIT the recorded container is overlaid onto the fresh
340
+ * run (below) so the payload matches the frozen prelude outside the holes.
341
+ * A server-component element holding a promise is a hole as a whole, so its
342
+ * other props come from the fresh run (loader-snapshot.ts
343
+ * elideLoaderContainer). cookies()/headers() in its body (nested promise
344
+ * bodies included) trip the capture guard.
241
345
  * - LIVE lane: every other loader, whatever its entry's loading(). Never
242
346
  * executes at capture: the slot gets a never-resolving promise
243
347
  * (loader-mask.ts) and postpones at the reader's boundary — loading() or an
@@ -269,6 +373,8 @@ export function resolveLoaderData<TEnv>(
269
373
  );
270
374
  maskedPromise.catch(() => {});
271
375
  reqCtx?._shellCaptureLoaderRecords?.set(bakeSegmentKey, maskedPromise);
376
+ // Its data bakes into the shell, so its tags tag the shell.
377
+ linkLoaderTagsTo(SHELL_BAKE_TAG_OWNER, loaderEntry.loader.$$id, reqCtx);
272
378
  return maskedPromise;
273
379
  }
274
380
  return createMaskedLoaderPromise();
@@ -280,31 +386,31 @@ export function resolveLoaderData<TEnv>(
280
386
  const recorded = seed.get(bakeSegmentKey)!;
281
387
  if (!recorded.holes) {
282
388
  // Pin-first (hole-free record): the pinned container is what the
283
- // payload serves either way (recorded paths win wholesale), so
284
- // resolve it immediately instead of gating on the fresh run's
285
- // latency — a slow bake-lane loader body otherwise stalls the HIT
286
- // tail for values that get discarded. The fresh run still executes
287
- // for its side effects and cache read-through writes, lifetime-
288
- // extended so the runtime cannot cancel it when the stream closes
289
- // first; its rejection is swallowed here (the payload already
290
- // matches the prelude, which a fresh error value never could).
389
+ // payload serves (recorded paths win wholesale), resolved
390
+ // immediately. The body does not run: a HIT is rendered from the
391
+ // shell, like the handlers it replays, and the loader's settled
392
+ // pushes (and those of loaders it awaits) are restored from the
393
+ // record. Only a record whose capture saw a push it could not keep
394
+ // (`runs`) still runs the body, in the background, lifetime-extended
395
+ // so the runtime cannot cancel it when the stream closes first; its
396
+ // rejection is swallowed (the payload already matches the prelude,
397
+ // which a fresh error value never could).
291
398
  //
292
399
  // CONTRACT (deliberate divergence from the gated overlay's
293
400
  // fresh-only-keys passthrough): a hole-free pin serves the pinned
294
- // SHAPE wholesale — a key the fresh run adds mid-TTL is dropped.
295
- // It could never render server-side anyway: no hole was postponed
296
- // for it at capture, so the prelude froze the without-that-field
297
- // branch and the resume pass has nothing to fill; passing it
298
- // through only made the hydration payload disagree with the frozen
299
- // prelude (client-side mismatch repair — the divergence class the
300
- // snapshot exists to prevent). A field that is per-request must be
301
- // promise-shaped at capture (masked -> hole marker -> holes: 1 ->
302
- // the gated path below, which preserves fresh-only passthrough) or
303
- // live behind loading(). Anything else is uncached nondeterminism
304
- // in shell material — the documented drift residual.
305
- const fresh = executeLoaderData(loaderEntry, ctx, pathname);
306
- fresh.catch(() => {});
307
- reqCtx?.executionContext?.waitUntil?.(fresh);
401
+ // SHAPE wholesale. A field the body would return now had no hole
402
+ // postponed for it at capture, so the prelude froze the
403
+ // without-that-field branch and the resume pass has nothing to
404
+ // fill. A field that is per-request must be promise-shaped at
405
+ // capture (masked -> hole marker -> holes: 1 -> the gated path
406
+ // below, which preserves fresh-only passthrough) or live behind
407
+ // loading(). Anything else is uncached nondeterminism in shell
408
+ // material — the documented drift residual.
409
+ if (recorded.runs) {
410
+ const fresh = executeLoaderData(loaderEntry, ctx, pathname);
411
+ fresh.catch(() => {});
412
+ reqCtx?.executionContext?.waitUntil?.(fresh);
413
+ }
308
414
  return Promise.resolve(
309
415
  overlayLoaderContainer(undefined, recorded.container),
310
416
  );
@@ -368,7 +474,13 @@ function executeLoaderData<TEnv>(
368
474
  internal._loaderCacheOriginalUse = originalUse;
369
475
  ctx.use = ((item: any) => {
370
476
  const cached = overrides!.get(item?.$$id);
371
- if (cached) return cached;
477
+ if (cached) {
478
+ // A read is a consumption (#957, #964): see createLoaderExecutor's
479
+ // useLoader.
480
+ linkLoaderTags(item.$$id);
481
+ readValueTags(cached);
482
+ return cached;
483
+ }
372
484
  return originalUse(item);
373
485
  }) as typeof ctx.use;
374
486
  }
@@ -395,7 +507,17 @@ function executeLoaderData<TEnv>(
395
507
  const swrWindow = resolveSwrWindow(options.swr, store.defaults);
396
508
  const swr = swrWindow || undefined;
397
509
  const tags = resolveTags(loaderEntry);
398
- recordRequestTags(tags);
510
+ recordLoaderTags(loaderId, tags);
511
+ // Production arms at match, before any loader runs (match-api.ts,
512
+ // bindsLoaderCache); no production path relies on this. It only keeps a
513
+ // resolveLoaders call outside a match from running unarmed after this
514
+ // point (loaders it started earlier stay unrecorded).
515
+ armLoaderTagSets();
516
+ // An execution of this loader outside the binding (a reader started it
517
+ // first, a stale refresh) answers for the cache() tags too.
518
+ if (tags && tags.length > 0) {
519
+ internal._bindLoaderCacheTags?.(loaderId, new Set(tags));
520
+ }
399
521
 
400
522
  // Handle pushes: the store and the owning segment are read synchronously
401
523
  // at kickoff, as createLoaderExecutor does for the body's own pushes.
@@ -406,10 +528,14 @@ function executeLoaderData<TEnv>(
406
528
  // concurrently. A dependency can still run on a HIT for another reader
407
529
  // (memoized per request); the replay's claim keeps its pushes to one copy.
408
530
  const isOwnBodyPush = () => isInsideLoaderBody(loaderId);
531
+ // What a reader of this value takes on (tagLoaderValue below,
532
+ // readValueTags): the cache() tags, plus the entry's tags on a HIT or the
533
+ // execution's on a MISS.
534
+ const valueTags = new Set(tags);
409
535
 
410
536
  const dataPromise = (async () => {
411
537
  const codec = await getCodec();
412
- const key = await resolveLoaderKey(
538
+ const { key, declared } = await resolveLoaderKey(
413
539
  loaderEntry,
414
540
  store,
415
541
  loaderId,
@@ -428,15 +554,21 @@ function executeLoaderData<TEnv>(
428
554
 
429
555
  // One readThroughItem call runs at most one execution — the foreground
430
556
  // MISS or the background stale revalidation (flagged by wrapBackground)
431
- // — so a single capture slot serves the setItem that follows it.
557
+ // — so one handle capture and one tag set serve the setItem after it.
558
+ // The entry's tags: its cache() tags plus what the execution recorded.
432
559
  let capture: HandleCapture | undefined;
560
+ const bodyTags = new Set(tags);
433
561
  let revalidating = false;
434
562
  let hitHandles: string | undefined;
435
563
  const onCachedRead = (label: string, cached: CacheItemResult) => {
436
564
  hitHandles = cached.handles;
565
+ const { read } = unmarkIdentity(cached.value);
566
+ if (read) markIdentityRead(valueTags, read);
567
+ recordLoaderTags(loaderId, cached.tags);
568
+ for (const tag of cached.tags ?? []) valueTags.add(tag);
437
569
  debugLoaderCacheLog(`[LoaderCache] ${label}: ${key}`);
438
570
  };
439
- const execute = async (): Promise<any> => {
571
+ const runBody = async (): Promise<any> => {
440
572
  // A stale-hit revalidation runs on its own executor: it diverts its
441
573
  // pushes (below), so sharing the page's memoized run of a dependency
442
574
  // would take that dependency's pushes off the page.
@@ -457,6 +589,26 @@ function executeLoaderData<TEnv>(
457
589
  c.stop();
458
590
  }
459
591
  };
592
+ // #972: the execution's identity reads, and those of the loader values it
593
+ // read, are on the bodyTags links whoever started them. With an undeclared
594
+ // key one fails the fill (the MISS or the stale refresh); one that settles
595
+ // after the value (a nested promise, a pending handle push) skips the write
596
+ // instead. With a declared key it is stored as the entry's identity mark
597
+ // for the HITs, as this loader's readers see it (through this loader when
598
+ // another made it), and a foreground execution marks the value it serves.
599
+ // A stale refresh does not: the page is served the stale entry, which its
600
+ // own mark (or its lack) describes.
601
+ const identityRead = (): LoaderIdentityRead | undefined => {
602
+ const read = recordedIdentityRead(bodyTags);
603
+ if (!read) return undefined;
604
+ if (!declared) throw loaderCacheIdentityError(read, loaderId);
605
+ const seen =
606
+ read.bodyId === undefined || read.bodyId === loaderId
607
+ ? { ...read, bodyId: loaderId, via: undefined }
608
+ : { ...read, via: loaderId };
609
+ if (!revalidating) markIdentityRead(valueTags, seen);
610
+ return seen;
611
+ };
460
612
 
461
613
  // Flight encodes a rejected promise in the value or a handle push as an
462
614
  // error row and completes normally. setItem runs after serialize and
@@ -473,15 +625,27 @@ function executeLoaderData<TEnv>(
473
625
  // pushes awaited up to its timeout, the whole blob dropped on a timeout
474
626
  // or a thrown encode). Encoded here, inside the deferred write, so a
475
627
  // MISS response never waits on it.
476
- setItem: async (k, v, o) => {
477
- const handles = capture
478
- ? await encodeHandles(capture.data, onFlightError)
479
- : "";
480
- if (flightErrors.length > 0) throw flightErrors[0];
481
- await store.setItem!(k, v, handles ? { ...o, handles } : o);
482
- },
628
+ setItem: (k, v, o) =>
629
+ captureRecordedTags(bodyTags, async () => {
630
+ const handles = capture
631
+ ? await encodeHandles(capture.data, onFlightError)
632
+ : "";
633
+ if (flightErrors.length > 0) throw flightErrors[0];
634
+ // After the handle encode: it settles pending pushes, which can read.
635
+ const read = identityRead();
636
+ const entryTags = [...flattenRecordedTags(bodyTags)];
637
+ await store.setItem!(k, markIdentity(v, read), {
638
+ ...o,
639
+ tags: entryTags.length > 0 ? entryTags : undefined,
640
+ ...(handles ? { handles } : {}),
641
+ });
642
+ }),
483
643
  key,
484
- execute,
644
+ execute: async () => {
645
+ const value = await captureRecordedTags(bodyTags, runBody);
646
+ identityRead();
647
+ return value;
648
+ },
485
649
  // The rango.background span (kind=loader-revalidation) wraps the WHOLE
486
650
  // stale revalidation — the re-execution AND the serialize/setItem write
487
651
  // (read-through-swr routes the full task through wrapBackground) — so
@@ -495,12 +659,18 @@ function executeLoaderData<TEnv>(
495
659
  observePhase(PHASES.background("loader-revalidation"), run),
496
660
  );
497
661
  },
498
- serialize: (d) => codec.serializeResult(d, onFlightError),
499
- deserialize: (v) => codec.deserializeResult(v),
662
+ serialize: (d) =>
663
+ captureRecordedTags(bodyTags, () =>
664
+ codec.serializeResult(d, onFlightError),
665
+ ),
666
+ deserialize: (v) => codec.deserializeResult(unmarkIdentity(v).payload),
500
667
  storeOptions: { ttl, swr, tags },
501
668
  onHit: (cached) => onCachedRead("HIT", cached),
502
669
  onStale: (cached) => onCachedRead("STALE", cached),
503
- onMiss: () => debugLoaderCacheLog(`[LoaderCache] MISS: ${key}`),
670
+ onMiss: () => {
671
+ linkRecordedTags(valueTags, bodyTags);
672
+ debugLoaderCacheLog(`[LoaderCache] MISS: ${key}`);
673
+ },
504
674
  onCached: () => debugLoaderCacheLog(`[LoaderCache] Cached: ${key}`),
505
675
  host: requestCtxForExecute,
506
676
  });
@@ -522,6 +692,7 @@ function executeLoaderData<TEnv>(
522
692
  // push after full drain throws LateHandlePushError.
523
693
  handleStore?.trackAuxiliary(dataPromise);
524
694
 
695
+ tagLoaderValue(dataPromise, valueTags);
525
696
  overrides.set(loaderId, dataPromise);
526
697
 
527
698
  return dataPromise;