@rangojs/router 0.0.0-experimental.8a4d0430 → 0.0.0-experimental.8bd1b239

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 (152) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +76 -18
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +853 -435
  5. package/package.json +17 -16
  6. package/skills/cache-guide/SKILL.md +32 -0
  7. package/skills/caching/SKILL.md +45 -4
  8. package/skills/handler-use/SKILL.md +362 -0
  9. package/skills/intercept/SKILL.md +20 -0
  10. package/skills/layout/SKILL.md +22 -0
  11. package/skills/links/SKILL.md +3 -1
  12. package/skills/loader/SKILL.md +53 -43
  13. package/skills/middleware/SKILL.md +34 -3
  14. package/skills/migrate-nextjs/SKILL.md +560 -0
  15. package/skills/migrate-react-router/SKILL.md +764 -0
  16. package/skills/parallel/SKILL.md +185 -0
  17. package/skills/prerender/SKILL.md +110 -68
  18. package/skills/rango/SKILL.md +24 -22
  19. package/skills/route/SKILL.md +55 -0
  20. package/skills/router-setup/SKILL.md +87 -2
  21. package/skills/typesafety/SKILL.md +10 -0
  22. package/src/__internal.ts +1 -1
  23. package/src/browser/app-version.ts +14 -0
  24. package/src/browser/event-controller.ts +5 -0
  25. package/src/browser/navigation-bridge.ts +40 -15
  26. package/src/browser/navigation-client.ts +142 -57
  27. package/src/browser/navigation-store.ts +43 -8
  28. package/src/browser/navigation-transaction.ts +11 -9
  29. package/src/browser/partial-update.ts +94 -17
  30. package/src/browser/prefetch/cache.ts +82 -12
  31. package/src/browser/prefetch/fetch.ts +98 -27
  32. package/src/browser/prefetch/policy.ts +6 -0
  33. package/src/browser/prefetch/queue.ts +92 -20
  34. package/src/browser/prefetch/resource-ready.ts +77 -0
  35. package/src/browser/react/Link.tsx +76 -9
  36. package/src/browser/react/NavigationProvider.tsx +40 -4
  37. package/src/browser/react/context.ts +7 -2
  38. package/src/browser/react/use-handle.ts +9 -58
  39. package/src/browser/react/use-navigation.ts +11 -10
  40. package/src/browser/react/use-router.ts +21 -8
  41. package/src/browser/rsc-router.tsx +134 -59
  42. package/src/browser/scroll-restoration.ts +41 -42
  43. package/src/browser/segment-reconciler.ts +36 -9
  44. package/src/browser/server-action-bridge.ts +8 -6
  45. package/src/browser/types.ts +36 -5
  46. package/src/build/generate-manifest.ts +6 -6
  47. package/src/build/generate-route-types.ts +3 -0
  48. package/src/build/route-trie.ts +50 -24
  49. package/src/build/route-types/include-resolution.ts +8 -1
  50. package/src/build/route-types/router-processing.ts +223 -74
  51. package/src/build/route-types/scan-filter.ts +8 -1
  52. package/src/cache/cache-runtime.ts +15 -11
  53. package/src/cache/cache-scope.ts +48 -7
  54. package/src/cache/cf/cf-cache-store.ts +453 -11
  55. package/src/cache/cf/index.ts +5 -1
  56. package/src/cache/document-cache.ts +17 -7
  57. package/src/cache/index.ts +1 -0
  58. package/src/cache/taint.ts +55 -0
  59. package/src/client.tsx +84 -230
  60. package/src/context-var.ts +72 -2
  61. package/src/debug.ts +2 -2
  62. package/src/handle.ts +40 -0
  63. package/src/index.rsc.ts +3 -1
  64. package/src/index.ts +46 -6
  65. package/src/prerender/store.ts +5 -4
  66. package/src/prerender.ts +138 -77
  67. package/src/reverse.ts +25 -1
  68. package/src/route-definition/dsl-helpers.ts +224 -37
  69. package/src/route-definition/helpers-types.ts +67 -19
  70. package/src/route-definition/index.ts +3 -0
  71. package/src/route-definition/redirect.ts +11 -3
  72. package/src/route-definition/resolve-handler-use.ts +149 -0
  73. package/src/route-map-builder.ts +7 -1
  74. package/src/route-types.ts +11 -0
  75. package/src/router/content-negotiation.ts +100 -1
  76. package/src/router/find-match.ts +4 -2
  77. package/src/router/handler-context.ts +82 -23
  78. package/src/router/intercept-resolution.ts +11 -4
  79. package/src/router/lazy-includes.ts +4 -1
  80. package/src/router/loader-resolution.ts +156 -21
  81. package/src/router/logging.ts +5 -2
  82. package/src/router/manifest.ts +9 -3
  83. package/src/router/match-api.ts +124 -189
  84. package/src/router/match-middleware/background-revalidation.ts +30 -2
  85. package/src/router/match-middleware/cache-lookup.ts +94 -17
  86. package/src/router/match-middleware/cache-store.ts +53 -10
  87. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  88. package/src/router/match-middleware/segment-resolution.ts +61 -5
  89. package/src/router/match-result.ts +104 -10
  90. package/src/router/metrics.ts +6 -1
  91. package/src/router/middleware-types.ts +6 -8
  92. package/src/router/middleware.ts +4 -6
  93. package/src/router/navigation-snapshot.ts +182 -0
  94. package/src/router/prerender-match.ts +110 -10
  95. package/src/router/preview-match.ts +30 -102
  96. package/src/router/request-classification.ts +310 -0
  97. package/src/router/route-snapshot.ts +245 -0
  98. package/src/router/router-context.ts +6 -1
  99. package/src/router/router-interfaces.ts +36 -4
  100. package/src/router/router-options.ts +37 -11
  101. package/src/router/segment-resolution/fresh.ts +198 -20
  102. package/src/router/segment-resolution/helpers.ts +29 -24
  103. package/src/router/segment-resolution/loader-cache.ts +1 -0
  104. package/src/router/segment-resolution/revalidation.ts +438 -300
  105. package/src/router/segment-wrappers.ts +2 -0
  106. package/src/router/types.ts +1 -0
  107. package/src/router.ts +59 -6
  108. package/src/rsc/handler.ts +472 -372
  109. package/src/rsc/loader-fetch.ts +23 -3
  110. package/src/rsc/manifest-init.ts +5 -1
  111. package/src/rsc/progressive-enhancement.ts +14 -2
  112. package/src/rsc/rsc-rendering.ts +12 -1
  113. package/src/rsc/server-action.ts +8 -0
  114. package/src/rsc/ssr-setup.ts +2 -2
  115. package/src/rsc/types.ts +9 -1
  116. package/src/segment-content-promise.ts +67 -0
  117. package/src/segment-loader-promise.ts +122 -0
  118. package/src/segment-system.tsx +109 -23
  119. package/src/server/context.ts +140 -14
  120. package/src/server/handle-store.ts +19 -0
  121. package/src/server/loader-registry.ts +9 -8
  122. package/src/server/request-context.ts +185 -19
  123. package/src/ssr/index.tsx +4 -0
  124. package/src/static-handler.ts +18 -6
  125. package/src/types/cache-types.ts +4 -4
  126. package/src/types/handler-context.ts +137 -33
  127. package/src/types/loader-types.ts +36 -9
  128. package/src/types/route-entry.ts +8 -1
  129. package/src/types/segments.ts +2 -0
  130. package/src/urls/path-helper-types.ts +39 -6
  131. package/src/urls/path-helper.ts +48 -13
  132. package/src/urls/pattern-types.ts +12 -0
  133. package/src/urls/response-types.ts +16 -6
  134. package/src/use-loader.tsx +77 -5
  135. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  136. package/src/vite/discovery/discover-routers.ts +5 -1
  137. package/src/vite/discovery/prerender-collection.ts +128 -74
  138. package/src/vite/discovery/state.ts +13 -6
  139. package/src/vite/index.ts +4 -0
  140. package/src/vite/plugin-types.ts +51 -79
  141. package/src/vite/plugins/expose-action-id.ts +1 -3
  142. package/src/vite/plugins/expose-id-utils.ts +12 -0
  143. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  144. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  145. package/src/vite/plugins/performance-tracks.ts +88 -0
  146. package/src/vite/plugins/refresh-cmd.ts +88 -26
  147. package/src/vite/plugins/version-plugin.ts +13 -1
  148. package/src/vite/rango.ts +163 -211
  149. package/src/vite/router-discovery.ts +178 -45
  150. package/src/vite/utils/banner.ts +3 -3
  151. package/src/vite/utils/prerender-utils.ts +37 -5
  152. 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,9 @@ 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;
