@rangojs/router 0.0.0-experimental.112 → 0.0.0-experimental.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/bin/rango.js +74 -3
  2. package/dist/vite/index.js +133 -18
  3. package/package.json +1 -1
  4. package/skills/cache-guide/SKILL.md +35 -24
  5. package/skills/caching/SKILL.md +115 -7
  6. package/skills/document-cache/SKILL.md +78 -55
  7. package/skills/hooks/SKILL.md +40 -22
  8. package/skills/links/SKILL.md +10 -10
  9. package/skills/loader/SKILL.md +3 -3
  10. package/skills/rango/SKILL.md +16 -10
  11. package/skills/react-compiler/SKILL.md +168 -0
  12. package/skills/use-cache/SKILL.md +34 -5
  13. package/skills/view-transitions/SKILL.md +85 -3
  14. package/src/browser/react/location-state-shared.ts +93 -3
  15. package/src/browser/react/use-reverse.ts +19 -12
  16. package/src/build/route-types/per-module-writer.ts +4 -1
  17. package/src/build/route-types/router-processing.ts +14 -1
  18. package/src/build/route-types/source-scan.ts +118 -0
  19. package/src/cache/cache-scope.ts +28 -42
  20. package/src/cache/cf/cf-cache-store.ts +49 -6
  21. package/src/handle.ts +3 -5
  22. package/src/loader-store.ts +62 -25
  23. package/src/loader.rsc.ts +2 -5
  24. package/src/loader.ts +3 -10
  25. package/src/missing-id-error.ts +68 -0
  26. package/src/reverse.ts +16 -13
  27. package/src/route-definition/dsl-helpers.ts +5 -2
  28. package/src/route-definition/helpers-types.ts +31 -10
  29. package/src/router/loader-resolution.ts +16 -2
  30. package/src/router/match-middleware/cache-lookup.ts +44 -91
  31. package/src/router/match-middleware/cache-store.ts +3 -2
  32. package/src/router/router-options.ts +24 -0
  33. package/src/router/segment-resolution/fresh.ts +17 -4
  34. package/src/router/segment-resolution/revalidation.ts +17 -4
  35. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  36. package/src/router/types.ts +8 -0
  37. package/src/router.ts +2 -0
  38. package/src/segment-system.tsx +59 -10
  39. package/src/server/context.ts +26 -0
  40. package/src/server/cookie-store.ts +28 -4
  41. package/src/types/handler-context.ts +5 -2
  42. package/src/types/segments.ts +18 -1
  43. package/src/urls/path-helper-types.ts +9 -1
  44. package/src/use-loader.tsx +89 -42
  45. package/src/vite/plugins/expose-ids/export-analysis.ts +68 -12
  46. package/src/vite/plugins/expose-internal-ids.ts +12 -4
  47. package/src/vite/plugins/use-cache-transform.ts +12 -10
  48. package/src/vite/router-discovery.ts +14 -2
@@ -56,6 +56,15 @@ export const CACHE_STALE_AT_HEADER = "x-edge-cache-stale-at";
56
56
  /** Header storing cache status: HIT | REVALIDATING */
57
57
  export const CACHE_STATUS_HEADER = "x-edge-cache-status";
58
58
 
59
+ /**
60
+ * Header stashing the route author's original Cache-Control on L1 document
61
+ * entries. putResponse/promoteResponseToL1 overwrite Cache-Control with a long
62
+ * `max-age` so the CF Cache API retains the entry across the whole SWR window;
63
+ * getResponse restores this original value before serving so the client and any
64
+ * upstream CDN see the author's intended directive, not the internal edge TTL.
65
+ */
66
+ const CACHE_ORIG_CC_HEADER = "x-edge-cache-orig-cc";
67
+
59
68
  /**
60
69
  * Maximum age in seconds for REVALIDATING status before allowing new revalidation.
61
70
  * After this period, a stale entry in REVALIDATING status will trigger revalidation again.
@@ -182,7 +191,7 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
182
191
  * Cache version string override. When this changes, all cached entries are
183
192
  * effectively invalidated (new keys won't match old entries).
184
193
  *
185
- * Defaults to the auto-generated VERSION from `rsc-router:version` virtual module.
194
+ * Defaults to the auto-generated VERSION from the `@rangojs/router:version` virtual module.
186
195
  * Only set this if you need a custom versioning strategy.
187
196
  */
