@rangojs/router 0.0.0-experimental.b02a2fec → 0.0.0-experimental.bf1b128c

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 (103) hide show
  1. package/README.md +50 -20
  2. package/dist/vite/index.js +1338 -462
  3. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  4. package/package.json +7 -5
  5. package/skills/breadcrumbs/SKILL.md +3 -1
  6. package/skills/handler-use/SKILL.md +362 -0
  7. package/skills/hooks/SKILL.md +28 -20
  8. package/skills/intercept/SKILL.md +20 -0
  9. package/skills/layout/SKILL.md +22 -0
  10. package/skills/links/SKILL.md +88 -16
  11. package/skills/loader/SKILL.md +66 -2
  12. package/skills/middleware/SKILL.md +32 -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 +66 -0
  16. package/skills/rango/SKILL.md +24 -22
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/streams-and-websockets/SKILL.md +283 -0
  20. package/skills/typesafety/SKILL.md +3 -1
  21. package/src/browser/app-shell.ts +52 -0
  22. package/src/browser/navigation-bridge.ts +71 -5
  23. package/src/browser/navigation-client.ts +64 -13
  24. package/src/browser/navigation-store.ts +25 -1
  25. package/src/browser/partial-update.ts +34 -3
  26. package/src/browser/prefetch/cache.ts +129 -21
  27. package/src/browser/prefetch/fetch.ts +148 -16
  28. package/src/browser/prefetch/queue.ts +36 -5
  29. package/src/browser/rango-state.ts +53 -13
  30. package/src/browser/react/Link.tsx +30 -2
  31. package/src/browser/react/NavigationProvider.tsx +50 -11
  32. package/src/browser/react/use-navigation.ts +22 -2
  33. package/src/browser/react/use-params.ts +11 -1
  34. package/src/browser/react/use-router.ts +8 -1
  35. package/src/browser/rsc-router.tsx +34 -6
  36. package/src/browser/segment-reconciler.ts +36 -14
  37. package/src/browser/types.ts +13 -0
  38. package/src/build/route-trie.ts +50 -24
  39. package/src/cache/cf/cf-cache-store.ts +5 -7
  40. package/src/client.tsx +82 -174
  41. package/src/index.rsc.ts +3 -0
  42. package/src/index.ts +40 -9
  43. package/src/outlet-context.ts +1 -1
  44. package/src/response-utils.ts +28 -0
  45. package/src/reverse.ts +7 -3
  46. package/src/route-definition/dsl-helpers.ts +175 -23
  47. package/src/route-definition/helpers-types.ts +63 -14
  48. package/src/route-definition/resolve-handler-use.ts +6 -0
  49. package/src/route-types.ts +7 -0
  50. package/src/router/handler-context.ts +24 -4
  51. package/src/router/lazy-includes.ts +6 -6
  52. package/src/router/loader-resolution.ts +3 -0
  53. package/src/router/manifest.ts +22 -13
  54. package/src/router/match-api.ts +3 -3
  55. package/src/router/middleware-types.ts +2 -22
  56. package/src/router/middleware.ts +54 -7
  57. package/src/router/pattern-matching.ts +60 -9
  58. package/src/router/revalidation.ts +15 -1
  59. package/src/router/segment-resolution/revalidation.ts +63 -58
  60. package/src/router/trie-matching.ts +10 -4
  61. package/src/router/url-params.ts +49 -0
  62. package/src/router.ts +1 -2
  63. package/src/rsc/handler.ts +8 -4
  64. package/src/rsc/helpers.ts +69 -41
  65. package/src/rsc/progressive-enhancement.ts +2 -0
  66. package/src/rsc/response-route-handler.ts +14 -1
  67. package/src/rsc/rsc-rendering.ts +7 -0
  68. package/src/rsc/server-action.ts +2 -0
  69. package/src/segment-content-promise.ts +67 -0
  70. package/src/segment-loader-promise.ts +122 -0
  71. package/src/segment-system.tsx +11 -61
  72. package/src/server/context.ts +26 -3
  73. package/src/server/request-context.ts +10 -42
  74. package/src/types/handler-context.ts +12 -39
  75. package/src/types/loader-types.ts +5 -6
  76. package/src/types/request-scope.ts +126 -0
  77. package/src/types/route-entry.ts +11 -0
  78. package/src/types/segments.ts +0 -1
  79. package/src/urls/include-helper.ts +24 -14
  80. package/src/urls/path-helper-types.ts +30 -4
  81. package/src/urls/response-types.ts +2 -10
  82. package/src/vite/debug.ts +184 -0
  83. package/src/vite/discovery/discover-routers.ts +31 -3
  84. package/src/vite/discovery/gate-state.ts +171 -0
  85. package/src/vite/discovery/prerender-collection.ts +48 -1
  86. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  87. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  88. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  89. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  90. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  91. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  92. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  93. package/src/vite/plugins/expose-action-id.ts +52 -28
  94. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  95. package/src/vite/plugins/expose-internal-ids.ts +516 -486
  96. package/src/vite/plugins/performance-tracks.ts +17 -9
  97. package/src/vite/plugins/use-cache-transform.ts +56 -43
  98. package/src/vite/plugins/version-injector.ts +37 -11
  99. package/src/vite/rango.ts +49 -14
  100. package/src/vite/router-discovery.ts +558 -53
  101. package/src/vite/utils/banner.ts +1 -1
  102. package/src/vite/utils/package-resolution.ts +41 -1
  103. package/src/vite/utils/prerender-utils.ts +20 -6
