@rangojs/router 0.0.0-experimental.ea6d5eec → 0.0.0-experimental.ede38110

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 (142) hide show
  1. package/README.md +76 -18
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +719 -240
  4. package/package.json +3 -3
  5. package/skills/cache-guide/SKILL.md +32 -0
  6. package/skills/caching/SKILL.md +8 -0
  7. package/skills/handler-use/SKILL.md +362 -0
  8. package/skills/intercept/SKILL.md +20 -0
  9. package/skills/layout/SKILL.md +22 -0
  10. package/skills/links/SKILL.md +3 -1
  11. package/skills/loader/SKILL.md +53 -43
  12. package/skills/middleware/SKILL.md +34 -3
  13. package/skills/migrate-nextjs/SKILL.md +560 -0
  14. package/skills/migrate-react-router/SKILL.md +764 -0
  15. package/skills/parallel/SKILL.md +185 -0
  16. package/skills/prerender/SKILL.md +110 -68
  17. package/skills/rango/SKILL.md +24 -22
  18. package/skills/route/SKILL.md +55 -0
  19. package/skills/router-setup/SKILL.md +87 -2
  20. package/skills/typesafety/SKILL.md +10 -0
  21. package/src/__internal.ts +1 -1
  22. package/src/browser/app-version.ts +14 -0
  23. package/src/browser/event-controller.ts +5 -0
  24. package/src/browser/navigation-bridge.ts +37 -5
  25. package/src/browser/navigation-client.ts +107 -75
  26. package/src/browser/navigation-store.ts +43 -8
  27. package/src/browser/partial-update.ts +51 -6
  28. package/src/browser/prefetch/cache.ts +22 -12
  29. package/src/browser/prefetch/fetch.ts +81 -20
  30. package/src/browser/prefetch/queue.ts +61 -29
  31. package/src/browser/prefetch/resource-ready.ts +77 -0
  32. package/src/browser/react/Link.tsx +67 -8
  33. package/src/browser/react/NavigationProvider.tsx +13 -4
  34. package/src/browser/react/context.ts +7 -2
  35. package/src/browser/react/use-handle.ts +9 -58
  36. package/src/browser/react/use-navigation.ts +11 -10
  37. package/src/browser/react/use-router.ts +21 -8
  38. package/src/browser/rsc-router.tsx +45 -3
  39. package/src/browser/scroll-restoration.ts +10 -8
  40. package/src/browser/segment-reconciler.ts +36 -9
  41. package/src/browser/server-action-bridge.ts +8 -6
  42. package/src/browser/types.ts +27 -5
  43. package/src/build/generate-manifest.ts +6 -6
  44. package/src/build/generate-route-types.ts +3 -0
  45. package/src/build/route-trie.ts +50 -24
  46. package/src/build/route-types/include-resolution.ts +8 -1
  47. package/src/build/route-types/router-processing.ts +211 -72
  48. package/src/build/route-types/scan-filter.ts +8 -1
  49. package/src/cache/cache-runtime.ts +15 -11
  50. package/src/cache/cache-scope.ts +46 -5
  51. package/src/cache/document-cache.ts +17 -7
  52. package/src/cache/taint.ts +55 -0
  53. package/src/client.tsx +84 -230
  54. package/src/context-var.ts +72 -2
  55. package/src/debug.ts +2 -2
  56. package/src/handle.ts +40 -0
  57. package/src/index.rsc.ts +3 -1
  58. package/src/index.ts +46 -6
  59. package/src/prerender/store.ts +5 -4
  60. package/src/prerender.ts +138 -77
  61. package/src/reverse.ts +25 -1
  62. package/src/route-definition/dsl-helpers.ts +224 -37
  63. package/src/route-definition/helpers-types.ts +67 -19
  64. package/src/route-definition/index.ts +3 -0
  65. package/src/route-definition/redirect.ts +9 -1
  66. package/src/route-definition/resolve-handler-use.ts +149 -0
  67. package/src/route-types.ts +18 -0
  68. package/src/router/content-negotiation.ts +100 -1
  69. package/src/router/handler-context.ts +82 -23
  70. package/src/router/intercept-resolution.ts +9 -4
  71. package/src/router/lazy-includes.ts +7 -6
  72. package/src/router/loader-resolution.ts +156 -21
  73. package/src/router/logging.ts +1 -1
  74. package/src/router/manifest.ts +28 -15
  75. package/src/router/match-api.ts +124 -189
  76. package/src/router/match-middleware/background-revalidation.ts +30 -2
  77. package/src/router/match-middleware/cache-lookup.ts +94 -17
  78. package/src/router/match-middleware/cache-store.ts +53 -10
  79. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  80. package/src/router/match-middleware/segment-resolution.ts +60 -5
  81. package/src/router/match-result.ts +104 -10
  82. package/src/router/metrics.ts +6 -1
  83. package/src/router/middleware-types.ts +6 -8
  84. package/src/router/middleware.ts +2 -5
  85. package/src/router/navigation-snapshot.ts +182 -0
  86. package/src/router/prerender-match.ts +110 -10
  87. package/src/router/preview-match.ts +30 -102
  88. package/src/router/request-classification.ts +310 -0
  89. package/src/router/route-snapshot.ts +245 -0
  90. package/src/router/router-context.ts +1 -0
  91. package/src/router/router-interfaces.ts +36 -4
  92. package/src/router/router-options.ts +37 -11
  93. package/src/router/segment-resolution/fresh.ts +198 -20
  94. package/src/router/segment-resolution/helpers.ts +29 -24
  95. package/src/router/segment-resolution/loader-cache.ts +1 -0
  96. package/src/router/segment-resolution/revalidation.ts +433 -296
  97. package/src/router/types.ts +1 -0
  98. package/src/router.ts +55 -6
  99. package/src/rsc/handler.ts +472 -372
  100. package/src/rsc/loader-fetch.ts +23 -3
  101. package/src/rsc/manifest-init.ts +5 -1
  102. package/src/rsc/progressive-enhancement.ts +14 -2
  103. package/src/rsc/rsc-rendering.ts +10 -1
  104. package/src/rsc/server-action.ts +8 -0
  105. package/src/rsc/ssr-setup.ts +2 -2
  106. package/src/rsc/types.ts +9 -1
  107. package/src/segment-content-promise.ts +67 -0
  108. package/src/segment-loader-promise.ts +122 -0
  109. package/src/segment-system.tsx +109 -23
  110. package/src/server/context.ts +166 -17
  111. package/src/server/handle-store.ts +19 -0
  112. package/src/server/loader-registry.ts +9 -8
  113. package/src/server/request-context.ts +175 -15
  114. package/src/ssr/index.tsx +4 -0
  115. package/src/static-handler.ts +18 -6
  116. package/src/types/cache-types.ts +4 -4
  117. package/src/types/handler-context.ts +137 -33
  118. package/src/types/loader-types.ts +36 -9
  119. package/src/types/route-entry.ts +12 -1
  120. package/src/types/segments.ts +2 -0
  121. package/src/urls/include-helper.ts +24 -14
  122. package/src/urls/path-helper-types.ts +39 -6
  123. package/src/urls/path-helper.ts +48 -13
  124. package/src/urls/pattern-types.ts +12 -0
  125. package/src/urls/response-types.ts +16 -6
  126. package/src/use-loader.tsx +77 -5
  127. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  128. package/src/vite/discovery/discover-routers.ts +5 -1
  129. package/src/vite/discovery/prerender-collection.ts +128 -74
  130. package/src/vite/discovery/state.ts +13 -4
  131. package/src/vite/index.ts +4 -0
  132. package/src/vite/plugin-types.ts +60 -5
  133. package/src/vite/plugins/expose-id-utils.ts +12 -0
  134. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  135. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  136. package/src/vite/plugins/performance-tracks.ts +88 -0
  137. package/src/vite/plugins/refresh-cmd.ts +88 -26
  138. package/src/vite/rango.ts +19 -2
  139. package/src/vite/router-discovery.ts +178 -37
  140. package/src/vite/utils/banner.ts +3 -3
  141. package/src/vite/utils/prerender-utils.ts +37 -5
  142. package/src/vite/utils/shared-utils.ts +3 -2
