@rangojs/router 0.0.0-experimental.69 → 0.0.0-experimental.6c70a2ab

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 (123) hide show
  1. package/README.md +112 -17
  2. package/dist/vite/index.js +1456 -467
  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 +364 -0
  7. package/skills/hooks/SKILL.md +54 -20
  8. package/skills/i18n/SKILL.md +276 -0
  9. package/skills/intercept/SKILL.md +45 -0
  10. package/skills/layout/SKILL.md +24 -0
  11. package/skills/links/SKILL.md +234 -16
  12. package/skills/loader/SKILL.md +70 -3
  13. package/skills/middleware/SKILL.md +34 -3
  14. package/skills/migrate-nextjs/SKILL.md +562 -0
  15. package/skills/migrate-react-router/SKILL.md +769 -0
  16. package/skills/parallel/SKILL.md +68 -0
  17. package/skills/rango/SKILL.md +26 -22
  18. package/skills/response-routes/SKILL.md +8 -0
  19. package/skills/route/SKILL.md +48 -0
  20. package/skills/server-actions/SKILL.md +739 -0
  21. package/skills/streams-and-websockets/SKILL.md +283 -0
  22. package/skills/typesafety/SKILL.md +9 -1
  23. package/skills/view-transitions/SKILL.md +212 -0
  24. package/src/browser/app-shell.ts +52 -0
  25. package/src/browser/event-controller.ts +44 -4
  26. package/src/browser/navigation-bridge.ts +80 -5
  27. package/src/browser/navigation-client.ts +64 -13
  28. package/src/browser/navigation-store.ts +25 -1
  29. package/src/browser/partial-update.ts +58 -12
  30. package/src/browser/prefetch/cache.ts +129 -21
  31. package/src/browser/prefetch/fetch.ts +148 -16
  32. package/src/browser/prefetch/queue.ts +36 -5
  33. package/src/browser/rango-state.ts +53 -13
  34. package/src/browser/react/Link.tsx +30 -2
  35. package/src/browser/react/NavigationProvider.tsx +70 -18
  36. package/src/browser/react/filter-segment-order.ts +51 -7
  37. package/src/browser/react/index.ts +3 -0
  38. package/src/browser/react/use-navigation.ts +22 -2
  39. package/src/browser/react/use-params.ts +17 -4
  40. package/src/browser/react/use-reverse.ts +99 -0
  41. package/src/browser/react/use-router.ts +8 -1
  42. package/src/browser/react/use-segments.ts +11 -8
  43. package/src/browser/rsc-router.tsx +34 -6
  44. package/src/browser/scroll-restoration.ts +22 -14
  45. package/src/browser/segment-reconciler.ts +36 -14
  46. package/src/browser/types.ts +19 -0
  47. package/src/build/route-trie.ts +52 -25
  48. package/src/cache/cf/cf-cache-store.ts +5 -7
  49. package/src/client.rsc.tsx +3 -0
  50. package/src/client.tsx +87 -175
  51. package/src/href-client.ts +4 -1
  52. package/src/index.rsc.ts +3 -0
  53. package/src/index.ts +40 -9
  54. package/src/outlet-context.ts +1 -1
  55. package/src/response-utils.ts +28 -0
  56. package/src/reverse.ts +62 -36
  57. package/src/route-definition/dsl-helpers.ts +175 -23
  58. package/src/route-definition/helpers-types.ts +63 -14
  59. package/src/route-definition/resolve-handler-use.ts +6 -0
  60. package/src/route-types.ts +7 -0
  61. package/src/router/handler-context.ts +21 -38
  62. package/src/router/lazy-includes.ts +6 -6
  63. package/src/router/loader-resolution.ts +3 -0
  64. package/src/router/manifest.ts +22 -13
  65. package/src/router/match-api.ts +4 -3
  66. package/src/router/match-handlers.ts +1 -0
  67. package/src/router/match-middleware/cache-lookup.ts +2 -1
  68. package/src/router/match-result.ts +101 -4
  69. package/src/router/middleware-types.ts +14 -25
  70. package/src/router/middleware.ts +54 -7
  71. package/src/router/pattern-matching.ts +101 -17
  72. package/src/router/revalidation.ts +15 -1
  73. package/src/router/segment-resolution/fresh.ts +13 -0
  74. package/src/router/segment-resolution/revalidation.ts +135 -101
  75. package/src/router/substitute-pattern-params.ts +56 -0
  76. package/src/router/trie-matching.ts +18 -13
  77. package/src/router/url-params.ts +49 -0
  78. package/src/router.ts +1 -2
  79. package/src/rsc/handler.ts +16 -8
  80. package/src/rsc/helpers.ts +69 -41
  81. package/src/rsc/progressive-enhancement.ts +4 -0
  82. package/src/rsc/response-route-handler.ts +14 -1
  83. package/src/rsc/rsc-rendering.ts +10 -0
  84. package/src/rsc/server-action.ts +4 -0
  85. package/src/rsc/types.ts +6 -0
  86. package/src/segment-content-promise.ts +67 -0
  87. package/src/segment-loader-promise.ts +122 -0
  88. package/src/segment-system.tsx +71 -70
  89. package/src/server/context.ts +26 -3
  90. package/src/server/request-context.ts +10 -42
  91. package/src/ssr/index.tsx +5 -1
  92. package/src/types/handler-context.ts +12 -39
  93. package/src/types/loader-types.ts +5 -6
  94. package/src/types/request-scope.ts +126 -0
  95. package/src/types/route-entry.ts +11 -0
  96. package/src/types/segments.ts +18 -1
  97. package/src/urls/include-helper.ts +24 -14
  98. package/src/urls/path-helper-types.ts +30 -4
  99. package/src/urls/response-types.ts +2 -10
  100. package/src/use-loader.tsx +4 -1
  101. package/src/vite/debug.ts +184 -0
  102. package/src/vite/discovery/discover-routers.ts +31 -3
  103. package/src/vite/discovery/gate-state.ts +171 -0
  104. package/src/vite/discovery/prerender-collection.ts +172 -84
  105. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  106. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  107. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  108. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  109. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  110. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  111. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  112. package/src/vite/plugins/expose-action-id.ts +52 -28
  113. package/src/vite/plugins/expose-id-utils.ts +12 -0
  114. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  115. package/src/vite/plugins/expose-internal-ids.ts +540 -376
  116. package/src/vite/plugins/performance-tracks.ts +17 -9
  117. package/src/vite/plugins/use-cache-transform.ts +56 -43
  118. package/src/vite/plugins/version-injector.ts +37 -11
  119. package/src/vite/rango.ts +49 -14
  120. package/src/vite/router-discovery.ts +558 -53
  121. package/src/vite/utils/banner.ts +1 -1
  122. package/src/vite/utils/package-resolution.ts +41 -1
  123. package/src/vite/utils/prerender-utils.ts +21 -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
  }