@@ -13,6 +13,10 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
13
13
  * useRouter() do not re-render on navigation state changes.
14
14
  * For reactive navigation state, use useNavigation() instead.
15
15
  *
16
+ * Methods read `basename` from the live context on each call so that
17
+ * cross-app navigation (app-switch) sees the current app's basename
18
+ * rather than the one captured at mount time.
19
+ *
16
20
  * @example
17
21
  * ```tsx
18
22
  * const router = useRouter();
@@ -29,7 +33,10 @@ export function useRouter(): RouterInstance {
29
33
  throw new Error("useRouter must be used within NavigationProvider");
30
34
  }
31
35
 
32
- // Stable reference: ctx is itself stable (NavigationProvider memoizes with [])
36
+ // Stable reference: ctx itself is stable, and reads on each method call
37
+ // pick up live basename values from the context (backed by a live ref
38
+ // in NavigationProvider), so app-switch transitions are reflected without
39
+ // recreating this object.
33
40
  return useMemo<RouterInstance>(() => {
34
41
  /** Prefix a root-relative path with basename if not already prefixed. */
35
42
  function withBasename(url: string): string {
@@ -28,6 +28,7 @@ import {
28
28
  isInterceptSegment,
29
29
  splitInterceptSegments,
30
30
  } from "./intercept-utils.js";
31
+ import { createAppShellRef } from "./app-shell.js";
31
32
 
32
33
  // Vite HMR types are provided by vite/client
33
34
 
@@ -114,6 +115,13 @@ export interface BrowserAppContext {
114
115
  warmupEnabled?: boolean;
115
116
  /** App version for prefetch version mismatch detection */
116
117
  version?: string;
118
+ /**
119
+ * Live app-shell ref. Cross-app navigations replace its contents so the
120
+ * NavigationProvider and renderSegments pick up the target app's
121
+ * rootLayout, basename, and version without consumer rerenders. Theme,
122
+ * warmup, and prefetch TTL are document-lifetime (see AppShell).
123
+ */
124
+ appShellRef?: import("./app-shell.js").AppShellRef;
117
125
  }
118
126
 
119
127
  // Module-level state for the initialized app
@@ -204,13 +212,23 @@ export async function initBrowserApp(
204
212
  // Create composable utilities
205
213
  const client = createNavigationClient(deps);
206
214
 
207
- // Extract rootLayout and version from metadata for browser-side re-renders
208
- const rootLayout = initialPayload.metadata?.rootLayout;
215
+ // Capture the per-router app-shell so cross-app navigations can replace
216
+ // it atomically. rootLayout, basename, and version live here and are
217
+ // read through the ref at call time rather than closed over. Theme,
218
+ // warmup, and prefetch TTL are deliberately excluded — they are
219
+ // document-lifetime and stay stable across smooth cross-app transitions.
209
220
  const version = initialPayload.metadata?.version;
221
+ const appShellRef = createAppShellRef({
222
+ routerId: initialPayload.metadata?.routerId,
223
+ rootLayout: initialPayload.metadata?.rootLayout,
224
+ basename: initialPayload.metadata?.basename,
225
+ version,
226
+ });
210
227
 
211
228
  // Initialize the localStorage state key for cache invalidation.
212
- // Uses the build version so a new deploy automatically busts all cached prefetches.
213
- initRangoState(version ?? "0");
229
+ // The build version busts cached prefetches on deploy; the routerId
230
+ // namespaces the key so sibling apps on the same origin don't collide.
231
+ initRangoState(version ?? "0", initialPayload.metadata?.routerId);
214
232
  setAppVersion(version);
215
233
 
216
234
  // Initialize the in-memory prefetch cache TTL from server config.
@@ -220,11 +238,17 @@ export async function initBrowserApp(
220
238
  initPrefetchCache(prefetchCacheTTL);
221
239
  }
222
240
 
223
- // Create a bound renderSegments that includes rootLayout
241
+ // Create a bound renderSegments that reads rootLayout through the shell
242
+ // ref. On app switch the ref is updated before the tree re-renders, so
243
+ // the new app's Document (rootLayout) replaces the previous one.
224
244
  const renderSegments = (
225
245
  segments: ResolvedSegment[],
226
246
  options?: RenderSegmentsOptions,
227
- ) => baseRenderSegments(segments, { ...options, rootLayout });
247
+ ) =>
248
+ baseRenderSegments(segments, {
249
+ ...options,
250
+ rootLayout: appShellRef.get().rootLayout,
251
+ });
228
252
 
229
253
  // Lazy reference for navigation bridge — the action bridge is created first
230
254
  // but may need to trigger SPA navigation for action redirects.
@@ -256,6 +280,7 @@ export async function initBrowserApp(
256
280
  onUpdate: (update) => store.emitUpdate(update),
257
281
  renderSegments,
258
282
  version: version,
283
+ appShellRef,
259
284
  });
260
285
 
261
286
  // Connect action redirect → navigation bridge (now that both are initialized)
@@ -416,6 +441,7 @@ export async function initBrowserApp(
416
441
  initialTheme: effectiveInitialTheme,
417
442
  warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
418
443
  version,
444
+ appShellRef,
419
445
  };
420
446
  browserAppContext = context;
421
447
 
@@ -481,6 +507,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
481
507
  initialTheme,
482
508
  warmupEnabled,
483
509
  version,
510
+ appShellRef,
484
511
  } = getBrowserAppContext();
485
512
 
486
513
  // Signal that the React tree has hydrated. useEffect only fires after
@@ -501,6 +528,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
501
528
  warmupEnabled={warmupEnabled}
502
529
  version={version}
503
530
  basename={initialPayload.metadata?.basename}
531
+ appShellRef={appShellRef}
504
532
  />
505
533
  );
506
534
  }
@@ -6,6 +6,7 @@ import {
6
6
  } from "./merge-segment-loaders.js";
7
7
  import { assertSegmentStructure } from "./segment-structure-assert.js";
8
8
  import { splitInterceptSegments } from "./intercept-utils.js";
9
+ import { debugLog } from "./logging.js";
9
10
 
10
11
  /**
11
12
  * Determines the merging behavior for segment reconciliation.
@@ -85,14 +86,29 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
85
86
  const cachedSegments = new Map<string, ResolvedSegment>();
86
87
  input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
87
88
 
89
+ const diffSet = new Set(diff);
90
+ debugLog(
91
+ `[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
92
+ );
93
+ debugLog(
94
+ `[reconcile] server segments: ${[...serverSegments.keys()].join(", ")}`,
95
+ );
96
+ debugLog(
97
+ `[reconcile] cached segments: ${[...cachedSegments.keys()].join(", ")}`,
98
+ );
99
+
88
100
  const segments = matched