@@ -157,10 +157,24 @@ export type InterceptEntry = {
157
157
  when: InterceptWhenFn[]; // Selector conditions - all must return true to intercept
158
158
  };
159
159
 
160
+ export interface ParallelEntryData
161
+ extends EntryPropCommon, EntryPropDatas, EntryPropSegments {
162
+ type: "parallel";
163
+ handler: Record<`@${string}`, Handler<any, any, any> | ReactNode>;
164
+ loading?: ReactNode | false;
165
+ transition?: TransitionConfig;
166
+ /** Set when any parallel slot is a Static definition */
167
+ isStaticPrerender?: true;
168
+ /** Per-slot static handler $$ids for build-time store lookup */
169
+ staticHandlerIds?: Record<string, string>;
170
+ }
171
+
172
+ export type ParallelEntries = Partial<Record<`@${string}`, ParallelEntryData>>;
173
+
160
174
  export type EntryPropSegments = {
161
175
  loader: LoaderEntry[];
162
176
  layout: EntryData[];
163
- parallel: EntryData[]; // type: "parallel" entries with their own loaders/revalidate/loading
177
+ parallel: ParallelEntries; // slot -> parallel entry (same entry may back multiple slots)
164
178
  intercept: InterceptEntry[]; // intercept definitions for soft navigation
165
179
  };
166
180
 
