@rangojs/router 0.19.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,12 @@
30
30
  */
31
31
  export interface CacheExecScope {
32
32
  readonly parent?: CacheExecScope;
33
+ /**
34
+ * Loader values its chain read through an already-started execution, to
35
+ * their loader ids (cache-tag.ts readStartedLoaderValue), checked again
36
+ * before its write (#1011).
37
+ */
38
+ loaderReads?: Map<object, string>;
33
39
  }
34
40
  /**
35
41
  * Run a callback whose identity reads never reach a shared entry, so
@@ -10,7 +10,8 @@
10
10
  * profile/DSL tags before storing.
11
11
  */
12
12
  import { type RequestContext } from "../server/request-context.js";
13
- import { type LoaderIdentityRead } from "../server/context.js";
13
+ import { type LoaderIdentityRead, type LoaderRunIdentity } from "../server/context.js";
14
+ import { type CacheExecScope } from "./cache-exec-scope.js";
14
15
  import type { ExecutionStart } from "./tag-invalidation.js";
15
16
  /**
16
17
  * Call `fn` outside any "use cache" tag scope: detached work a cached body
@@ -153,6 +154,28 @@ export declare function markIdentityRead(set: Set<string>, read: LoaderIdentityR
153
154
  * recorded, nearest first (breadth-first, so the execution's own read wins).
154
155
  */
155
156
  export declare function recordedIdentityRead(root: Set<string>): LoaderIdentityRead | undefined;
157
+ /**
158
+ * `value` is the promise of the execution `run` records, started by the
159
+ * current execution (if any), which reads it. Both loader runners call it
160
+ * (loader-resolution.ts createLoaderExecutor, request-context.ts
161
+ * createUseFunction).
162
+ */
163
+ export declare function trackLoaderRun(value: object, run: LoaderRunIdentity): void;
164
+ /**
165
+ * A ctx.use(Loader) or getRequestContext().use(Loader) read that returns an
166
+ * already-started value (the request memo, a loader cache() binding's value).
167
+ * The current loader execution, if any, links it (it reads the value).
168
+ * Inside a "use cache" body the value is refused when its execution read
169
+ * request identity, as a body entered there would have been
170
+ * (guardIdentityRead). The check runs once the value settles, so a read the
171
+ * body makes after an await counts. Each enclosing execution also notes the
172
+ * value, and checks it again after encoding its result
173
+ * (assertLoaderReadsClean): a read that settles after the value (a nested
174
+ * promise in it) then fails the write instead.
175
+ */
176
+ export declare function readStartedLoaderValue<T>(value: Promise<T>, loaderId: string): Promise<T>;
177
+ /** Throw when a loader value `scope` read carries an identity read. */
178
+ export declare function assertLoaderReadsClean(scope: CacheExecScope): void;
156
179
  /** `root`'s tags plus those of every set it links to, transitively. */
157
180
  export declare function flattenRecordedTags(root: Set<string>): Set<string>;
158
181
  /** Record where the execution recording into `set` started. */
