@rangojs/router 0.0.0-experimental.8678bb02 → 0.0.0-experimental.88

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 (147) hide show
  1. package/README.md +126 -38
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +867 -385
  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 +5 -5
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/handler-use/SKILL.md +362 -0
  9. package/skills/hooks/SKILL.md +28 -20
  10. package/skills/intercept/SKILL.md +20 -0
  11. package/skills/layout/SKILL.md +22 -0
  12. package/skills/links/SKILL.md +91 -17
  13. package/skills/loader/SKILL.md +35 -2
  14. package/skills/middleware/SKILL.md +34 -3
  15. package/skills/migrate-nextjs/SKILL.md +560 -0
  16. package/skills/migrate-react-router/SKILL.md +765 -0
  17. package/skills/parallel/SKILL.md +59 -0
  18. package/skills/prerender/SKILL.md +110 -68
  19. package/skills/rango/SKILL.md +24 -22
  20. package/skills/response-routes/SKILL.md +8 -0
  21. package/skills/route/SKILL.md +24 -0
  22. package/skills/router-setup/SKILL.md +35 -0
  23. package/skills/streams-and-websockets/SKILL.md +283 -0
  24. package/skills/typesafety/SKILL.md +3 -1
  25. package/src/__internal.ts +1 -1
  26. package/src/browser/app-shell.ts +52 -0
  27. package/src/browser/app-version.ts +14 -0
  28. package/src/browser/navigation-bridge.ts +87 -6
  29. package/src/browser/navigation-client.ts +128 -77
  30. package/src/browser/navigation-store.ts +68 -9
  31. package/src/browser/partial-update.ts +60 -7
  32. package/src/browser/prefetch/cache.ts +129 -21
  33. package/src/browser/prefetch/fetch.ts +156 -18
  34. package/src/browser/prefetch/queue.ts +36 -5
  35. package/src/browser/rango-state.ts +53 -13
  36. package/src/browser/react/Link.tsx +72 -8
  37. package/src/browser/react/NavigationProvider.tsx +57 -11
  38. package/src/browser/react/context.ts +7 -2
  39. package/src/browser/react/use-handle.ts +9 -58
  40. package/src/browser/react/use-navigation.ts +22 -2
  41. package/src/browser/react/use-params.ts +11 -1
  42. package/src/browser/react/use-router.ts +29 -9
  43. package/src/browser/rsc-router.tsx +60 -9
  44. package/src/browser/scroll-restoration.ts +10 -8
  45. package/src/browser/segment-reconciler.ts +36 -14
  46. package/src/browser/server-action-bridge.ts +8 -18
  47. package/src/browser/types.ts +33 -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 +211 -72
  53. package/src/build/route-types/scan-filter.ts +8 -1
  54. package/src/cache/cf/cf-cache-store.ts +5 -7
  55. package/src/client.tsx +84 -230
  56. package/src/deps/browser.ts +0 -1
  57. package/src/handle.ts +40 -0
  58. package/src/index.rsc.ts +6 -1
  59. package/src/index.ts +49 -6
  60. package/src/outlet-context.ts +1 -1
  61. package/src/prerender/store.ts +5 -4
  62. package/src/prerender.ts +138 -77
  63. package/src/response-utils.ts +28 -0
  64. package/src/reverse.ts +27 -2
  65. package/src/route-definition/dsl-helpers.ts +210 -35
  66. package/src/route-definition/helpers-types.ts +61 -14
  67. package/src/route-definition/index.ts +3 -0
  68. package/src/route-definition/redirect.ts +9 -1
  69. package/src/route-definition/resolve-handler-use.ts +155 -0
  70. package/src/route-types.ts +18 -0
  71. package/src/router/content-negotiation.ts +100 -1
  72. package/src/router/handler-context.ts +70 -17
  73. package/src/router/intercept-resolution.ts +9 -4
  74. package/src/router/lazy-includes.ts +6 -6
  75. package/src/router/loader-resolution.ts +153 -21
  76. package/src/router/manifest.ts +22 -13
  77. package/src/router/match-api.ts +127 -192
  78. package/src/router/match-middleware/cache-lookup.ts +28 -8
  79. package/src/router/match-middleware/segment-resolution.ts +53 -0
  80. package/src/router/match-result.ts +82 -4
  81. package/src/router/middleware-types.ts +2 -28
  82. package/src/router/middleware.ts +32 -7
  83. package/src/router/navigation-snapshot.ts +182 -0
  84. package/src/router/pattern-matching.ts +60 -9
  85. package/src/router/prerender-match.ts +110 -10
  86. package/src/router/preview-match.ts +30 -102
  87. package/src/router/request-classification.ts +310 -0
  88. package/src/router/route-snapshot.ts +245 -0
  89. package/src/router/router-interfaces.ts +36 -4
  90. package/src/router/router-options.ts +37 -11
  91. package/src/router/segment-resolution/fresh.ts +70 -5
  92. package/src/router/segment-resolution/revalidation.ts +87 -9
  93. package/src/router/trie-matching.ts +10 -4
  94. package/src/router/url-params.ts +49 -0
  95. package/src/router.ts +54 -7
  96. package/src/rsc/handler.ts +478 -399
  97. package/src/rsc/helpers.ts +69 -41
  98. package/src/rsc/loader-fetch.ts +18 -3
  99. package/src/rsc/manifest-init.ts +5 -1
  100. package/src/rsc/progressive-enhancement.ts +14 -3
  101. package/src/rsc/response-route-handler.ts +14 -1
  102. package/src/rsc/rsc-rendering.ts +15 -2
  103. package/src/rsc/server-action.ts +10 -2
  104. package/src/rsc/ssr-setup.ts +2 -2
  105. package/src/rsc/types.ts +6 -4
  106. package/src/segment-content-promise.ts +67 -0
  107. package/src/segment-loader-promise.ts +122 -0
  108. package/src/segment-system.tsx +11 -61
  109. package/src/server/context.ts +65 -5
  110. package/src/server/handle-store.ts +19 -0
  111. package/src/server/loader-registry.ts +9 -8
  112. package/src/server/request-context.ts +142 -55
  113. package/src/ssr/index.tsx +3 -0
  114. package/src/static-handler.ts +18 -6
  115. package/src/types/cache-types.ts +4 -4
  116. package/src/types/handler-context.ts +17 -43
  117. package/src/types/loader-types.ts +37 -11
  118. package/src/types/request-scope.ts +126 -0
  119. package/src/types/route-entry.ts +12 -1
  120. package/src/types/segments.ts +1 -1
  121. package/src/urls/include-helper.ts +24 -14
  122. package/src/urls/path-helper-types.ts +39 -6
  123. package/src/urls/path-helper.ts +47 -12
  124. package/src/urls/pattern-types.ts +12 -0
  125. package/src/urls/response-types.ts +18 -16
  126. package/src/use-loader.tsx +77 -5
  127. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  128. package/src/vite/discovery/discover-routers.ts +5 -1
  129. package/src/vite/discovery/prerender-collection.ts +128 -74
  130. package/src/vite/discovery/state.ts +13 -4
  131. package/src/vite/index.ts +4 -0
  132. package/src/vite/plugin-types.ts +60 -5
  133. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  134. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  135. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  136. package/src/vite/plugins/expose-id-utils.ts +12 -0
  137. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  138. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  139. package/src/vite/plugins/performance-tracks.ts +64 -206
  140. package/src/vite/plugins/refresh-cmd.ts +88 -26
  141. package/src/vite/rango.ts +50 -20
  142. package/src/vite/router-discovery.ts +237 -37
  143. package/src/vite/utils/banner.ts +1 -1
  144. package/src/vite/utils/package-resolution.ts +41 -1
  145. package/src/vite/utils/prerender-utils.ts +37 -5
  146. package/src/vite/utils/shared-utils.ts +3 -2
  147. package/src/browser/debug-channel.ts +0 -93
