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

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 (112) hide show
  1. package/README.md +112 -17
  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 +33 -20
  8. package/skills/intercept/SKILL.md +20 -0
  9. package/skills/layout/SKILL.md +22 -0
  10. package/skills/links/SKILL.md +90 -16
  11. package/skills/loader/SKILL.md +70 -3
  12. package/skills/middleware/SKILL.md +34 -3
  13. package/skills/migrate-nextjs/SKILL.md +562 -0
  14. package/skills/migrate-react-router/SKILL.md +769 -0
  15. package/skills/parallel/SKILL.md +66 -0
  16. package/skills/rango/SKILL.md +25 -22
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/server-actions/SKILL.md +739 -0
  20. package/skills/streams-and-websockets/SKILL.md +283 -0
  21. package/skills/typesafety/SKILL.md +3 -1
  22. package/src/browser/app-shell.ts +52 -0
  23. package/src/browser/event-controller.ts +44 -4
  24. package/src/browser/navigation-bridge.ts +71 -5
  25. package/src/browser/navigation-client.ts +64 -13
  26. package/src/browser/navigation-store.ts +25 -1
  27. package/src/browser/partial-update.ts +34 -3
  28. package/src/browser/prefetch/cache.ts +129 -21
  29. package/src/browser/prefetch/fetch.ts +148 -16
  30. package/src/browser/prefetch/queue.ts +36 -5
  31. package/src/browser/rango-state.ts +53 -13
  32. package/src/browser/react/Link.tsx +30 -2
  33. package/src/browser/react/NavigationProvider.tsx +70 -18
  34. package/src/browser/react/filter-segment-order.ts +51 -7
  35. package/src/browser/react/use-navigation.ts +22 -2
  36. package/src/browser/react/use-params.ts +11 -1
  37. package/src/browser/react/use-router.ts +8 -1
  38. package/src/browser/react/use-segments.ts +11 -8
  39. package/src/browser/rsc-router.tsx +34 -6
  40. package/src/browser/segment-reconciler.ts +36 -14
  41. package/src/browser/types.ts +19 -0
  42. package/src/build/route-trie.ts +50 -24
  43. package/src/cache/cf/cf-cache-store.ts +5 -7
  44. package/src/client.tsx +82 -174
  45. package/src/index.rsc.ts +3 -0
  46. package/src/index.ts +40 -9
  47. package/src/outlet-context.ts +1 -1
  48. package/src/response-utils.ts +28 -0
  49. package/src/reverse.ts +7 -3
  50. package/src/route-definition/dsl-helpers.ts +175 -23
  51. package/src/route-definition/helpers-types.ts +63 -14
  52. package/src/route-definition/resolve-handler-use.ts +6 -0
  53. package/src/route-types.ts +7 -0
  54. package/src/router/handler-context.ts +24 -4
  55. package/src/router/lazy-includes.ts +6 -6
  56. package/src/router/loader-resolution.ts +3 -0
  57. package/src/router/manifest.ts +22 -13
  58. package/src/router/match-api.ts +4 -3
  59. package/src/router/match-handlers.ts +1 -0
  60. package/src/router/match-result.ts +21 -2
  61. package/src/router/middleware-types.ts +2 -22
  62. package/src/router/middleware.ts +54 -7
  63. package/src/router/pattern-matching.ts +87 -17
  64. package/src/router/revalidation.ts +15 -1
  65. package/src/router/segment-resolution/fresh.ts +8 -0
  66. package/src/router/segment-resolution/revalidation.ts +128 -100
  67. package/src/router/trie-matching.ts +18 -13
  68. package/src/router/url-params.ts +49 -0
  69. package/src/router.ts +1 -2
  70. package/src/rsc/handler.ts +8 -4
  71. package/src/rsc/helpers.ts +69 -41
  72. package/src/rsc/progressive-enhancement.ts +4 -0
  73. package/src/rsc/response-route-handler.ts +14 -1
  74. package/src/rsc/rsc-rendering.ts +10 -0
  75. package/src/rsc/server-action.ts +4 -0
  76. package/src/rsc/types.ts +6 -0
  77. package/src/segment-content-promise.ts +67 -0
  78. package/src/segment-loader-promise.ts +122 -0
  79. package/src/segment-system.tsx +11 -61
  80. package/src/server/context.ts +26 -3
  81. package/src/server/request-context.ts +10 -42
  82. package/src/ssr/index.tsx +5 -1
  83. package/src/types/handler-context.ts +12 -39
  84. package/src/types/loader-types.ts +5 -6
  85. package/src/types/request-scope.ts +126 -0
  86. package/src/types/route-entry.ts +11 -0
  87. package/src/types/segments.ts +17 -1
  88. package/src/urls/include-helper.ts +24 -14
  89. package/src/urls/path-helper-types.ts +30 -4
  90. package/src/urls/response-types.ts +2 -10
  91. package/src/vite/debug.ts +184 -0
  92. package/src/vite/discovery/discover-routers.ts +31 -3
  93. package/src/vite/discovery/gate-state.ts +171 -0
  94. package/src/vite/discovery/prerender-collection.ts +48 -1
  95. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  96. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  97. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  98. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  99. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  100. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  101. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  102. package/src/vite/plugins/expose-action-id.ts +52 -28
  103. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  104. package/src/vite/plugins/expose-internal-ids.ts +516 -486
  105. package/src/vite/plugins/performance-tracks.ts +17 -9
  106. package/src/vite/plugins/use-cache-transform.ts +56 -43
  107. package/src/vite/plugins/version-injector.ts +37 -11
  108. package/src/vite/rango.ts +49 -14
  109. package/src/vite/router-discovery.ts +558 -53
  110. package/src/vite/utils/banner.ts +1 -1
  111. package/src/vite/utils/package-resolution.ts +41 -1
  112. package/src/vite/utils/prerender-utils.ts +20 -6