273
283
  }
274
284
  // Use a global symbol key so the AsyncLocalStorage instance survives HMR
275
285
  // module re-evaluation. Without this, Vite's RSC module runner may create
@@ -553,6 +563,80 @@ export function getRootScoped(): boolean {
553
563
  // Export HelperContext type for use in other modules
554
564
  export type { HelperContext };
555
565
 
566
+ /**
567
+ * Return an isolated copy of a lazy include's captured parent entry.
568
+ *
569
+ * DSL helpers (loader(), middleware(), etc.) mutate ctx.parent in place.
570
+ * Multiple include() scopes capture the *same* syntheticMapRoot as their
571
+ * parent, so without isolation one include's loaders/middleware leak into
572
+ * every other route that shares that root.
573
+ *
574
+ * The clone is shallow: only the mutable arrays are copied so each
575
+ * include pushes to its own list. The rest of the entry (id, shortCode,
576
+ * parent pointer, handler) stays shared, which is correct and cheap.
577
+ */
578
+ export function getIsolatedLazyParent(
579
+ captured: EntryData | null | undefined,
580
+ ): EntryData | null {
581
+ if (!captured) return null;
582
+ return {
583
+ ...captured,
584
+ loader: [...captured.loader],
585
+ middleware: [...captured.middleware],
586
+ revalidate: [...captured.revalidate],
587
+ errorBoundary: [...captured.errorBoundary],
588
+ notFoundBoundary: [...captured.notFoundBoundary],
589
+ layout: [...captured.layout],
590
+ parallel: { ...captured.parallel },
591
+ intercept: [...captured.intercept],
592
+ };
593
+ }
594
+
595
+ export function getParallelEntries(
596
+ parallels: ParallelEntries | EntryData[] | undefined,
597
+ ): ParallelEntryData[] {
598
+ if (!parallels) return [];
599
+ if (Array.isArray(parallels)) {
600
+ return parallels.filter(
601
+ (entry): entry is ParallelEntryData => entry.type === "parallel",
602
+ );
603
+ }
604
+ return Object.values(parallels).filter(
605
+ (entry): entry is ParallelEntryData => !!entry,
606
+ );
607
+ }
608
+
609
+ export function getParallelSlotEntries(
610
+ parallels: ParallelEntries | EntryData[] | undefined,
611
+ ): Array<{ slot: `@${string}`; entry: ParallelEntryData }> {
612
+ if (!parallels) return [];
613
+
614
+ if (Array.isArray(parallels)) {
615
+ return getParallelEntries(parallels).flatMap((entry) =>
616
+ (Object.keys(entry.handler) as `@${string}`[]).map((slot) => ({
617
+ slot,
618
+ entry,
619
+ })),
620
+ );
621
+ }
622
+
623
+ return Object.entries(parallels)
624
+ .filter(([, entry]) => !!entry)
625
+ .map(([slot, entry]) => ({
626
+ slot: slot as `@${string}`,
627
+ entry: entry!,
628
+ }));
629
+ }
630
+
631
+ export function getParallelSlotCount(
632
+ parallels: ParallelEntries | EntryData[] | undefined,
633
+ ): number {
634
+ if (!parallels) return 0;
635
+ return Array.isArray(parallels)
636
+ ? parallels.filter((entry) => entry?.type === "parallel").length
637
+ : Object.keys(parallels).length;
638
+ }
639
+
556
640
  // ============================================================================
557
641
  // Performance Metrics Helpers
558
642
  // ============================================================================
@@ -589,3 +673,45 @@ export function track(label: string, depth?: number): () => void {
589
673
  });