@@ -20,6 +20,9 @@ export { useSegments, type SegmentsState } from "./use-segments.js";
20
20
  // Handle data hook
21
21
  export { useHandle } from "./use-handle.js";
22
22
 
23
+ // Mount-aware reverse hook
24
+ export { useReverse } from "./use-reverse.js";
25
+
23
26
  // Client cache controls hook
24
27
  export {
25
28
  useClientCache,
@@ -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,17 +16,30 @@ 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. The default and selector input use
31
+ // `string | undefined` because absent optional params are omitted from
32
+ // the params record at runtime — the type must reflect that so callers
33
+ // don't write `p.locale.length` and crash when the segment is absent.
34
+ export function useParams<
35
+ T extends object = Record<string, string | undefined>,
36
+ >(): Readonly<T>;
24
37
  export function useParams<T>(
25
- selector: (params: Record<string, string>) => T,
38
+ selector: (params: Record<string, string | undefined>) => T,
26
39
  ): T;
27
40
  export function useParams<T>(
28
- selector?: (params: Record<string, string>) => T,
29
- ): T | Record<string, string> {
41
+ selector?: (params: Record<string, string | undefined>) => T,
42
+ ): T | Record<string, string | undefined> {
30
43
  const ctx = useContext(NavigationStoreContext);
31
44
 
32
45
  const [value, setValue] = useState<T | Record<string, string>>(() => {
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import type { LocalReverseFunction } from "../../reverse.js";
5
+ import { substitutePatternParams } from "../../router/substitute-pattern-params.js";
6
+ import { serializeSearchParams } from "../../search-params.js";
7
+ import { useMount } from "./use-mount.js";
8
+ import { useParams } from "./use-params.js";
9
+
10
+ type RouteEntry = string | { readonly path: string };
11
+ type LocalRouteMap = Readonly<Record<string, RouteEntry>>;
12
+
13
+ function getPattern(entry: RouteEntry | undefined): string | undefined {
14
+ if (entry === undefined) return undefined;
15
+ return typeof entry === "string" ? entry : entry.path;
16
+ }
17
+
18
+ /**
19
+ * Join an include mount prefix with a mount-relative pattern.
20
+ *
21
+ * `pattern === "/"` is the index of the local module — under a non-root
22
+ * mount it must collapse so `/` under `/blog` becomes `/blog`, not
23
+ * `/blog/`. This matches `ctx.reverse(".index")` on the server.
24
+ */
25
+ function joinMount(mount: string, pattern: string): string {
26
+ if (pattern === "/") {
27
+ if (mount === "" || mount === "/") return "/";
28
+ return mount.endsWith("/") ? mount.slice(0, -1) : mount;
29
+ }
30
+ const normalizedMount = mount === "/" ? "" : mount.replace(/\/+$/, "");
31
+ return normalizedMount + pattern;
32
+ }
33
+
34
+ /**
35
+ * Mount-aware reverse function for a locally-imported `routes` map.
36
+ *
37
+ * Resolves dot-prefixed route names against the passed `routes` (typically
38
+ * a generated `routes` from a `urls()` module's `.gen.ts`), prefixes the
39
+ * result with the surrounding `include()` mount path, and substitutes
40
+ * params — auto-filling from the current matched route's params and
41
+ * letting explicit params override.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * "use client";
46
+ * import { Link, useReverse } from "@rangojs/router/client";
47
+ * import { routes as blogRoutes } from "../urls/blog.gen.js";
48
+ *
49
+ * function BlogNav() {
50
+ * const reverse = useReverse(blogRoutes);
51
+ * return (
52
+ * <>
53
+ * <Link to={reverse(".index")}>Blog</Link>
54
+ * <Link to={reverse(".post", { postId: "hello" })}>Post</Link>
55
+ * </>
56
+ * );
57
+ * }
58
+ * ```
59
+ */
60
+ export function useReverse<const TRoutes extends LocalRouteMap>(
61
+ routes: TRoutes,
62
+ ): LocalReverseFunction<TRoutes> {
63
+ const mount = useMount();
64
+ const currentParams = useParams();
65
+
66
+ return useCallback(
67
+ ((
68
+ name: string,
69
+ explicitParams?: Record<string, string | undefined>,
70
+ search?: Record<string, unknown>,
71
+ ): string => {
72
+ if (!name.startsWith(".")) {
73
+ throw new Error(`Local route names must start with ".": "${name}"`);
74
+ }
75
+ const lookupName = name.slice(1);
76
+ const entry = (routes as LocalRouteMap)[lookupName];
77
+ const pattern = getPattern(entry);
78
+ if (pattern === undefined) {
79
+ throw new Error(`Unknown local route: "${name}"`);
80
+ }
81
+
82
+ const joined = joinMount(mount, pattern);
83
+
84
+ const mergedParams = explicitParams
85
+ ? { ...currentParams, ...explicitParams }
86
+ : currentParams;
87
+
88
+ const substituted = substitutePatternParams(joined, mergedParams, name);
89
+
90
+ if (search) {
91
+ const qs = serializeSearchParams(search);
92
+ if (qs) return `${substituted}?${qs}`;
93
+ }
94
+
95
+ return substituted;
96
+ }) as LocalReverseFunction<TRoutes>,
97
+ [routes, mount, currentParams],
98
+ );
99
+ }
@@ -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
  }
@@ -332,6 +332,8 @@ export function scrollToHash(): boolean {
332
332
  * Scroll to top of page
333
333
  */
334
334
  export function scrollToTop(): void {
335
+ if (typeof window === "undefined") return;
336
+ if (typeof window.scrollTo !== "function") return;
335
337
  window.scrollTo(0, 0);
336
338
  }
337
339
 
@@ -374,20 +376,26 @@ export function handleNavigationEnd(options: {
374
376
  // Fall through to hash or top if no saved position
375
377
  }
376
378
 
377
- // Defer hash and scroll-to-top to after React paints the new content,
378
- // so the user doesn't see the current page jump before the new route appears.
379
- deferToNextPaint(() => {
380
- // Re-check: the deferred callback may fire after environment teardown
381
- if (typeof window === "undefined") return;
382
-
383
- // Try hash scrolling first
384
- if (scrollToHash()) {
385
- return;
386
- }
387
-
388
- // Default: scroll to top
389
- scrollToTop();
390
- });
379
+ // scrollToHash / scrollToTop run synchronously here.
380
+ // handleNavigationEnd is invoked from NavigationProvider's
381
+ // useLayoutEffect (post-commit, pre-paint), so a sync scrollTo is
382
+ // captured by the upcoming paint AND by startViewTransition's snapshot.
383
+ // Deferring via rAF here pushed the call past the snapshot capture,
384
+ // making forward navigations wrapped in a layout/route view transition
385
+ // skip scroll-to-top the live DOM scrolled but the captured snapshot
386
+ // was at the previous scroll position, so the user-facing page stayed
387
+ // visually clamped at the source page's scrollY (often the new tree's
388
+ // max scroll for tall→short navs). Y=0 / a hash element are robust
389
+ // against unmeasured layout, so sync scroll is correct here even
390
+ // before the new tree's scrollHeight settles.
391
+ //
392
+ // (The restore branch above keeps deferToNextPaint because savedY
393
+ // depends on the new tree's max scroll; sync scrollTo against an
394
+ // unmeasured DOM would clamp savedY to whatever the old/zero max was.)
395
+ if (scrollToHash()) {
396
+ return;
397
+ }
398
+ scrollToTop();
391
399
  }
392
400
 
393
401
  /**
@@ -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
  /**