@rangojs/router 0.19.0 → 0.20.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.
@@ -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). */
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The client entry's head hint at default fetch priority, after the head chunk
3
+ * scripts (issue #1025).
4
+ *
5
+ * For `bootstrapModules`, Fizz writes `<link rel="modulepreload"
6
+ * fetchPriority="low">` into the head and claims the URL
7
+ * (`moduleScriptResources[url] = null`, react-dom 19.3.0
8
+ * `react-dom-server.edge.production.js:546-565`) before the render starts, so
9
+ * no `preloadModule`/`preinitModule` call can replace it, and the
10
+ * `bootstrapModules` entry shape has no priority field. Chromium fetches that
11
+ * hint at Low, and once it boosts in-viewport images to High after first
12
+ * layout, the entry queues behind them; the executing end-of-shell module
13
+ * script reuses the in-flight Low fetch through the module map. Hydration
14
+ * then waits on a few-hundred-byte file while the head chunk scripts it
15
+ * imports (High) have long arrived: measured +3.8 s on a priority-honouring
16
+ * HTTP/2 link at Slow 4G.
17
+ *
18
+ * The fix serves that one tag without the attribute, so it fetches at
19
+ * Chromium's modulepreload default (High), and moves it after the head
20
+ * chunk scripts. Fizz flushes the bootstrap hint before the head chunk scripts; at
21
+ * High in that position it took one of HTTP/1.1's six connections ahead of
22
+ * the react chunk and delayed the last head chunk (hydration +91 ms, LCP
23
+ * +84 ms measured). After the scripts it is the last High request in the
24
+ * queue, so it never displaces a chunk hydration also needs. The accepted
25
+ * cost: an HTTP/2 server that sends equal-priority streams first in, first
26
+ * out delivers it with the last head chunk instead of before it (text-only
27
+ * pages +12 to +56 ms measured, against -0.15 to -3.3 s hydration on image
28
+ * pages; CHANGELOG has the matrix). Everything else about the bootstrap pair
29
+ * stays Fizz's: the nonce, the executing `id="_R_"` tag, the URL claim that
30
+ * keeps plugin-rsc's and the preinit hook's calls for the entry inert, and the
31
+ * PPR prelude/resume behaviour.
32
+ *
33
+ * The match is the exact tag Fizz writes for a string `bootstrapModules`
34
+ * entry (`rel`, `fetchPriority`, `nonce`, `href` in that order), not a
35
+ * pattern, and only before the first `<body`. A Fizz version that writes it
36
+ * differently leaves the bytes untouched: the output keeps the Low hint.
37
+ *
38
+ * Where the tag goes: right after the run of Fizz resource tags that directly
39
+ * follows the hint, before the first token that is anything else (`</head>`,
40
+ * the app's own head children, or body content in a head-less document).
41
+ * Fizz flushes the head chunk scripts and the bulk preloads straight after
42
+ * the bootstrap hint (react-dom 19.3.0 `flushCompletedQueues`, `:7321-7325`),
43
+ * as `<script …></script>` with no body and `<link …/>`; the scan accepts
44
+ * only those two shapes and stops at any other token without reading into it.
45
+ * It never searches the app's head content for `</head>` or `<body`: React
46
+ * does not escape either inside `<script>` or `<style>` text, so a head script
47
+ * holding the string `"<body>"` once received the tag inside its source and
48
+ * threw. Fizz escapes `>` in attribute values, so the first `>` ends a tag.
49
+ * Every insertion point is the boundary between two complete tags; a stream
50
+ * that ends mid-run, or a run tag longer than {@link MAX_HELD_TAG}, gets the
51
+ * tag before the unfinished one. Only that one tag is held back while the run
52
+ * streams; each complete run tag is forwarded as soon as it is scanned.
53
+ */
54
+ /**
55
+ * Rewrite a complete document prefix (the PPR shell capture's prelude).
56
+ * Returns `bytes` unchanged when no hint precedes the first `<body`.
57
+ */
58
+ export declare function rewriteEntryPreloadPriority(bytes: Uint8Array, entryUrl: string, nonce?: string): Uint8Array;
59
+ /**
60
+ * Streaming form for live SSR: the same rewrite as
61
+ * {@link rewriteEntryPreloadPriority}, then pass-through. Before the hint is
62
+ * found, only a chunk tail that may be the start of a split `<body` or hint
63
+ * tag is held back. After it, complete run tags are forwarded as they are
64
+ * scanned and only an unfinished one (at most {@link MAX_HELD_TAG} bytes) is
65
+ * held, so every byte is scanned a bounded number of times. Once the tag is
66
+ * placed, or once `<body` passes without a hint, chunks are forwarded
67
+ * untouched and unscanned.
68
+ */
69
+ export declare function entryPreloadPriorityTransform(entryUrl: string, nonce?: string): TransformStream<Uint8Array, Uint8Array>;
@@ -145,6 +145,7 @@ export interface SSRDependencies<TEnv = unknown> {
145
145
  * undefined keeps the inline bootstrap verbatim, so a custom SSR entry that
146
146
  * never installed the preinit hook cannot drift into the half-converted
147
147
  * state on upgrade (the generated entry always passes an explicit value).
148
+ * Any other value throws when the handler is created.
148
149
  */
149
150
  headScripts?: HeadScriptsOption;
150
151
  /**
@@ -55,11 +55,23 @@ export declare function runWithPreinitNonce<T>(nonce: string | undefined, fn: ()
55
55
  *
56
56
  * Known trades (deliberate, measured neutral-to-positive on the e2e apps —
57
57
  * PR #694 has the Lighthouse/hydration numbers):
58
- * - Fetch priority: an executing async module script fetches at Chromium's
59
- * async-script priority, below a bare modulepreload hint; preinitModule
60
- * forwards no fetchPriority (react-dom's public API drops it — only
61
- * `preinit` forwards it). Execution-overlap is bought with hint priority,
62
- * the same trade Next.js ships via ReactDOM.preinit.
58
+ * - Fetch priority: Chromium gives a parser-inserted `async` module script
59
+ * initial priority High: below the render-blocking stylesheet (Highest),
60
+ * and alongside the LCP image once Chromium boosts the in-viewport image to
61
+ * High (#1021). Every head chunk gets the same priority: the shared
62
+ * dependency chunks (react, router, entry.rsc and the bundler runtime) and
63
+ * every client-component chunk. The entry itself is a Fizz bootstrapModules
64
+ * hint, not a preinit (Fizz claims its URL, so this hook's call for it is
65
+ * inert); the handlers serve that hint at default priority, High as well,
66
+ * after these scripts (entry-preload-priority.ts, #1025).
67
+ * Lowering the chunks to `fetchpriority="low"` was measured and rejected
68
+ * (#1021; Chromium 153, 20 interleaved runs per variant and cell). On pages
69
+ * with images above the fold the Low chunks queue behind the boosted
70
+ * images: hydration came 206 to 6407 ms later on HTTP/1.1 and 65 to 642 ms
71
+ * later on HTTP/2. The one gain, FCP -136 ms, needed an image-free page
72
+ * with 35 KB of render-blocking CSS on HTTP/1.1 Slow 4G, and hydration was
73
+ * still 98 ms later there. `headScripts: "preload"` is the way to get Low
74
+ * chunk fetches.
63
75
  * - Build only: plugin-rsc's dev load path reports `js: []` per reference, so
64
76
  * dev documents have no head chunk scripts — a client module whose module
65
77
  * scope assumes body-parsed DOM can break in production only. The
@@ -68,6 +68,12 @@ export interface ClientChunkMeta {
68
68
  * strategy**. It splits app `"use client"` modules by **route id** — the segment
69
69
  * after a route-root directory (`routes`, `app`, `pages`, `features`, `handlers`,
70
70
  * …) — so `routes/dashboard/**` becomes `app-dashboard` at any nesting depth.
71
+ * The first marker in the path wins, except that an `app/` directly followed
72
+ * by another marker with a directory after it is treated as a source root:
73
+ * `app/routes/product/**` becomes `app-product`, while `app/dashboard/**`
74
+ * (Next-style), `app/components/**` and files directly in `app/routes/` stay
75
+ * `app-dashboard` / `app-components` / `app-routes`. The same route id under
76
+ * different markers is one group.
71
77
  * Where it finds NO route structure (a flat `src/components/`, or host sub-apps
72
78
  * already split by a dynamic `import()`), it inherits the default grouping
73
79
  * unchanged — so the shared `src/components` chunk stays shared and host apps do
@@ -79,10 +85,14 @@ export interface ClientChunkMeta {
79
85
  * back to the default grouping for that one module. Forwarded directly to
80
86
  * `@vitejs/plugin-rsc`'s `clientChunks`.
81
87
  *
82
- * Every module maps to exactly one group, so there is no byte duplication: a
83
- * component used by two routes lives in one group and is fetched whenever it
84
- * renders. Put genuinely shared client components OUTSIDE route directories so
85
- * they land in the shared group rather than one route's chunk.
88
+ * Every module maps to exactly one group, so there is no byte duplication. A
89
+ * group is also the loading unit: rendering any one member downloads the whole
90
+ * group's chunk, every other member included. A component used by two routes
91
+ * lives in one group, and rendering it on either route fetches that entire
92
+ * group. Put genuinely shared client components OUTSIDE route directories so
93
+ * they land in the shared group rather than one route's chunk, and keep an
94
+ * always-rendered component (a layout header) out of a large group.
95
+ * `DEBUG=rango:chunks vite build` logs each group's module count and size.
86
96
  *
87
97
  * @default true
88
98
  */
@@ -114,14 +124,18 @@ interface RangoBaseOptions {
114
124
  * - `"preinit"` (**default**): client-reference chunks render as EXECUTING
115
125
  * `<script type="module" async>` tags hoisted into `<head>` (upgrading
116
126
  * plugin-rsc's modulepreload hints in place), and the browser entry ships
117
- * as Fizz `bootstrapModules` — a head `modulepreload fetchpriority=low`
118
- * hint plus the executing end-of-shell `id="_R_"` module script. Chunk
127
+ * as Fizz `bootstrapModules` — a `modulepreload` hint after the head
128
+ * chunk scripts (default priority; Fizz's `fetchpriority=low` is dropped,
129
+ * #1025)
130
+ * plus the executing end-of-shell `id="_R_"` module script. Chunk
119
131
  * execution overlaps body streaming instead of waiting for the hydration
120
132
  * import walk; under PPR everything lands in the stored shell prelude.
121
133
  * - `"preload"`: the previous behavior — `<link rel="modulepreload">` hints
122
134
  * only, entry as an inline `import()` script at end of shell. Chunks
123
135
  * fetch+compile early but execute only when hydration imports them.
124
136
  *
137
+ * Any other value throws when the generated SSR entry is built.
138
+ *
125
139
  * Build-only for the chunk half: plugin-rsc resolves no JS deps per client
126
140
  * reference in dev, so dev documents carry no head chunk scripts in either
127
141
  * mode (the bootstrap conversion does apply in dev). Trades and upstream
@@ -5,7 +5,9 @@ export declare const VIRTUAL_ENTRY_BROWSER: string;
5
5
  * option: "preinit" (default) installs the client-reference preinit hook and
6
6
  * threads `getClientEntryUrl` so Fizz emits `bootstrapModules`;
7
7
  * "preload" omits the hook and uses the deprecated inline
8
- * `loadBootstrapScriptContent` bootstrap.
8
+ * `loadBootstrapScriptContent` bootstrap. Any other value throws: an unknown
9
+ * string such as "prenit" used to run as "preinit" silently
10
+ * (`headScripts !== "preload"` since #694).
9
11
  */
10
12
  export declare function getVirtualEntrySSR(headScripts?: HeadScriptsOption, progressiveChunkSize?: number): string;
11
13
  /**
@@ -1,4 +1,6 @@
1
+ import type { Plugin } from "vite";
1
2
  import type { ClientChunkMeta, ClientChunks } from "../plugin-types.js";
3
+ import { type Debugger } from "../debug.js";
2
4
  /** The callback shape @vitejs/plugin-rsc's `clientChunks` option accepts. */
3
5
  export type RscClientChunksFn = (meta: ClientChunkMeta) => string | undefined;
4
6
  /**
@@ -36,8 +38,11 @@ export interface ClientChunkContext {
36
38
  * 1. Shared runtime (React / router / node_modules) -> `undefined` (never split).
37
39
  * 2. A registered error/notFound fallback (`ctx.fallbackRefs`) -> `app-fallback`,
38
40
  * regardless of location, so the error UI is decoupled from the happy path.
39
- * 3. A {@link ROUTE_ROOT_DIRS} marker with a directory after it -> key on that
40
- * next segment (the route id), robust to any nesting depth.
41
+ * 3. The first {@link ROUTE_ROOT_DIRS} marker with a directory after it -> key on
42
+ * that next segment (the route id), robust to any nesting depth. A
43
+ * {@link SOURCE_ROOT_DIRS} marker (`app`) directly followed by another marker
44
+ * with a directory after it defers to that inner marker:
45
+ * `app/routes/<id>/…` -> `app-<id>`, while `app/<segment>/…` -> `app-<segment>`.
41
46
  * 4. Otherwise `undefined` (inherit the default `serverChunk` grouping).
42
47
  */
43
48
  export declare function directoryClientChunks(meta: ClientChunkMeta, ctx?: ClientChunkContext): string | undefined;
@@ -53,3 +58,18 @@ export declare function directoryClientChunks(meta: ClientChunkMeta, ctx?: Clien
53
58
  * fallback refinement does not apply — the consumer owns the grouping).
54
59
  */
55
60
  export declare function resolveClientChunks(option: ClientChunks | undefined, ctx?: ClientChunkContext): RscClientChunksFn | undefined;
61
+ /**
62
+ * Build-end group report under `DEBUG=rango:chunks`: one line per client
63
+ * reference group with its client-reference count and the size of the group's
64
+ * own chunk (code split into shared chunks such as `react`/`router` is not
65
+ * counted). A group is the loading unit (plugin-rsc imports the group's virtual
66
+ * module to resolve any one member), so a large group that an always-rendered
67
+ * component belongs to is downloaded on every route; this line makes that
68
+ * visible. Covers every group, including ones named by a custom `clientChunks`
69
+ * function or by plugin-rsc's default `serverChunk` grouping. `undefined` when
70
+ * the namespace is off, so a normal build does not register it. Group names are
71
+ * printed with NUL escaped: plugin-rsc's default group is named
72
+ * `facade:\0virtual:…`, and a raw NUL makes `grep` treat the output as binary
73
+ * and drop that line, usually the largest group. `log` is injectable for tests.
74
+ */
75
+ export declare function clientChunksReport(log?: Debugger | undefined): Plugin | undefined;
@@ -4369,7 +4369,12 @@ function emitProgressiveChunkSize(value) {
4369
4369
  return JSON.stringify(value);
4370
4370
  }
4371
4371
  function getVirtualEntrySSR(headScripts = "preinit", progressiveChunkSize) {
4372
- const preinit = headScripts !== "preload";
4372
+ if (headScripts !== "preinit" && headScripts !== "preload") {
4373
+ throw new Error(
4374
+ `rango({ headScripts }) must be "preinit" or "preload", received ${JSON.stringify(headScripts)}`
4375
+ );
4376
+ }
4377
+ const preinit = headScripts === "preinit";
4373
4378
  const depsImportNames = preinit ? "createFromReadableStream,\n setOnClientReference,\n getClientEntryUrl," : "createFromReadableStream,";
4374
4379
  const ssrImportNames = preinit ? "\n installClientReferencePreinit," : "";
4375
4380
  const install = preinit ? `
@@ -4578,7 +4583,7 @@ import { resolve } from "node:path";
4578
4583
  // package.json
4579
4584
  var package_default = {
4580
4585
  name: "@rangojs/router",
4581
- version: "0.19.0",
4586
+ version: "0.20.0",
4582
4587
  description: "Django-inspired RSC router with composable URL patterns",
4583
4588
  keywords: [
4584
4589
  "react",
@@ -6443,6 +6448,7 @@ function getManualChunks(id) {
6443
6448
  // src/vite/utils/client-chunks.ts
6444
6449
  init_debug();
6445
6450
  init_client_ref_hashing();
6451
+ import { gzipSync } from "node:zlib";
6446
6452
  var debugChunks = createRangoDebugger(NS.chunks);
6447
6453
  function isSharedRuntime(meta) {
6448
6454
  return [meta.id, meta.normalizedId].some(
@@ -6468,6 +6474,7 @@ var ROUTE_ROOT_DIRS = /* @__PURE__ */ new Set([
6468
6474
  "screens",
6469
6475
  "sections"
6470
6476
  ]);
6477
+ var SOURCE_ROOT_DIRS = /* @__PURE__ */ new Set(["app"]);
6471
6478
  function directoryClientChunks(meta, ctx) {
6472
6479
  if (isSharedRuntime(meta)) {
6473
6480
  return void 0;
@@ -6478,14 +6485,13 @@ function directoryClientChunks(meta, ctx) {
6478
6485
  }
6479
6486
  const segments = meta.normalizedId.split("/").filter(Boolean);
6480
6487
  const dirCount = segments.length - 1;
6481
- if (dirCount >= 1) {
6482
- for (let i = 0; i < dirCount - 1; i++) {
6483
- if (ROUTE_ROOT_DIRS.has(segments[i].toLowerCase())) {
6484
- const group = `app-${sanitizeGroup(segments[i + 1])}`;
6485
- debugChunks?.("split %s -> %s", meta.normalizedId, group);
6486
- return group;
6487
- }
6488
- }
6488
+ const isMarkerAt = (i) => i < dirCount - 1 && ROUTE_ROOT_DIRS.has(segments[i].toLowerCase());
6489
+ for (let i = 0; i < dirCount - 1; i++) {
6490
+ if (!isMarkerAt(i)) continue;
6491
+ const marker = SOURCE_ROOT_DIRS.has(segments[i].toLowerCase()) && isMarkerAt(i + 1) ? i + 1 : i;
6492
+ const group = `app-${sanitizeGroup(segments[marker + 1])}`;
6493
+ debugChunks?.("split %s -> %s", meta.normalizedId, group);
6494
+ return group;
6489
6495
  }
6490
6496
  debugChunks?.(
6491
6497
  "shared %s (no route-root marker; inherits default grouping)",
@@ -6498,6 +6504,30 @@ function resolveClientChunks(option, ctx) {
6498
6504
  if (option === true) return (meta) => directoryClientChunks(meta, ctx);
6499
6505
  return option;
6500
6506
  }
6507
+ var CLIENT_GROUP_PREFIX = "\0virtual:vite-rsc/client-references/group/";
6508
+ function clientChunksReport(log = debugChunks) {
6509
+ if (!log) return void 0;
6510
+ return {
6511
+ name: "@rangojs/router:client-chunks-report",
6512
+ apply: "build",
6513
+ applyToEnvironment: (env) => env.name === "client",
6514
+ generateBundle(_options, bundle) {
6515
+ for (const chunk of Object.values(bundle)) {
6516
+ if (chunk.type !== "chunk") continue;
6517
+ const facade = chunk.facadeModuleId;
6518
+ if (!facade?.startsWith(CLIENT_GROUP_PREFIX)) continue;
6519
+ log(
6520
+ "group %s: %d client reference(s), %d B (%d B gzip) -> %s",
6521
+ facade.slice(CLIENT_GROUP_PREFIX.length).replaceAll("\0", "\\0"),
6522
+ this.getModuleInfo(facade)?.importedIds.length ?? 0,
6523
+ Buffer.byteLength(chunk.code),
6524
+ gzipSync(chunk.code).length,
6525
+ chunk.fileName
6526
+ );
6527
+ }
6528
+ }
6529
+ };
6530
+ }
6501
6531
 
6502
6532
  // src/vite/plugins/vercel-output.ts
6503
6533
  import { rm, mkdir, cp, writeFile } from "node:fs/promises";
@@ -6957,6 +6987,7 @@ var NON_CACHEABLE_READ = {
6957
6987
  ...requestReadCaptureFix("per-request variables")
6958
6988
  }
6959
6989
  };
6990
+ var loaderRunALS = globalThis[/* @__PURE__ */ Symbol.for("rangojs-router:loader-run-identity")] ??= new AsyncLocalStorage2();
6960
6991
  function getLoaderBodyTags() {
6961
6992
  return loaderBodyScopeALS.getStore()?.tags;
6962
6993
  }
@@ -9938,6 +9969,7 @@ globalThis[/* @__PURE__ */ Symbol.for("rangojs-router:identity-read-recorder")]
9938
9969
  if (set === void 0 || identityReads.has(set)) return;
9939
9970
  identityReads.set(set, { surface, verb, bodyId: getCurrentLoaderBodyId() });
9940
9971
  };
9972
+ var loaderRuns = globalThis[/* @__PURE__ */ Symbol.for("rangojs-router:loader-runs")] ??= /* @__PURE__ */ new WeakMap();
9941
9973
 
9942
9974
  // src/router/router-context.ts
9943
9975
  import { AsyncLocalStorage as AsyncLocalStorage4 } from "async_hooks";
@@ -13347,6 +13379,7 @@ If this is a multi-app host router, export a createHostRouter() instance and set
13347
13379
  );
13348
13380
  plugins.push(clientRefDedup());
13349
13381
  }
13382
+ plugins.push(clientChunksReport());
13350
13383
  plugins.push({
13351
13384
  name: "@rangojs/router:client-component-hmr",
13352
13385
  hotUpdate(ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -15,7 +15,7 @@ Your app builds in three Vite environments — `client`, `ssr`, and `rsc` — an
15
15
  1. **Server code leaking into the client.** A file that imports `node:fs`, calls a database, or contains action logic ends up in the client bundle because a client component pulled it in transitively. Symptom: your client bundle is much larger than expected, sometimes with imports that fail at runtime.
16
16
  2. **Both dev and prod React in the SSR/RSC bundle.** When `process.env.NODE_ENV` isn't folded at build time, React's CJS files ship both `.development.js` _and_ `.production.js` variants — doubling React's footprint. The Cloudflare Vite plugin and rango's `vercel` preset fold NODE_ENV automatically; on the default `node` preset, `vite build` does it for the client but not always for SSR/RSC, so add the `define` from Step 2.
17
17
  3. **An oversized routes-manifest in your RSC worker.** The `virtual:rsc-router/routes-manifest/<routerId>` chunk holds your route trie and precomputed entries — large only in proportion to your route count. If it's surprisingly big, you may have unintentionally generated routes (e.g., parametrized fixtures) that bloated the trie.
18
- 4. **Poor client-chunk grouping.** `rango({ clientChunks })` decides how `"use client"` modules are grouped into browser chunks. The default groups them by route directory (`routes/<id>/**` → `app-<id>`), with flat folders like `src/components/` in one shared chunk; `clientChunks: false` ships one client chunk per router. A route that pulls in unrelated routes' client code, or one oversized shared chunk, points at the grouping.
18
+ 4. **Poor client-chunk grouping.** `rango({ clientChunks })` decides how `"use client"` modules are grouped into browser chunks. The default groups them by route directory (`routes/<id>/**` → `app-<id>`), with flat folders like `src/components/` in one shared chunk; `clientChunks: false` ships one client chunk per router. A group is the loading unit: rendering any one member downloads the whole group's chunk. A route that pulls in unrelated routes' client code, or one oversized shared chunk, points at the grouping; `DEBUG=rango:chunks vite build` logs each group's size at build end.
19
19
 
20
20
  Tree-shaking does _not_ catch generated data inlined as string literals, or data-dependent conditionals like React's `NODE_ENV` branches. You need a visualizer.
21
21
 
@@ -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
@@ -234,8 +234,14 @@ Notes:
234
234
 
235
235
  - `clientChunks` default groups app client components by the directory after a
236
236
  route root (`routes/`, `app/`, `pages/`, `features/`, `handlers/`, ...), so
237
- `routes/dashboard/**` becomes chunk `app-dashboard`. Flat folders such as
238
- `src/components/` stay in the shared chunk. `false` restores
237
+ `routes/dashboard/**` becomes chunk `app-dashboard`. An `app/` directly
238
+ followed by another marker with a directory after it is a source root, so
239
+ `app/routes/product/**` is `app-product`; `app/<segment>/**`,
240
+ `app/components/**` and files directly in `app/routes/` keep `app-<segment>`
241
+ / `app-components` / `app-routes`. Flat folders such as
242
+ `src/components/` stay in the shared chunk. A group is the loading unit:
243
+ rendering any member downloads the whole group's chunk;
244
+ `DEBUG=rango:chunks vite build` logs each group's size. `false` restores
239
245
  `@vitejs/plugin-rsc`'s grouping (one client chunk per router). A function
240
246
  `(meta) => string | undefined` names the group per module (`undefined` keeps
241
247
  the default); `directoryClientChunks(meta)`, the default strategy, is
@@ -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.