@@ -539,8 +539,44 @@ export interface LoaderIdentityRead {
539
539
  * (cache-tag.ts recordedIdentityRead), so both orders fail the same way.
540
540
  * Response directives do not record: a key cannot make a skipped body's side
541
541
  * effect reach a HIT.
542
+ *
543
+ * The read also lands on the execution's LoaderRunIdentity (#1011), which
544
+ * every execution has, from either loader runner (runInsideLoaderRun).
542
545
  */
543
546
  export declare function recordLoaderIdentityRead(surface: string, verb?: LoaderIdentityReadVerb): void;
547
+ /**
548
+ * One loader execution's request-identity record (#1011): the first identity
549
+ * read its body made, and the executions whose values it read with
550
+ * ctx.use(Loader). Every execution has one; the recorded-tag sets that carry
551
+ * the same reads for a loader cache() fill (cache-tag.ts) exist only in a
552
+ * request that binds one.
553
+ *
554
+ * A "use cache" function that reads a loader's value through the request
555
+ * memo (a handler, the route's loader() binding or a parent layout's started
556
+ * it first) does not run the body, so guardIdentityRead never sees the read.
557
+ * Scar: the entry, keyed without the cookie, stored the first visitor's value
558
+ * and served it to the next. cache-tag.ts readStartedLoaderValue refuses such
559
+ * a value through this record.
560
+ */
561
+ export interface LoaderRunIdentity {
562
+ readonly loaderId: string;
563
+ read?: LoaderIdentityRead;
564
+ reads?: Set<LoaderRunIdentity>;
565
+ }
566
+ /**
567
+ * Run a loader body with `run` as its identity record: both loader runners
568
+ * (loader-resolution.ts createLoaderExecutor, request-context.ts
569
+ * createUseFunction) enter it around the loader function.
570
+ */
571
+ export declare function runInsideLoaderRun<T>(run: LoaderRunIdentity, fn: () => T): T;
572
+ /** The innermost loader execution's identity record (runInsideLoaderRun). */
573
+ export declare function getLoaderRunIdentity(): LoaderRunIdentity | undefined;
574
+ /**
575
+ * The error a "use cache" function's read of a loader value fails with when
576
+ * the loader's execution, or one whose value it read, made `read` (#1011).
577
+ * Worded like guardIdentityRead's "use cache" refusal, naming the loaders.
578
+ */
579
+ export declare function useCacheLoaderIdentityError(read: LoaderIdentityRead, loaderId: string): Error;
544
580
  /** The error a loader cache() fill with no declared key fails with (#972). */
545
581
  export declare function loaderCacheIdentityError(read: LoaderIdentityRead, cachedLoaderId: string): Error;
546
582
  /**
@@ -632,7 +668,8 @@ export declare function runInsideLoaderScope<T>(fn: () => T): T;
632
668
  * and handler-invoked via ctx.use) so request-scoped reads inside a loader
633
669
  * never trip the cache() guard — a route cache() never stores loader values.
634
670
  * A "use cache" body the loader was entered in still refuses them
635
- * (guardIdentityRead).
671
+ * (guardIdentityRead); one that reads the value later refuses what the run
672
+ * recorded (runInsideLoaderRun, cache-tag.ts readStartedLoaderValue).
636
673
  */
637
674
  export declare function runInsideLoaderBodyScope<T>(fn: () => T, loaderId?: string, tags?: Set<string>): T;
638
675
  /** The innermost loader body's execution tag set (runInsideLoaderBodyScope). */
@@ -4578,7 +4578,7 @@ import { resolve } from "node:path";
4578
4578
  // package.json
4579
4579
  var package_default = {
4580
4580
  name: "@rangojs/router",
4581
- version: "0.19.0",
4581
+ version: "0.19.1",
4582
4582
  description: "Django-inspired RSC router with composable URL patterns",
4583
4583
  keywords: [
4584
4584
  "react",
@@ -6957,6 +6957,7 @@ var NON_CACHEABLE_READ = {
6957
6957
  ...requestReadCaptureFix("per-request variables")
6958
6958
  }
6959
6959
  };
6960
+ var loaderRunALS = globalThis[/* @__PURE__ */ Symbol.for("rangojs-router:loader-run-identity")] ??= new AsyncLocalStorage2();
6960
6961
  function getLoaderBodyTags() {
6961
6962
  return loaderBodyScopeALS.getStore()?.tags;
6962
6963
  }
@@ -9938,6 +9939,7 @@ globalThis[/* @__PURE__ */ Symbol.for("rangojs-router:identity-read-recorder")]
9938
9939
  if (set === void 0 || identityReads.has(set)) return;
9939
9940
  identityReads.set(set, { surface, verb, bodyId: getCurrentLoaderBodyId() });
9940
9941
  };
9942
+ var loaderRuns = globalThis[/* @__PURE__ */ Symbol.for("rangojs-router:loader-runs")] ??= /* @__PURE__ */ new WeakMap();
9941
9943
 
9942
9944
  // src/router/router-context.ts
9943
9945
  import { AsyncLocalStorage as AsyncLocalStorage4 } from "async_hooks";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -560,9 +560,9 @@ Loaders are exempt from the `cache()` guard because they run outside the cache
560
560
  scope and resolve fresh every request. A loader with its own `cache()` does not:
561
561
  its value is stored, so it falls under the loader `cache()` guard above. Nor
562
562
  does a loader awaited inside a `"use cache"` body: its value is part of what the
563
- function returns, so the same reads throw there when the cached function is
564
- the first to read it in the request (an already-started loader's value is
565
- stored unchecked).
563
+ function returns, so the same reads throw there, whether the cached function
564
+ starts the loader or reads a value a handler or a `loader()` binding already
565
+ started.
566
566
 
567
567
  ## Loaders Are Always Fresh
568
568
 
@@ -870,10 +870,10 @@ This is the **consumption-lane rule**, and it holds for every shared
870
870
  artifact — `cache()`, `"use cache"`, and the PPR shell (`/ppr`): handler
871
871
  consumption = baked copy; client-side `useLoader` = live. The tiers differ on
872
872
  identity reads inside a handler-consumed loader: `cache()` permits them (the
873
- leak above); `"use cache"` throws when the cached function is the first to
874
- read the loader in the request, because the loader body then runs inside it
875
- and its value is part of the result (a loader already started elsewhere
876
- returns its value unchecked, and that value is stored); a PPR shell capture
873
+ leak above); `"use cache"` throws, because the loader's value is part of the
874
+ result: inside the loader body when the cached function starts it, and at
875
+ `ctx.use()` when a handler or a `loader()` binding started it first and its
876
+ run made such a read; a PPR shell capture
877
877
  refuses them and the route stays uncached. It is stated once in `/rango` → Invariants.