590
674
  };
591
675
  }
676
+
677
+ /**
678
+ * Separate ALS for tracking loader execution scope.
679
+ * Uses a dedicated ALS (not RSCRouterContext) to avoid issues with
680
+ * nested RSCRouterContext.run() calls in Vite's module runner.
681
+ */
682
+ const LOADER_SCOPE_KEY = Symbol.for("rangojs-router:loader-scope");
683
+ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
684
+ globalThis as any
685
+ )[LOADER_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
686
+
687
+ /**
688
+ * Check if the current execution is inside a cache() DSL boundary.
689
+ * Returns false inside loader execution — loaders are always fresh
690
+ * (never cached), so non-cacheable reads are safe.
691
+ */
692
+ export function isInsideCacheScope(): boolean {
693
+ if (RSCRouterContext.getStore()?.insideCacheScope !== true) return false;
694
+ // Loaders are always fresh — even inside a cache() boundary, the loader
695
+ // function re-executes on every request. Skip the guard when running
696
+ // inside a loader.
697
+ if (loaderScopeALS.getStore()?.active) return false;
698
+ return true;
699
+ }
700
+
701
+ /**
702
+ * Check if the current execution is inside a DSL loader scope
703
+ * (wrapped by runInsideLoaderScope). Used by rendered() barrier
704
+ * to distinguish DSL loaders from handler-invoked loaders.
705
+ */
706
+ export function isInsideLoaderScope(): boolean {
707
+ return loaderScopeALS.getStore()?.active === true;
708
+ }
709
+
710
+ /**
711
+ * Run `fn` inside a loader scope. While active, cache-scope guards
712
+ * are bypassed because loaders are always fresh (never cached) and
713
+ * their side effects (setCookie, header, etc.) are safe.
714
+ */
715
+ export function runInsideLoaderScope<T>(fn: () => T): T {
716
+ return loaderScopeALS.run({ active: true }, fn);
717
+ }
@@ -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,7 +40,11 @@ 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";
33
- import { createReverseFunction } from "../router/handler-context.js";
43
+ import { isInsideCacheScope } from "./context.js";
44
+ import {
45
+ createReverseFunction,
46
+ stripInternalParams,
47
+ } from "../router/handler-context.js";
34
48
  import { getGlobalRouteMap, isRouteRootScoped } from "../route-map-builder.js";
35
49
  import { invariant } from "../errors.js";
36
50
  import { isAutoGeneratedRouteName } from "../route-name.js";
@@ -58,10 +72,10 @@ export interface RequestContext<
58
72
  originalUrl: URL;
59
73
  /** URL pathname */
60
74
  pathname: string;
61
- /** URL search params (system params like _rsc* are NOT filtered here) */
75
+ /** URL search params (with internal `_rsc*` params stripped, same as `url.searchParams`) */
62
76
  searchParams: URLSearchParams;
63
- /** Variables set by middleware (same as ctx.var) */
64
- var: Record<string, any>;
77
+ /** @internal Shared variable backing store for ctx.get()/ctx.set(). */
78
+ _variables: Record<string, any>;
65
79
  /** Get a variable set by middleware */
66
80
  get: {
67
81
  <T>(contextVar: ContextVar<T>): T | undefined;
@@ -69,8 +83,12 @@ export interface RequestContext<
69
83
  };
70
84
  /** Set a variable (shared with middleware and handlers) */
71
85
  set: {
72
- <T>(contextVar: ContextVar<T>, value: T): void;
73
- <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;
74
92
  };
75
93
  /**
76
94
  * Route params (populated after route matching)
@@ -258,6 +276,54 @@ export interface RequestContext<
258
276
  /** @internal Previous route key (from the navigation source), used for revalidation */
259
277
  _prevRouteKey?: string;
260
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
+
261
327
  /** @internal Per-request error dedup set for onError reporting */
262
328
  _reportedErrors: WeakSet<object>;
263
329
 
@@ -274,6 +340,15 @@ export interface RequestContext<
274
340
 
275
341
  /** @internal Request-scoped performance metrics store */
276
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;
277
352
  }
