@rangojs/router 0.0.0-experimental.78adc454 → 0.0.0-experimental.7dc955ec

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.
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
1745
1745
  // package.json
1746
1746
  var package_default = {
1747
1747
  name: "@rangojs/router",
1748
- version: "0.0.0-experimental.78adc454",
1748
+ version: "0.0.0-experimental.7dc955ec",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.78adc454",
3
+ "version": "0.0.0-experimental.7dc955ec",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -162,6 +162,38 @@ middleware(async (ctx, next) => {
162
162
  });
163
163
  ```
164
164
 
165
+ ## Context Variable Cache Safety
166
+
167
+ Context variables created with `createVar()` are cacheable by default and can
168
+ be read freely inside `cache()` and `"use cache"` scopes. Non-cacheable vars
169
+ throw at read time to prevent request-specific data from being captured.
170
+
171
+ There are two ways to mark a value as non-cacheable:
172
+
173
+ ```typescript
174
+ // Var-level policy — inherently request-specific data
175
+ const Session = createVar<SessionData>({ cache: false });
176
+
177
+ // Write-level escalation — this specific write is non-cacheable
178
+ ctx.set(Theme, derivedTheme, { cache: false });
179
+ ```
180
+
181
+ "Least cacheable wins": if either the var definition or the `ctx.set()` call
182
+ specifies `cache: false`, the value is non-cacheable.
183
+
184
+ **Behavior inside cache scopes:**
185
+
186
+ | Operation | Inside `cache()` / `"use cache"` |
187
+ | ----------------------------------- | -------------------------------- |
188
+ | `ctx.get(cacheableVar)` | Allowed |
189
+ | `ctx.get(nonCacheableVar)` | Throws |
190
+ | `ctx.set(var, value)` (cacheable) | Allowed |
191
+ | `ctx.header()`, `ctx.cookie()`, etc | Throws (response side effects) |
192
+
193
+ Write is dumb — `ctx.set()` stores the cache metadata but does not enforce.
194
+ Enforcement happens at read time (`ctx.get()`), where ALS detects the cache
195
+ scope and rejects non-cacheable reads.
196
+
165
197
  ## Loaders Are Always Fresh
166
198
 
167
199
  Loaders are **never cached** by route-level `cache()`. Even on a full cache hit
@@ -173,6 +173,14 @@ const router = createRouter<AppBindings>({
173
173
  KV entries require `expirationTtl >= 60s`. Short-lived entries (< 60s total TTL)
174
174
  are only cached in L1.
175
175
 
176
+ ## Context Variables Inside Cache Boundaries
177
+
178
+ Context variables (`createVar`) are cacheable by default and can be read and
179
+ written inside `cache()` scopes. Variables marked with `{ cache: false }` (at
180
+ the var level or write level) throw when read inside a cache scope. Response
181
+ side effects (`ctx.header()`, `ctx.cookie()`) always throw inside cache
182
+ boundaries. See `/cache-guide` for the full cache safety table.
183
+
176
184
  ## Nested Cache Boundaries
177
185
 
178
186
  Override cache settings for specific sections:
@@ -181,6 +181,37 @@ String keys still work (`ctx.set("key", value)` / `ctx.get("key")`), but
181
181
  Only route handlers and middleware can call `ctx.set()`. Layouts, parallels,
182
182
  and intercepts can only read via `ctx.get()`.
183
183
 
184
+ #### Non-cacheable context variables
185
+
186
+ Mark a var as non-cacheable when it holds inherently request-specific data
187
+ (sessions, auth tokens, per-request IDs). There are two ways:
188
+
189
+ ```typescript
190
+ // Var-level: every value written to this var is non-cacheable
191
+ const Session = createVar<SessionData>({ cache: false });
192
+
193
+ // Write-level: escalate a normally-cacheable var for this specific write
194
+ const Theme = createVar<string>();
195
+ ctx.set(Theme, userTheme, { cache: false });
196
+ ```
197
+
198
+ "Least cacheable wins" — if either the var definition or the write site says
199
+ `cache: false`, the value is non-cacheable.
200
+
201
+ Reading a non-cacheable var inside `cache()` or `"use cache"` throws at
202
+ runtime. This prevents request-specific data from leaking into cached output:
203
+
204
+ ```typescript
205
+ // This throws — Session is non-cacheable
206
+ async function CachedWidget(ctx) {
207
+ "use cache";
208
+ const session = ctx.get(Session); // Error: non-cacheable var read inside cache scope
209
+ return <Widget />;
210
+ }
211
+ ```
212
+
213
+ Cacheable vars (the default) can be read freely inside cache scopes.
214
+
184
215
  ### Revalidation Contracts for Handler Data
185
216
 
186
217
  Handler-first guarantees apply within a single full render pass. For partial
@@ -369,8 +369,18 @@ interface PaginationData {
369
369
  perPage: number;
370
370
  }
371
371
  export const Pagination = createVar<PaginationData>();
372
+
373
+ // Non-cacheable var — reading inside cache() or "use cache" throws at runtime
374
+ const Session = createVar<SessionData>({ cache: false });
372
375
  ```
373
376
 
377
+ `createVar` accepts an optional options object. The `cache` option (default
378
+ `true`) controls whether the var's values can be read inside cache scopes.
379
+ Write-level escalation is also supported: `ctx.set(Var, value, { cache: false })`
380
+ marks a specific write as non-cacheable even if the var itself is cacheable.
381
+ "Least cacheable wins" — if either says `cache: false`, the value throws on
382
+ read inside `cache()` or `"use cache"`.
383
+
374
384
  ### Producer (handler or middleware)
375
385
 
376
386
  ```typescript
@@ -93,6 +93,14 @@ export async function resolveLoaders<TEnv>(
93
93
  const loaderEntries = entry.loader ?? [];
94
94
  if (loaderEntries.length === 0) return [];
95
95
 
96
+ // DSL loaders are always fresh (never cached), so temporarily clear the
97
+ // cache scope flag. This allows loaders to read non-cacheable vars even
98
+ // inside cache() boundaries. Handler ctx.use(loader) does NOT get this
99
+ // exemption — the handler is cached, so its loader results are too.
100
+ const store = RSCRouterContext.getStore();
101
+ const savedCacheScope = store?.insideCacheScope;
102
+ if (store) store.insideCacheScope = false;
103
+
96
104
  const shortCode = shortCodeOverride ?? entry.shortCode;
97
105
  const hasLoading = "loading" in entry && entry.loading !== undefined;
98
106
  const loadingDisabled = hasLoading && entry.loading === false;
@@ -100,9 +108,7 @@ export async function resolveLoaders<TEnv>(
100
108
 
101
109
  if (!loadingDisabled) {
102
110
  // Streaming loaders: promises kick off now, settle during RSC serialization.
103
- // No per-loader timing here settlement happens asynchronously during
104
- // RSC/SSR stream consumption, after the perf timeline is logged.
105
- return loaderEntries.map((loaderEntry, i) => {
111
+ const segments = loaderEntries.map((loaderEntry, i) => {
106
112
  const { loader } = loaderEntry;
107
113
  const segmentId = `${shortCode}D${i}.${loader.$$id}`;
108
114
  return {
@@ -122,16 +128,20 @@ export async function resolveLoaders<TEnv>(
122
128
  belongsToRoute,
123
129
  };
124
130
  });
131
+ // Restore cache scope after all loader promises are kicked off
132
+ if (store) store.insideCacheScope = savedCacheScope;
133
+ return segments;
125
134
  }
126
135
 
127
136
  // Loading disabled: still start all loaders in parallel, but only emit
128
137
  // settled promises so handlers don't stream loading placeholders.
129
- // We can measure actual execution time here since we await all loaders.
130
138
  const pendingLoaderData = loaderEntries.map((loaderEntry) => {
131
139
  const start = performance.now();
132
140
  const promise = resolveLoaderData(loaderEntry, ctx, ctx.pathname);
133
141
  return { promise, start, loaderId: loaderEntry.loader.$$id };
134
142
  });
143
+ // Restore cache scope after all loader promises are kicked off
144
+ if (store) store.insideCacheScope = savedCacheScope;
135
145
  await Promise.all(pendingLoaderData.map((p) => p.promise));
136
146
 
137
147
  return loaderEntries.map((loaderEntry, i) => {
@@ -146,6 +146,12 @@ export async function resolveLoadersWithRevalidation<TEnv>(
146
146
  const loaderEntries = entry.loader ?? [];
147
147
  if (loaderEntries.length === 0) return { segments: [], matchedIds: [] };
148
148
 
149
+ // DSL loaders are always fresh — temporarily clear cache scope
150
+ // so non-cacheable var reads are allowed inside loader functions.
151
+ const store = RSCRouterContext.getStore();
152
+ const savedCacheScope = store?.insideCacheScope;
153
+ if (store) store.insideCacheScope = false;
154
+
149
155
  const shortCode = shortCodeOverride ?? entry.shortCode;
150
156
 
151
157
  const loaderMeta = loaderEntries.map((loaderEntry, i) => ({
@@ -242,6 +248,9 @@ export async function resolveLoadersWithRevalidation<TEnv>(
242
248
  }),
243
249
  );
244
250
 
251
+ // Restore cache scope after all loader promises are kicked off
252
+ if (store) store.insideCacheScope = savedCacheScope;
253
+
245
254
  return { segments, matchedIds };
246
255
  }
247
256
 
@@ -672,7 +672,8 @@ export function track(label: string, depth?: number): () => void {
672
672
 
673
673
  /**
674
674
  * Check if the current execution is inside a cache() DSL boundary.
675
- * Reads from the RSCRouterContext ALS store (set during segment resolution).
675
+ * Returns false inside loader execution loaders are always fresh
676
+ * (never cached), so non-cacheable reads are safe.
676
677
  */
677
678
  export function isInsideCacheScope(): boolean {
678
679
  return RSCRouterContext.getStore()?.insideCacheScope === true;
@@ -29,7 +29,7 @@ import {
29
29
  } from "../context-var.js";
30
30
  import { createHandleStore, type HandleStore } from "./handle-store.js";
31
31
  import { isHandle } from "../handle.js";
32
- import { track, type MetricsStore } from "./context.js";
32
+ import { track, RSCRouterContext, type MetricsStore } from "./context.js";
33
33
  import { getFetchableLoader } from "./fetchable-loader-store.js";
34
34
  import type { SegmentCacheStore } from "../cache/types.js";
35
35
  import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
@@ -942,7 +942,6 @@ export function createUseFunction<TEnv>(
942
942
  ),
943
943
  };
944
944
 
945
- // Start loader execution with tracking
946
945
  const doneLoader = track(`loader:${loader.$$id}`, 2);
947
946
  const promise = Promise.resolve(loaderFn(loaderCtx)).finally(() => {
948
947
  doneLoader();