@rangojs/router 0.0.0-experimental.10

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 (172) hide show
  1. package/CLAUDE.md +43 -0
  2. package/README.md +19 -0
  3. package/dist/bin/rango.js +227 -0
  4. package/dist/vite/index.js +3039 -0
  5. package/package.json +171 -0
  6. package/skills/caching/SKILL.md +191 -0
  7. package/skills/debug-manifest/SKILL.md +108 -0
  8. package/skills/document-cache/SKILL.md +180 -0
  9. package/skills/fonts/SKILL.md +165 -0
  10. package/skills/hooks/SKILL.md +442 -0
  11. package/skills/intercept/SKILL.md +190 -0
  12. package/skills/layout/SKILL.md +213 -0
  13. package/skills/links/SKILL.md +180 -0
  14. package/skills/loader/SKILL.md +246 -0
  15. package/skills/middleware/SKILL.md +202 -0
  16. package/skills/mime-routes/SKILL.md +124 -0
  17. package/skills/parallel/SKILL.md +228 -0
  18. package/skills/prerender/SKILL.md +283 -0
  19. package/skills/rango/SKILL.md +54 -0
  20. package/skills/response-routes/SKILL.md +358 -0
  21. package/skills/route/SKILL.md +173 -0
  22. package/skills/router-setup/SKILL.md +346 -0
  23. package/skills/tailwind/SKILL.md +129 -0
  24. package/skills/theme/SKILL.md +78 -0
  25. package/skills/typesafety/SKILL.md +394 -0
  26. package/src/__internal.ts +175 -0
  27. package/src/bin/rango.ts +24 -0
  28. package/src/browser/event-controller.ts +876 -0
  29. package/src/browser/index.ts +18 -0
  30. package/src/browser/link-interceptor.ts +121 -0
  31. package/src/browser/lru-cache.ts +69 -0
  32. package/src/browser/merge-segment-loaders.ts +126 -0
  33. package/src/browser/navigation-bridge.ts +913 -0
  34. package/src/browser/navigation-client.ts +165 -0
  35. package/src/browser/navigation-store.ts +823 -0
  36. package/src/browser/partial-update.ts +600 -0
  37. package/src/browser/react/Link.tsx +248 -0
  38. package/src/browser/react/NavigationProvider.tsx +346 -0
  39. package/src/browser/react/ScrollRestoration.tsx +94 -0
  40. package/src/browser/react/context.ts +53 -0
  41. package/src/browser/react/index.ts +52 -0
  42. package/src/browser/react/location-state-shared.ts +120 -0
  43. package/src/browser/react/location-state.ts +62 -0
  44. package/src/browser/react/mount-context.ts +32 -0
  45. package/src/browser/react/use-action.ts +240 -0
  46. package/src/browser/react/use-client-cache.ts +56 -0
  47. package/src/browser/react/use-handle.ts +203 -0
  48. package/src/browser/react/use-href.tsx +40 -0
  49. package/src/browser/react/use-link-status.ts +134 -0
  50. package/src/browser/react/use-mount.ts +31 -0
  51. package/src/browser/react/use-navigation.ts +140 -0
  52. package/src/browser/react/use-segments.ts +188 -0
  53. package/src/browser/request-controller.ts +164 -0
  54. package/src/browser/rsc-router.tsx +352 -0
  55. package/src/browser/scroll-restoration.ts +324 -0
  56. package/src/browser/segment-structure-assert.ts +67 -0
  57. package/src/browser/server-action-bridge.ts +762 -0
  58. package/src/browser/shallow.ts +35 -0
  59. package/src/browser/types.ts +478 -0
  60. package/src/build/generate-manifest.ts +377 -0
  61. package/src/build/generate-route-types.ts +828 -0
  62. package/src/build/index.ts +36 -0
  63. package/src/build/route-trie.ts +239 -0
  64. package/src/cache/cache-scope.ts +563 -0
  65. package/src/cache/cf/cf-cache-store.ts +428 -0
  66. package/src/cache/cf/index.ts +19 -0
  67. package/src/cache/document-cache.ts +340 -0
  68. package/src/cache/index.ts +58 -0
  69. package/src/cache/memory-segment-store.ts +150 -0
  70. package/src/cache/memory-store.ts +253 -0
  71. package/src/cache/types.ts +392 -0
  72. package/src/client.rsc.tsx +83 -0
  73. package/src/client.tsx +643 -0
  74. package/src/component-utils.ts +76 -0
  75. package/src/components/DefaultDocument.tsx +23 -0
  76. package/src/debug.ts +233 -0
  77. package/src/default-error-boundary.tsx +88 -0
  78. package/src/deps/browser.ts +8 -0
  79. package/src/deps/html-stream-client.ts +2 -0
  80. package/src/deps/html-stream-server.ts +2 -0
  81. package/src/deps/rsc.ts +10 -0
  82. package/src/deps/ssr.ts +2 -0
  83. package/src/errors.ts +295 -0
  84. package/src/handle.ts +130 -0
  85. package/src/handles/MetaTags.tsx +193 -0
  86. package/src/handles/index.ts +6 -0
  87. package/src/handles/meta.ts +247 -0
  88. package/src/host/cookie-handler.ts +159 -0
  89. package/src/host/errors.ts +97 -0
  90. package/src/host/index.ts +56 -0
  91. package/src/host/pattern-matcher.ts +214 -0
  92. package/src/host/router.ts +330 -0
  93. package/src/host/testing.ts +79 -0
  94. package/src/host/types.ts +138 -0
  95. package/src/host/utils.ts +25 -0
  96. package/src/href-client.ts +202 -0
  97. package/src/href-context.ts +33 -0
  98. package/src/index.rsc.ts +121 -0
  99. package/src/index.ts +165 -0
  100. package/src/loader.rsc.ts +207 -0
  101. package/src/loader.ts +47 -0
  102. package/src/network-error-thrower.tsx +21 -0
  103. package/src/outlet-context.ts +15 -0
  104. package/src/prerender/param-hash.ts +35 -0
  105. package/src/prerender/store.ts +40 -0
  106. package/src/prerender.ts +156 -0
  107. package/src/reverse.ts +267 -0
  108. package/src/root-error-boundary.tsx +277 -0
  109. package/src/route-content-wrapper.tsx +193 -0
  110. package/src/route-definition.ts +1431 -0
  111. package/src/route-map-builder.ts +242 -0
  112. package/src/route-types.ts +220 -0
  113. package/src/router/error-handling.ts +287 -0
  114. package/src/router/handler-context.ts +158 -0
  115. package/src/router/intercept-resolution.ts +387 -0
  116. package/src/router/loader-resolution.ts +327 -0
  117. package/src/router/manifest.ts +216 -0
  118. package/src/router/match-api.ts +621 -0
  119. package/src/router/match-context.ts +264 -0
  120. package/src/router/match-middleware/background-revalidation.ts +236 -0
  121. package/src/router/match-middleware/cache-lookup.ts +382 -0
  122. package/src/router/match-middleware/cache-store.ts +276 -0
  123. package/src/router/match-middleware/index.ts +81 -0
  124. package/src/router/match-middleware/intercept-resolution.ts +281 -0
  125. package/src/router/match-middleware/segment-resolution.ts +184 -0
  126. package/src/router/match-pipelines.ts +214 -0
  127. package/src/router/match-result.ts +213 -0
  128. package/src/router/metrics.ts +62 -0
  129. package/src/router/middleware.ts +791 -0
  130. package/src/router/pattern-matching.ts +407 -0
  131. package/src/router/revalidation.ts +190 -0
  132. package/src/router/router-context.ts +301 -0
  133. package/src/router/segment-resolution.ts +1315 -0
  134. package/src/router/trie-matching.ts +172 -0
  135. package/src/router/types.ts +163 -0
  136. package/src/router.gen.ts +6 -0
  137. package/src/router.ts +2423 -0
  138. package/src/rsc/handler.ts +1443 -0
  139. package/src/rsc/helpers.ts +64 -0
  140. package/src/rsc/index.ts +56 -0
  141. package/src/rsc/nonce.ts +18 -0
  142. package/src/rsc/types.ts +236 -0
  143. package/src/segment-system.tsx +442 -0
  144. package/src/server/context.ts +466 -0
  145. package/src/server/handle-store.ts +229 -0
  146. package/src/server/loader-registry.ts +174 -0
  147. package/src/server/request-context.ts +554 -0
  148. package/src/server/root-layout.tsx +10 -0
  149. package/src/server/tsconfig.json +14 -0
  150. package/src/server.ts +171 -0
  151. package/src/ssr/index.tsx +296 -0
  152. package/src/theme/ThemeProvider.tsx +291 -0
  153. package/src/theme/ThemeScript.tsx +61 -0
  154. package/src/theme/constants.ts +59 -0
  155. package/src/theme/index.ts +58 -0
  156. package/src/theme/theme-context.ts +70 -0
  157. package/src/theme/theme-script.ts +152 -0
  158. package/src/theme/types.ts +182 -0
  159. package/src/theme/use-theme.ts +44 -0
  160. package/src/types.ts +1757 -0
  161. package/src/urls.gen.ts +8 -0
  162. package/src/urls.ts +1282 -0
  163. package/src/use-loader.tsx +346 -0
  164. package/src/vite/expose-action-id.ts +344 -0
  165. package/src/vite/expose-handle-id.ts +209 -0
  166. package/src/vite/expose-loader-id.ts +426 -0
  167. package/src/vite/expose-location-state-id.ts +177 -0
  168. package/src/vite/expose-prerender-handler-id.ts +429 -0
  169. package/src/vite/index.ts +2068 -0
  170. package/src/vite/package-resolution.ts +125 -0
  171. package/src/vite/version.d.ts +12 -0
  172. package/src/vite/virtual-entries.ts +114 -0