878
878
 
879
879
  ```typescript
@@ -280,7 +280,7 @@ layout(<AccountLayout />, () => [
280
280
 
281
281
  A slot's `loading()` (whether from `handler.use` or explicit) makes that slot an independent streaming unit, exactly as in the **Streaming Behavior** section above.
282
282
 
283
- Under a shared artifact (`cache()`, `"use cache"`, a PPR shell), the server-side `await ctx.use(CartLoader)` above is the BAKED lane — the capture-time value freezes into the artifact. An identity read (`cookies()`, `headers()`, `ctx.request.headers`) inside the loader bakes too under `cache()`; inside `"use cache"` it throws when the cached function is the first to read the loader in the request (an already-started loader's value is stored unchecked); under a PPR shell it refuses the capture, so the route stays uncached. Consume the loader client-side (`useLoader` in a `"use client"` component, the slot's `loading()` as its boundary) to keep the slot live per request. One rule, stated once: `/rango` → Invariants ("the consumption-lane rule").
283
+ Under a shared artifact (`cache()`, `"use cache"`, a PPR shell), the server-side `await ctx.use(CartLoader)` above is the BAKED lane — the capture-time value freezes into the artifact. An identity read (`cookies()`, `headers()`, `ctx.request.headers`) inside the loader bakes too under `cache()`; inside `"use cache"` it throws, whichever code started the loader first; under a PPR shell it refuses the capture, so the route stays uncached. Consume the loader client-side (`useLoader` in a `"use client"` component, the slot's `loading()` as its boundary) to keep the slot live per request. One rule, stated once: `/rango` → Invariants ("the consumption-lane rule").
284
284
 
285
285
  The `parallel` mount site has the narrowest allow-list for `handler.use` items — slots cannot bring their own middleware or layout, only `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, and `transition`. See [skills/handler-use](../handler-use/SKILL.md) for the full table and merge rules.
286
286
 
@@ -135,8 +135,8 @@ stated, greppable contract.
135
135
  `ctx.request.headers`, a `{ cache: false }` variable) inside that loader
136
136
  are permitted under a route `cache()` (a documented footgun; see
137
137
  `/caching` → "Cache purity & tainted objects"). They throw inside