89
101
  .map((segId: string) => {
90
102
  const fromServer = serverSegments.get(segId);
91
103
  const fromCache = cachedSegments.get(segId);
92
104
 
93
105
  if (fromServer) {
106
+ const inDiff = diffSet.has(segId);
94
107
  // Merge partial loader data when server returns fewer loaders than cached
95
108
  if (shouldMergeLoaders && needsLoaderMerge(fromServer, fromCache)) {
109
+ debugLog(
110
+ `[reconcile] ${segId}: MERGE loaders (server partial, ${inDiff ? "in diff" : "not in diff"})`,
111
+ );
96
112
  return mergeSegmentLoaders(fromServer, fromCache);
97
113
  }
98
114
 
@@ -143,8 +159,14 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
143
159
  // above fails to preserve a value it should have.
144
160
  assertSegmentStructure(fromCache, merged, context);
145
161
 
162
+ debugLog(
163
+ `[reconcile] ${segId}: SERVER+CACHE merge (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, component=${fromServer.component === null ? "null→cached" : "server"})`,
164
+ );
146
165
  return merged;
147
166
  }
167
+ debugLog(
168
+ `[reconcile] ${segId}: SERVER only (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, no cache entry)`,
169
+ );
148
170
  return fromServer;
149
171
  }
150
172
 
@@ -158,20 +180,20 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
158
180
  return fromCache;
159
181
  }
