@rangojs/router 0.0.0-experimental.8123bb7e → 0.0.0-experimental.82

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 (129) hide show
  1. package/README.md +76 -18
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +829 -380
  4. package/dist/vite/index.js.bak +5448 -0
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +4 -4
  7. package/skills/handler-use/SKILL.md +362 -0
  8. package/skills/hooks/SKILL.md +24 -18
  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/middleware/SKILL.md +34 -3
  13. package/skills/migrate-nextjs/SKILL.md +560 -0
  14. package/skills/migrate-react-router/SKILL.md +765 -0
  15. package/skills/parallel/SKILL.md +59 -0
  16. package/skills/prerender/SKILL.md +110 -68
  17. package/skills/rango/SKILL.md +24 -22
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/router-setup/SKILL.md +35 -0
  20. package/src/__internal.ts +1 -1
  21. package/src/browser/app-version.ts +14 -0
  22. package/src/browser/navigation-bridge.ts +37 -5
  23. package/src/browser/navigation-client.ts +128 -77
  24. package/src/browser/navigation-store.ts +43 -8
  25. package/src/browser/partial-update.ts +41 -7
  26. package/src/browser/prefetch/cache.ts +113 -21
  27. package/src/browser/prefetch/fetch.ts +156 -18
  28. package/src/browser/prefetch/queue.ts +36 -5
  29. package/src/browser/react/Link.tsx +72 -8
  30. package/src/browser/react/NavigationProvider.tsx +14 -3
  31. package/src/browser/react/context.ts +7 -2
  32. package/src/browser/react/use-handle.ts +9 -58
  33. package/src/browser/react/use-navigation.ts +22 -2
  34. package/src/browser/react/use-params.ts +11 -1
  35. package/src/browser/react/use-router.ts +21 -8
  36. package/src/browser/rsc-router.tsx +26 -3
  37. package/src/browser/scroll-restoration.ts +10 -8
  38. package/src/browser/segment-reconciler.ts +36 -14
  39. package/src/browser/server-action-bridge.ts +8 -18
  40. package/src/browser/types.ts +20 -5
  41. package/src/build/generate-manifest.ts +6 -6
  42. package/src/build/generate-route-types.ts +3 -0
  43. package/src/build/route-trie.ts +50 -24
  44. package/src/build/route-types/include-resolution.ts +8 -1
  45. package/src/build/route-types/router-processing.ts +211 -72
  46. package/src/build/route-types/scan-filter.ts +8 -1
  47. package/src/client.tsx +84 -230
  48. package/src/deps/browser.ts +0 -1
  49. package/src/handle.ts +40 -0
  50. package/src/index.rsc.ts +3 -1
  51. package/src/index.ts +46 -6
  52. package/src/prerender/store.ts +5 -4
  53. package/src/prerender.ts +138 -77
  54. package/src/reverse.ts +25 -1
  55. package/src/route-definition/dsl-helpers.ts +194 -32
  56. package/src/route-definition/helpers-types.ts +61 -14
  57. package/src/route-definition/index.ts +3 -0
  58. package/src/route-definition/redirect.ts +9 -1
  59. package/src/route-definition/resolve-handler-use.ts +149 -0
  60. package/src/route-types.ts +18 -0
  61. package/src/router/content-negotiation.ts +100 -1
  62. package/src/router/handler-context.ts +51 -15
  63. package/src/router/intercept-resolution.ts +9 -4
  64. package/src/router/lazy-includes.ts +5 -5
  65. package/src/router/loader-resolution.ts +150 -21
  66. package/src/router/manifest.ts +22 -13
  67. package/src/router/match-api.ts +124 -189
  68. package/src/router/match-middleware/cache-lookup.ts +28 -8
  69. package/src/router/match-middleware/segment-resolution.ts +53 -0
  70. package/src/router/match-result.ts +82 -4
  71. package/src/router/middleware-types.ts +0 -6
  72. package/src/router/middleware.ts +0 -3
  73. package/src/router/navigation-snapshot.ts +182 -0
  74. package/src/router/prerender-match.ts +110 -10
  75. package/src/router/preview-match.ts +30 -102
  76. package/src/router/request-classification.ts +310 -0
  77. package/src/router/route-snapshot.ts +245 -0
  78. package/src/router/router-interfaces.ts +36 -4
  79. package/src/router/router-options.ts +37 -11
  80. package/src/router/segment-resolution/fresh.ts +70 -5
  81. package/src/router/segment-resolution/revalidation.ts +87 -9
  82. package/src/router.ts +53 -5
  83. package/src/rsc/handler.ts +472 -397
  84. package/src/rsc/loader-fetch.ts +18 -3
  85. package/src/rsc/manifest-init.ts +5 -1
  86. package/src/rsc/progressive-enhancement.ts +14 -3
  87. package/src/rsc/rsc-rendering.ts +15 -2
  88. package/src/rsc/server-action.ts +10 -2
  89. package/src/rsc/ssr-setup.ts +2 -2
  90. package/src/rsc/types.ts +6 -4
  91. package/src/segment-content-promise.ts +67 -0
  92. package/src/segment-loader-promise.ts +122 -0
  93. package/src/segment-system.tsx +11 -61
  94. package/src/server/context.ts +65 -5
  95. package/src/server/handle-store.ts +19 -0
  96. package/src/server/loader-registry.ts +9 -8
  97. package/src/server/request-context.ts +132 -13
  98. package/src/ssr/index.tsx +3 -0
  99. package/src/static-handler.ts +18 -6
  100. package/src/types/cache-types.ts +4 -4
  101. package/src/types/handler-context.ts +17 -11
  102. package/src/types/loader-types.ts +32 -5
  103. package/src/types/route-entry.ts +12 -1
  104. package/src/types/segments.ts +1 -1
  105. package/src/urls/include-helper.ts +24 -14
  106. package/src/urls/path-helper-types.ts +39 -6
  107. package/src/urls/path-helper.ts +47 -12
  108. package/src/urls/pattern-types.ts +12 -0
  109. package/src/urls/response-types.ts +16 -6
  110. package/src/use-loader.tsx +77 -5
  111. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  112. package/src/vite/discovery/discover-routers.ts +5 -1
  113. package/src/vite/discovery/prerender-collection.ts +128 -74
  114. package/src/vite/discovery/state.ts +13 -4
  115. package/src/vite/index.ts +4 -0
  116. package/src/vite/plugin-types.ts +60 -5
  117. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  118. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  119. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  120. package/src/vite/plugins/expose-id-utils.ts +12 -0
  121. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  122. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  123. package/src/vite/plugins/performance-tracks.ts +64 -211
  124. package/src/vite/plugins/refresh-cmd.ts +88 -26
  125. package/src/vite/rango.ts +17 -11
  126. package/src/vite/router-discovery.ts +237 -37
  127. package/src/vite/utils/prerender-utils.ts +37 -5
  128. package/src/vite/utils/shared-utils.ts +3 -2
  129. package/src/browser/debug-channel.ts +0 -93