@@ -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
  }
@@ -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);
@@ -26,12 +26,19 @@ import {
26
26
  contextSet,
27
27
  isNonCacheable,
28
28
  } from "../context-var.js";
29
- import { createHandleStore, type HandleStore } from "./handle-store.js";
29
+ import {
30
+ createHandleStore,
31
+ buildHandleSnapshot,
32
+ type HandleStore,
33
+ type HandleData,
34
+ } from "./handle-store.js";
30
35
  import { isHandle } from "../handle.js";
31
36
  import { track, type MetricsStore } from "./context.js";
32
37
  import { getFetchableLoader } from "./fetchable-loader-store.js";
33
38
  import type { SegmentCacheStore } from "../cache/types.js";
34
39
  import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
40
+ import type { ExecutionContext, RequestScope } from "../types/request-scope.js";
41
+ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
35
42
  import { THEME_COOKIE } from "../theme/constants.js";
36
43
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
37
44
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
@@ -53,24 +60,9 @@ import { isAutoGeneratedRouteName } from "../route-name.js";
53
60
  export interface RequestContext<
54
61
  TEnv = DefaultEnv,
55
62
  TParams = Record<string, string>,
56
- > {
57
- /** Platform bindings (Cloudflare env, etc.) */
58
- env: TEnv;
59
- /** Original HTTP request */
60
- request: Request;
61
- /** Parsed URL (with internal `_rsc*` params stripped) */
62
- url: URL;
63
- /**
64
- * The original request URL with all parameters intact, including
65
- * internal `_rsc*` transport params.
66
- */
67
- originalUrl: URL;
68
- /** URL pathname */
69
- pathname: string;
70
- /** URL search params (with internal `_rsc*` params stripped, same as `url.searchParams`) */
71
- searchParams: URLSearchParams;
72
- /** Variables set by middleware (same as ctx.var) */
73
- var: Record<string, any>;
63
+ > extends RequestScope<TEnv> {
64
+ /** @internal Shared variable backing store for ctx.get()/ctx.set(). */
65
+ _variables: Record<string, any>;
74
66
  /** Get a variable set by middleware */
75
67
  get: {
76
68
  <T>(contextVar: ContextVar<T>): T | undefined;
@@ -154,20 +146,6 @@ export interface RequestContext<
154
146
  import("../cache/profile-registry.js").CacheProfile
155
147
  >;
156
148
 
157
- /**
158
- * Schedule work to run after the response is sent.
159
- * On Cloudflare Workers, uses ctx.waitUntil().
160
- * On Node.js, runs as fire-and-forget.
161
- *
162
- * @example
163
- * ```typescript
164
- * ctx.waitUntil(async () => {
165
- * await cacheStore.set(key, data, ttl);
166
- * });
167
- * ```
168
- */
169
- waitUntil(fn: () => Promise<void>): void;
170
-
171
149
  /**
172
150
  * Register a callback to run when the response is created.
173
151
  * Callbacks are sync and receive the response. They can:
@@ -271,6 +249,54 @@ export interface RequestContext<
271
249
  /** @internal Previous route key (from the navigation source), used for revalidation */
272
250
  _prevRouteKey?: string;
273
251
 
252
+ /**
253
+ * @internal Render barrier for experimental `rendered()` API.
254
+ * Resolves when all non-loader segments have settled and handle data
255
+ * is available. Used by DSL loaders that call `ctx.rendered()`.
256
+ */
257
+ _renderBarrier: Promise<void>;
258
+
259
+ /**
260
+ * @internal Resolve the render barrier. Accepts resolved segments, filters
261
+ * out loaders, and captures non-loader segment IDs as the handle ordering.
262
+ * Called after segment resolution (fresh) or handle replay (cache/prerender).
263
+ */
264
+ _resolveRenderBarrier: (
265
+ segments: Array<{ type: string; id: string }>,
266
+ ) => void;
267
+
268
+ /**
269
+ * @internal Segment order at barrier resolution time, used by loader
270
+ * ctx.use(handle) to collect handle data in correct order.
271
+ */
272
+ _renderBarrierSegmentOrder?: string[];
273
+
274
+ /**
275
+ * @internal Set to true when the matched entry tree contains any `loading()`
276
+ * entries (streaming). Used by rendered() to fail fast.
277
+ */
278
+ _treeHasStreaming?: boolean;
279
+
280
+ /**
281
+ * @internal Loader IDs that have called rendered() and are waiting for the
282
+ * barrier. Used to detect deadlocks when a handler tries to await the same
283
+ * loader via ctx.use(Loader).
284
+ */
285
+ _renderBarrierWaiters?: Set<string>;
286
+
287
+ /**
288
+ * @internal Loader IDs that handlers have started awaiting via ctx.use().
289
+ * Used for bidirectional deadlock detection: if a loader later calls
290
+ * rendered() and a handler already awaits it, we can detect the deadlock.
291
+ */
292
+ _handlerLoaderDeps?: Set<string>;
293
+
294
+ /**
295
+ * @internal Cached HandleData snapshot built at barrier resolution time.
296
+ * Avoids rebuilding the snapshot on every loader ctx.use(handle) call.
297
+ */
298
+ _renderBarrierHandleSnapshot?: HandleData;
299
+
274
300
  /** @internal Per-request error dedup set for onError reporting */
275
301
  _reportedErrors: WeakSet<object>;
276
302
 
@@ -288,11 +314,14 @@ export interface RequestContext<
288
314
  /** @internal Request-scoped performance metrics store */
289
315
  _metricsStore?: MetricsStore;
290
316
 
291
- /** @internal Dev-only: debug channel for React Performance Tracks */
292
- _debugChannel?: {
293
- readable: ReadableStream;
294
- writable: WritableStream;
295
- };
317
+ /** @internal Router basename for this request (used by redirect()) */
318
+ _basename?: string;
319
+
320
+ /**
321
+ * @internal RouteSnapshot from classifyRequest, reused by match/matchPartial
322
+ * to avoid a second resolveRoute call. Cleared on HMR invalidation.
323
+ */
324
+ _classifiedRoute?: import("../router/route-snapshot.js").RouteSnapshot;
296
325
  }
297
326
 
298
327
  /**
@@ -319,11 +348,20 @@ export type PublicRequestContext<
319
348
  | "_routeName"
320
349
  | "_prevRouteKey"
321
350
  | "_reportedErrors"
351
+ | "_renderBarrier"
352
+ | "_resolveRenderBarrier"
353
+ | "_renderBarrierSegmentOrder"
354
+ | "_treeHasStreaming"
355
+ | "_renderBarrierWaiters"
356
+ | "_handlerLoaderDeps"
357
+ | "_renderBarrierHandleSnapshot"
322
358
  | "_reportBackgroundError"
323
359
  | "_debugPerformance"
324
360
  | "_metricsStore"
325
- | "_debugChannel"
361
+ | "_basename"
326
362
  | "_setStatus"
363
+ | "_variables"
364
+ | "_classifiedRoute"
327
365
  | "res"
328
366
  >;
329
367
 
@@ -433,13 +471,7 @@ export function requireRequestContext<
433
471
  return getRequestContext<TEnv>();
434
472
  }
435
473
 
436
- /**
437
- * Cloudflare Workers ExecutionContext (subset we need)
438
- */
439
- export interface ExecutionContext {
440
- waitUntil(promise: Promise<any>): void;
441
- passThroughOnException(): void;
442
- }
474
+ export type { ExecutionContext };
443
475
 
444
476
  /**
445
477
  * Options for creating a request context
@@ -598,7 +630,7 @@ export function createRequestContext<TEnv>(
598
630
  originalUrl: new URL(request.url),
599
631
  pathname: url.pathname,
600
632
  searchParams: cleanUrl.searchParams,
601
- var: variables,
633
+ _variables: variables,
602
634
  get: ((keyOrVar: any) => {
603
635
  if (isNonCacheable(variables, keyOrVar) && isInsideCacheScope()) {
604
636
  throw new Error(
@@ -703,16 +735,14 @@ export function createRequestContext<TEnv>(
703
735
 
704
736
  waitUntil(fn: () => Promise<void>): void {
705
737
  if (executionContext?.waitUntil) {
706
- // Cloudflare Workers: use native waitUntil
707
738
  executionContext.waitUntil(fn());
708
739
  } else {
709
- // Node.js / dev: fire-and-forget with error logging
710
- fn().catch((err) =>
711
- console.error("[waitUntil] Background task failed:", err),
712
- );
740
+ fireAndForgetWaitUntil(fn);
713
741
  }
714
742
  },
715
743
 
744
+ executionContext,
745
+
716
746
  _onResponseCallbacks: [],
717
747
 
718
748
  onResponse(callback: (response: Response) => Response): void {
@@ -745,9 +775,58 @@ export function createRequestContext<TEnv>(
745
775
  _reportedErrors: new WeakSet<object>(),
746
776
  _metricsStore: undefined,
747
777
 
778
+ // Render barrier: deferred promise resolved after non-loader segments settle.
779
+ _renderBarrier: null as any, // set below
780
+ _resolveRenderBarrier: null as any, // set below
781
+ _renderBarrierSegmentOrder: undefined,
782
+
748
783
  reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
749
784
  };
750
785
 
786
+ // Lazy render barrier: only allocate the Promise when a loader actually
787
+ // calls rendered(). Requests that don't use rendered() pay zero cost.
788
+ let barrierResolved = false;
789
+ let resolveBarrier: (() => void) | undefined;
790
+ ctx._renderBarrier = null as any; // lazy — created on first access
791
+ ctx._resolveRenderBarrier = (
792
+ segments: Array<{ type: string; id: string }>,
793
+ ) => {
794
+ if (barrierResolved) return;
795
+ barrierResolved = true;
796
+ const segOrder = segments
797
+ .filter((s) => s.type !== "loader")
798
+ .map((s) => s.id);
799
+ ctx._renderBarrierSegmentOrder = segOrder;
800
+ // Build and cache handle snapshot so loader ctx.use(handle) calls
801
+ // don't rebuild it on every invocation.
802
+ ctx._renderBarrierHandleSnapshot = buildHandleSnapshot(
803
+ handleStore,
804
+ segOrder,
805
+ );
806
+ ctx._renderBarrierWaiters = undefined;
807
+ ctx._handlerLoaderDeps = undefined;
808
+ if (resolveBarrier) resolveBarrier();
809
+ };
810
+ Object.defineProperty(ctx, "_renderBarrier", {
811
+ get() {
812
+ // Barrier already resolved (cache/prerender hit) or first lazy access.
813
+ // Either way, replace the getter with a concrete value to avoid
814
+ // repeated Promise.resolve() allocations on subsequent reads.
815
+ const p = barrierResolved
816
+ ? Promise.resolve()
817
+ : new Promise<void>((resolve) => {
818
+ resolveBarrier = resolve;
819
+ });
820
+ Object.defineProperty(ctx, "_renderBarrier", {
821
+ value: p,
822
+ writable: false,
823
+ configurable: false,
824
+ });
825
+ return p;
826
+ },
827
+ configurable: true,
828
+ });
829
+
751
830
  // Now create use() with access to ctx
752
831
  ctx.use = createUseFunction({
753
832
  handleStore,
@@ -929,15 +1008,17 @@ export function createUseFunction<TEnv>(
929
1008
  search: (ctx as any).search ?? {},
930
1009
  pathname: ctx.pathname,
931
1010
  url: ctx.url,
1011
+ originalUrl: ctx.originalUrl,
932
1012
  env: ctx.env as any,
933
- var: ctx.var as any,
1013
+ waitUntil: ctx.waitUntil.bind(ctx),
1014
+ executionContext: ctx.executionContext,
934
1015
  get: ctx.get as any,
935
- use: <TDep, TDepParams = any>(
1016
+ use: (<TDep, TDepParams = any>(
936
1017
  dep: LoaderDefinition<TDep, TDepParams>,
937
1018
  ): Promise<TDep> => {
938
1019
  // Recursive call - will start dep loader if not already started
939
1020
  return ctx.use(dep);
940
- },
1021
+ }) as LoaderContext["use"],
941
1022
  method: "GET",
942
1023
  body: undefined,
943
1024
  reverse: createReverseFunction(
@@ -946,6 +1027,12 @@ export function createUseFunction<TEnv>(
946
1027
  ctx.params as Record<string, string>,
947
1028
  ctx._routeName ? isRouteRootScoped(ctx._routeName) : undefined,
948
1029
  ),
1030
+ rendered: () => {
1031
+ throw new Error(
1032
+ `ctx.rendered() is only available in DSL loaders (registered via loader() in urls()). ` +
1033
+ `It cannot be used from request-context loaders or server actions.`,
1034
+ );
1035
+ },
949
1036
  };
950
1037
 
951
1038
  const doneLoader = track(`loader:${loader.$$id}`, 2);
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;
@@ -261,6 +262,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
261
262
  function SsrRoot() {
262
263
  payload ??= createFromReadableStream<RscPayload>(rscStream1);
263
264
  const resolved = React.use(payload);
265
+
264
266
  const themeConfig = resolved.metadata?.themeConfig ?? null;
265
267
  const pathname = resolved.metadata?.pathname ?? "/";
266
268
 
@@ -286,6 +288,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
286
288
  navigate: async () => {},
287
289
  refresh: async () => {},
288
290
  version: resolved.metadata?.version,
291
+ basename: resolved.metadata?.basename,
289
292
  };
290
293
 
291
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