@@ -1,11 +1,55 @@
1
1
  /**
2
- * Filter segment IDs to only include routes and layouts.
3
- * Excludes parallels (contain .@) and loaders (contain D followed by digit).
2
+ * Build the handle-collection segment order from a raw `matched` list.
3
+ *
4
+ * Two responsibilities:
5
+ *
6
+ * 1. Drop loader sub-ids ("D" followed by a digit, e.g. "M0L0D1.user") —
7
+ * loaders never push handles.
8
+ *
9
+ * 2. Place each parallel slot id (contains ".@") immediately after its
10
+ * parent layout/route id. Raw segment-resolution emission order does NOT
11
+ * guarantee this: route-mounted parallels are resolved/pushed BEFORE the
12
+ * route handler's segment is appended (see fresh.ts:resolveSegment for
13
+ * routes, and revalidation.ts ~915-919), so matched can read
14
+ * `[..., R0.@panel, R0]`. collectHandleData consumes segmentOrder verbatim
15
+ * with later-wins semantics, so without normalization the route handler's
16
+ * Meta would override the slot's more-specific Meta — backwards.
17
+ *
18
+ * Slot-id format is `<parentShortCode>.@<slotName>`; `parentShortCode` never
19
+ * contains ".@", so splitting at the first ".@" reliably yields the parent.
4
20
  */
5
21
  export function filterSegmentOrder(matched: string[]): string[] {
6
- return matched.filter((id) => {
7
- if (id.includes(".@")) return false;
8
- if (/D\d+\./.test(id)) return false;
9
- return true;
10
- });
22
+ const slotsByParent = new Map<string, string[]>();
23
+ const nonSlots: string[] = [];
24
+ const nonSlotSet = new Set<string>();
25
+
26
+ for (const id of matched) {
27
+ if (/D\d+\./.test(id)) continue;
28
+ const slotIdx = id.indexOf(".@");
29
+ if (slotIdx >= 0) {
30
+ const parent = id.slice(0, slotIdx);
31
+ const list = slotsByParent.get(parent);
32
+ if (list) {
33
+ list.push(id);
34
+ } else {
35
+ slotsByParent.set(parent, [id]);
36
+ }
37
+ } else {
38
+ nonSlots.push(id);
39
+ nonSlotSet.add(id);
40
+ }
41
+ }
42
+
43
+ const result: string[] = [];
44
+ for (const id of nonSlots) {
45
+ result.push(id);
46
+ const slots = slotsByParent.get(id);
47
+ if (slots) result.push(...slots);
48
+ }
49
+ // Defensive: any slot whose parent is missing from the filtered list still
50
+ // gets included rather than silently dropped. Shouldn't happen in practice.
51
+ for (const [parent, slots] of slotsByParent) {
52
+ if (!nonSlotSet.has(parent)) result.push(...slots);
53
+ }
54
+ return result;
11
55
  }