278
353
 
279
354
  /**
@@ -300,10 +375,20 @@ export type PublicRequestContext<
300
375
  | "_routeName"
301
376
  | "_prevRouteKey"
302
377
  | "_reportedErrors"
378
+ | "_renderBarrier"
379
+ | "_resolveRenderBarrier"
380
+ | "_renderBarrierSegmentOrder"
381
+ | "_treeHasStreaming"
382
+ | "_renderBarrierWaiters"
383
+ | "_handlerLoaderDeps"
384
+ | "_renderBarrierHandleSnapshot"
303
385
  | "_reportBackgroundError"
304
386
  | "_debugPerformance"
305
387
  | "_metricsStore"
388
+ | "_basename"
306
389
  | "_setStatus"
390
+ | "_variables"
391
+ | "_classifiedRoute"
307
392
  | "res"
308
393
  >;
309
394
 
@@ -503,6 +588,18 @@ export function createRequestContext<TEnv>(
503
588
  responseCookieCache = null;
504
589
  };
505
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
+
506
603
  // Effective cookie read: response stub Set-Cookie wins, then original header.
507
604
  // The stub IS the source of truth for same-request mutations.
508
605
  const effectiveCookie = (name: string): string | undefined => {
@@ -555,20 +652,31 @@ export function createRequestContext<TEnv>(
555
652
  invalidateResponseCookieCache();
556
653
  };
557
654
 
655
+ // Strip internal _rsc* params so userland sees a clean URL.
656
+ const cleanUrl = stripInternalParams(url);
657
+
558
658
  // Build the context object first (without use), then add use
559
659
  const ctx: RequestContext<TEnv> = {
560
660
  env,
561
661
  request,
562
- url,
662
+ url: cleanUrl,
563
663
  originalUrl: new URL(request.url),
564
664
  pathname: url.pathname,
565
- searchParams: url.searchParams,
566
- var: variables,
567
- get: ((keyOrVar: any) =>
568
- contextGet(variables, keyOrVar)) as RequestContext<TEnv>["get"],
569
- set: ((keyOrVar: any, value: any) => {
665
+ searchParams: cleanUrl.searchParams,
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) => {
570
678
  assertNotInsideCacheExec(ctx, "set");
571
- contextSet(variables, keyOrVar, value);
679
+ contextSet(variables, keyOrVar, value, options);
572
680
  }) as RequestContext<TEnv>["set"],
573
681
  params: {} as Record<string, string>,
574
682
 
@@ -606,6 +714,7 @@ export function createRequestContext<TEnv>(
606
714
 
607
715
  setCookie(name: string, value: string, options?: CookieOptions): void {
608
716
  assertNotInsideCacheExec(ctx, "setCookie");
717
+ assertNotInsideCacheScopeALS("setCookie");
609
718
  stubResponse.headers.append(
610
719
  "Set-Cookie",
611
720
  serializeCookieValue(name, value, options),
@@ -618,6 +727,7 @@ export function createRequestContext<TEnv>(
618
727
  options?: Pick<CookieOptions, "domain" | "path">,
619
728
  ): void {
620
729
  assertNotInsideCacheExec(ctx, "deleteCookie");
730
+ assertNotInsideCacheScopeALS("deleteCookie");
621
731
  stubResponse.headers.append(
622
732
  "Set-Cookie",
623
733
  serializeCookieValue(name, "", { ...options, maxAge: 0 }),
@@ -627,11 +737,13 @@ export function createRequestContext<TEnv>(
627
737
 
628
738
  header(name: string, value: string): void {
629
739
  assertNotInsideCacheExec(ctx, "header");
740
+ assertNotInsideCacheScopeALS("header");
630
741
  stubResponse.headers.set(name, value);
631
742
  },
632
743
 
633
744
  setStatus(status: number): void {
634
745
  assertNotInsideCacheExec(ctx, "setStatus");
746
+ assertNotInsideCacheScopeALS("setStatus");
635
747
  stubResponse = new Response(null, {
636
748
  status,
637
749
  headers: stubResponse.headers,
@@ -670,6 +782,7 @@ export function createRequestContext<TEnv>(
670
782
 
671
783
  onResponse(callback: (response: Response) => Response): void {
672
784
  assertNotInsideCacheExec(ctx, "onResponse");
785
+ assertNotInsideCacheScopeALS("onResponse");
673
786
  this._onResponseCallbacks.push(callback);
674
787
  },
675
788
 
@@ -697,9 +810,58 @@ export function createRequestContext<TEnv>(
697
810
  _reportedErrors: new WeakSet<object>(),
698
811
  _metricsStore: undefined,
699
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
+
700
818
  reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
701
819
  };
702
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
+
703
865
  // Now create use() with access to ctx
704
866
  ctx.use = createUseFunction({
705
867
  handleStore,
@@ -882,14 +1044,13 @@ export function createUseFunction<TEnv>(
882
1044
  pathname: ctx.pathname,
883
1045
  url: ctx.url,
884
1046
  env: ctx.env as any,
885
- var: ctx.var as any,
886
1047
  get: ctx.get as any,
887
- use: <TDep, TDepParams = any>(
1048
+ use: (<TDep, TDepParams = any>(
888
1049
  dep: LoaderDefinition<TDep, TDepParams>,
889
1050
  ): Promise<TDep> => {
890
1051
  // Recursive call - will start dep loader if not already started
891
1052
  return ctx.use(dep);
892
- },
1053
+ }) as LoaderContext["use"],
893
1054
  method: "GET",
894
1055
  body: undefined,
895
1056
  reverse: createReverseFunction(
@@ -898,9 +1059,14 @@ export function createUseFunction<TEnv>(
898
1059
  ctx.params as Record<string, string>,
899
1060
  ctx._routeName ? isRouteRootScoped(ctx._routeName) : undefined,
900
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
+ },
901
1068
  };
902
1069
 
903
- // Start loader execution with tracking
904
1070
  const doneLoader = track(`loader:${loader.$$id}`, 2);
905
1071
  const promise = Promise.resolve(loaderFn(loaderCtx)).finally(() => {
906
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.
@@ -32,11 +32,21 @@
32
32
  */