138
- `"use cache"` (when the cached function is the first to read the loader in
139
- the request; an already-started loader's value is stored unchecked) and REFUSE a PPR capture: the route serves uncached until
138
+ `"use cache"` (whichever code started the loader first: a handler, a
139
+ `loader()` binding, or the cached function itself) and REFUSE a PPR capture: the route serves uncached until
140
140
  the read moves to a live loader. Client-side consumption
141
141
  (`useLoader` in a `"use client"` component) is the LIVE lane. DSL
142
142
  `loader()` segments follow their PPR lane (only `ssr: false` bakes; see
@@ -283,11 +283,14 @@ is part of that body: its value is captured into the shared cache entry like
283
283
  any other computed data, so a `{ cache: false }` variable read there throws,
284
284
  exactly as `cookies()`, `headers()`, `ctx.request.headers` and a theme read do
285
285
  (one guard follows the
286
- cached body's whole async chain, loaders included). The check runs only when
287
- the loader body starts inside the cached function: a loader something else in
288
- the request already started (a handler's `ctx.use()`, a route `loader()`)
289
- returns its memoized value without it, and that value is still stored in the
290
- entry. Read the value outside and pass it in as an argument. Handler/cached-scope consumption = baked copy,
286
+ cached body's whole async chain, loaders included). The order does not
287
+ matter: a loader something else in the request already started (a handler's
288
+ `ctx.use()`, the route's or a parent layout's `loader()`) returns its memoized
289
+ value, and `ctx.use()` (or `getRequestContext().use()`) inside the cached function rejects with the same error
290
+ when that run, or a loader it read, made such a read. A read the run makes
291
+ after its value settled (a nested promise in the value) fails the entry's
292
+ write instead: the caller gets its own value, nothing is stored, and `onError`
293
+ reports it. Read the value outside and pass it in as an argument. Handler/cached-scope consumption = baked copy,
291
294
  client-side `useLoader` = live (the consumption-lane rule, `/rango` →
292
295
  Invariants). Under a route `cache()` a loader body's reads stay allowed (the
293
296
  route entry never stores a loader's value); during a PPR capture they refuse
@@ -34,6 +34,12 @@ import { _setCacheExecScopeProbe } from "./taint.js";
34
34
  */
35
35
  export interface CacheExecScope {
36
36
  readonly parent?: CacheExecScope;
37
+ /**
38
+ * Loader values its chain read through an already-started execution, to
39
+ * their loader ids (cache-tag.ts readStartedLoaderValue), checked again
40
+ * before its write (#1011).
41
+ */
42
+ loaderReads?: Map<object, string>;
37
43
  }
38
44
 
39
45
  const cacheExecStorage = new AsyncLocalStorage<CacheExecScope>();
@@ -47,6 +47,7 @@ import { encodeKV } from "../encode-kv.js";
47
47
  import { runBackground } from "./background-task.js";
48
48
  import { observePhase, PHASES } from "../router/instrument.js";
49
49
  import {
50
+ assertLoaderReadsClean,
50
51
  normalizeTags,
51
52
  outsideCacheTagScope,
52
53
  recordRequestTags,
@@ -753,6 +754,7 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
753
754
  // An error row would replace the stale entry; the catch
754
755
  // below reports it and the stale entry keeps serving.
755
756
  if (flightErrors.length > 0) throw flightErrors[0];
757
+ assertLoaderReadsClean(bgScope);
756
758
  const freshTags = entryTags(scoped.tags);
757
759
  // One of the refreshed value's tags was invalidated after
758
760
  // the refresh started: written now, a value that may
@@ -972,6 +974,9 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
972
974
  // promise in the result or a handle value, as an error row and
973
975
  // completes normally; stored, every hit would serve that error.
974
976
  if (flightErrors.length > 0) throw flightErrors[0];
977
+ // The encode awaited the result's nested promises: an identity read a
978
+ // loader value made after it settled is recorded now (#1011).
979
+ assertLoaderReadsClean(execScope);
975
980
  } catch (buildError) {
976
981
  // Serialize/handle-encode failed: no envelope for followers (they run
977
982
  // fresh) and nothing to write.
@@ -17,11 +17,19 @@ import {
17
17
  } from "../server/request-context.js";
18
18
  import {
19
19
  getCurrentLoaderBodyId,
20
+ getLoaderRunIdentity,
20
21
  getLoaderBodyTags,
21
22
  isInsideLoaderScope,
23
+ useCacheLoaderIdentityError,
22
24
  type LoaderIdentityRead,
23
25
  type LoaderIdentityReadVerb,
26
+ type LoaderRunIdentity,
24
27
  } from "../server/context.js";
28
+ import {
29
+ getCacheExecScope,
30
+ isInsideIdentityExempt,
31
+ type CacheExecScope,
32
+ } from "./cache-exec-scope.js";
25
33
  import type { ExecutionStart } from "./tag-invalidation.js";
26
34
 
27
35
  const cacheTagStorage = new AsyncLocalStorage<Set<string>>();
@@ -481,6 +489,102 @@ export function recordedIdentityRead(
481
489
  return undefined;
482
490
  }
483
491
 
492
+ /**
493
+ * Loader value identity (#1011). Each loader execution's value promise maps to
494
+ * its LoaderRunIdentity (server/context.ts); a loader-cache binding's value
495
+ * carries its reads on its recorded-tag set instead (tagLoaderValue,
496
+ * markIdentityRead). On globalThis like identityReads: the executor that
497
+ * tracks a value and the reader that checks it must share the map.
498
+ */
499
+ const loaderRuns: WeakMap<object, LoaderRunIdentity> = ((globalThis as any)[
500
+ Symbol.for("rangojs-router:loader-runs")
501
+ ] ??= new WeakMap());
502
+
503
+ /**
504
+ * `value` is the promise of the execution `run` records, started by the
505
+ * current execution (if any), which reads it. Both loader runners call it
506
+ * (loader-resolution.ts createLoaderExecutor, request-context.ts
507
+ * createUseFunction).
508
+ */
509
+ export function trackLoaderRun(value: object, run: LoaderRunIdentity): void {
510
+ loaderRuns.set(value, run);
511
+ linkLoaderRun(value);
512
+ }
513
+
514
+ /** The current loader execution reads `value`: its record links the value's. */
515
+ function linkLoaderRun(value: object): void {
516
+ const reader = getLoaderRunIdentity();
517
+ const run = loaderRuns.get(value);
518
+ if (reader && run && reader !== run) (reader.reads ??= new Set()).add(run);
519
+ }
520
+
521
+ /**
522
+ * The identity read behind a loader value: its execution's or one it read
523
+ * (nearest first), or the one its recorded-tag set links to.
524
+ */
525
+ function loaderValueIdentityRead(
526
+ value: object,
527
+ ): LoaderIdentityRead | undefined {
528
+ const root = loaderRuns.get(value);
529
+ if (root) {
530
+ const queue = [root];
531
+ const seen = new Set<LoaderRunIdentity>();
532
+ for (let i = 0; i < queue.length; i++) {
533
+ const run = queue[i]!;
534
+ if (seen.has(run)) continue;
535
+ seen.add(run);
536
+ if (run.read) return run.read;
537
+ for (const linked of run.reads ?? []) queue.push(linked);
538
+ }
539
+ }
540
+ const tags = loaderValueTags.get(value);
541
+ return tags && recordedIdentityRead(tags);
542
+ }
543
+
544
+ /**
545
+ * A ctx.use(Loader) or getRequestContext().use(Loader) read that returns an
546
+ * already-started value (the request memo, a loader cache() binding's value).
547
+ * The current loader execution, if any, links it (it reads the value).
548
+ * Inside a "use cache" body the value is refused when its execution read
549
+ * request identity, as a body entered there would have been
550
+ * (guardIdentityRead). The check runs once the value settles, so a read the
551
+ * body makes after an await counts. Each enclosing execution also notes the
552
+ * value, and checks it again after encoding its result
553
+ * (assertLoaderReadsClean): a read that settles after the value (a nested
554
+ * promise in it) then fails the write instead.
555
+ */
556
+ export function readStartedLoaderValue<T>(
557
+ value: Promise<T>,
558
+ loaderId: string,
559
+ ): Promise<T> {
560
+ linkLoaderRun(value);
561
+ const scope = getCacheExecScope();
562
+ if (!scope || isInsideIdentityExempt()) return value;
563
+ for (let s: CacheExecScope | undefined = scope; s; s = s.parent) {
564
+ (s.loaderReads ??= new Map()).set(value, loaderId);
565
+ }
566
+ const checked = value.then((resolved) => {
567
+ assertLoaderValueClean(value, loaderId);
568
+ return resolved;
569
+ });
570
+ // A read the body never awaits must not crash the process as an unhandled
571
+ // rejection; the write check (assertLoaderReadsClean) still refuses it.
572
+ checked.catch(() => {});
573
+ return checked;
574
+ }
575
+
576
+ /** Throw when a loader value `scope` read carries an identity read. */
577
+ export function assertLoaderReadsClean(scope: CacheExecScope): void {
578
+ for (const [value, loaderId] of scope.loaderReads ?? []) {
579
+ assertLoaderValueClean(value, loaderId);
580
+ }
581
+ }
582
+
583
+ function assertLoaderValueClean(value: object, loaderId: string): void {
584
+ const read = loaderValueIdentityRead(value);
585
+ if (read) throw useCacheLoaderIdentityError(read, loaderId);
586
+ }
587
+
484
588
  /** `root` and every set it links to, transitively. */
485
589
  function linkedSets(root: Set<string>): Set<Set<string>> {
486
590
  const seen = new Set<Set<string>>();
@@ -15,8 +15,10 @@ import {
15
15
  linkValueTags,
16
16
  loaderTagSetsArmed,
17
17
  markTagSetStart,
18
+ readStartedLoaderValue,
18
19
  readValueTags,
19
20
  tagLoaderValue,
21
+ trackLoaderRun,
20
22
  } from "../cache/cache-tag.js";
21
23
  import { executionStart } from "../cache/tag-invalidation.js";
22
24
  import type {
@@ -57,8 +59,10 @@ import {
57
59
  assertNonCacheableReadAllowed,
58
60
  isInsideLoaderScope,
59
61
  runInsideLoaderBodyScope,
62
+ runInsideLoaderRun,
60
63
  isInsidePushCallbackScope,
61
64
  runInsidePushCallbackScope,
65
+ type LoaderRunIdentity,
62
66
  } from "../server/context.js";
63
67
  import { debugLog } from "./logging.js";
64
68
 
@@ -479,7 +483,9 @@ function createLoaderExecutor<TEnv>(
479
483
  const memo = bound || loaderPromises.get(loader.$$id);
480
484
  if (memo) {
481
485
  if (recordTags) readValueTags(memo);
482
- return memo;
486
+ // A "use cache" body reading it refuses an identity read the run made
487
+ // (#1011): the body ran outside the cached function, unguarded.
488
+ return readStartedLoaderValue(memo, loader.$$id);
483
489
  }
484
490
 
485
491
  // Get loader function - either from loader object or fetchable registry
@@ -724,8 +730,10 @@ function createLoaderExecutor<TEnv>(
724
730
  // BAKED copy in every shared artifact (cache(), "use cache", the PPR
725
731
  // shell). The cache() guard permits its identity reads; a "use cache"
726
732
  // body it was entered in and a PPR capture do not (server/context.ts
727
- // guardIdentityRead).
733
+ // guardIdentityRead), and a "use cache" body that reads the value after
734
+ // another reader started it refuses what `identity` recorded (#1011).
728
735
 
736
+ const identity: LoaderRunIdentity = { loaderId: loader.$$id };
729
737
  const recordedTags = recordTags ? new Set<string>() : undefined;
730
738
  // A loader-cache write that stores or reads this value gates on the
731
739
  // earliest start behind it (#977, earliestRecordedStart).
@@ -736,7 +744,10 @@ function createLoaderExecutor<TEnv>(
736
744
  const promise = observePhase(PHASES.loader(loader.$$id), () =>
737
745
  Promise.resolve(
738
746
  runInsideLoaderBodyScope(
739
- () => loaderFn(loaderCtx as LoaderContext<any, TEnv>),
747
+ () =>
748
+ runInsideLoaderRun(identity, () =>
749
+ loaderFn(loaderCtx as LoaderContext<any, TEnv>),
750
+ ),
740
751
  loader.$$id,
741
752
  recordedTags,
742
753
  ),
@@ -747,6 +758,7 @@ function createLoaderExecutor<TEnv>(
747
758
  runEndStore?.settleLoaderRun(loader.$$id);
748
759
  }),
749
760
  );
761
+ trackLoaderRun(promise, identity);
750
762
  if (recordedTags) {
751
763
  tagLoaderValue(promise, recordedTags);
752
764
  readValueTags(promise);
@@ -96,6 +96,7 @@ import {
96
96
  linkRecordedTags,
97
97
  markIdentityRead,
98
98
  markTagSetStart,
99
+ readStartedLoaderValue,
99
100
  readValueTags,
100
101
  recordedIdentityRead,
101
102
  recordLoaderTags,
@@ -591,7 +592,7 @@ function executeLoaderData<TEnv>(
591
592
  // useLoader.
592
593
  linkLoaderTags(item.$$id);
593
594
  readValueTags(cached);
594
- return cached;
595
+ return readStartedLoaderValue(cached, item.$$id);
595
596
  }
596
597
  return originalUse(item);
597
598
  }) as typeof ctx.use;
@@ -1131,18 +1131,91 @@ const IDENTITY_READ_RECORDER_KEY = Symbol.for(
1131
1131
  * (cache-tag.ts recordedIdentityRead), so both orders fail the same way.
1132
1132
  * Response directives do not record: a key cannot make a skipped body's side
1133
1133
  * effect reach a HIT.
1134
+ *
1135
+ * The read also lands on the execution's LoaderRunIdentity (#1011), which
1136
+ * every execution has, from either loader runner (runInsideLoaderRun).
1134
1137
  */
1135
1138
  export function recordLoaderIdentityRead(
1136
1139
  surface: string,
1137
1140
  verb: LoaderIdentityReadVerb = "called",
1138
1141
  ): void {
1139
1142
  if (isInsideIdentityExempt()) return;
1143
+ const run = loaderRunALS.getStore();
1144
+ if (run && run.read === undefined) {
1145
+ run.read = { surface, verb, bodyId: run.loaderId };
1146
+ }
1140
1147
  const recorder = (globalThis as Record<symbol, unknown>)[
1141
1148
  IDENTITY_READ_RECORDER_KEY
1142
1149
  ] as ((surface: string, verb: LoaderIdentityReadVerb) => void) | undefined;
1143
1150
  recorder?.(surface, verb);
1144
1151
  }
1145
1152
 
1153
+ /**
1154
+ * One loader execution's request-identity record (#1011): the first identity
1155
+ * read its body made, and the executions whose values it read with
1156
+ * ctx.use(Loader). Every execution has one; the recorded-tag sets that carry
1157
+ * the same reads for a loader cache() fill (cache-tag.ts) exist only in a
1158
+ * request that binds one.
1159
+ *
1160
+ * A "use cache" function that reads a loader's value through the request
1161
+ * memo (a handler, the route's loader() binding or a parent layout's started
1162
+ * it first) does not run the body, so guardIdentityRead never sees the read.
1163
+ * Scar: the entry, keyed without the cookie, stored the first visitor's value
1164
+ * and served it to the next. cache-tag.ts readStartedLoaderValue refuses such
1165
+ * a value through this record.
1166
+ */
1167
+ export interface LoaderRunIdentity {
1168
+ readonly loaderId: string;
1169
+ read?: LoaderIdentityRead;
1170
+ reads?: Set<LoaderRunIdentity>;
1171
+ }
1172
+
1173
+ // Its own scope, not a LoaderBodyScope field: the request-context runner
1174
+ // (request-context.ts createUseFunction) records reads too, but does not
1175
+ // enter a loader body scope, whose cache() exemption it never had.
1176
+ const loaderRunALS: AsyncLocalStorage<LoaderRunIdentity> = ((globalThis as any)[
1177
+ Symbol.for("rangojs-router:loader-run-identity")
1178
+ ] ??= new AsyncLocalStorage<LoaderRunIdentity>());
1179
+
1180
+ /**
1181
+ * Run a loader body with `run` as its identity record: both loader runners
1182
+ * (loader-resolution.ts createLoaderExecutor, request-context.ts
1183
+ * createUseFunction) enter it around the loader function.
1184
+ */
1185
+ export function runInsideLoaderRun<T>(run: LoaderRunIdentity, fn: () => T): T {
1186
+ return loaderRunALS.run(run, fn);
1187
+ }
1188
+
1189
+ /** The innermost loader execution's identity record (runInsideLoaderRun). */
1190
+ export function getLoaderRunIdentity(): LoaderRunIdentity | undefined {
1191
+ return loaderRunALS.getStore();
1192
+ }
1193
+
1194
+ /**
1195
+ * The error a "use cache" function's read of a loader value fails with when
1196
+ * the loader's execution, or one whose value it read, made `read` (#1011).
1197
+ * Worded like guardIdentityRead's "use cache" refusal, naming the loaders.
1198
+ */
1199
+ export function useCacheLoaderIdentityError(
1200
+ read: LoaderIdentityRead,
1201
+ loaderId: string,
1202
+ ): Error {
1203
+ const bodyId = read.bodyId ?? loaderId;
1204
+ const source =
1205
+ bodyId === loaderId
1206
+ ? `Loader "${loaderId}" ${read.verb} it, and the cached function reads that loader's value`
1207
+ : `Loader "${bodyId}" ${read.verb} it, and the cached function reads loader "${loaderId}", whose value is built from it`;
1208
+ return new Error(
1209
+ `${read.surface} cannot be ${read.verb} inside a "use cache" function. ` +
1210
+ `${source}. The loader ran outside the function (a handler or a ` +
1211
+ `loader() binding started it first), but its value becomes part of ` +
1212
+ `what the function returns, and the cache key does not include it, so ` +
1213
+ `the first caller's value would be stored and served to later callers. ` +
1214
+ `Read the loader before calling the cached function and pass the value ` +
1215
+ `in as an argument so it becomes part of the cache key.`,
1216
+ );
1217
+ }
1218
+
1146
1219
  /** The error a loader cache() fill with no declared key fails with (#972). */
1147
1220
  export function loaderCacheIdentityError(
1148
1221
  read: LoaderIdentityRead,
@@ -1344,7 +1417,8 @@ export function runInsideLoaderScope<T>(fn: () => T): T {
1344
1417
  * and handler-invoked via ctx.use) so request-scoped reads inside a loader
1345
1418
  * never trip the cache() guard — a route cache() never stores loader values.
1346
1419
  * A "use cache" body the loader was entered in still refuses them
1347
- * (guardIdentityRead).
1420
+ * (guardIdentityRead); one that reads the value later refuses what the run
1421
+ * recorded (runInsideLoaderRun, cache-tag.ts readStartedLoaderValue).
1348
1422
  */
1349
1423
  export function runInsideLoaderBodyScope<T>(
1350
1424
  fn: () => T,
@@ -64,7 +64,10 @@ import {
64
64
  assertCachedHeaderWriteAllowed,
65
65
  assertNonCacheableReadAllowed,
66
66
  clearPprHeaderScope,
67
+ runInsideLoaderRun,
68
+ type LoaderRunIdentity,
67
69
  } from "./context.js";
70
+ import { readStartedLoaderValue, trackLoaderRun } from "../cache/cache-tag.js";
68
71
  import {
69
72
  guardRawCookieRead,
70
73
  guardRequestHeaders,
@@ -1859,8 +1862,11 @@ export function createUseFunction<TEnv>(
1859
1862
 
1860
1863
  const loader = item as LoaderDefinition<any, any>;
1861
1864
 
1862
- if (loaderPromises.has(loader.$$id)) {
1863
- return loaderPromises.get(loader.$$id);
1865
+ const memo = loaderPromises.get(loader.$$id);
1866
+ if (memo) {
1867
+ // Same refusal as the handler ctx.use memo hit (#1011): a "use cache"
1868
+ // body reading a value whose run read request identity.
1869
+ return readStartedLoaderValue(memo, loader.$$id);
1864
1870
  }
1865
1871
 
1866
1872
  let loaderFn = loader.fn;
@@ -1947,9 +1953,11 @@ export function createUseFunction<TEnv>(
1947
1953
  // (observePhase), so a loader resolved via this base request-context ctx.use
1948
1954
  // co-emits the "loader:<id>" perf metric AND the "rango.loader" span — no
1949
1955
  // drift between the two ctx.use implementations.
1956
+ const run: LoaderRunIdentity = { loaderId: loader.$$id };
1950
1957
  const promise = observePhase(PHASES.loader(loader.$$id), () =>
1951
- Promise.resolve(loaderFn(loaderCtx)),
1958
+ Promise.resolve(runInsideLoaderRun(run, () => loaderFn(loaderCtx))),
1952
1959
  );
1960
+ trackLoaderRun(promise, run);
1953
1961
 
1954
1962
  loaderPromises.set(loader.$$id, promise);
1955
1963