@rangojs/router 0.0.0-experimental.29 → 0.0.0-experimental.2a0dea97

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 (156) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +78 -19
  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/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +32 -0
  8. package/skills/caching/SKILL.md +45 -4
  9. package/skills/handler-use/SKILL.md +362 -0
  10. package/skills/hooks/SKILL.md +22 -4
  11. package/skills/intercept/SKILL.md +20 -0
  12. package/skills/layout/SKILL.md +22 -0
  13. package/skills/links/SKILL.md +3 -1
  14. package/skills/loader/SKILL.md +71 -21
  15. package/skills/middleware/SKILL.md +34 -3
  16. package/skills/migrate-nextjs/SKILL.md +560 -0
  17. package/skills/migrate-react-router/SKILL.md +764 -0
  18. package/skills/parallel/SKILL.md +185 -0
  19. package/skills/prerender/SKILL.md +110 -68
  20. package/skills/rango/SKILL.md +24 -22
  21. package/skills/route/SKILL.md +56 -2
  22. package/skills/router-setup/SKILL.md +87 -2
  23. package/skills/typesafety/SKILL.md +33 -21
  24. package/src/__internal.ts +92 -0
  25. package/src/browser/app-version.ts +14 -0
  26. package/src/browser/event-controller.ts +5 -0
  27. package/src/browser/link-interceptor.ts +4 -0
  28. package/src/browser/navigation-bridge.ts +125 -16
  29. package/src/browser/navigation-client.ts +142 -57
  30. package/src/browser/navigation-store.ts +43 -8
  31. package/src/browser/navigation-transaction.ts +11 -9
  32. package/src/browser/partial-update.ts +94 -17
  33. package/src/browser/prefetch/cache.ts +82 -12
  34. package/src/browser/prefetch/fetch.ts +98 -27
  35. package/src/browser/prefetch/policy.ts +6 -0
  36. package/src/browser/prefetch/queue.ts +92 -20
  37. package/src/browser/prefetch/resource-ready.ts +77 -0
  38. package/src/browser/react/Link.tsx +88 -9
  39. package/src/browser/react/NavigationProvider.tsx +40 -4
  40. package/src/browser/react/context.ts +7 -2
  41. package/src/browser/react/use-handle.ts +9 -58
  42. package/src/browser/react/use-router.ts +21 -8
  43. package/src/browser/rsc-router.tsx +134 -59
  44. package/src/browser/scroll-restoration.ts +41 -42
  45. package/src/browser/segment-reconciler.ts +72 -10
  46. package/src/browser/server-action-bridge.ts +8 -6
  47. package/src/browser/types.ts +55 -5
  48. package/src/build/generate-manifest.ts +6 -6
  49. package/src/build/generate-route-types.ts +3 -0
  50. package/src/build/route-trie.ts +50 -24
  51. package/src/build/route-types/include-resolution.ts +8 -1
  52. package/src/build/route-types/router-processing.ts +223 -74
  53. package/src/build/route-types/scan-filter.ts +8 -1
  54. package/src/cache/cache-runtime.ts +15 -11
  55. package/src/cache/cache-scope.ts +48 -7
  56. package/src/cache/cf/cf-cache-store.ts +453 -11
  57. package/src/cache/cf/index.ts +5 -1
  58. package/src/cache/document-cache.ts +17 -7
  59. package/src/cache/index.ts +1 -0
  60. package/src/cache/taint.ts +55 -0
  61. package/src/client.rsc.tsx +2 -0
  62. package/src/client.tsx +6 -66
  63. package/src/context-var.ts +72 -2
  64. package/src/debug.ts +2 -2
  65. package/src/handle.ts +40 -0
  66. package/src/handles/breadcrumbs.ts +66 -0
  67. package/src/handles/index.ts +1 -0
  68. package/src/index.rsc.ts +6 -36
  69. package/src/index.ts +50 -43
  70. package/src/prerender/store.ts +5 -4
  71. package/src/prerender.ts +138 -77
  72. package/src/reverse.ts +25 -1
  73. package/src/route-definition/dsl-helpers.ts +224 -37
  74. package/src/route-definition/helpers-types.ts +67 -19
  75. package/src/route-definition/index.ts +3 -0
  76. package/src/route-definition/redirect.ts +11 -3
  77. package/src/route-definition/resolve-handler-use.ts +149 -0
  78. package/src/route-map-builder.ts +7 -1
  79. package/src/route-types.ts +11 -0
  80. package/src/router/content-negotiation.ts +100 -1
  81. package/src/router/find-match.ts +4 -2
  82. package/src/router/handler-context.ts +111 -25
  83. package/src/router/intercept-resolution.ts +11 -4
  84. package/src/router/lazy-includes.ts +4 -1
  85. package/src/router/loader-resolution.ts +156 -21
  86. package/src/router/logging.ts +5 -2
  87. package/src/router/manifest.ts +9 -3
  88. package/src/router/match-api.ts +125 -190
  89. package/src/router/match-middleware/background-revalidation.ts +30 -2
  90. package/src/router/match-middleware/cache-lookup.ts +94 -17
  91. package/src/router/match-middleware/cache-store.ts +53 -10
  92. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  93. package/src/router/match-middleware/segment-resolution.ts +61 -5
  94. package/src/router/match-result.ts +104 -10
  95. package/src/router/metrics.ts +6 -1
  96. package/src/router/middleware-types.ts +16 -22
  97. package/src/router/middleware.ts +24 -30
  98. package/src/router/navigation-snapshot.ts +182 -0
  99. package/src/router/prerender-match.ts +114 -10
  100. package/src/router/preview-match.ts +30 -102
  101. package/src/router/request-classification.ts +310 -0
  102. package/src/router/route-snapshot.ts +245 -0
  103. package/src/router/router-context.ts +6 -1
  104. package/src/router/router-interfaces.ts +36 -4
  105. package/src/router/router-options.ts +37 -11
  106. package/src/router/segment-resolution/fresh.ts +198 -20
  107. package/src/router/segment-resolution/helpers.ts +30 -25
  108. package/src/router/segment-resolution/loader-cache.ts +1 -0
  109. package/src/router/segment-resolution/revalidation.ts +438 -300
  110. package/src/router/segment-wrappers.ts +2 -0
  111. package/src/router/types.ts +1 -0
  112. package/src/router.ts +59 -6
  113. package/src/rsc/handler.ts +472 -372
  114. package/src/rsc/loader-fetch.ts +23 -3
  115. package/src/rsc/manifest-init.ts +5 -1
  116. package/src/rsc/progressive-enhancement.ts +14 -2
  117. package/src/rsc/rsc-rendering.ts +12 -1
  118. package/src/rsc/server-action.ts +8 -0
  119. package/src/rsc/ssr-setup.ts +2 -2
  120. package/src/rsc/types.ts +9 -1
  121. package/src/segment-content-promise.ts +33 -0
  122. package/src/segment-system.tsx +164 -23
  123. package/src/server/context.ts +140 -14
  124. package/src/server/handle-store.ts +19 -0
  125. package/src/server/loader-registry.ts +9 -8
  126. package/src/server/request-context.ts +204 -28
  127. package/src/ssr/index.tsx +4 -0
  128. package/src/static-handler.ts +18 -6
  129. package/src/types/cache-types.ts +4 -4
  130. package/src/types/handler-context.ts +149 -49
  131. package/src/types/loader-types.ts +36 -9
  132. package/src/types/route-entry.ts +8 -1
  133. package/src/types/segments.ts +6 -0
  134. package/src/urls/path-helper-types.ts +39 -6
  135. package/src/urls/path-helper.ts +48 -13
  136. package/src/urls/pattern-types.ts +12 -0
  137. package/src/urls/response-types.ts +16 -6
  138. package/src/use-loader.tsx +77 -5
  139. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  140. package/src/vite/discovery/discover-routers.ts +5 -1
  141. package/src/vite/discovery/prerender-collection.ts +128 -74
  142. package/src/vite/discovery/state.ts +13 -6
  143. package/src/vite/index.ts +4 -0
  144. package/src/vite/plugin-types.ts +51 -79
  145. package/src/vite/plugins/expose-action-id.ts +1 -3
  146. package/src/vite/plugins/expose-id-utils.ts +12 -0
  147. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  148. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  149. package/src/vite/plugins/performance-tracks.ts +88 -0
  150. package/src/vite/plugins/refresh-cmd.ts +88 -26
  151. package/src/vite/plugins/version-plugin.ts +13 -1
  152. package/src/vite/rango.ts +163 -211
  153. package/src/vite/router-discovery.ts +178 -45
  154. package/src/vite/utils/banner.ts +3 -3
  155. package/src/vite/utils/prerender-utils.ts +37 -5
  156. 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";