188
197
  version?: string;
@@ -419,7 +428,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
419
428
  }
420
429
 
421
430
  // L2: persist to KV
422
- this.kvSetSegment(key, data, staleAt, totalTtl);
431
+ this.kvSetSegment(key, data, staleAt, totalTtl, swrWindow);
423
432
  } catch (error) {
424
433
  console.error("[CFCacheStore] set failed:", error);
425
434
  }
@@ -478,7 +487,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
478
487
  const isStale = staleAt > 0 && Date.now() > staleAt;
479
488
 
480
489
  return {
481
- response,
490
+ response: this.toClientResponse(response),
482
491
  shouldRevalidate: isStale,
483
492
  };
484
493
  } catch (error) {
@@ -487,6 +496,30 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
487
496
  }
488
497
  }
489
498
 
499
+ /**
500
+ * Strip internal edge headers and restore the author's Cache-Control before a
501
+ * cached document Response is served to a client. L1 entries carry the
502
+ * internal staleness/status headers and a rewritten Cache-Control; none of
503
+ * those should reach the browser or an upstream CDN.
504
+ */
505
+ private toClientResponse(response: Response): Response {
506
+ const headers = new Headers(response.headers);
507
+ const originalCacheControl = headers.get(CACHE_ORIG_CC_HEADER);
508
+ if (originalCacheControl !== null) {
509
+ headers.set("Cache-Control", originalCacheControl);
510
+ } else {
511
+ headers.delete("Cache-Control");
512
+ }
513
+ headers.delete(CACHE_ORIG_CC_HEADER);
514
+ headers.delete(CACHE_STALE_AT_HEADER);
515
+ headers.delete(CACHE_STATUS_HEADER);
516
+ return new Response(response.body, {
517
+ status: response.status,
518
+ statusText: response.statusText,
519
+ headers,
520
+ });
521
+ }
522
+
490
523
  /**
491
524
  * Store a Response with TTL and optional SWR window (for document-level caching).
492
525
  * When KV is configured, also persists to L2.
@@ -513,8 +546,14 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
513
546
  : [null, null]
514
547
  : [response.body, null];
515
548
 
516
- // Clone and add cache headers
549
+ // Clone and add cache headers. The author's Cache-Control is stashed and
550
+ // replaced with a long max-age so the CF Cache API holds the entry across
551
+ // the SWR window; getResponse restores the original before serving.
517
552
  const headers = new Headers(response.headers);
553
+ const originalCacheControl = response.headers.get("Cache-Control");
554
+ if (originalCacheControl !== null) {
555
+ headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
556
+ }
518
557
  headers.set("Cache-Control", `public, max-age=${totalTtl}`);
519
558
  headers.set(CACHE_STALE_AT_HEADER, String(staleAt));
520
559
 
@@ -764,13 +803,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
764
803
  data: CachedEntryData,
765
804
  staleAt: number,
766
805
  totalTtl: number,
806
+ swrWindow: number,
767
807
  ): void {
768
808
  // KV requires expirationTtl >= 60s. Skip write for short-lived entries.
769
809
  if (!this.kv || !this.waitUntil || totalTtl < 60) return;
770
810
 
771
811
  const kvKey = this.toKVKey(key);
772
- const swrWindow = totalTtl * 1000 - (staleAt - Date.now());
773
- const expiresAt = staleAt + swrWindow;
812
+ const expiresAt = staleAt + swrWindow * 1000;
774
813
 
775
814
  this.waitUntil(async () => {
776
815
  try {
@@ -937,6 +976,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
937
976
  const request = this.keyToRequest(`doc:${key}`);
938
977
 
939
978
  const headers = new Headers(envelope.hd);
979
+ const originalCacheControl = headers.get("Cache-Control");
980
+ if (originalCacheControl !== null) {
981
+ headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
982
+ }
940
983
  headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
941
984
  headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
942
985
 
package/src/handle.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { missingInjectedIdError } from "./missing-id-error.js";
2
+
1
3
  /**
2
4
  * Handle definition for accumulating data across route segments.
3
5
  *
@@ -96,11 +98,7 @@ export function createHandle<TData, TAccumulated = TData[]>(
96
98
  const handleId = __injectedId ?? "";
97
99
 
98
100
  if (!handleId && process.env.NODE_ENV === "development") {
99
- throw new Error(
100
- "[rango] Handle is missing $$id. " +
101
- "Make sure the exposeInternalIds Vite plugin is enabled and " +
102
- "the handle is exported with: export const MyHandle = createHandle(...)",
103
- );
101
+ throw missingInjectedIdError("Handle", "createHandle");
104
102
  }
105
103
 
106
104
  const collectFn =
@@ -36,6 +36,13 @@
36
36
 
37
37
  export interface LoaderEntry<T = unknown> {
38
38
  readonly value: T | undefined;
39
+ /**
40
+ * Whether a load has committed a value to this bucket. Distinguishes a
41
+ * committed `null`/`undefined` result from "never loaded", so a loader that
42
+ * resolves to a falsy value is not mistaken for an empty bucket and is not
43
+ * overridden by the server-seeded context value.
44
+ */
45
+ readonly hasValue: boolean;
39
46
  readonly error: Error | null;
