@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.
- package/dist/types/cache/cache-exec-scope.d.ts +6 -0
- package/dist/types/cache/cache-tag.d.ts +24 -1
- package/dist/types/server/context.d.ts +38 -1
- package/dist/vite/index.js +3 -1
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +3 -3
- package/skills/caching/SKILL.md +4 -4
- package/skills/parallel/SKILL.md +1 -1
- package/skills/rango/SKILL.md +2 -2
- package/skills/use-cache/SKILL.md +8 -5
- package/src/cache/cache-exec-scope.ts +6 -0
- package/src/cache/cache-runtime.ts +5 -0
- package/src/cache/cache-tag.ts +104 -0
- package/src/router/loader-resolution.ts +15 -3
- package/src/router/segment-resolution/loader-cache.ts +2 -1
- package/src/server/context.ts +75 -1
- package/src/server/request-context.ts +11 -3
|
@@ -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). */
|
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
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
|
@@ -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
|
|
564
|
-
the
|
|
565
|
-
|
|
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
|
|
package/skills/caching/SKILL.md
CHANGED
|
@@ -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
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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
|
package/skills/parallel/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
package/skills/rango/SKILL.md
CHANGED
|
@@ -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"` (
|
|
139
|
-
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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.
|
package/src/cache/cache-tag.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
() =>
|
|
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;
|
package/src/server/context.ts
CHANGED
|
@@ -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
|
-
|
|
1863
|
-
|
|
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
|
|