33
33
  import type { ReactNode } from "react";
34
34
  import type { Handler } from "./types.js";
35
- import type { PrerenderOptions, StaticBuildContext } from "./prerender.js";
35
+ import type { StaticBuildContext } from "./prerender.js";
36
+ import type { UseItems, HandlerUseItem } from "./route-types.js";
36
37
  import { isCachedFunction } from "./cache/taint.js";
37
38
 
38
39
  // -- Types ------------------------------------------------------------------
39
40
 
41
+ export interface StaticHandlerOptions {
42
+ /**
43
+ * Keep handler in server bundle for live fallback (default: false).
44
+ * false: handler replaced with stub, source-only APIs excluded from bundle.
45
+ * true: handler stays in bundle, renders live at request time.
46
+ */
47
+ passthrough?: boolean;
48
+ }
49
+
40
50
  export interface StaticHandlerDefinition<
41
51
  TParams extends Record<string, any> = any,
42
52
  > {
@@ -46,14 +56,16 @@ export interface StaticHandlerDefinition<
46
56
  /** In dev mode, the actual handler function that layout/path/parallel can call. */
47
57
  handler: Handler<TParams>;
48
58
  /** Static handler options (passthrough support). */
49
- options?: PrerenderOptions;
59
+ options?: StaticHandlerOptions;
60
+ /** Composable default DSL items merged when the handler is mounted. */
61
+ use?: () => UseItems<HandlerUseItem>;
50
62
  }