@@ -49,14 +63,19 @@ export interface RequestContext<
49
63
  env: TEnv;
50
64
  /** Original HTTP request */
51
65
  request: Request;
52
- /** Parsed URL (system params like _rsc* are NOT filtered here) */
66
+ /** Parsed URL (with internal `_rsc*` params stripped) */
53
67
  url: URL;
68
+ /**
69
+ * The original request URL with all parameters intact, including
70
+ * internal `_rsc*` transport params.
71
+ */
72
+ originalUrl: URL;
54
73
  /** URL pathname */
55
74
  pathname: string;
56
- /** URL search params (system params like _rsc* are NOT filtered here) */
75
+ /** URL search params (with internal `_rsc*` params stripped, same as `url.searchParams`) */
57
76
  searchParams: URLSearchParams;
58
- /** Variables set by middleware (same as ctx.var) */
59
- var: Record<string, any>;
77
+ /** @internal Shared variable backing store for ctx.get()/ctx.set(). */
78
+ _variables: Record<string, any>;
60
79
  /** Get a variable set by middleware */
61
80
  get: {
62
81
  <T>(contextVar: ContextVar<T>): T | undefined;
@@ -64,20 +83,19 @@ export interface RequestContext<
64
83
  };
65
84
  /** Set a variable (shared with middleware and handlers) */