@@ -177,8 +191,12 @@ export type EntryData =
177
191
  /** Original PrerenderHandlerDefinition (for build-time getParams access) */
178
192
  prerenderDef?: {
179
193
  getParams?: (ctx: any) => Promise<any[]> | any[];
180
- options?: { passthrough?: boolean };
194
+ options?: { concurrency?: number };
181
195
  };
196
+ /** Set when route is wrapped with Passthrough() — has a separate live handler */
197
+ isPassthrough?: true;
198
+ /** Live handler for runtime fallback (only set on Passthrough routes) */
199
+ liveHandler?: Handler<any, any, any>;
182
200
  /** Set when handler is a Static definition (build-time only) */
183
201
  isStaticPrerender?: true;
184
202
  /** Static handler $$id for build-time store lookup */
@@ -200,18 +218,7 @@ export type EntryData =
200
218
  } & EntryPropCommon &
201
219
  EntryPropDatas &
202
220
  EntryPropSegments)
203
- | ({
204
- type: "parallel";
205
- handler: Record<`@${string}`, Handler<any, any, any> | ReactNode>;
206
- loading?: ReactNode | false;
207
- transition?: TransitionConfig;
208
- /** Set when any parallel slot is a Static definition */
209
- isStaticPrerender?: true;
210
- /** Per-slot static handler $$ids for build-time store lookup */
211
- staticHandlerIds?: Record<string, string>;
212
- } & EntryPropCommon &
213
- EntryPropDatas &
214
- EntryPropSegments)
221
+ | ParallelEntryData
215
222
  | ({
216
223
  type: "cache";
217
224
  /** Cache entries create cache boundaries and render like layouts (with Outlet) */
@@ -270,6 +277,25 @@ interface HelperContext {
270
277
  string,
271
278
  import("../cache/profile-registry.js").CacheProfile
272
279
  >;
280
+ /** True when resolving handlers inside a cache() DSL boundary.
281
+ * Read by ctx.get() to guard non-cacheable variable reads. */
282
+ insideCacheScope?: boolean;
283
+ /**
284
+ * Include scope string applied to direct-descendant shortCodes.
285
+ *
286
+ * Each `include(...)` call allocates a sibling-positional token like `I0`,
287
+ * `I1` from its parent's include counter and stores the composed scope
288
+ * (`${parentScope}I${idx}`) in its lazyContext. When the include's handler
289
+ * evaluates lazily, the store's `includeScope` is set from that context so
290
+ * every direct-descendant shortCode is generated as
291
+ * `${parent.shortCode}${includeScope}${prefix}${index}` — preventing
292
+ * collisions with siblings declared outside the include.
293
+ *
294
+ * The scope is NOT propagated through `store.run(...)`, so layouts /
295
+ * parallels / caches inside the include absorb the scope into their own
296
+ * shortCodes and their children start fresh.
297
+ */
298
+ includeScope?: string;
273
299
  }
274
300
  // Use a global symbol key so the AsyncLocalStorage instance survives HMR
275
301
  // module re-evaluation. Without this, Vite's RSC module runner may create
@@ -372,6 +398,8 @@ export const getContext = (): {
372
398
  const mountPrefix =
373
399
  store.mountIndex !== undefined ? `M${store.mountIndex}` : "";
374
400
 
401
+ const includeScope = store.includeScope ?? "";
402
+
375
403
  if (!parent) {
376
404
  // Root entry: prefix with mount index and use mount-scoped counter
377
405
  const counterKey = mountPrefix
@@ -382,12 +410,16 @@ export const getContext = (): {
382
410
  store.counters[counterKey] = index + 1;
383
411
  return `${mountPrefix}${prefix}${index}`;
384
412
  } else {
385
- // Child entry: use parent-scoped counter (parent already has M prefix)
386
- const counterKey = `${parent.shortCode}_${type}`;
413
+ // Child entry: use parent-scoped counter with includeScope appended.
414
+ // When we're evaluating a lazy include's direct children, includeScope
415
+ // is a per-include token like "I0" / "I1I0" that partitions the
416
+ // parent's counter namespace so routes inside one include cannot
417
+ // collide with siblings declared outside it.
418
+ const counterKey = `${parent.shortCode}${includeScope}_${type}`;
387
419
  store.counters[counterKey] ??= 0;
388
420
  const index = store.counters[counterKey];
389
421
  store.counters[counterKey] = index + 1;
390
- return `${parent.shortCode}${prefix}${index}`;
422
+ return `${parent.shortCode}${includeScope}${prefix}${index}`;
391
423
  }
392
424
  },
393
425
  runWithStore: <T>(
@@ -414,6 +446,7 @@ export const getContext = (): {
414
446
  rootScoped: store.rootScoped,
415
447
  trackedIncludes: store.trackedIncludes,
416
448
  cacheProfiles: store.cacheProfiles,
449
+ includeScope: store.includeScope,
417
450
  },
418
451
  callback,
419
452
  );
@@ -553,6 +586,80 @@ export function getRootScoped(): boolean {
553
586
  // Export HelperContext type for use in other modules
554
587
  export type { HelperContext };
555
588
 
589
+ /**
590
+ * Return an isolated copy of a lazy include's captured parent entry.
591
+ *
592
+ * DSL helpers (loader(), middleware(), etc.) mutate ctx.parent in place.
593
+ * Multiple include() scopes capture the *same* syntheticMapRoot as their
594
+ * parent, so without isolation one include's loaders/middleware leak into
595
+ * every other route that shares that root.
596
+ *
597
+ * The clone is shallow: only the mutable arrays are copied so each
598
+ * include pushes to its own list. The rest of the entry (id, shortCode,
599
+ * parent pointer, handler) stays shared, which is correct and cheap.
600
+ */
601
+ export function getIsolatedLazyParent(
602
+ captured: EntryData | null | undefined,
603
+ ): EntryData | null {
604
+ if (!captured) return null;
605
+ return {
606
+ ...captured,
607
+ loader: [...captured.loader],
608
+ middleware: [...captured.middleware],
609
+ revalidate: [...captured.revalidate],
610
+ errorBoundary: [...captured.errorBoundary],
611
+ notFoundBoundary: [...captured.notFoundBoundary],
612
+ layout: [...captured.layout],
613
+ parallel: { ...captured.parallel },
614
+ intercept: [...captured.intercept],
615
+ };
616
+ }
617
+
618
+ export function getParallelEntries(
619
+ parallels: ParallelEntries | EntryData[] | undefined,
620
+ ): ParallelEntryData[] {
621
+ if (!parallels) return [];
622
+ if (Array.isArray(parallels)) {
623
+ return parallels.filter(
624
+ (entry): entry is ParallelEntryData => entry.type === "parallel",
625
+ );
626
+ }
627
+ return Object.values(parallels).filter(
628
+ (entry): entry is ParallelEntryData => !!entry,
629
+ );
630
+ }
631
+
632
+ export function getParallelSlotEntries(
633
+ parallels: ParallelEntries | EntryData[] | undefined,
634
+ ): Array<{ slot: `@${string}`; entry: ParallelEntryData }> {
635
+ if (!parallels) return [];
636
+
637
+ if (Array.isArray(parallels)) {
638
+ return getParallelEntries(parallels).flatMap((entry) =>
639
+ (Object.keys(entry.handler) as `@${string}`[]).map((slot) => ({
640
+ slot,
641
+ entry,
642
+ })),
643
+ );
644
+ }
645
+
646
+ return Object.entries(parallels)
647
+ .filter(([, entry]) => !!entry)
648
+ .map(([slot, entry]) => ({
649
+ slot: slot as `@${string}`,
650
+ entry: entry!,
651
+ }));
652
+ }
653
+
654
+ export function getParallelSlotCount(
655
+ parallels: ParallelEntries | EntryData[] | undefined,
656
+ ): number {
657
+ if (!parallels) return 0;
658
+ return Array.isArray(parallels)
659
+ ? parallels.filter((entry) => entry?.type === "parallel").length
660
+ : Object.keys(parallels).length;
661
+ }
662
+
556
663
  // ============================================================================
557
664
  // Performance Metrics Helpers
558
665
  // ============================================================================
@@ -589,3 +696,45 @@ export function track(label: string, depth?: number): () => void {
589
696
  });
590
697
  };
591
698
  }
699
+
700
+ /**
701
+ * Separate ALS for tracking loader execution scope.
702
+ * Uses a dedicated ALS (not RSCRouterContext) to avoid issues with
703
+ * nested RSCRouterContext.run() calls in Vite's module runner.
704
+ */
705
+ const LOADER_SCOPE_KEY = Symbol.for("rangojs-router:loader-scope");
706
+ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
707
+ globalThis as any
708
+ )[LOADER_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
709
+
710
+ /**
711
+ * Check if the current execution is inside a cache() DSL boundary.
712
+ * Returns false inside loader execution — loaders are always fresh
713
+ * (never cached), so non-cacheable reads are safe.
714
+ */
715
+ export function isInsideCacheScope(): boolean {
716
+ if (RSCRouterContext.getStore()?.insideCacheScope !== true) return false;
717
+ // Loaders are always fresh — even inside a cache() boundary, the loader
718
+ // function re-executes on every request. Skip the guard when running
719
+ // inside a loader.
720
+ if (loaderScopeALS.getStore()?.active) return false;
721
+ return true;
722
+ }
723
+
724
+ /**
725
+ * Check if the current execution is inside a DSL loader scope
726
+ * (wrapped by runInsideLoaderScope). Used by rendered() barrier
727
+ * to distinguish DSL loaders from handler-invoked loaders.
728
+ */
729
+ export function isInsideLoaderScope(): boolean {
730
+ return loaderScopeALS.getStore()?.active === true;
731
+ }
732
+
733
+ /**
734
+ * Run `fn` inside a loader scope. While active, cache-scope guards
735
+ * are bypassed because loaders are always fresh (never cached) and
736
+ * their side effects (setCookie, header, etc.) are safe.
737
+ */
738
+ export function runInsideLoaderScope<T>(fn: () => T): T {
739
+ return loaderScopeALS.run({ active: true }, fn);
740
+ }
@@ -13,6 +13,25 @@
13
13
  */
14
14
  export type HandleData = Record<string, Record<string, unknown[]>>;
15
15
 
16
+ /**
17
+ * Build a HandleData snapshot from a HandleStore using segment ordering.
18
+ * Reads data directly from the store for each segment in order.
19
+ */
20
+ export function buildHandleSnapshot(
21
+ handleStore: HandleStore,
22
+ segmentOrder: string[],
23
+ ): HandleData {
24
+ const data: HandleData = {};
25
+ for (const segmentId of segmentOrder) {
26
+ const segData = handleStore.getDataForSegment(segmentId);
27
+ for (const handleName in segData) {
28
+ if (!data[handleName]) data[handleName] = {};
29
+ data[handleName][segmentId] = segData[handleName];
30
+ }
31
+ }
32
+ return data;
33
+ }
34
+
16
35
  function createLateHandlePushError(
17
36
  handleName: string,
18
37
  segmentId: string,
@@ -44,20 +44,21 @@ export function setLoaderImports(
44
44
  export async function getLoaderLazy(
45
45
  id: string,
46
46
  ): Promise<LoaderRegistryEntry | undefined> {
47
- // Check if already cached in main registry
48
- const existing = loaderRegistry.get(id);
49
- if (existing) {
50
- return existing;
51
- }
52
-
53
- // Check the fetchable loader registry (populated by createLoader)
47
+ // Always check fetchableLoaderRegistry first it's the source of truth.
48
+ // createLoader() updates it during module re-evaluation (HMR), so checking
49
+ // here ensures we pick up the fresh function after a loader file change.
54
50
  const fetchable = getFetchableLoader(id);
55
51
  if (fetchable) {
56
- // Cache in main registry for future requests
57
52
  loaderRegistry.set(id, fetchable);
58
53
  return fetchable;
59
54
  }
60
55
 
56
+ // Fall back to local cache (populated by previous lazy imports in production)
57
+ const existing = loaderRegistry.get(id);
58
+ if (existing) {
59
+ return existing;
60
+ }
61
+
61
62
  // Try to lazy load from the import map (production mode)
62
63
  if (lazyLoaderImports && lazyLoaderImports.size > 0) {
63
64
  const lazyImport = lazyLoaderImports.get(id);
@@ -20,8 +20,18 @@ import type {
20
20
  DefaultRouteName,
21
21
  } from "../types/global-namespace.js";
22
22
  import type { Handle } from "../handle.js";
23
- import { type ContextVar, contextGet, contextSet } from "../context-var.js";
24
- import { createHandleStore, type HandleStore } from "./handle-store.js";
23
+ import {
24
+ type ContextVar,
25
+ contextGet,
26
+ contextSet,
27
+ isNonCacheable,
28
+ } from "../context-var.js";
29
+ import {
30
+ createHandleStore,
31
+ buildHandleSnapshot,
32
+ type HandleStore,
33
+ type HandleData,
34
+ } from "./handle-store.js";
25
35
  import { isHandle } from "../handle.js";
26
36
  import { track, type MetricsStore } from "./context.js";
27
37
  import { getFetchableLoader } from "./fetchable-loader-store.js";
@@ -30,6 +40,7 @@ import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
30
40
  import { THEME_COOKIE } from "../theme/constants.js";
31
41
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
32
42
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
43
+ import { isInsideCacheScope } from "./context.js";
33
44
  import {
34
45
  createReverseFunction,
35
46
  stripInternalParams,
@@ -63,8 +74,8 @@ export interface RequestContext<
63
74
  pathname: string;
64
75
  /** URL search params (with internal `_rsc*` params stripped, same as `url.searchParams`) */
65
76
  searchParams: URLSearchParams;
66
- /** Variables set by middleware (same as ctx.var) */
67
- var: Record<string, any>;
77
+ /** @internal Shared variable backing store for ctx.get()/ctx.set(). */
78
+ _variables: Record<string, any>;
68
79
  /** Get a variable set by middleware */
69
80
  get: {
70
81
  <T>(contextVar: ContextVar<T>): T | undefined;
@@ -72,8 +83,12 @@ export interface RequestContext<
72
83
  };
73
84
  /** Set a variable (shared with middleware and handlers) */
74
85
  set: {
75
- <T>(contextVar: ContextVar<T>, value: T): void;
76
- <K extends string>(key: K, value: any): void;
86
+ <T>(
87
+ contextVar: ContextVar<T>,
88
+ value: T,
89
+ options?: { cache?: boolean },
90
+ ): void;
91
+ <K extends string>(key: K, value: any, options?: { cache?: boolean }): void;
77
92
  };
78
93
  /**
79
94
  * Route params (populated after route matching)
@@ -261,6 +276,54 @@ export interface RequestContext<
261
276
  /** @internal Previous route key (from the navigation source), used for revalidation */
262
277
  _prevRouteKey?: string;
263
278
 
279
+ /**
280
+ * @internal Render barrier for experimental `rendered()` API.
281
+ * Resolves when all non-loader segments have settled and handle data
282
+ * is available. Used by DSL loaders that call `ctx.rendered()`.
283
+ */
284
+ _renderBarrier: Promise<void>;
285
+
286
+ /**
287
+ * @internal Resolve the render barrier. Accepts resolved segments, filters
288
+ * out loaders, and captures non-loader segment IDs as the handle ordering.
289
+ * Called after segment resolution (fresh) or handle replay (cache/prerender).
290
+ */
291
+ _resolveRenderBarrier: (
292
+ segments: Array<{ type: string; id: string }>,
293
+ ) => void;
294
+
295
+ /**
296
+ * @internal Segment order at barrier resolution time, used by loader
297
+ * ctx.use(handle) to collect handle data in correct order.
298
+ */
299
+ _renderBarrierSegmentOrder?: string[];
300
+
301
+ /**
302
+ * @internal Set to true when the matched entry tree contains any `loading()`
303
+ * entries (streaming). Used by rendered() to fail fast.
304
+ */
305
+ _treeHasStreaming?: boolean;
306
+
307
+ /**
308
+ * @internal Loader IDs that have called rendered() and are waiting for the
309
+ * barrier. Used to detect deadlocks when a handler tries to await the same
310
+ * loader via ctx.use(Loader).
311
+ */
312
+ _renderBarrierWaiters?: Set<string>;
313
+
314
+ /**
315
+ * @internal Loader IDs that handlers have started awaiting via ctx.use().
316
+ * Used for bidirectional deadlock detection: if a loader later calls
317
+ * rendered() and a handler already awaits it, we can detect the deadlock.
318
+ */
319
+ _handlerLoaderDeps?: Set<string>;
320
+
321
+ /**
322
+ * @internal Cached HandleData snapshot built at barrier resolution time.
323
+ * Avoids rebuilding the snapshot on every loader ctx.use(handle) call.
324
+ */
325
+ _renderBarrierHandleSnapshot?: HandleData;
326
+
264
327
  /** @internal Per-request error dedup set for onError reporting */
265
328
  _reportedErrors: WeakSet<object>;
266
329
 
@@ -277,6 +340,15 @@ export interface RequestContext<
277
340
 
278
341
  /** @internal Request-scoped performance metrics store */
279
342
  _metricsStore?: MetricsStore;
343
+
344
+ /** @internal Router basename for this request (used by redirect()) */
345
+ _basename?: string;
346
+
347
+ /**
348
+ * @internal RouteSnapshot from classifyRequest, reused by match/matchPartial
349
+ * to avoid a second resolveRoute call. Cleared on HMR invalidation.
350
+ */
351
+ _classifiedRoute?: import("../router/route-snapshot.js").RouteSnapshot;
280
352
  }
281
353
 
282
354
  /**
@@ -303,10 +375,20 @@ export type PublicRequestContext<
303
375
  | "_routeName"
304
376
  | "_prevRouteKey"
305
377
  | "_reportedErrors"
378
+ | "_renderBarrier"
379
+ | "_resolveRenderBarrier"
380
+ | "_renderBarrierSegmentOrder"
381
+ | "_treeHasStreaming"
382
+ | "_renderBarrierWaiters"
383
+ | "_handlerLoaderDeps"
384
+ | "_renderBarrierHandleSnapshot"
306
385
  | "_reportBackgroundError"
307
386
  | "_debugPerformance"
308
387
  | "_metricsStore"
388
+ | "_basename"
309
389
  | "_setStatus"
390
+ | "_variables"
391
+ | "_classifiedRoute"
310
392
  | "res"
311
393
  >;
312
394
 
@@ -506,6 +588,18 @@ export function createRequestContext<TEnv>(
506
588
  responseCookieCache = null;
507
589
  };
508
590
 
591
+ // Guard: throw if a response-level side effect is called inside a cache() scope.
592
+ // Uses ALS to detect the scope (set during segment resolution).
593
+ function assertNotInsideCacheScopeALS(methodName: string): void {
594
+ if (isInsideCacheScope()) {
595
+ throw new Error(
596
+ `ctx.${methodName}() cannot be called inside a cache() boundary. ` +
597
+ `On cache hit the handler is skipped, so this side effect would be lost. ` +
598
+ `Move ctx.${methodName}() to a middleware or layout outside the cache() scope.`,
599
+ );
600
+ }
601
+ }
602
+
509
603
  // Effective cookie read: response stub Set-Cookie wins, then original header.
510
604
  // The stub IS the source of truth for same-request mutations.
511
605
  const effectiveCookie = (name: string): string | undefined => {
@@ -569,12 +663,20 @@ export function createRequestContext<TEnv>(
569
663
  originalUrl: new URL(request.url),
570
664
  pathname: url.pathname,
571
665
  searchParams: cleanUrl.searchParams,
572
- var: variables,
573
- get: ((keyOrVar: any) =>
574
- contextGet(variables, keyOrVar)) as RequestContext<TEnv>["get"],
575
- set: ((keyOrVar: any, value: any) => {
666
+ _variables: variables,
667
+ get: ((keyOrVar: any) => {
668
+ if (isNonCacheable(variables, keyOrVar) && isInsideCacheScope()) {
669
+ throw new Error(
670
+ `ctx.get() for a non-cacheable variable cannot be called inside a cache() boundary. ` +
671
+ `The variable was created with { cache: false } or set with { cache: false }, ` +
672
+ `and its value would be stale on cache hit. Move the read outside the cached scope.`,
673
+ );
674
+ }
675
+ return contextGet(variables, keyOrVar);
676
+ }) as RequestContext<TEnv>["get"],
677
+ set: ((keyOrVar: any, value: any, options?: any) => {
576
678
  assertNotInsideCacheExec(ctx, "set");
577
- contextSet(variables, keyOrVar, value);
679
+ contextSet(variables, keyOrVar, value, options);
578
680
  }) as RequestContext<TEnv>["set"],
579
681
  params: {} as Record<string, string>,
580
682
 
@@ -612,6 +714,7 @@ export function createRequestContext<TEnv>(
612
714
 
613
715
  setCookie(name: string, value: string, options?: CookieOptions): void {
614
716
  assertNotInsideCacheExec(ctx, "setCookie");
717
+ assertNotInsideCacheScopeALS("setCookie");
615
718
  stubResponse.headers.append(
616
719
  "Set-Cookie",
617
720
  serializeCookieValue(name, value, options),
@@ -624,6 +727,7 @@ export function createRequestContext<TEnv>(
624
727
  options?: Pick<CookieOptions, "domain" | "path">,
625
728
  ): void {
626
729
  assertNotInsideCacheExec(ctx, "deleteCookie");
730
+ assertNotInsideCacheScopeALS("deleteCookie");
627
731
  stubResponse.headers.append(
628
732
  "Set-Cookie",
629
733
  serializeCookieValue(name, "", { ...options, maxAge: 0 }),
@@ -633,11 +737,13 @@ export function createRequestContext<TEnv>(
633
737
 
634
738
  header(name: string, value: string): void {
635
739
  assertNotInsideCacheExec(ctx, "header");
740
+ assertNotInsideCacheScopeALS("header");
636
741
  stubResponse.headers.set(name, value);
637
742
  },
638
743
 
639
744
  setStatus(status: number): void {
640
745
  assertNotInsideCacheExec(ctx, "setStatus");
746
+ assertNotInsideCacheScopeALS("setStatus");
641
747
  stubResponse = new Response(null, {
642
748
  status,
643
749
  headers: stubResponse.headers,
@@ -676,6 +782,7 @@ export function createRequestContext<TEnv>(
676
782
 
677
783
  onResponse(callback: (response: Response) => Response): void {
678
784
  assertNotInsideCacheExec(ctx, "onResponse");
785
+ assertNotInsideCacheScopeALS("onResponse");
679
786
  this._onResponseCallbacks.push(callback);
680
787
  },
681
788
 
@@ -703,9 +810,58 @@ export function createRequestContext<TEnv>(
703
810
  _reportedErrors: new WeakSet<object>(),
704
811
  _metricsStore: undefined,
705
812
 
813
+ // Render barrier: deferred promise resolved after non-loader segments settle.
814
+ _renderBarrier: null as any, // set below
815
+ _resolveRenderBarrier: null as any, // set below
816
+ _renderBarrierSegmentOrder: undefined,
817
+
706
818
  reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
707
819
  };
708
820
 
821
+ // Lazy render barrier: only allocate the Promise when a loader actually
822
+ // calls rendered(). Requests that don't use rendered() pay zero cost.
823
+ let barrierResolved = false;
824
+ let resolveBarrier: (() => void) | undefined;
825
+ ctx._renderBarrier = null as any; // lazy — created on first access
826
+ ctx._resolveRenderBarrier = (
827
+ segments: Array<{ type: string; id: string }>,
828
+ ) => {
829
+ if (barrierResolved) return;
830
+ barrierResolved = true;
831
+ const segOrder = segments
832
+ .filter((s) => s.type !== "loader")
833
+ .map((s) => s.id);
834
+ ctx._renderBarrierSegmentOrder = segOrder;
835
+ // Build and cache handle snapshot so loader ctx.use(handle) calls
836
+ // don't rebuild it on every invocation.
837
+ ctx._renderBarrierHandleSnapshot = buildHandleSnapshot(
838
+ handleStore,
839
+ segOrder,
840
+ );
841
+ ctx._renderBarrierWaiters = undefined;
842
+ ctx._handlerLoaderDeps = undefined;
843
+ if (resolveBarrier) resolveBarrier();
844
+ };
845
+ Object.defineProperty(ctx, "_renderBarrier", {
846
+ get() {
847
+ // Barrier already resolved (cache/prerender hit) or first lazy access.
848
+ // Either way, replace the getter with a concrete value to avoid
849
+ // repeated Promise.resolve() allocations on subsequent reads.
850
+ const p = barrierResolved
851
+ ? Promise.resolve()
852
+ : new Promise<void>((resolve) => {
853
+ resolveBarrier = resolve;
854
+ });
855
+ Object.defineProperty(ctx, "_renderBarrier", {
856
+ value: p,
857
+ writable: false,
858
+ configurable: false,
859
+ });
860
+ return p;
861
+ },
862
+ configurable: true,
863
+ });
864
+
709
865
  // Now create use() with access to ctx
710
866
  ctx.use = createUseFunction({
711
867
  handleStore,
@@ -888,14 +1044,13 @@ export function createUseFunction<TEnv>(
888
1044
  pathname: ctx.pathname,
889
1045
  url: ctx.url,
890
1046
  env: ctx.env as any,
891
- var: ctx.var as any,
892
1047
  get: ctx.get as any,
893
- use: <TDep, TDepParams = any>(
1048
+ use: (<TDep, TDepParams = any>(
894
1049
  dep: LoaderDefinition<TDep, TDepParams>,
895
1050
  ): Promise<TDep> => {
896
1051
  // Recursive call - will start dep loader if not already started
897
1052
  return ctx.use(dep);
898
- },
1053
+ }) as LoaderContext["use"],
899
1054
  method: "GET",
900
1055
  body: undefined,
901
1056
  reverse: createReverseFunction(
@@ -904,9 +1059,14 @@ export function createUseFunction<TEnv>(
904
1059
  ctx.params as Record<string, string>,
905
1060
  ctx._routeName ? isRouteRootScoped(ctx._routeName) : undefined,
906
1061
  ),
1062
+ rendered: () => {
1063
+ throw new Error(
1064
+ `ctx.rendered() is only available in DSL loaders (registered via loader() in urls()). ` +
1065
+ `It cannot be used from request-context loaders or server actions.`,
1066
+ );
1067
+ },
907
1068
  };
908
1069
 
909
- // Start loader execution with tracking
910
1070
  const doneLoader = track(`loader:${loader.$$id}`, 2);
911
1071
  const promise = Promise.resolve(loaderFn(loaderCtx)).finally(() => {
912
1072
  doneLoader();
package/src/ssr/index.tsx CHANGED
@@ -129,6 +129,7 @@ interface RscPayload {
129
129
  matched?: string[];
130
130
  pathname?: string;
131
131
  params?: Record<string, string>;
132
+ basename?: string;
132
133
  themeConfig?: ResolvedThemeConfig | null;
133
134
  initialTheme?: Theme;
134
135
  version?: string;
@@ -168,6 +169,7 @@ function createSsrEventController(opts: {
168
169
  const state: DerivedNavigationState = {
169
170
  state: "idle",
170
171
  isStreaming: false,
172
+ isNavigating: false,
171
173
  location,
172
174
  pendingUrl: null,
173
175
  inflightActions: [],
@@ -260,6 +262,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
260
262
  function SsrRoot() {
261
263
  payload ??= createFromReadableStream<RscPayload>(rscStream1);
262
264
  const resolved = React.use(payload);
265
+
263
266
  const themeConfig = resolved.metadata?.themeConfig ?? null;
264
267
  const pathname = resolved.metadata?.pathname ?? "/";
265
268
 
@@ -285,6 +288,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
285
288
  navigate: async () => {},
286
289
  refresh: async () => {},
287
290
  version: resolved.metadata?.version,
291
+ basename: resolved.metadata?.basename,
288
292
  };
289
293
 
290
294
  // Build content tree from segments.