@@ -168,11 +168,17 @@ export async function handleLoaderFetch<TEnv>(
168
168
  loaderResult: unknown;
169
169
  }
170
170
  const loaderPayload: LoaderPayload = { loaderResult: result };
171
- const debugChannel = reqCtx._debugChannel;
172
171
  const rscStream = ctx.renderToReadableStream<LoaderPayload>(
173
172
  loaderPayload,
174
173
  {
175
- ...(debugChannel && { debugChannel }),
174
+ onError: (error: unknown) => {
175
+ ctx.callOnError(error, "rendering", {
176
+ request,
177
+ url,
178
+ env,
179
+ loaderName: loaderId,
180
+ });
181
+ },
176
182
  },
177
183
  );
178
184
 
@@ -204,7 +210,16 @@ export async function handleLoaderFetch<TEnv>(
204
210
  name: err.name,
205
211
  },
206
212
  };
207
- const rscStream = ctx.renderToReadableStream(errorPayload);
213
+ const rscStream = ctx.renderToReadableStream(errorPayload, {
214
+ onError: (error: unknown) => {
215
+ ctx.callOnError(error, "rendering", {
216
+ request,
217
+ url,
218
+ env,
219
+ loaderName: loaderId,
220
+ });
221
+ },
222
+ });
208
223
 