66
85
  set: {
67
- <T>(contextVar: ContextVar<T>, value: T): void;
68
- <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;
69
92
  };
70
93
  /**
71
94
  * Route params (populated after route matching)
72
95
  * Initially empty, then set to matched params
73
96
  */
74
97
  params: TParams;
75
- /**
76
- * Stub response for setting headers/cookies (read-only).
77
- * Headers set here are merged into the final response.
78
- * Use header() or setStatus() to mutate response headers/status.
79
- * Use cookies().set()/cookies().delete() for cookie mutations.
80
- */
98
+ /** @internal Stub response for collecting headers/cookies. Use ctx.headers or ctx.header() instead. */
81
99
  readonly res: Response;
82
100
 
83
101
  /** @internal Get a cookie value (effective: request + response mutations). Use cookies().get() instead. */
@@ -95,6 +113,8 @@ export interface RequestContext<
95
113
  header(name: string, value: string): void;
96
114
  /** Set the response status code */
97
115
  setStatus(status: number): void;
116
+ /** @internal Set status bypassing cache-exec guard (for framework error handling) */
117
+ _setStatus(status: number): void;
98
118
 
99
119
  /**
100
120
  * Access loader data or push handle data.
@@ -256,6 +276,54 @@ export interface RequestContext<
256
276
  /** @internal Previous route key (from the navigation source), used for revalidation */
257
277
  _prevRouteKey?: string;
258
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
+
259
327
  /** @internal Per-request error dedup set for onError reporting */
260
328
  _reportedErrors: WeakSet<object>;
261
329
 
@@ -272,6 +340,15 @@ export interface RequestContext<
272
340
 
273
341
  /** @internal Request-scoped performance metrics store */
274
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;
275
352
  }
276
353
 