160
182
 
161
- // For non-action actors: cached segments the server decided not to re-render.
162
- // - Preserve loading=false (suppressed boundary) to maintain tree structure
163
- // - Preserve parallel segment loading so renderSegments can reconstruct
164
- // parallel-owned loader markers from the cached slot metadata
165
- // - Clear other truthy loading values to prevent suspense on cached content
166
- if (actor !== "action") {
167
- if (fromCache.type === "parallel" && fromCache.loading !== undefined) {
168
- return fromCache;
169
- }
170
- if (fromCache.loading !== undefined && fromCache.loading !== false) {
171
- return { ...fromCache, loading: undefined };
172
- }
173
- }
174
-
183
+ debugLog(
184
+ `[reconcile] ${segId}: CACHE only (not from server, type=${fromCache.type}, component=${fromCache.component != null ? "yes" : "null"})`,
185
+ );
186
+
187
+ // Return the cached segment as-is, regardless of actor. We used to clear
188
+ // truthy `loading` here to prevent a stale Suspense fallback from
189
+ // committing against cached content, but that swapped the render tree
190
+ // from the LoaderBoundary branch to the plain OutletProvider branch
191
+ // inside renderSegments, causing React to unmount the entire chain
192
+ // (LoaderBoundary > Suspense > LoaderResolver > RouteContentWrapper >
193
+ // Suspender) every time the user opened an intercept or navigated back
194
+ // to a cached page. The flicker is now prevented by renderSegments'
195
+ // promise memoization keeping React's use() in "known fulfilled" state,
196
+ // so preserving `loading` keeps the element tree stable.
175
197
  return fromCache;
176
198
  })
177
199
  .filter(Boolean) as ResolvedSegment[];