51
63
 
52
64
  // -- Function ---------------------------------------------------------------
53
65
 
54
66
  export function Static<TParams extends Record<string, any> = {}>(
55
67
  handler: (ctx: StaticBuildContext) => ReactNode | Promise<ReactNode>,
56
- options?: PrerenderOptions,
68
+ options?: StaticHandlerOptions,
57
69
  __injectedId?: string,
58
70
  ): StaticHandlerDefinition<TParams>;
59
71
 
@@ -61,7 +73,7 @@ export function Static<TParams extends Record<string, any> = {}>(
61
73
 
62
74
  export function Static<TParams extends Record<string, any>>(
63
75
  handler: Function,
64
- optionsOrId?: PrerenderOptions | string,
76
+ optionsOrId?: StaticHandlerOptions | string,
65
77
  maybeId?: string,
66
78
  ): StaticHandlerDefinition<TParams> {
67
79
  if (isCachedFunction(handler)) {
@@ -72,13 +84,13 @@ export function Static<TParams extends Record<string, any>>(
72
84
  );
73
85
  }
74
86
 
75
- let options: PrerenderOptions | undefined;
87
+ let options: StaticHandlerOptions | undefined;
76
88
  let id: string;
77
89
 
78
90
  if (typeof optionsOrId === "string") {
79
91
  id = optionsOrId;
80
92
  } else {
81
- options = optionsOrId as PrerenderOptions | undefined;
93
+ options = optionsOrId as StaticHandlerOptions | undefined;
82
94
  id = maybeId ?? "";
83
95
  }
84
96