40
47
  readonly isLoading: boolean;
41
48
  /** Identifies the request that produced this snapshot. 0 means "no request". */
@@ -44,6 +51,7 @@ export interface LoaderEntry<T = unknown> {
44
51
 
45
52
  const EMPTY_SNAPSHOT: LoaderEntry = Object.freeze({
46
53
  value: undefined,
54
+ hasValue: false,
47
55
  error: null,
48
56
  isLoading: false,
49
57
  requestId: 0,
@@ -67,14 +75,15 @@ export interface SubscribeOptions {
67
75
  */
68
76
  ephemeral?: boolean;
69
77
  /**
70
- * Cross-loader refresh group name. Tags this bucket so `refreshGroup(name)`
71
- * can refresh it alongside buckets of other loaders. Group membership follows
72
- * subscriber presence: a bucket leaves its group when its last subscriber
73
- * unsubscribes.
78
+ * Cross-loader refresh group name(s). Tags this bucket so `refreshGroups(name)`
79
+ * can refresh it alongside buckets of other loaders. A bucket may be tagged
80
+ * with several group names at once (pass an array); it is then refreshed when
81
+ * ANY of its groups is refreshed. Group membership follows subscriber presence:
82
+ * a bucket leaves a group when that group's last subscriber unsubscribes.
74
83
  */
75
- group?: string;
84
+ group?: string | string[];
76
85
  /**
77
- * Plain-GET refresh thunk used by `refreshGroup`. Provided alongside `group`.
86
+ * Plain-GET refresh thunk used by `refreshGroups`. Provided alongside `group`.
78
87
  * Refreshes this bucket in place (no params/body) and rejects on failure.
79
88
  */
80
89
  refetch?: () => Promise<void>;
@@ -102,20 +111,31 @@ interface InternalEntry {
102
111
  /**
103
112
  * Cross-loader refresh groups this bucket belongs to, mapped to the number of
104
113
  * current subscribers that requested each group. A bucket can be in several
105
- * groups at once (different subscribers may tag the same shared bucket with
106
- * different group names); refcounting keeps membership independent of
107
- * subscribe/unsubscribe order.
114
+ * groups at once (one read tagged with multiple names, or different subscribers
115
+ * tagging the same shared bucket with different names); refcounting keeps
116
+ * membership independent of subscribe/unsubscribe order.
108
117
  */
109
118
  groups: Map<string, number>;
110
- /** Plain-GET refresh thunk for `refreshGroup`, set while in any group. */
119
+ /** Plain-GET refresh thunk for `refreshGroups`, set while in any group. */
111
120
  refetch: (() => Promise<void>) | undefined;
112
121
  }
113
122
 
123
+ /**
124
+ * Normalize a group tag option (`undefined | string | string[]`) to a deduped
125
+ * list of names. Deduping keeps a single subscriber from being counted more than
126
+ * once in one group when the caller passes a repeated name.
127
+ */
128
+ function normalizeGroups(group: string | string[] | undefined): string[] {
129
+ if (group === undefined) return [];
130
+ if (typeof group === "string") return [group];
131
+ return [...new Set(group)];
132
+ }
133
+
114
134
  export class LoaderStore {
115
135
  private readonly entries = new Map<string, InternalEntry>();
116
136
  /** loader.$$id -> set of bucket keys, so clearFamily() can reach every bucket. */
117
137
  private readonly families = new Map<string, Set<string>>();
118
- /** refresh group name -> set of bucket keys, for refreshGroup(). */
138
+ /** refresh group name -> set of bucket keys, for refreshGroups(). */
119
139
  private readonly groups = new Map<string, Set<string>>();
120
140
 
121
141
  private getOrCreate(bucketKey: string): InternalEntry {
@@ -163,8 +183,11 @@ export class LoaderStore {
163
183
  e.loaderId = loaderId;
164
184
  this.registerFamily(loaderId, bucketKey);
165
185
  if (options?.ephemeral !== true) e.sticky = true;
166
- const group = options?.group;
167
- if (group !== undefined) {
186
+ // Normalize the group tag(s) to a deduped list so one subscriber is counted
187
+ // once per distinct group, and subscribe/unsubscribe stay symmetric (the
188
+ // same list drives both addToGroup and releaseGroup).
189
+ const groups = normalizeGroups(options?.group);
190
+ for (const group of groups) {
168
191
  this.addToGroup(group, bucketKey, e, options?.refetch);
169
192
  }
170
193
  // A fresh subscriber means the bucket is wanted again: cancel any pending
@@ -174,11 +197,11 @@ export class LoaderStore {
174
197
  e.listeners.add(cb);
175
198
  return () => {
176
199
  e.listeners.delete(cb);
177
- // Group membership is refcounted per subscriber so refreshGroup() never
200
+ // Group membership is refcounted per subscriber so refreshGroups() never
178
201
  // fetches for an unmounted reader, and a bucket shared by subscribers in
179
202
  // different groups stays in each group until ALL of that group's
180
203
  // subscribers have left (order-independent).
181
- if (group !== undefined) this.releaseGroup(group, bucketKey, e);
204
+ for (const group of groups) this.releaseGroup(group, bucketKey, e);
182
205
  this.maybeScheduleRefcountClear(bucketKey, e);
183
206
  };
184
207
  }
@@ -232,17 +255,27 @@ export class LoaderStore {
232
255
  }
233
256
 
234
257
  /**
235
- * Refresh every currently-mounted bucket in a cross-loader refresh group with
236
- * a plain GET (no params/body). Buckets are deduped by key, so multiple reads
237
- * of one bucket trigger a single fetch. Resolves when all refreshes settle;
238
- * rejects with an `AggregateError` of the failures if any member fails — each
239
- * failing member also records its error on its own snapshot.
258
+ * Refresh every currently-mounted bucket tagged with ANY of the given group
259
+ * names, with a plain GET (no params/body). Accepts a single name or an array
260
+ * of names. A bucket that belongs to more than one of the named groups is
261
+ * refreshed exactly once (members are unioned and deduped by bucket key), as
262
+ * are multiple reads of one bucket. Resolves when all refreshes settle; rejects
263
+ * with an `AggregateError` of the failures if any member fails — each failing
264
+ * member also records its error on its own snapshot.
240
265
  */
241
- async refreshGroup(group: string): Promise<void> {
242
- const members = this.groups.get(group);
243
- if (!members || members.size === 0) return;
266
+ async refreshGroups(groups: string | string[]): Promise<void> {
267
+ const names = typeof groups === "string" ? [groups] : groups;
268
+ // Union the member buckets across every named group, deduped by key, so a
269
+ // bucket tagged into two of the requested groups is fetched a single time.
270
+ const buckets = new Set<string>();
271
+ for (const name of names) {
272
+ const members = this.groups.get(name);
273
+ if (!members) continue;
274
+ for (const bucketKey of members) buckets.add(bucketKey);
275
+ }
276
+ if (buckets.size === 0) return;
244
277
  const thunks: Array<() => Promise<void>> = [];
245
- for (const bucketKey of members) {
278
+ for (const bucketKey of buckets) {
246
279
  const e = this.entries.get(bucketKey);
247
280
  if (!e || e.listeners.size === 0 || !e.refetch) continue;
248
281
  thunks.push(e.refetch);
@@ -253,9 +286,10 @@ export class LoaderStore {
253
286
  .filter((r): r is PromiseRejectedResult => r.status === "rejected")
254
287
  .map((r) => r.reason);
255
288
  if (reasons.length > 0) {
289
+ const label = names.map((n) => `"${n}"`).join(", ");
256
290
  throw new AggregateError(
257
291
  reasons,
258
- `refreshGroup("${group}") had ${reasons.length} failure(s)`,
292
+ `refreshGroups(${label}) had ${reasons.length} failure(s)`,
259
293
  );
260
294
  }
261
295
  }
@@ -346,6 +380,7 @@ export class LoaderStore {
346
380
  if (e.snapshot.isLoading && e.snapshot.error === null) return;
347
381
  e.snapshot = Object.freeze({
348
382
  value: e.snapshot.value,
383
+ hasValue: e.snapshot.hasValue,
349
384
  error: null,
350
385
  isLoading: true,
351
386
  requestId,
@@ -363,6 +398,7 @@ export class LoaderStore {
363
398
  if (!e || requestId !== e.latestRequestId) return;
364
399
  e.snapshot = Object.freeze({
365
400
  value,
401
+ hasValue: true,
366
402
  error: null,
367
403
  isLoading: false,
368
404
  requestId,
@@ -381,6 +417,7 @@ export class LoaderStore {
381
417
  if (!e || requestId !== e.latestRequestId) return;
382
418
  e.snapshot = Object.freeze({
383
419
  value: e.snapshot.value,
420
+ hasValue: e.snapshot.hasValue,
384
421
  error,
385
422
  isLoading: false,
386
423
  requestId,
package/src/loader.rsc.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  registerFetchableLoader,
22
22
  getFetchableLoader,
23
23
  } from "./server/fetchable-loader-store.js";
24
+ import { missingInjectedIdError } from "./missing-id-error.js";
24
25
 
25
26
  export { getFetchableLoader };
26
27
 
@@ -53,11 +54,7 @@ export function createLoader<T>(
53
54
  const loaderId = __injectedId || "";
54
55
 
55
56
  if (!loaderId && process.env.NODE_ENV === "development") {
56
- throw new Error(
57
- "[rango] Loader is missing $$id. " +
58
- "Make sure the exposeInternalIds Vite plugin is enabled and " +
59
- "the loader is exported with: export const MyLoader = createLoader(...)",
60
- );
57
+ throw missingInjectedIdError("Loader", "createLoader");
61
58
  }
62
59
 
63
60
  // If not fetchable, store fn in registry (for SSR ctx.use() resolution)
package/src/loader.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * rsc-router/loader (client version)
2
+ * @rangojs/router/loader (client version)
3
3
  *
4
4
  * Client-only stub for createLoader. Returns a minimal loader definition
5
5
  * ({ __brand, $$id }) that can be passed to hooks like useLoader.
@@ -18,6 +18,7 @@ import type {
18
18
  LoaderDefinition,
19
19
  LoaderFn,
20
20
  } from "./types.js";
21
+ import { missingInjectedIdError } from "./missing-id-error.js";
21
22
 
22
23
  // Overload 1: With function only (not fetchable)
23
24
  export function createLoader<T>(
@@ -38,10 +39,6 @@ export function createLoader<T>(
38
39
 
39
40
  // Implementation - client stub that just returns the loader definition
40
41
  // The $$id parameter is injected by Vite plugin, not user-provided
41
- //
42
- // NOTE: For export-only loader files, the Vite plugin replaces the entire
43
- // file with object literals (bypassing this function). This function only
44
- // runs when loaders are in mixed files (not export-only).
45
42
  export function createLoader<T>(
46
43
  _fn: LoaderFn<T, Record<string, string | undefined>, any>,
47
44
  _fetchable?: true | FetchableLoaderOptions,
@@ -50,11 +47,7 @@ export function createLoader<T>(
50
47
  const loaderId = __injectedId || "";
51
48
 
52
49
  if (!loaderId && process.env.NODE_ENV === "development") {
53
- throw new Error(
54
- "[rango] Loader is missing $$id. " +
55
- "Make sure the exposeInternalIds Vite plugin is enabled and " +
56
- "the loader is exported with: export const MyLoader = createLoader(...)",
57
- );
50
+ throw missingInjectedIdError("Loader", "createLoader");
58
51
  }
59
52
 
60
53
  return {
@@ -0,0 +1,68 @@
1
+ // Builds the error thrown when a create*() call (createLoader / createHandle)
2
+ // reaches runtime without an injected $$id. The exposeInternalIds Vite transform
3
+ // injects $$id only for an EXPORTED const declaration, so a non-exported const,
4
+ // an `export let/var`, or an inline create*() call gets none. Previously this
5
+ // failed with a terse message and no source location; this helper adds the
6
+ // offending call site (best-effort, from the stack) and actionable guidance.
7
+ //
8
+ // The "<Kind> is missing $$id" prefix is preserved so existing tests and any
9
+ // log scrapers keep matching. Dev-only: the call sites guard on
10
+ // process.env.NODE_ENV === "development", so production builds fold the branch
11
+ // away and tree-shake this module out.
12
+
13
+ // create*() implementation files to skip when locating the user's call site.
14
+ const SELF_FILES = new Set([
15
+ "missing-id-error",
16
+ "loader",
17
+ "loader.rsc",
18
+ "handle",
19
+ ]);
20
+
21
+ /**
22
+ * Best-effort "path:line:column" of the user's create*() call, parsed from the
23
+ * current stack. Skips @rangojs/router internals and node_modules. Returns
24
+ * undefined if nothing usable is found (stack parsing is inherently fragile).
25
+ */
26
+ function findUserCallSite(): string | undefined {
27
+ try {
28
+ const stack = new Error().stack;
29
+ if (!stack) return undefined;
30
+ for (const frame of stack.split("\n").slice(1)) {
31
+ const m = frame.match(
32
+ /(?:\(|@|\s)(?:file:\/\/)?((?:\/|[A-Za-z]:[\\/])[^()\s]+?\.(?:ts|tsx|js|jsx|mts|cts)):(\d+):(\d+)\)?/,
33
+ );
34
+ if (!m) continue;
35
+ const path = m[1];
36
+ if (path.includes("node_modules") || path.includes("@rangojs/router")) {
37
+ continue;
38
+ }
39
+ const base = path
40
+ .split(/[\\/]/)
41
+ .pop()!
42
+ .replace(/\.(?:ts|tsx|js|jsx|mts|cts)$/, "");
43
+ if (SELF_FILES.has(base)) continue;
44
+ return `${path}:${m[2]}:${m[3]}`;
45
+ }
46
+ } catch {
47
+ // best-effort only
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ export function missingInjectedIdError(
53
+ kind: "Loader" | "Handle",
54
+ fnName: "createLoader" | "createHandle",
55
+ ): Error {
56
+ const site = findUserCallSite();
57
+ const at = site ? ` (created at ${site})` : "";
58
+ return new Error(
59
+ `[rango] ${kind} is missing $$id${at}.\n` +
60
+ `The @rangojs/router:expose-internal-ids Vite transform injects ${fnName}()'s ` +
61
+ `stable $$id from an EXPORTED const declaration only:\n` +
62
+ ` export const X = ${fnName}(...)\n` +
63
+ ` const X = ${fnName}(...); export { X }\n` +
64
+ `A non-exported const, an \`export let/var\`, or an inline ${fnName}(...) ` +
65
+ `call gets no $$id — export it as \`export const\`. (A matching ` +
66
+ `"Unsupported ${fnName} shape" warning names the exact file:line.)`,
67
+ );
68
+ }
package/src/reverse.ts CHANGED
@@ -231,47 +231,50 @@ export type LocalReverseParams<TPattern extends string> =
231
231
  };
232
232
 
233
233
  /**
234
- * Type-safe local reverse function with dot-prefixed names only.
234
+ * Type-safe local reverse function.
235
235
  *
236
236
  * Returned by `useReverse(routes)` on the client. The route map is the
237
237
  * exposure boundary (a generated `routes` from a `urls()` module) and the
238
- * scope is implicit from that import there is no global namespace, so
239
- * names must be dot-prefixed to mirror `ctx.reverse(".name")`.
238
+ * scope is implicit from that import. Names may be written with or without a
239
+ * leading dot `reverse("post")` and `reverse(".post")` are identical. The dot
240
+ * is a cosmetic readability convention (and parity with `ctx.reverse(".name")`);
241
+ * there is no separate global namespace here, so it carries no meaning.
240
242
  *
241
243
  * @example
242
244
  * ```typescript
243
245
  * const reverse = useReverse(blogRoutes);
244
- * reverse(".index"); // ✓ no params
245
- * reverse(".post", { postId: "hello" }); // ✓ with params
246
- * reverse(".search", {}, { q: "hi" }); // ✓ with search schema
247
- * reverse(".typo"); // compile error
246
+ * reverse("index"); // ✓ no params (dot optional)
247
+ * reverse(".index"); // ✓ identical to the above
248
+ * reverse("post", { postId: "hello" }); // ✓ with params
249
+ * reverse("search", {}, { q: "hi" }); // with search schema
250
+ * reverse("typo"); // ✗ compile error
248
251
  * ```
249
252
  */
250
253
  export type LocalReverseFunction<TLocalRoutes> = {
251
254
  /**
252
- * Dot-prefixed local route without params
255
+ * Route without params (leading dot optional)
253
256
  */
254
257
  <TName extends keyof TLocalRoutes & string>(
255
258
  name: IsEmptyObject<
256
259
  ExtractParams<RoutePatternFor<TLocalRoutes, TName>>
257
260
  > extends true
258
- ? `.${TName}`
261
+ ? TName | `.${TName}`
259
262
  : never,
260
263
  ): string;
261
264
 
262
265
  /**
263
- * Dot-prefixed local route with params
266
+ * Route with params (leading dot optional)
264
267
  */
265
268
  <TName extends keyof TLocalRoutes & string>(
266
- name: `.${TName}`,
269
+ name: TName | `.${TName}`,
267
270
  params: LocalReverseParams<RoutePatternFor<TLocalRoutes, TName>>,
268
271
  ): string;
269
272
 
270
273
  /**
271
- * Dot-prefixed local route with params and search
274
+ * Route with params and search (leading dot optional)
272
275
  */
273
276
  <TName extends keyof TLocalRoutes & string>(
274
- name: `.${TName}`,
277
+ name: TName | `.${TName}`,
275
278
  params: LocalReverseParams<RoutePatternFor<TLocalRoutes, TName>>,
276
279
  search: ResolveSearchSchema<ExtractSearchSchema<TLocalRoutes, TName>>,
277
280
  ): string;
@@ -866,8 +866,11 @@ const loading: RouteHelpers<any, any>["loading"] = (component, options) => {
866
866
  };
867
867
 
868
868
  /**
869
- * Transition helper - attaches a ViewTransition config to the current entry
870
- * or wraps a group of routes in a transparent layout with ViewTransition
869
+ * Transition helper - opts the entry (or a wrapped group of routes) into
870
+ * transition-driven navigation by attaching a TransitionConfig. This drives the
871
+ * commit through startTransition (content hold on all React versions) and, on
872
+ * experimental React, places a `<ViewTransition>` boundary unless
873
+ * `viewTransition: false`. See skills/view-transitions for the matrix.
871
874
  */
872
875
  const transition = (
873
876
  configOrChildren?: TransitionConfig | (() => UseItems<AllUseItems>),
@@ -449,10 +449,30 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
449
449
  ): CacheItem;
450
450
  };
451
451
  /**
452
- * Attach a ViewTransition boundary to the current segment or a group of routes
453
- *
454
- * Wraps segment content with React's `<ViewTransition>` component.
455
- * Only takes effect when React experimental is used (no-op on stable React).
452
+ * Opt a route (or group of routes) into transition-driven navigation.
453
+ *
454
+ * `transition()` does two independent things, and you choose how far to go:
455
+ * 1. startTransition (ALL React versions): the navigation commit is driven
456
+ * through React's startTransition, so a same-route nav (same route,
457
+ * different params, e.g. /product/1 -> /product/2) holds the previous
458
+ * content while the new loader resolves instead of flashing the route's
459
+ * loading() skeleton (see segment-system.tsx inTransitionScope). This is
460
+ * also the precondition for any view-transition animation.
461
+ * 2. <ViewTransition> (experimental React only): the segment content is also
462
+ * wrapped in React's <ViewTransition>, so the held swap cross-fades/morphs.
463
+ * Layered on by default; pass { viewTransition: false } to keep #1 without
464
+ * the router boundary (and place your own <ViewTransition> instead).
465
+ *
466
+ * A view transition cannot fire without a startTransition, so the meaningful
467
+ * choices are (see skills/view-transitions for the full matrix):
468
+ * - no transition() -> neither (remount + skeleton)
469
+ * - transition({ viewTransition: false }) -> startTransition only (hold)
470
+ * - transition({}) / transition({ enter… }) -> startTransition + ViewTransition
471
+ *
472
+ * Precedence: a bare transition({}) inherits createRouter({ viewTransition })
473
+ * (default "auto"); an explicit per-route `viewTransition` always wins. So
474
+ * transition({}) is startTransition + ViewTransition under the default and
475
+ * startTransition only when the router sets viewTransition: false.
456
476
  *
457
477
  * ```typescript
458
478
  * // Attach to a single route
@@ -466,13 +486,14 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
466
486
  * path("/about", AboutPage),
467
487
  * ])
468
488
  *
469
- * // Direction-aware transitions
470
- * transition({
471
- * enter: { "navigation": "slide-right", "navigation-back": "slide-left" },
472
- * exit: { "navigation": "slide-left", "navigation-back": "slide-right" },
473
- * })
489
+ * // Hold content + drive view transitions, but place no router boundary:
490
+ * path("/product/:id", ProductPage, { name: "product" }, () => [
491
+ * transition({ viewTransition: false }),
492
+ * ])
474
493
  * ```
475
- * @param config - ViewTransition configuration (enter, exit, update, share, default, name)
494
+ * @param config - ViewTransition configuration (enter, exit, update, share,
495
+ * default, name) plus `viewTransition: "auto" | false` to toggle the router
496
+ * boundary (createRouter({ viewTransition }) sets the app-wide default)
476
497
  * @param children - Optional callback returning child routes to wrap
477
498
  */
478
499
  transition: {
@@ -24,7 +24,10 @@ import { isHandle, collectHandleData, type Handle } from "../handle.js";
24
24
  import { buildHandleSnapshot } from "../server/handle-store.js";
25
25
  import { getFetchableLoader } from "../server/fetchable-loader-store.js";
26
26
  import { _getRequestContext } from "../server/request-context.js";
27
- import { isInsideLoaderScope } from "../server/context.js";
27
+ import {
28
+ isInsideLoaderScope,
29
+ runInsideLoaderBodyScope,
30
+ } from "../server/context.js";
28
31
  import { debugLog } from "./logging.js";
29
32
 
30
33
  /**
@@ -353,8 +356,19 @@ function createLoaderExecutor<TEnv>(
353
356
  };
354
357
 
355
358
  const doneLoader = track(`loader:${loader.$$id}`, 2);
359
+ // Run the loader body inside loader scope so request-scoped reads
360
+ // (cookies()/headers() and non-cacheable ctx.get) are exempt from the
361
+ // cache-purity guards: loaders always run fresh, so their reads never leak
362
+ // into a cached segment. DSL loaders are already wrapped by fresh.ts; this
363
+ // also covers handler-invoked loaders (ctx.use(Loader) from a handler),
364
+ // which otherwise execute in the caller's cache scope and would wrongly
365
+ // throw. rendered() gating uses the captured isDslLoader (above), so this
366
+ // does not grant rendered() to handler-invoked loaders. Uses a body-only
367
+ // scope, so isInsideLoaderScope() / barrier / deadlock gating is unchanged.
356
368
  const promise = Promise.resolve(
357
- loaderFn(loaderCtx as LoaderContext<any, TEnv>),
369
+ runInsideLoaderBodyScope(() =>
370
+ loaderFn(loaderCtx as LoaderContext<any, TEnv>),
371
+ ),
358
372
  ).finally(() => {
359
373
  pendingLoaders.delete(loader.$$id);
360
374
  doneLoader();