209
224
  return createResponseWithMergedHeaders(rscStream, {
210
225
  status: 500,
@@ -31,7 +31,11 @@ export async function buildRouterTrieFromUrlpatterns(
31
31
  ): Promise<void> {
32
32
  const { generateManifestFull } =
33
33
  await import("../build/generate-manifest.js");
34
- const generated = generateManifestFull(router.urlpatterns);
34
+ const generated = generateManifestFull(
35
+ router.urlpatterns,
36
+ undefined,
37
+ router.basename ? { urlPrefix: router.basename } : undefined,
38
+ );
35
39
  if (
36
40
  generated._routeAncestry &&
37
41
  Object.keys(generated._routeAncestry).length > 0
@@ -243,9 +243,12 @@ export async function handleProgressiveEnhancement<TEnv>(
243
243
  const payload: RscPayload = {
244
244
  metadata: {
245
245
  pathname: url.pathname,
246
+ routerId: ctx.router.id,
247
+ basename: ctx.router.basename,
246
248
  segments: match.segments,
247
249
  matched: match.matched,
248
250
  diff: match.diff,
251
+ params: match.params,
249
252
  isPartial: false,
250
253
  rootLayout: ctx.router.rootLayout,
251
254
  handles: handleStore.stream(),
@@ -257,9 +260,10 @@ export async function handleProgressiveEnhancement<TEnv>(
257
260
  formState: actionResult,
258
261
  };
259
262
 
260
- const debugChannel = requireRequestContext()._debugChannel;
261
263
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
262
- ...(debugChannel && { debugChannel }),
264
+ onError: (error: unknown) => {
265
+ ctx.callOnError(error, "rendering", { request, url, env });
266
+ },
263
267
  });
264
268
  // metricsStore=undefined is safe: the handler already stashed the early
265
269
  // SSR setup promise on request variables, so getSSRSetup returns it
@@ -345,9 +349,12 @@ async function renderPeErrorBoundary<TEnv>(
345
349
  const payload: RscPayload = {
346
350
  metadata: {
347
351
  pathname: url.pathname,
352
+ routerId: ctx.router.id,
353
+ basename: ctx.router.basename,
348
354
  segments: errorResult.segments,
349
355
  matched: errorResult.matched,
350
356
  diff: errorResult.diff,
357
+ params: errorResult.params,
351
358
  isPartial: false,
352
359
  isError: true,
353
360
  rootLayout: ctx.router.rootLayout,
@@ -359,7 +366,11 @@ async function renderPeErrorBoundary<TEnv>(
359
366
  },
360
367
  };
361
368
 
362
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
369
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
370
+ onError: (error: unknown) => {
371
+ ctx.callOnError(error, "rendering", { request, url, env });
372
+ },
373
+ });
363
374
  // metricsStore=undefined is safe: the handler already stashed the early
364
375
  // SSR setup promise on request variables, so getSSRSetup returns it
365
376
  // without falling back to a fresh startSSRSetup.
@@ -54,6 +54,8 @@ export async function handleRscRendering<TEnv>(
54
54
  payload = {
55
55
  metadata: {
56
56
  pathname: url.pathname,
57
+ routerId: ctx.router.id,
58
+ basename: ctx.router.basename,
57
59
  segments: match.segments,
58
60
  matched: match.matched,
59
61
  diff: match.diff,
@@ -75,6 +77,7 @@ export async function handleRscRendering<TEnv>(
75
77
  payload = {
76
78
  metadata: {
77
79
  pathname: url.pathname,
80
+ routerId: ctx.router.id,
78
81
  segments: result.segments,
79
82
  matched: result.matched,
80
83
  diff: result.diff,
@@ -136,6 +139,8 @@ export async function handleRscRendering<TEnv>(
136
139
 
137
140
  metadata: {
138
141
  pathname: url.pathname,
142
+ routerId: ctx.router.id,
143
+ basename: ctx.router.basename,
139
144
  segments: match.segments,
140
145
  matched: match.matched,
141
146
  diff: match.diff,
@@ -168,9 +173,10 @@ export async function handleRscRendering<TEnv>(
168
173
 
169
174
  // Serialize to RSC stream
170
175
  const rscSerializeStart = performance.now();
171
- const debugChannel = reqCtx._debugChannel;
172
176
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
173
- ...(debugChannel && { debugChannel }),
177
+ onError: (error: unknown) => {
178
+ ctx.callOnError(error, "rendering", { request, url, env });
179
+ },
174
180
  });
175
181
  const rscSerializeDur = performance.now() - rscSerializeStart;
176
182
  // This measures synchronous stream creation, not end-to-end stream consumption.
@@ -198,6 +204,13 @@ export async function handleRscRendering<TEnv>(
198
204
  "content-type": "text/x-component;charset=utf-8",
199
205
  vary: "accept, X-Rango-State, X-RSC-Router-Client-Path",
200
206
  };
207
+ // Tell the client's prefetch cache to scope this response to its source
208
+ // URL (instead of the default source-agnostic wildcard). Intercept
209
+ // responses depend on the source page matching an intercept rule, so
210
+ // they must not be reused for navigations from other sources.
211
+ if (hasInterceptSlots) {
212
+ rscHeaders["x-rsc-prefetch-scope"] = "source";
213
+ }
201
214
  // Enable browser HTTP caching for prefetch responses only.
202
215
  // Requires X-Rango-Prefetch header (sent by Link prefetch fetch),
203
216
  // non-intercept context (intercept responses depend on source page),
@@ -208,10 +208,12 @@ export async function executeServerAction<TEnv>(
208
208
  const payload: RscPayload = {
209
209
  metadata: {
210
210
  pathname: url.pathname,
211
+ routerId: ctx.router.id,
211
212
  segments: errorResult.segments,
212
213
  isPartial: true,
213
214
  matched: errorResult.matched,
214
215
  diff: errorResult.diff,
216
+ params: errorResult.params,
215
217
  isError: true,
216
218
  handles: handleStore.stream(),
217
219
  version: ctx.version,
@@ -223,10 +225,11 @@ export async function executeServerAction<TEnv>(
223
225
  // location state is a success-only semantic. Error boundary responses
224
226
  // update the error UI but should not mutate browser history state.
225
227
 
226
- const debugChannel = requireRequestContext()._debugChannel;
227
228
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
228
229
  temporaryReferences,
229
- ...(debugChannel && { debugChannel }),
230
+ onError: (error: unknown) => {
231
+ ctx.callOnError(error, "rendering", { request, url, env });
232
+ },
230
233
  });
231
234
 
232
235
  return createResponseWithMergedHeaders(rscStream, {
@@ -316,10 +319,12 @@ export async function revalidateAfterAction<TEnv>(
316
319
  const payload: RscPayload = {
317
320
  metadata: {
318
321
  pathname: url.pathname,
322
+ routerId: ctx.router.id,
319
323
  segments: matchResult.segments,
320
324
  isPartial: true,
321
325
  matched: matchResult.matched,
322
326
  diff: matchResult.diff,
327
+ params: matchResult.params,
323
328
  slots: matchResult.slots,
324
329
  handles: handleStore.stream(),
325
330
  version: ctx.version,
@@ -332,6 +337,9 @@ export async function revalidateAfterAction<TEnv>(
332
337
  const renderStart = performance.now();
333
338
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
334
339
  temporaryReferences,
340
+ onError: (error: unknown) => {
341
+ ctx.callOnError(error, "rendering", { request, url, env });
342
+ },
335
343
  });
336
344
  const rscSerializeDur = performance.now() - renderStart;
337
345
  // This measures synchronous stream creation, not end-to-end stream consumption.
@@ -77,7 +77,7 @@ export function getSSRSetup<TEnv>(
77
77
  url: URL,
78
78
  metricsStore: MetricsStore | undefined,
79
79
  ): Promise<SSRSetup> {
80
- const early = _getRequestContext()?.var?.[SSR_SETUP_VAR] as
80
+ const early = _getRequestContext()?._variables?.[SSR_SETUP_VAR] as
81
81
  | Promise<SSRSetup>
82
82
  | undefined;
83
83
  if (early) return early;
@@ -98,7 +98,7 @@ export function getSSRSetup<TEnv>(
98
98
  * the isRscRequest decision in rsc-rendering.ts.
99
99
  *
100
100
  * Note: response/mime routes are excluded by the caller — this function
101
- * runs after previewMatch() classifies the route type.
101
+ * runs after classifyRequest() determines the request mode.
102
102
  */
103
103
  export function mayNeedSSR(request: Request, url: URL): boolean {
104
104
  if (
package/src/rsc/types.ts CHANGED
@@ -19,6 +19,9 @@ export interface RscPayload {
19
19
  metadata?: {
20
20
  pathname: string;
21
21
  segments: ResolvedSegment[];
22
+ /** Router instance ID. When this changes between navigations, the client
23
+ * discards cached segments and does a full tree replacement (app switch). */
24
+ routerId?: string;
22
25
  isPartial?: boolean;
23
26
  isError?: boolean;
24
27
  matched?: string[];
@@ -38,6 +41,8 @@ export interface RscPayload {
38
41
  themeConfig?: ResolvedThemeConfig | null;
39
42
  /** Initial theme from cookie (for SSR hydration) */
40
43
  initialTheme?: Theme;
44
+ /** URL prefix for all routes (from createRouter({ basename })). */
45
+ basename?: string;
41
46
  /** Whether connection warmup is enabled */
42
47
  warmupEnabled?: boolean;
43
48
  /** Server-side redirect with optional state (for partial requests) */
@@ -65,10 +70,7 @@ export interface RSCDependencies {
65
70
  payload: T,
66
71
  options?: {
67
72
  temporaryReferences?: unknown;
68
- debugChannel?: {
69
- readable?: ReadableStream;
70
- writable?: WritableStream;
71
- };
73
+ onError?: (error: unknown) => string | void;
72
74
  },
73
75
  ) => ReadableStream<Uint8Array>;
74
76
 
@@ -0,0 +1,67 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * Stable Promise wrappers keyed on the component itself. Objects (React
5
+ * elements, functions, lazy payloads) land in a WeakMap so entries GC when
6
+ * the underlying component is released; primitives (string, number, boolean,
7
+ * null) land in a Map so memoization still applies to text-/null-backed
8
+ * segments like those in partial-update flows. Keeping this cache outside
9
+ * the segment eliminates preservation fields on ResolvedSegment — it survives
10
+ * reconciliation naturally because the component ref is what's stable.
11
+ *
12
+ * Browser-only. On the server each SSR render needs a fresh pending promise
13
+ * so Suspense can emit the loading fallback HTML before content streams. A
14
+ * shared already-resolved promise has `.status === "fulfilled"` attached by
15
+ * React on its first observation — subsequent `use()` calls return
16
+ * synchronously without suspending, so the Suspense fallback never makes it
17
+ * into the initial HTML. Route-definition components share refs across
18
+ * requests, so a global cache would leak tracked state between renders.
19
+ */
20
+ const IS_BROWSER = typeof window !== "undefined";
21
+ const objectContentCache = IS_BROWSER
22
+ ? new WeakMap<object, Promise<ReactNode>>()
23
+ : null;
24
+ const primitiveContentCache = IS_BROWSER
25
+ ? new Map<unknown, Promise<ReactNode>>()
26
+ : null;
27
+
28
+ /**
29
+ * Return a stable Promise wrapping `component`, memoized on the component ref.
30
+ *
31
+ * A fresh `Promise.resolve(component)` each render would suspend for one
32
+ * microtask and briefly commit the loading fallback inside Suspender — the
33
+ * intercept / parallel-slot flicker this indirection prevents. Reusing the
34
+ * same Promise ref keeps React's `use()` in "known fulfilled" state after
35
+ * the first observation.
36
+ *
37
+ * @internal
38
+ */
39
+ export function getMemoizedContentPromise(
40
+ component: ReactNode,
41
+ ): Promise<ReactNode> {
42
+ if (component instanceof Promise) {
43
+ return component as Promise<ReactNode>;
44
+ }
45
+
46
+ if (!objectContentCache || !primitiveContentCache) {
47
+ return Promise.resolve(component);
48
+ }
49
+
50
+ if (component !== null && typeof component === "object") {
51
+ const cached = objectContentCache.get(component);
52
+ if (cached) {
53
+ return cached;
54
+ }
55
+ const promise = Promise.resolve(component);
56
+ objectContentCache.set(component, promise);
57
+ return promise;
58
+ }
59
+
60
+ const cached = primitiveContentCache.get(component);
61
+ if (cached) {
62
+ return cached;
63
+ }
64
+ const promise = Promise.resolve(component);
65
+ primitiveContentCache.set(component, promise);
66
+ return promise;
67
+ }
@@ -0,0 +1,122 @@
1
+ import type { ResolvedSegment } from "./types.js";
2
+
3
+ /**
4
+ * Cache of aggregate Promise.all results keyed on the first loader's
5
+ * `loaderData` reference. Each entry holds the source refs it was built from
6
+ * plus the resulting Promise/array; lookup scans entries for the matching
7
+ * source array (typically a single entry, since distinct loader groups rarely
8
+ * share a first source). Object first-refs live in a WeakMap (auto-GC);
9
+ * primitive first-refs (strings/numbers/booleans/null) live in a Map so
10
+ * loaders that resolve to primitive data are memoized too — bounded in
11
+ * practice by the application's loader set.
12
+ *
13
+ * Keying externally means reconciliation's fresh segment objects no longer
14
+ * drop memoization — the cache survives as long as the underlying loader
15
+ * segments do, and GC collects entries when those loaders are released
16
+ * (object keys only).
17
+ *
18
+ * Browser-only. On the server each SSR render needs a fresh Promise so
19
+ * Suspense can actually suspend and emit the loading fallback HTML before
20
+ * content streams. A shared already-resolved promise has `.status` attached
21
+ * by React on first `use()`; subsequent observations return synchronously
22
+ * and skip the fallback. The zero-loader case is especially prone because
23
+ * every empty-loader site would otherwise share one promise across requests.
24
+ */
25
+ const IS_BROWSER = typeof window !== "undefined";
26
+
27
+ interface LoaderCacheEntry {
28
+ sources: any[];
29
+ promise: Promise<any[]> | any[];
30
+ }
31
+
32
+ const objectLoaderCache = IS_BROWSER
33
+ ? new WeakMap<object, LoaderCacheEntry[]>()
34
+ : null;
35
+ const primitiveLoaderCache = IS_BROWSER
36
+ ? new Map<unknown, LoaderCacheEntry[]>()
37
+ : null;
38
+
39
+ // In the browser, a single shared empty aggregate is safe (and desirable) —
40
+ // reusing the same resolved promise keeps React's `use()` in a known-fulfilled
41
+ // state across renders. On the server it would leak `.status = "fulfilled"`
42
+ // across requests and skip the Suspense fallback, so we rebuild on each call.
43
+ const SHARED_EMPTY_LOADER_PROMISE: Promise<any[]> | null = IS_BROWSER
44
+ ? Promise.resolve([])
45
+ : null;
46
+
47
+ function hasSameReferences(a: any[], b: any[]): boolean {
48
+ if (a.length !== b.length) {
49
+ return false;
50
+ }
51
+ for (let i = 0; i < a.length; i++) {
52
+ if (a[i] !== b[i]) {
53
+ return false;
54
+ }
55
+ }
56
+ return true;
57
+ }
58
+
59
+ function buildLoaderPromise(loaders: ResolvedSegment[]): Promise<any[]> {
60
+ if (loaders.length === 0) {
61
+ return Promise.resolve([]);
62
+ }
63
+ return Promise.all(
64
+ loaders.map((loader) =>
65
+ loader.loaderData instanceof Promise
66
+ ? loader.loaderData
67
+ : Promise.resolve(loader.loaderData),
68
+ ),
69
+ );
70
+ }
71
+
72
+ function isObjectLike(value: unknown): value is object {
73
+ return (
74
+ value !== null && (typeof value === "object" || typeof value === "function")
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Memoize an aggregate Promise.all for a set of loader segments. Reusing the
80
+ * same aggregate across renders — invalidated only when any underlying
81
+ * loader.loaderData ref changes — keeps React's `use()` in "known fulfilled"
82
+ * state and prevents a fresh Promise.all from suspending (and briefly
83
+ * committing the Suspense fallback) on every partial update that doesn't
84
+ * actually change loader data.
85
+ *
86
+ * @internal
87
+ */
88
+ export function getMemoizedLoaderPromise(
89
+ loaders: ResolvedSegment[],
90
+ ): Promise<any[]> | any[] {
91
+ if (loaders.length === 0) {
92
+ return SHARED_EMPTY_LOADER_PROMISE ?? buildLoaderPromise(loaders);
93
+ }
94
+ if (!objectLoaderCache || !primitiveLoaderCache) {
95
+ return buildLoaderPromise(loaders);
96
+ }
97
+
98
+ const sources = loaders.map((loader) => loader.loaderData);
99
+ const first = sources[0];
100
+ const entries = isObjectLike(first)
101
+ ? objectLoaderCache.get(first)
102
+ : primitiveLoaderCache.get(first);
103
+
104
+ if (entries) {
105
+ for (const entry of entries) {
106
+ if (hasSameReferences(entry.sources, sources)) {
107
+ return entry.promise;
108
+ }
109
+ }
110
+ }
111
+
112
+ const promise = buildLoaderPromise(loaders);
113
+ const newEntry: LoaderCacheEntry = { sources, promise };
114
+ if (entries) {
115
+ entries.push(newEntry);
116
+ } else if (isObjectLike(first)) {
117
+ objectLoaderCache.set(first, [newEntry]);
118
+ } else {
119
+ primitiveLoaderCache.set(first, [newEntry]);
120
+ }
121
+ return promise;
122
+ }
@@ -2,11 +2,7 @@ import * as React from "react";
2
2
  import { createElement, type ReactNode, type ComponentType } from "react";
3
3
  import { OutletProvider } from "./client.js";
4
4
  import { MountContextProvider } from "./browser/react/mount-context.js";
5
- import type {
6
- ResolvedSegment,
7
- LoaderDataResult,
8
- RootLayoutProps,
9
- } from "./types.js";
5
+ import type { ResolvedSegment, RootLayoutProps } from "./types.js";
10
6
  import { isLoaderDataResult } from "./types.js";
11
7
  import { invariant } from "./errors.js";
12
8
  import {
@@ -14,6 +10,8 @@ import {
14
10
  LoaderBoundary,
15
11
  } from "./route-content-wrapper.js";
16
12
  import { RootErrorBoundary } from "./root-error-boundary.js";
13
+ import { getMemoizedContentPromise } from "./segment-content-promise.js";
14
+ import { getMemoizedLoaderPromise } from "./segment-loader-promise.js";
17
15
 
18
16
  // ViewTransition is only available in React experimental.
19
17
  // Access via namespace import to avoid compile-time errors on stable React.
@@ -61,20 +59,6 @@ function restoreParallelLoaderMarkers(
61
59
  return nextSegments ?? segments;
62
60
  }
63
61
 
64
- function hasSameReferences(a: unknown[] | undefined, b: unknown[]): boolean {
65
- if (!a || a.length !== b.length) {
66
- return false;
67
- }
68
-
69
- for (let i = 0; i < a.length; i++) {
70
- if (a[i] !== b[i]) {
71
- return false;
72
- }
73
- }
74
-
75
- return true;
76
- }
77
-
78
62
  /**
79
63
  * Resolve loader data from raw results, unwrapping LoaderDataResult wrappers
80
64
  */
@@ -278,10 +262,7 @@ export async function renderSegments(
278
262
  loading !== null && loading !== undefined && loading !== false
279
263
  ? createElement(RouteContentWrapper, {
280
264
  key: `suspense-loading-${id}`,
281
- content:
282
- resolvedComponent instanceof Promise
283
- ? resolvedComponent
284
- : Promise.resolve(resolvedComponent),
265
+ content: getMemoizedContentPromise(resolvedComponent),
285
266
  fallback: loading,
286
267
  segmentId: id,
287
268
  })
@@ -305,16 +286,7 @@ export async function renderSegments(
305
286
 
306
287
  // Prepare loader data if there are loaders
307
288
  const loaderIds = loaderEntries.map((loader) => loader.loaderId!);
308
- const loaderDataPromise =
309
- loaderEntries.length > 0
310
- ? Promise.all(
311
- loaderEntries.map((loader) =>
312
- loader.loaderData instanceof Promise
313
- ? loader.loaderData
314
- : Promise.resolve(loader.loaderData),
315
- ),
316
- )
317
- : Promise.resolve([]);
289
+ const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
318
290
 
319
291
  // Use LoaderBoundary when loading is defined to maintain consistent tree structure
320
292
  // This ensures cached segments (which may not have loader segments) have the same
@@ -396,34 +368,12 @@ export async function renderSegments(
396
368
  continue;
397
369
  }
398
370
 
399
- const parallelLoaderIds = ownedLoaders.map((l) => l.loaderId!);
400
- const parallelLoaderSources = ownedLoaders.map((l) => l.loaderData);
401
- p.loaderIds = parallelLoaderIds;
402
-
403
- const shouldReuseParallelPromise =
404
- p.loaderDataPromise !== undefined &&
405
- hasSameReferences(p.parallelLoaderSources, parallelLoaderSources);
406
-
407
- const parallelLoaderDataPromise = shouldReuseParallelPromise
408
- ? p.loaderDataPromise
409
- : forceAwait || isAction
410
- ? await Promise.all(
411
- ownedLoaders.map((l) =>
412
- l.loaderData instanceof Promise
413
- ? l.loaderData
414
- : Promise.resolve(l.loaderData),
415
- ),
416
- )
417
- : Promise.all(
418
- ownedLoaders.map((l) =>
419
- l.loaderData instanceof Promise
420
- ? l.loaderData
421
- : Promise.resolve(l.loaderData),
422
- ),
423
- );
424
-
425
- p.loaderDataPromise = parallelLoaderDataPromise;
426
- p.parallelLoaderSources = parallelLoaderSources;
371
+ p.loaderIds = ownedLoaders.map((l) => l.loaderId!);
372
+ const aggregated = getMemoizedLoaderPromise(ownedLoaders);
373
+ p.loaderDataPromise =
374
+ (forceAwait || isAction) && aggregated instanceof Promise
375
+ ? await aggregated
376
+ : aggregated;
427
377
  }
428
378
  }
429
379
 
@@ -191,8 +191,12 @@ export type EntryData =
191
191
  /** Original PrerenderHandlerDefinition (for build-time getParams access) */
192
192
  prerenderDef?: {
193
193
  getParams?: (ctx: any) => Promise<any[]> | any[];
194
- options?: { passthrough?: boolean };
194
+ options?: { concurrency?: number };
195
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>;
196
200
  /** Set when handler is a Static definition (build-time only) */
197
201
  isStaticPrerender?: true;
198
202
  /** Static handler $$id for build-time store lookup */
@@ -276,6 +280,22 @@ interface HelperContext {
276
280
  /** True when resolving handlers inside a cache() DSL boundary.
277
281
  * Read by ctx.get() to guard non-cacheable variable reads. */
278
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;
279
299
  }
280
300
  // Use a global symbol key so the AsyncLocalStorage instance survives HMR
281
301
  // module re-evaluation. Without this, Vite's RSC module runner may create
@@ -378,6 +398,8 @@ export const getContext = (): {
378
398
  const mountPrefix =
379
399
  store.mountIndex !== undefined ? `M${store.mountIndex}` : "";
380
400
 
401
+ const includeScope = store.includeScope ?? "";
402
+
381
403
  if (!parent) {
382
404
  // Root entry: prefix with mount index and use mount-scoped counter
383
405
  const counterKey = mountPrefix
@@ -388,12 +410,16 @@ export const getContext = (): {
388
410
  store.counters[counterKey] = index + 1;
389
411
  return `${mountPrefix}${prefix}${index}`;
390
412
  } else {
391
- // Child entry: use parent-scoped counter (parent already has M prefix)
392
- 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}`;
393
419
  store.counters[counterKey] ??= 0;
394
420
  const index = store.counters[counterKey];
395
421
  store.counters[counterKey] = index + 1;
396
- return `${parent.shortCode}${prefix}${index}`;
422
+ return `${parent.shortCode}${includeScope}${prefix}${index}`;
397
423
  }
398
424
  },
399
425
  runWithStore: <T>(
@@ -420,6 +446,7 @@ export const getContext = (): {
420
446
  rootScoped: store.rootScoped,
421
447
  trackedIncludes: store.trackedIncludes,
422
448
  cacheProfiles: store.cacheProfiles,
449
+ includeScope: store.includeScope,
423
450
  },
424
451
  callback,
425
452
  );
@@ -670,11 +697,44 @@ export function track(label: string, depth?: number): () => void {
670
697
  };
671
698
  }
672
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
+
673
710
  /**
674
711
  * Check if the current execution is inside a cache() DSL boundary.
675
712
  * Returns false inside loader execution — loaders are always fresh
676
713
  * (never cached), so non-cacheable reads are safe.
677
714
  */
678
715
  export function isInsideCacheScope(): boolean {
679
- return RSCRouterContext.getStore()?.insideCacheScope === true;
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);
680
740
  }