277
354
  /**
@@ -298,9 +375,21 @@ export type PublicRequestContext<
298
375
  | "_routeName"
299
376
  | "_prevRouteKey"
300
377
  | "_reportedErrors"
378
+ | "_renderBarrier"
379
+ | "_resolveRenderBarrier"
380
+ | "_renderBarrierSegmentOrder"
381
+ | "_treeHasStreaming"
382
+ | "_renderBarrierWaiters"
383
+ | "_handlerLoaderDeps"
384
+ | "_renderBarrierHandleSnapshot"
301
385
  | "_reportBackgroundError"
302
386
  | "_debugPerformance"
303
387
  | "_metricsStore"
388
+ | "_basename"
389
+ | "_setStatus"
390
+ | "_variables"
391
+ | "_classifiedRoute"
392
+ | "res"
304
393
  >;
305
394
 
306
395
  // AsyncLocalStorage instance for request context
@@ -499,6 +588,18 @@ export function createRequestContext<TEnv>(
499
588
  responseCookieCache = null;
500
589
  };
501
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
+
502
603
  // Effective cookie read: response stub Set-Cookie wins, then original header.
503
604
  // The stub IS the source of truth for same-request mutations.
504
605
  const effectiveCookie = (name: string): string | undefined => {
@@ -551,19 +652,31 @@ export function createRequestContext<TEnv>(
551
652
  invalidateResponseCookieCache();
552
653
  };
553
654
 
655
+ // Strip internal _rsc* params so userland sees a clean URL.
656
+ const cleanUrl = stripInternalParams(url);
657
+
554
658
  // Build the context object first (without use), then add use
555
659
  const ctx: RequestContext<TEnv> = {
556
660
  env,
557
661
  request,
558
- url,
662
+ url: cleanUrl,
663
+ originalUrl: new URL(request.url),
559
664
  pathname: url.pathname,
560
- searchParams: url.searchParams,
561
- var: variables,
562
- get: ((keyOrVar: any) =>
563
- contextGet(variables, keyOrVar)) as RequestContext<TEnv>["get"],
564
- 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) => {
565
678
  assertNotInsideCacheExec(ctx, "set");
566
- contextSet(variables, keyOrVar, value);
679
+ contextSet(variables, keyOrVar, value, options);
567
680
  }) as RequestContext<TEnv>["set"],
568
681
  params: {} as Record<string, string>,
569
682
 
@@ -601,6 +714,7 @@ export function createRequestContext<TEnv>(
601
714
 
602
715
  setCookie(name: string, value: string, options?: CookieOptions): void {
603
716
  assertNotInsideCacheExec(ctx, "setCookie");
717
+ assertNotInsideCacheScopeALS("setCookie");
604
718
  stubResponse.headers.append(
605
719
  "Set-Cookie",
606
720
  serializeCookieValue(name, value, options),
@@ -613,6 +727,7 @@ export function createRequestContext<TEnv>(
613
727
  options?: Pick<CookieOptions, "domain" | "path">,
614
728
  ): void {
615
729
  assertNotInsideCacheExec(ctx, "deleteCookie");
730
+ assertNotInsideCacheScopeALS("deleteCookie");
616
731
  stubResponse.headers.append(
617
732
  "Set-Cookie",
618
733
  serializeCookieValue(name, "", { ...options, maxAge: 0 }),
@@ -622,13 +737,20 @@ export function createRequestContext<TEnv>(
622
737
 
623
738
  header(name: string, value: string): void {
624
739
  assertNotInsideCacheExec(ctx, "header");
740
+ assertNotInsideCacheScopeALS("header");
625
741
  stubResponse.headers.set(name, value);
626
742
  },
627
743
 
628
744
  setStatus(status: number): void {
629
745
  assertNotInsideCacheExec(ctx, "setStatus");
630
- // Response.status is read-only, so we must create a new Response.
631
- // Headers are passed by reference — no cookie cache invalidation needed.
746
+ assertNotInsideCacheScopeALS("setStatus");
747
+ stubResponse = new Response(null, {
748
+ status,
749
+ headers: stubResponse.headers,
750
+ });
751
+ },
752
+
753
+ _setStatus(status: number): void {
632
754
  stubResponse = new Response(null, {
633
755
  status,
634
756
  headers: stubResponse.headers,
@@ -660,6 +782,7 @@ export function createRequestContext<TEnv>(
660
782
 
661
783
  onResponse(callback: (response: Response) => Response): void {
662
784
  assertNotInsideCacheExec(ctx, "onResponse");
785
+ assertNotInsideCacheScopeALS("onResponse");
663
786
  this._onResponseCallbacks.push(callback);
664
787
  },
665
788
 
@@ -687,9 +810,58 @@ export function createRequestContext<TEnv>(
687
810
  _reportedErrors: new WeakSet<object>(),
688
811
  _metricsStore: undefined,
689
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
+
690
818
  reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
691
819
  };
692
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
+
693
865
  // Now create use() with access to ctx
694
866
  ctx.use = createUseFunction({
695
867
  handleStore,
@@ -872,14 +1044,13 @@ export function createUseFunction<TEnv>(
872
1044
  pathname: ctx.pathname,
873
1045
  url: ctx.url,
874
1046
  env: ctx.env as any,
875
- var: ctx.var as any,
876
1047
  get: ctx.get as any,
877
- use: <TDep, TDepParams = any>(
1048
+ use: (<TDep, TDepParams = any>(
878
1049
  dep: LoaderDefinition<TDep, TDepParams>,
879
1050
  ): Promise<TDep> => {
880
1051
  // Recursive call - will start dep loader if not already started
881
1052
  return ctx.use(dep);
882
- },
1053
+ }) as LoaderContext["use"],
883
1054
  method: "GET",
884
1055
  body: undefined,
885
1056
  reverse: createReverseFunction(
@@ -888,9 +1059,14 @@ export function createUseFunction<TEnv>(
888
1059
  ctx.params as Record<string, string>,
889
1060
  ctx._routeName ? isRouteRootScoped(ctx._routeName) : undefined,
890
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
+ },
891
1068
  };
892
1069
 
893
- // Start loader execution with tracking
894
1070
  const doneLoader = track(`loader:${loader.$$id}`, 2);
895
1071
  const promise = Promise.resolve(loaderFn(loaderCtx)).finally(() => {
896
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.