@@ -427,6 +427,12 @@ export interface NavigationStore {
427
427
  markCacheAsStale(): void;
428
428
  markCacheAsStaleAndBroadcast(): void;
429
429
  clearHistoryCache(): void;
430
+ /**
431
+ * Clear this tab's nav + prefetch caches without broadcasting or rotating
432
+ * shared state. Intended for app-switch transitions that affect only this
433
+ * tab's session.
434
+ */
435
+ clearHistoryCacheLocal(): void;
430
436
  broadcastCacheInvalidation(): void;
431
437
 
432
438
  // Cross-tab refresh callback (set by navigation bridge)
@@ -542,6 +548,13 @@ export interface NavigationBridge {
542
548
  registerLinkInterception(): () => void;
543
549
  /** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
544
550
  updateVersion(newVersion: string): void;
551
+ /**
552
+ * Replace the active app-shell snapshot (rootLayout, basename, version)
553
+ * atomically. Used on cross-app navigations when the response's routerId
554
+ * indicates the user entered a different app. Theme, warmup, and prefetch
555
+ * TTL are document-lifetime and not part of the shell.
556
+ */
557
+ updateAppShell(next: import("./app-shell.js").AppShell): void;
545
558
  }
546
559
 
547
560
  /**
@@ -98,8 +98,14 @@ export function buildRouteTrie(
98
98
  }
99
99
 
100
100
  /**
101
- * Insert a route into the trie, handling optional params by forking
102
- * the insertion path (one terminal without the param, one with).
101
+ * Insert a route into the trie. Optional params expand into two branches at
102
+ * registration time (skip-first, then present), so each terminal lives at the
103
+ * correct depth for its number of bound params and carries a branch-local
104
+ * `pa` listing only those names. The trie's single-slot `node.p` is reused
105
+ * across branches because matching ignores `node.p.n` — the leaf's `pa` is
106
+ * the source of truth for naming. Skip-first ordering lets `mergeLeaf`'s
107
+ * last-wins rule produce greedy-leftmost semantics for free at any shared
108
+ * terminal depth.
103
109
  */
104
110
  function insertRoute(
105
111
  node: TrieNode,
@@ -107,14 +113,13 @@ function insertRoute(
107
113
  index: number,
108
114
  leaf: Omit<TrieLeaf, "op" | "cv" | "pa">,
109
115
  ): void {
110
- // Collect param names, optional param names, and constraints across all segments
111
- const paramNames: string[] = [];
116
+ // op (full optional list) and cv (full constraint map) are route-level and
117
+ // identical on every terminal, so compute them once on the shared base.
112
118
  const optionalParams: string[] = [];
113
119
  const constraints: Record<string, string[]> = {};
114
120
 
115
121
  for (const seg of segments) {
116
122
  if (seg.type === "param") {
117
- paramNames.push(seg.value);
118
123
  if (seg.optional) {
119
124
  optionalParams.push(seg.value);
120
125
  }
@@ -124,21 +129,15 @@ function insertRoute(
124
129
  }
125
130
  }
126
131
 
127
- const fullLeaf: TrieLeaf = {
132
+ const leafBase: Omit<TrieLeaf, "pa"> = {
128
133
  ...leaf,
129
- ...(paramNames.length > 0 ? { pa: paramNames } : {}),
130
134
  ...(optionalParams.length > 0 ? { op: optionalParams } : {}),
131
135
  ...(Object.keys(constraints).length > 0 ? { cv: constraints } : {}),
132
136
  };
133
137
 
134
- insertSegments(node, segments, index, fullLeaf);
138
+ insertSegments(node, segments, index, leafBase, []);
135
139
  }
136
140
 
137
- /**
138
- * Recursively insert segments into the trie.
139
- * For optional params, we add a terminal at the current node (param absent)
140
- * AND continue inserting into the param child (param present).
141
- */
142
141
  /**
143
142
  * Extract ancestry map from a built trie by visiting all leaf nodes.
144
143
  * Returns { routeName: ancestryShortCodes[] } for every route in the trie.
@@ -218,15 +217,25 @@ function mergeLeaf(node: TrieNode, leaf: TrieLeaf): void {
218
217
  node.r = mergeLeaves(node.r, leaf);
219
218
  }
220
219
 
220
+ function buildLeaf(
221
+ leafBase: Omit<TrieLeaf, "pa">,
222
+ paramNames: string[],
223
+ ): TrieLeaf {
224
+ return paramNames.length > 0
225
+ ? { ...leafBase, pa: [...paramNames] }
226
+ : { ...leafBase };
227
+ }
228
+
221
229
  function insertSegments(
222
230
  node: TrieNode,
223
231
  segments: ParsedSegment[],
224
232
  index: number,
225
- leaf: TrieLeaf,
233
+ leafBase: Omit<TrieLeaf, "pa">,
234
+ paramNames: string[],
226
235
  ): void {
227
- // Base case: all segments consumed, add terminal
236
+ // Base case: all segments consumed, add terminal with branch-local pa
228
237
  if (index >= segments.length) {
229
- mergeLeaf(node, leaf);
238
+ mergeLeaf(node, buildLeaf(leafBase, paramNames));
230
239
  return;
231
240
  }
232
241
 
@@ -235,12 +244,19 @@ function insertSegments(
235
244
  if (segment.type === "static") {
236
245
  if (!node.s) node.s = {};
237
246
  if (!node.s[segment.value]) node.s[segment.value] = {};
238
- insertSegments(node.s[segment.value], segments, index + 1, leaf);
247
+ insertSegments(
248
+ node.s[segment.value],
249
+ segments,
250
+ index + 1,
251
+ leafBase,
252
+ paramNames,
253
+ );
239
254
  } else if (segment.type === "param") {
240
255
  if (segment.optional) {
241
- // Optional param: add terminal at current node (param absent)
242
- mergeLeaf(node, leaf);
243
- // AND continue with param child (param present)
256
+ // SKIP first: continue at the same node without binding this name.
257
+ // Skip-first ordering means the present-branch's TAKE overwrites any
258
+ // shared terminal later, giving greedy-leftmost semantics.
259
+ insertSegments(node, segments, index + 1, leafBase, paramNames);
244
260
  }
245
261
  if (segment.suffix) {
246
262
  // Suffix param: keyed by suffix string (e.g., ".html")
@@ -248,16 +264,26 @@ function insertSegments(
248
264
  if (!node.xp[segment.suffix]) {
249
265
  node.xp[segment.suffix] = { n: segment.value, c: {} };
250
266
  }
251
- insertSegments(node.xp[segment.suffix].c, segments, index + 1, leaf);
267
+ insertSegments(node.xp[segment.suffix].c, segments, index + 1, leafBase, [
268
+ ...paramNames,
269
+ segment.value,
270
+ ]);
252
271
  } else {
253
272
  if (!node.p) {
254
273
  node.p = { n: segment.value, c: {} };
255
274
  }
256
- insertSegments(node.p.c, segments, index + 1, leaf);
275
+ insertSegments(node.p.c, segments, index + 1, leafBase, [
276
+ ...paramNames,
277
+ segment.value,
278
+ ]);
257
279
  }
258
280
  } else if (segment.type === "wildcard") {
259
- // Wildcard consumes all remaining segments
260
- const wildLeaf = { ...leaf, pn: "*" };
281
+ // Wildcard consumes all remaining segments. Carry any params bound before
282
+ // the wildcard in pa so they zip correctly against paramValues at match.
283
+ const wildLeaf: TrieLeaf & { pn: string } = {
284
+ ...buildLeaf(leafBase, paramNames),
285
+ pn: "*",
286
+ };
261
287
  const existing = node.w ? ({ ...node.w } as TrieLeaf) : undefined;
262
288
  const merged = mergeLeaves(existing, wildLeaf);
263
289
  node.w = merged as TrieLeaf & { pn: string };
@@ -67,13 +67,11 @@ export const MAX_REVALIDATION_INTERVAL = 30;
67
67
  // Types
68
68
  // ============================================================================
69
69
 
70
- /**
71
- * Cloudflare Workers ExecutionContext (subset we need)
72
- */
73
- export interface ExecutionContext {
74
- waitUntil(promise: Promise<any>): void;
75
- passThroughOnException(): void;
76
- }
70
+ // Re-exported from the canonical home so cf-cache-store consumers keep
71
+ // importing `ExecutionContext` from this module without a second interface
72
+ // drifting over time.
73
+ export type { ExecutionContext } from "../../types/request-scope.js";
74
+ import type { ExecutionContext } from "../../types/request-scope.js";
77
75
 
78
76
  /**
79
77
  * Minimal Cloudflare KV Namespace interface.
package/src/client.tsx CHANGED
@@ -21,6 +21,83 @@ import {
21
21
  } from "./route-content-wrapper.js";
22
22
  import { OutletProvider } from "./outlet-provider.js";
23
23
  import { MountContextProvider } from "./browser/react/mount-context.js";
24
+ import { getMemoizedContentPromise } from "./segment-content-promise.js";
25
+
26
+ /**
27
+ * Render the content for a named parallel/intercept slot segment.
28
+ *
29
+ * Shared by Outlet (with `name` prop) and ParallelOutlet — both resolve a
30
+ * segment from context.parallel by slot name and then render it through the
31
+ * same layout/loader/mountPath wrapping pipeline.
32
+ */
33
+ function renderSlotContent(segment: ResolvedSegment | null): ReactNode {
34
+ if (!segment) return null;
35
+
36
+ const content: ReactNode =
37
+ segment.loading || segment.component instanceof Promise ? (
38
+ <RouteContentWrapper
39
+ content={getMemoizedContentPromise(segment.component)}
40
+ fallback={segment.loading}
41
+ segmentId={segment.id}
42
+ />
43
+ ) : (
44
+ (segment.component ?? null)
45
+ );
46
+
47
+ const hasOwnLoaders = !!(segment.loaderDataPromise && segment.loaderIds);
48
+ const loaderWrapped = hasOwnLoaders ? (
49
+ <LoaderBoundary
50
+ loaderDataPromise={segment.loaderDataPromise!}
51
+ loaderIds={segment.loaderIds!}
52
+ fallback={segment.loading}
53
+ outletKey={segment.id + "-loader"}
54
+ outletContent={null}
55
+ segment={segment}
56
+ >
57
+ {content}
58
+ </LoaderBoundary>
59
+ ) : null;
60
+
61
+ let result: ReactNode;
62
+ if (segment.layout) {
63
+ // Layout renders immediately; if loaders exist, the LoaderBoundary becomes
64
+ // the outlet content so layout's <Outlet /> suspends until loaders resolve.
65
+ result = (
66
+ <OutletProvider
67
+ content={hasOwnLoaders ? loaderWrapped : content}
68
+ segment={segment}
69
+ >
70
+ {segment.layout}
71
+ </OutletProvider>
72
+ );
73
+ } else if (hasOwnLoaders) {
74
+ // No layout but has loaders — wrap content with LoaderBoundary for useLoader context.
75
+ // Common for intercept routes that use useLoader without a custom layout.
76
+ result = loaderWrapped;
77
+ } else {
78
+ result = content;
79
+ }
80
+
81
+ if (segment.mountPath) {
82
+ return (
83
+ <MountContextProvider value={segment.mountPath}>
84
+ {result}
85
+ </MountContextProvider>
86
+ );
87
+ }
88
+
89
+ return result;
90
+ }
91
+
92
+ function useSlotSegment(
93
+ context: OutletContextValue | null,
94
+ name: `@${string}` | undefined,
95
+ ): ResolvedSegment | null {
96
+ return useMemo(() => {
97
+ if (!name || !context?.parallel) return null;
98
+ return context.parallel.find((seg) => seg.slot === name) ?? null;
99
+ }, [context, name]);
100
+ }
24
101
 
25
102
  /**
26
103
  * Outlet component - renders child content in layouts
@@ -61,95 +138,10 @@ import { MountContextProvider } from "./browser/react/mount-context.js";
61
138
  */
62
139
  export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
63
140
  const context = useContext(OutletContext);
141
+ const namedSegment = useSlotSegment(context, name);
64
142
 
65
- // If name provided, render parallel/intercept content for that slot
66
143
  if (name) {
67
- const segment = context?.parallel?.find((seg) => seg.slot === name) ?? null;
68
-
69
- if (!segment) return null;
70
-
71
- // Determine the content to render
72
- let content: ReactNode;
73
- if (segment.loading || segment.component instanceof Promise) {
74
- // Use RouteContentWrapper to handle Suspense wrapping properly
75
- content = (
76
- <RouteContentWrapper
77
- content={
78
- segment.component instanceof Promise
79
- ? segment.component
80
- : Promise.resolve(segment.component)
81
- }
82
- fallback={segment.loading}
83
- segmentId={segment.id}
84
- />
85
- );
86
- } else {
87
- content = segment.component ?? null;
88
- }
89
-
90
- let result: ReactNode;
91
-
92
- // If segment has a layout, wrap appropriately
93
- if (segment.layout) {
94
- // Check if this segment has loaders that need streaming
95
- // The layout renders immediately, LoaderBoundary becomes the outlet content
96
- // When layout renders <Outlet />, it gets the LoaderBoundary which suspends
97
- if (segment.loaderDataPromise && segment.loaderIds) {
98
- const loaderAwareContent = (
99
- <LoaderBoundary
100
- loaderDataPromise={segment.loaderDataPromise}
101
- loaderIds={segment.loaderIds}
102
- fallback={segment.loading}
103
- outletKey={segment.id + "-loader"}
104
- outletContent={null}
105
- segment={segment}
106
- >
107
- {content}
108
- </LoaderBoundary>
109
- );
110
-
111
- result = (
112
- <OutletProvider content={loaderAwareContent} segment={segment}>
113
- {segment.layout}
114
- </OutletProvider>
115
- );
116
- } else {
117
- // No loaders - wrap in OutletProvider so layout can use <Outlet />
118
- result = (
119
- <OutletProvider content={content} segment={segment}>
120
- {segment.layout}
121
- </OutletProvider>
122
- );
123
- }
124
- } else if (segment.loaderDataPromise && segment.loaderIds) {
125
- // No layout but has loaders - wrap content with LoaderBoundary for useLoader context
126
- // This is common for intercept routes that use useLoader without a custom layout
127
- result = (
128
- <LoaderBoundary
129
- loaderDataPromise={segment.loaderDataPromise}
130
- loaderIds={segment.loaderIds}
131
- fallback={segment.loading}
132
- outletKey={segment.id + "-loader"}
133
- outletContent={null}
134
- segment={segment}
135
- >
136
- {content}
137
- </LoaderBoundary>
138
- );
139
- } else {
140
- result = content;
141
- }
142
-
143
- // Wrap with MountContextProvider for include() scoped parallel/intercept slots
144
- if (segment.mountPath) {
145
- return (
146
- <MountContextProvider value={segment.mountPath}>
147
- {result}
148
- </MountContextProvider>
149
- );
150
- }
151
-
152
- return result;
144
+ return renderSlotContent(namedSegment);
153
145
  }
154
146
 
155
147
  // Default: render child content
@@ -163,6 +155,7 @@ export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
163
155
 
164
156
  return content;
165
157
  }
158
+
166
159
  /**
167
160
  * ParallelOutlet component - renders content for a named parallel slot
168
161
  *
@@ -187,94 +180,9 @@ export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
187
180
  */
188
181
  export function ParallelOutlet({ name }: { name: `@${string}` }): ReactNode {
189
182
  const context = useContext(OutletContext);
190
- const segment = useMemo(() => {
191
- if (!context?.parallel) return null;
192
- return context.parallel.find((seg) => seg.slot === name) ?? null;
193
- }, [context, name]);
194
-
195
- if (!segment) return null;
196
-
197
- // Determine the content to render
198
- let content: ReactNode;
199
- if (segment.loading || segment.component instanceof Promise) {
200
- // Use RouteContentWrapper to handle Suspense wrapping properly
201
- content = (
202
- <RouteContentWrapper
203
- content={
204
- segment.component instanceof Promise
205
- ? segment.component
206
- : Promise.resolve(segment.component)
207
- }
208
- fallback={segment.loading}
209
- segmentId={segment.id}
210
- />
211
- );
212
- } else {
213
- content = segment.component ?? null;
214
- }
183
+ const segment = useSlotSegment(context, name);
215
184
 
216
- let result: ReactNode;
217
-
218
- // If segment has a layout, wrap appropriately
219
- if (segment.layout) {
220
- // Check if this segment has loaders that need streaming
221
- // The layout renders immediately, LoaderBoundary becomes the outlet content
222
- if (segment.loaderDataPromise && segment.loaderIds) {
223
- const loaderAwareContent = (
224
- <LoaderBoundary
225
- loaderDataPromise={segment.loaderDataPromise}
226
- loaderIds={segment.loaderIds}
227
- fallback={segment.loading}
228
- outletKey={segment.id + "-loader"}
229
- outletContent={null}
230
- segment={segment}
231
- >
232
- {content}
233
- </LoaderBoundary>
234
- );
235
-
236
- result = (
237
- <OutletProvider content={loaderAwareContent} segment={segment}>
238
- {segment.layout}
239
- </OutletProvider>
240
- );
241
- } else {
242
- // No loaders - wrap in OutletProvider so layout can use <Outlet />
243
- result = (
244
- <OutletProvider content={content} segment={segment}>
245
- {segment.layout}
246
- </OutletProvider>
247
- );
248
- }
249
- } else if (segment.loaderDataPromise && segment.loaderIds) {
250
- // No layout but has loaders - wrap content with LoaderBoundary for useLoader context
251
- // This is common for intercept routes that use useLoader without a custom layout
252
- result = (
253
- <LoaderBoundary
254
- loaderDataPromise={segment.loaderDataPromise}
255
- loaderIds={segment.loaderIds}
256
- fallback={segment.loading}
257
- outletKey={segment.id + "-loader"}
258
- outletContent={null}
259
- segment={segment}
260
- >
261
- {content}
262
- </LoaderBoundary>
263
- );
264
- } else {
265
- result = content;
266
- }
267
-
268
- // Wrap with MountContextProvider for include() scoped parallel/intercept slots
269
- if (segment.mountPath) {
270
- return (
271
- <MountContextProvider value={segment.mountPath}>
272
- {result}
273
- </MountContextProvider>
274
- );
275
- }
276
-
277
- return result;
185
+ return renderSlotContent(segment);
278
186
  }
279
187
 
280
188
  // OutletProvider is defined in outlet-provider.tsx to break a circular