@@ -0,0 +1,248 @@
1
+ "use client";
2
+
3
+ import React, { forwardRef, useCallback, useContext, useRef, type ForwardRefExoticComponent, type RefAttributes } from "react";
4
+ import { NavigationStoreContext } from "./context.js";
5
+ import { LinkContext } from "./use-link-status.js";
6
+ import type { NavigateOptions } from "../types.js";
7
+ import {
8
+ type LocationStateEntry,
9
+ isLocationStateEntry,
10
+ resolveLocationStateEntries,
11
+ } from "./location-state.js";
12
+
13
+ /**
14
+ * State value or getter function for just-in-time state resolution (legacy)
15
+ */
16
+ export type StateOrGetter<T = unknown> = T | (() => T);
17
+
18
+ /**
19
+ * State prop type for Link component
20
+ * - LocationStateEntry[]: Type-safe state entries (always lazy)
21
+ * - StateOrGetter: Legacy format for backwards compatibility
22
+ */
23
+ export type LinkState = LocationStateEntry[] | StateOrGetter;
24
+
25
+ // Track prefetched URLs to avoid duplicate <link> elements
26
+ const prefetchedUrls = new Set<string>();
27
+
28
+ /**
29
+ * Inject a <link rel="prefetch"> element into the document head
30
+ * for the given URL with RSC partial request parameters.
31
+ */
32
+ function prefetchUrl(url: string, segmentIds: string[]): void {
33
+ if (prefetchedUrls.has(url)) return;
34
+ prefetchedUrls.add(url);
35
+
36
+ // Build RSC partial URL with segment IDs
37
+ const targetUrl = new URL(url, window.location.origin);
38
+ targetUrl.searchParams.set("_rsc_partial", "true");
39
+ if (segmentIds.length > 0) {
40
+ targetUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
41
+ }
42
+
43
+ // Inject <link rel="prefetch"> into head
44
+ const link = document.createElement("link");
45
+ link.rel = "prefetch";
46
+ link.href = targetUrl.toString();
47
+ link.as = "fetch";
48
+ document.head.appendChild(link);
49
+ }
50
+
51
+ /**
52
+ * Prefetch strategy for the Link component
53
+ * - "hover": Prefetch on mouse enter (uses native <link rel="prefetch">)
54
+ * - "viewport": Prefetch when link enters viewport (not yet implemented)
55
+ * - "hybrid": Hover on desktop, viewport on mobile (not yet implemented)
56
+ * - "none": No prefetching (default)
57
+ */
58
+ export type PrefetchStrategy = "hover" | "viewport" | "hybrid" | "none";
59
+
60
+ /**
61
+ * Link component props
62
+ */
63
+ export interface LinkProps
64
+ extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
65
+ /**
66
+ * The URL to navigate to (typically from router.reverse())
67
+ */
68
+ to: string;
69
+ /**
70
+ * Replace current history entry instead of pushing
71
+ */
72
+ replace?: boolean;
73
+ /**
74
+ * Scroll to top after navigation (default: true)
75
+ */
76
+ scroll?: boolean;
77
+ /**
78
+ * Force full document navigation instead of SPA
79
+ */
80
+ reloadDocument?: boolean;
81
+ /**
82
+ * Prefetch strategy for the link destination
83
+ * @default "none"
84
+ */
85
+ prefetch?: PrefetchStrategy;
86
+ /**
87
+ * State to pass to history.pushState/replaceState.
88
+ * Accessible via useLocationState() hook.
89
+ *
90
+ * @example
91
+ * ```tsx
92
+ * // Type-safe state with createLocationState (recommended)
93
+ * const ProductState = createLocationState((p: Product) => ({ name: p.name }));
94
+ * <Link to="/product" state={[ProductState(product)]}>View</Link>
95
+ *
96
+ * // Multiple typed states
97
+ * <Link to="/checkout" state={[ProductState(p), CartState(c)]}>Checkout</Link>
98
+ *
99
+ * // Legacy: static state
100
+ * <Link to="/product" state={{ from: "list" }}>View</Link>
101
+ *
102
+ * // Legacy: dynamic state (called at click time)
103
+ * <Link to="/product" state={() => ({ scrollY: window.scrollY })}>View</Link>
104
+ * ```
105
+ */
106
+ state?: LinkState;
107
+ children: React.ReactNode;
108
+ }
109
+
110
+ /**
111
+ * Check if URL is external (different origin)
112
+ */
113
+ function isExternalUrl(href: string): boolean {
114
+ // Protocol-relative URLs
115
+ if (href.startsWith("//")) return true;
116
+
117
+ // Absolute URLs
118
+ if (href.startsWith("http://") || href.startsWith("https://")) {
119
+ try {
120
+ return new URL(href).origin !== window.location.origin;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ // Special protocols (mailto, tel, etc.)
127
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {
128
+ return true;
129
+ }
130
+
131
+ return false;
132
+ }
133
+
134
+ /**
135
+ * Type-safe Link component for SPA navigation
136
+ *
137
+ * Works with router.reverse() for type-safe URLs:
138
+ * ```tsx
139
+ * <Link to={router.reverse("shop.products.detail", { slug: "my-product" })}>
140
+ * View Product
141
+ * </Link>
142
+ * ```
143
+ *
144
+ * Also supports regular URLs:
145
+ * ```tsx
146
+ * <Link to="/about">About</Link>
147
+ * <Link to="https://example.com">External</Link>
148
+ * ```
149
+ */
150
+ export const Link: ForwardRefExoticComponent<LinkProps & RefAttributes<HTMLAnchorElement>> = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
151
+ {
152
+ to,
153
+ replace = false,
154
+ scroll = true,
155
+ reloadDocument = false,
156
+ prefetch = "none",
157
+ state,
158
+ children,
159
+ onClick,
160
+ ...props
161
+ },
162
+ ref
163
+ ) {
164
+ const ctx = useContext(NavigationStoreContext);
165
+ const isExternal = isExternalUrl(to);
166
+
167
+ // Use ref to always get the latest state/getter without adding to useCallback deps
168
+ // This enables just-in-time state resolution without causing re-renders
169
+ const stateRef = useRef(state);
170
+ stateRef.current = state;
171
+
172
+ const handleClick = useCallback(
173
+ (e: React.MouseEvent<HTMLAnchorElement>) => {
174
+ // Call user's onClick handler first
175
+ onClick?.(e);
176
+
177
+ // If user prevented default, respect that
178
+ if (e.defaultPrevented) return;
179
+
180
+ // External links - let browser handle normally
181
+ if (isExternal) return;
182
+
183
+ // Force document navigation if requested
184
+ if (reloadDocument) return;
185
+
186
+ // Allow modifier keys for opening in new tab/window
187
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
188
+
189
+ // Check for download attribute
190
+ if ((e.currentTarget as HTMLAnchorElement).hasAttribute("download"))
191
+ return;
192
+
193
+ // Check for target attribute
194
+ const target = (e.currentTarget as HTMLAnchorElement).target;
195
+ if (target && target !== "_self") return;
196
+
197
+ // Prevent default and use SPA navigation
198
+ e.preventDefault();
199
+ // Stop propagation to prevent link-interceptor from also handling this
200
+ e.stopPropagation();
201
+
202
+ if (ctx?.navigate) {
203
+ // Resolve state just-in-time based on format
204
+ let resolvedState: unknown;
205
+ const currentState = stateRef.current;
206
+
207
+ if (Array.isArray(currentState) && currentState.length > 0 && isLocationStateEntry(currentState[0])) {
208
+ // Type-safe LocationStateEntry[] - resolve each entry into keyed object
209
+ resolvedState = resolveLocationStateEntries(currentState as LocationStateEntry[]);
210
+ } else if (typeof currentState === "function") {
211
+ // Legacy getter function
212
+ resolvedState = currentState();
213
+ } else {
214
+ // Legacy static value
215
+ resolvedState = currentState;
216
+ }
217
+
218
+ ctx.navigate(to, { replace, scroll, state: resolvedState });
219
+ }
220
+ },
221
+ [to, isExternal, reloadDocument, replace, scroll, ctx, onClick]
222
+ );
223
+
224
+ const handleMouseEnter = useCallback(() => {
225
+ if (prefetch === "hover" && !isExternal && ctx?.store) {
226
+ const segmentState = ctx.store.getSegmentState();
227
+ prefetchUrl(to, segmentState.currentSegmentIds);
228
+ }
229
+ }, [prefetch, to, isExternal, ctx]);
230
+
231
+ return (
232
+ <a
233
+ ref={ref}
234
+ href={to}
235
+ onClick={handleClick}
236
+ onMouseEnter={handleMouseEnter}
237
+ data-link-component
238
+ data-external={isExternal ? "" : undefined}
239
+ data-scroll={scroll === false ? "false" : undefined}
240
+ data-replace={replace ? "true" : undefined}
241
+ {...props}
242
+ >
243
+ <LinkContext.Provider value={to}>
244
+ {children}
245
+ </LinkContext.Provider>
246
+ </a>
247
+ );
248
+ });
@@ -0,0 +1,346 @@
1
+ "use client";
2
+
3
+ import React, {
4
+ useState,
5
+ useEffect,
6
+ useCallback,
7
+ useMemo,
8
+ use,
9
+ type ReactNode,
10
+ } from "react";
11
+ import {
12
+ NavigationStoreContext,
13
+ type NavigationStoreContextValue,
14
+ } from "./context.js";
15
+ import type {
16
+ NavigationStore,
17
+ RscPayload,
18
+ NavigateOptions,
19
+ NavigationBridge,
20
+ } from "../types.js";
21
+ import type { EventController } from "../event-controller.js";
22
+ import { RootErrorBoundary } from "../../root-error-boundary.js";
23
+ import type { HandleData } from "../types.js";
24
+ import { ThemeProvider } from "../../theme/ThemeProvider.js";
25
+ import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
26
+
27
+ /**
28
+ * Process handles from an async generator, updating the event controller
29
+ * and cache as data streams in.
30
+ *
31
+ * This handles:
32
+ * 1. Consuming the async generator and calling setHandleData on each yield
33
+ * 2. Stopping early if user navigates away (historyKey changes)
34
+ * 3. Cleaning up stale data when generator yields nothing
35
+ * 4. Updating the cache after processing completes (if still on same page)
36
+ */
37
+ async function processHandles(
38
+ handlesGenerator: AsyncGenerator<HandleData>,
39
+ opts: {
40
+ eventController: EventController;
41
+ store: NavigationStore;
42
+ matched?: string[];
43
+ isPartial?: boolean;
44
+ historyKey: string;
45
+ }
46
+ ): Promise<void> {
47
+ const { eventController, store, matched, isPartial, historyKey } = opts;
48
+
49
+ let yieldCount = 0;
50
+ for await (const handleData of handlesGenerator) {
51
+ // Check if user navigated away before each update.
52
+ // This prevents handle data from cancelled navigations polluting
53
+ // the current route's breadcrumbs (e.g., quick popstate after clicking a link).
54
+ if (historyKey !== store.getHistoryKey()) {
55
+ console.log(
56
+ "[NavigationProvider] Stopping handle processing - user navigated away"
57
+ );
58
+ return;
59
+ }
60
+
61
+ yieldCount++;
62
+ eventController.setHandleData(handleData, matched, isPartial);
63
+ }
64
+
65
+ // Check again before final updates
66
+ if (historyKey !== store.getHistoryKey()) {
67
+ return;
68
+ }
69
+
70
+ // For partial updates where the generator yielded nothing (cached handlers),
71
+ // we still need to update the segment order to clean up stale handle data.
72
+ // This happens when navigating away from a route - the handlers for the new
73
+ // route might not push any breadcrumbs, but we still need to remove the old ones.
74
+ if (yieldCount === 0 && matched) {
75
+ eventController.setHandleData({}, matched, true);
76
+ }
77
+
78
+ // After handles processing completes, update the cache's handleData.
79
+ // This fixes a race condition where commit() caches stale handleData before
80
+ // the async handles processing completes.
81
+ // Only update if we're still on the same page (historyKey matches).
82
+ if (historyKey === store.getHistoryKey()) {
83
+ const finalHandleData = eventController.getHandleState().data;
84
+ store.updateCacheHandleData(historyKey, finalHandleData);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Props for NavigationProvider
90
+ */
91
+ export interface NavigationProviderProps {
92
+ /**
93
+ * Navigation store instance (for cache/segment management)
94
+ */
95
+ store: NavigationStore;
96
+
97
+ /**
98
+ * Event controller instance (for navigation/action state)
99
+ */
100
+ eventController: EventController;
101
+
102
+ /**
103
+ * Initial RSC payload from server
104
+ */
105
+ initialPayload: RscPayload;
106
+
107
+ /**
108
+ * Navigation bridge for handling navigation
109
+ */
110
+ bridge: NavigationBridge;
111
+
112
+ /**
113
+ * Theme configuration (null if theme not enabled)
114
+ * When provided, wraps content in ThemeProvider
115
+ */
116
+ themeConfig?: ResolvedThemeConfig | null;
117
+
118
+ /**
119
+ * Initial theme from server (from cookie)
120
+ * Only used when themeConfig is provided
121
+ */
122
+ initialTheme?: Theme;
123
+
124
+ /**
125
+ * Whether connection warmup is enabled.
126
+ * When true, keeps TLS alive by sending HEAD requests after idle periods.
127
+ */
128
+ warmupEnabled?: boolean;
129
+ }
130
+
131
+ /**
132
+ * Navigation provider component
133
+ *
134
+ * Provides navigation context to the component tree and handles:
135
+ * - Providing stable store and event controller references (never re-renders consumers)
136
+ * - Subscribing to UI updates to re-render the tree
137
+ * - Providing navigate/refresh methods (delegated to bridge)
138
+ *
139
+ * State subscriptions happen via useNavigation hook (via event controller), not via context.
140
+ * This means context consumers don't re-render on state changes.
141
+ *
142
+ * @example
143
+ * ```tsx
144
+ * <NavigationProvider
145
+ * store={store}
146
+ * eventController={eventController}
147
+ * initialPayload={payload}
148
+ * bridge={navigationBridge}
149
+ * />
150
+ * ```
151
+ */
152
+ export function NavigationProvider({
153
+ store,
154
+ eventController,
155
+ initialPayload,
156
+ bridge,
157
+ themeConfig,
158
+ initialTheme,
159
+ warmupEnabled,
160
+ }: NavigationProviderProps): ReactNode {
161
+ // Track current payload for rendering (this triggers re-renders)
162
+ const [payload, setPayload] = useState(initialPayload);
163
+
164
+ /**
165
+ * Navigate to a URL (delegates to bridge)
166
+ */
167
+ const navigate = useCallback(
168
+ async (url: string, options?: NavigateOptions): Promise<void> => {
169
+ await bridge.navigate(url, options);
170
+ },
171
+ []
172
+ );
173
+
174
+ /**
175
+ * Refresh current route (delegates to bridge)
176
+ */
177
+ const refresh = useCallback(async (): Promise<void> => {
178
+ await bridge.refresh();
179
+ }, []);
180
+
181
+ // Context value is stable (store, eventController, navigate, refresh never change)
182
+ const contextValue = useMemo<NavigationStoreContextValue>(
183
+ () => ({
184
+ store,
185
+ eventController,
186
+ navigate,
187
+ refresh,
188
+ }),
189
+ []
190
+ );
191
+
192
+ // Connection warmup: keep TLS alive after idle periods.
193
+ // After 60s of no user interaction, marks connection as "cold".
194
+ // On next interaction or visibility change, sends a HEAD request to warm TLS
195
+ // before the user actually clicks a link.
196
+ useEffect(() => {
197
+ if (!warmupEnabled) return;
198
+
199
+ const IDLE_TIMEOUT = 60_000;
200
+ const DEBOUNCE_DELAY = 150;
201
+
202
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
203
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
204
+ let isCold = false;
205
+ let warmupListenersAttached = false;
206
+
207
+ function sendWarmup() {
208
+ isCold = false;
209
+ fetch("/?_rsc_warmup", { method: "HEAD" }).catch(() => {});
210
+ }
211
+
212
+ function triggerWarmup() {
213
+ if (!isCold) return;
214
+ clearTimeout(debounceTimer);
215
+ debounceTimer = setTimeout(() => {
216
+ sendWarmup();
217
+ detachWarmupListeners();
218
+ resetIdleTimer();
219
+ }, DEBOUNCE_DELAY);
220
+ }
221
+
222
+ function onVisibilityChange() {
223
+ if (document.visibilityState === "visible" && isCold) {
224
+ triggerWarmup();
225
+ }
226
+ }
227
+
228
+ function attachWarmupListeners() {
229
+ if (warmupListenersAttached) return;
230
+ warmupListenersAttached = true;
231
+ document.addEventListener("visibilitychange", onVisibilityChange);
232
+ document.addEventListener("mousemove", triggerWarmup, { once: true });
233
+ document.addEventListener("touchstart", triggerWarmup, { once: true });
234
+ }
235
+
236
+ function detachWarmupListeners() {
237
+ warmupListenersAttached = false;
238
+ document.removeEventListener("visibilitychange", onVisibilityChange);
239
+ document.removeEventListener("mousemove", triggerWarmup);
240
+ document.removeEventListener("touchstart", triggerWarmup);
241
+ }
242
+
243
+ function markCold() {
244
+ isCold = true;
245
+ attachWarmupListeners();
246
+ }
247
+
248
+ function resetIdleTimer() {
249
+ clearTimeout(idleTimer);
250
+ isCold = false;
251
+ idleTimer = setTimeout(markCold, IDLE_TIMEOUT);
252
+ }
253
+
254
+ // Activity events that reset the idle timer
255
+ const activityEvents = ["mousemove", "keydown", "touchstart", "scroll"] as const;
256
+ const activityOptions: AddEventListenerOptions = { passive: true };
257
+
258
+ for (const event of activityEvents) {
259
+ document.addEventListener(event, resetIdleTimer, activityOptions);
260
+ }
261
+
262
+ resetIdleTimer();
263
+
264
+ return () => {
265
+ clearTimeout(idleTimer);
266
+ clearTimeout(debounceTimer);
267
+ detachWarmupListeners();
268
+ for (const event of activityEvents) {
269
+ document.removeEventListener(event, resetIdleTimer);
270
+ }
271
+ };
272
+ }, [warmupEnabled]);
273
+
274
+ // Subscribe to UI updates (for re-rendering the tree)
275
+ useEffect(() => {
276
+ const unsubscribe = store.onUpdate((update) => {
277
+ setPayload({
278
+ root: update.root,
279
+ metadata: update.metadata,
280
+ });
281
+
282
+ // Update handle data progressively as it streams in
283
+ if (update.metadata.handles) {
284
+ // Capture historyKey now - by the time async processing completes,
285
+ // the user might have navigated elsewhere
286
+ const historyKey = store.getHistoryKey();
287
+
288
+ processHandles(update.metadata.handles, {
289
+ eventController,
290
+ store,
291
+ matched: update.metadata.matched,
292
+ isPartial: update.metadata.isPartial,
293
+ historyKey,
294
+ }).catch((err) =>
295
+ console.error("[NavigationProvider] Error consuming handles:", err)
296
+ );
297
+ } else if (update.metadata.cachedHandleData) {
298
+ // For back/forward navigation from cache, restore the cached handleData
299
+ // This restores breadcrumbs to the exact state they were when the page was cached
300
+ eventController.setHandleData(
301
+ update.metadata.cachedHandleData,
302
+ update.metadata.matched,
303
+ false // full replace - restore entire cached state
304
+ );
305
+ } else if (update.metadata.matched) {
306
+ // For cached navigations without handleData, update segmentOrder to clean up stale data
307
+ eventController.setHandleData(
308
+ {}, // Empty data - all existing data not in matched will be cleaned up
309
+ update.metadata.matched,
310
+ true // partial update - will clean up segments not in matched
311
+ );
312
+ }
313
+ });
314
+
315
+ return unsubscribe;
316
+ }, []);
317
+
318
+ // Handle promise case - use() will suspend until resolved
319
+ const root =
320
+ payload.root instanceof Promise ? use(payload.root) : payload.root;
321
+
322
+ // Wrap content in RootErrorBoundary to catch:
323
+ // 1. Errors from NetworkErrorThrower (rendered during network failures)
324
+ // 2. Client component errors that occur before/outside the segment tree's error boundary
325
+ // 3. Errors during promise resolution or navigation state updates
326
+ // This acts as a safety net - the segment tree has its own RootErrorBoundary that
327
+ // catches most errors, but this outer boundary catches anything that slips through.
328
+
329
+ // Build the content tree
330
+ let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
331
+
332
+ // Wrap with ThemeProvider when theme is enabled
333
+ if (themeConfig) {
334
+ content = (
335
+ <ThemeProvider config={themeConfig} initialTheme={initialTheme}>
336
+ {content}
337
+ </ThemeProvider>
338
+ );
339
+ }
340
+
341
+ return (
342
+ <NavigationStoreContext.Provider value={contextValue}>
343
+ {content}
344
+ </NavigationStoreContext.Provider>
345
+ );
346
+ }
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { initScrollRestoration } from "../scroll-restoration.js";
5
+
6
+ /**
7
+ * Props for ScrollRestoration component
8
+ */
9
+ export interface ScrollRestorationProps {
10
+ /**
11
+ * Custom function to determine the scroll restoration key.
12
+ * By default, uses a unique key per history entry (location.key).
13
+ *
14
+ * Return location.pathname to restore scroll based on path
15
+ * (useful for keeping scroll position on the same page).
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * // Restore based on pathname (same URL = same scroll)
20
+ * <ScrollRestoration
21
+ * getKey={(location) => location.pathname}
22
+ * />
23
+ *
24
+ * // Restore based on unique history entry (default)
25
+ * <ScrollRestoration
26
+ * getKey={(location) => location.key}
27
+ * />
28
+ * ```
29
+ */
30
+ getKey?: (location: {
31
+ pathname: string;
32
+ search: string;
33
+ hash: string;
34
+ key: string;
35
+ }) => string;
36
+ }
37
+
38
+ /**
39
+ * ScrollRestoration component
40
+ *
41
+ * Enables scroll position restoration across navigations:
42
+ * - Saves scroll positions to sessionStorage
43
+ * - Restores scroll on back/forward navigation
44
+ * - Scrolls to top on new navigation
45
+ * - Supports hash link scrolling
46
+ *
47
+ * Should be rendered once in your app, typically in the root layout.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * // In your root layout
52
+ * export default function RootLayout({ children }) {
53
+ * return (
54
+ * <html>
55
+ * <body>
56
+ * <ScrollRestoration />
57
+ * {children}
58
+ * </body>
59
+ * </html>
60
+ * );
61
+ * }
62
+ * ```
63
+ */
64
+ export function ScrollRestoration({ getKey }: ScrollRestorationProps) {
65
+ useEffect(() => {
66
+ const cleanup = initScrollRestoration({ getKey });
67
+ return cleanup;
68
+ }, [getKey]);
69
+
70
+ // This component doesn't render anything
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Hook to initialize scroll restoration
76
+ *
77
+ * Alternative to the ScrollRestoration component for more control.
78
+ *
79
+ * @example
80
+ * ```tsx
81
+ * function App() {
82
+ * useScrollRestoration();
83
+ * return <div>...</div>;
84
+ * }
85
+ * ```
86
+ */
87
+ export function useScrollRestoration(options?: {
88
+ getKey?: ScrollRestorationProps["getKey"];
89
+ }): void {
90
+ useEffect(() => {
91
+ const cleanup = initScrollRestoration({ getKey: options?.getKey });
92
+ return cleanup;
93
+ }, [options?.getKey]);
94
+ }