@@ -53,6 +53,12 @@ export function useNavigation<T>(
53
53
  });
54
54
  const prevState = useRef(baseValue);
55
55
 
56
+ // Tracks whether the most recent setOptimisticValue call pinned the value
57
+ // to a non-idle state. Used to decide whether to emit a release update when
58
+ // returning to idle, so the optimistic store doesn't stay pinned if a
59
+ // parent transition (e.g. <Link> click) is still pending.
60
+ const optimisticPinnedRef = useRef(false);
61
+
56
62
  // useOptimistic allows immediate updates during transitions/actions
57
63
  const [value, setOptimisticValue] = useOptimistic(baseValue);
58
64
 
@@ -82,11 +88,25 @@ export function useNavigation<T>(
82
88
  const hasInflightActions =
83
89
  ctx.eventController.getInflightActions().size > 0;
84
90
 
85
- if (hasInflightActions || publicState.state !== "idle") {
86
- // Use optimistic update for immediate feedback during transitions
91
+ const shouldPin = hasInflightActions || publicState.state !== "idle";
92
+
93
+ if (shouldPin) {
94
+ // Pin the optimistic store so the loading value shows immediately
95
+ // even if a parent transition (e.g. <Link> click) defers the
96
+ // urgent setBaseValue commit.
97
+ startTransition(() => {
98
+ setOptimisticValue(nextSelected);
99
+ });
100
+ optimisticPinnedRef.current = true;
101
+ } else if (optimisticPinnedRef.current) {
102
+ // Release a previously-pinned optimistic value. Without this,
103
+ // useOptimistic keeps returning the stale loading value while
104
+ // any parent transition is still pending, even after baseValue
105
+ // flipped to idle.
87
106
  startTransition(() => {
88
107
  setOptimisticValue(nextSelected);
89
108
  });
109
+ optimisticPinnedRef.current = false;
90
110
  }
91
111
 
92
112
  // Always update base state so UI reflects current state
@@ -16,11 +16,21 @@ import { shallowEqual } from "./shallow-equal.js";
16
16
  * const params = useParams();
17
17
  * // { productId: "123" }
18
18
  *
19
+ * // Annotate the expected shape via a generic
20
+ * const { productId } = useParams<{ productId: string }>();
21
+ *
19
22
  * // With selector
20
23
  * const productId = useParams(p => p.productId);
21
24
  * ```
22
25
  */
23
- export function useParams(): Record<string, string>;
26
+ // `T extends object` (not `Record<string, string | undefined>`) so that
27
+ // interface shapes pass the constraint — interfaces lack an implicit
28
+ // index signature and would otherwise be rejected. The generic is a
29
+ // shape annotation, not a runtime check; the body always returns the
30
+ // underlying params map unchanged.
31
+ export function useParams<
32
+ T extends object = Record<string, string>,
33
+ >(): Readonly<T>;
24
34
  export function useParams<T>(
25
35
  selector: (params: Record<string, string>) => T,
26
36
  ): T;
@@ -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 {
@@ -25,15 +25,18 @@ function parsePathname(pathname: string): string[] {
25
25
  }
26
26
 
27
27
  /**
28
- * Build segments state from event controller
28
+ * Build segments state from event controller. `segmentIds` is the
29
+ * route-only list (parallels and loaders stripped) — distinct from the
30
+ * controller's `segmentOrder` which drives handle collection and includes
31
+ * parallel slot ids.
29
32
  */
30
33
  function buildSegmentsState(
31
34
  location: URL,
32
- segmentOrder: string[],
35
+ routeSegmentIds: string[],
33
36
  ): SegmentsState {
34
37
  return {
35
38
  path: parsePathname(location.pathname),
36
- segmentIds: segmentOrder,
39
+ segmentIds: routeSegmentIds,
37
40
  location,
38
41
  };
39
42
  }
@@ -74,7 +77,7 @@ export function useSegments<T>(
74
77
  const handleState = ctx.eventController.getHandleState();
75
78
  const segmentsState = buildSegmentsState(
76
79
  location as URL,
77
- handleState.segmentOrder,
80
+ handleState.routeSegmentIds,
78
81
  );
79
82
  return selector ? selector(segmentsState) : segmentsState;
80
83
  });
@@ -94,7 +97,7 @@ export function useSegments<T>(
94
97
  // render-time setState calls.
95
98
  const segmentsCache = useRef<{
96
99
  location: URL;
97
- segmentOrder: string[];
100
+ routeSegmentIds: string[];
98
101
  state: SegmentsState;
99
102
  } | null>(null);
100
103
 
@@ -113,17 +116,17 @@ export function useSegments<T>(
113
116
  if (
114
117
  cache &&
115
118
  cache.location === location &&
116
- cache.segmentOrder === handleState.segmentOrder
119
+ cache.routeSegmentIds === handleState.routeSegmentIds
117
120
  ) {
118
121
  segmentsState = cache.state;
119
122
  } else {
120
123
  segmentsState = buildSegmentsState(
121
124
  location as URL,
122
- handleState.segmentOrder,
125
+ handleState.routeSegmentIds,
123
126
  );
124
127
  segmentsCache.current = {
125
128
  location: location as URL,
126
- segmentOrder: handleState.segmentOrder,
129
+ routeSegmentIds: handleState.routeSegmentIds,
127
130
  state: segmentsState,
128
131
  };
129
132
  }
@@ -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[];
@@ -39,6 +39,12 @@ export interface RscMetadata {
39
39
  isError?: boolean;
40
40
  matched?: string[];
41
41
  diff?: string[];
42
+ /**
43
+ * All segment ids re-resolved on the server, including null-component
44
+ * ones excluded from `segments`/`diff`. Drives client-side handle-bucket
45
+ * cleanup. Superset of `diff`. See MatchResult.resolvedIds.
46
+ */
47
+ resolvedIds?: string[];
42
48
  /** Merged route params from the matched route */
43
49
  params?: Record<string, string>;
44
50
  /**
@@ -427,6 +433,12 @@ export interface NavigationStore {
427
433
  markCacheAsStale(): void;
428
434
  markCacheAsStaleAndBroadcast(): void;
429
435
  clearHistoryCache(): void;
436
+ /**
437
+ * Clear this tab's nav + prefetch caches without broadcasting or rotating
438
+ * shared state. Intended for app-switch transitions that affect only this
439
+ * tab's session.
440
+ */
441
+ clearHistoryCacheLocal(): void;
430
442
  broadcastCacheInvalidation(): void;
431
443
 
432
444
  // Cross-tab refresh callback (set by navigation bridge)
@@ -542,6 +554,13 @@ export interface NavigationBridge {
542
554
  registerLinkInterception(): () => void;
543
555
  /** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
544
556
  updateVersion(newVersion: string): void;
557
+ /**
558
+ * Replace the active app-shell snapshot (rootLayout, basename, version)
559
+ * atomically. Used on cross-app navigations when the response's routerId
560
+ * indicates the user entered a different app. Theme, warmup, and prefetch
561
+ * TTL are document-lifetime and not part of the shell.
562
+ */
563
+ updateAppShell(next: import("./app-shell.js").AppShell): void;
545
564
  }
546
565
 
547
566
  /**
@@ -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.