@ivogt/rsc-router 0.0.0-experimental.1

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 +19 -0
  2. package/package.json +131 -0
  3. package/src/__mocks__/version.ts +6 -0
  4. package/src/__tests__/route-definition.test.ts +63 -0
  5. package/src/browser/event-controller.ts +876 -0
  6. package/src/browser/index.ts +18 -0
  7. package/src/browser/link-interceptor.ts +121 -0
  8. package/src/browser/lru-cache.ts +69 -0
  9. package/src/browser/merge-segment-loaders.ts +126 -0
  10. package/src/browser/navigation-bridge.ts +891 -0
  11. package/src/browser/navigation-client.ts +155 -0
  12. package/src/browser/navigation-store.ts +823 -0
  13. package/src/browser/partial-update.ts +545 -0
  14. package/src/browser/react/Link.tsx +248 -0
  15. package/src/browser/react/NavigationProvider.tsx +228 -0
  16. package/src/browser/react/ScrollRestoration.tsx +94 -0
  17. package/src/browser/react/context.ts +53 -0
  18. package/src/browser/react/index.ts +52 -0
  19. package/src/browser/react/location-state-shared.ts +120 -0
  20. package/src/browser/react/location-state.ts +62 -0
  21. package/src/browser/react/use-action.ts +240 -0
  22. package/src/browser/react/use-client-cache.ts +56 -0
  23. package/src/browser/react/use-handle.ts +178 -0
  24. package/src/browser/react/use-link-status.ts +134 -0
  25. package/src/browser/react/use-navigation.ts +150 -0
  26. package/src/browser/react/use-segments.ts +188 -0
  27. package/src/browser/request-controller.ts +149 -0
  28. package/src/browser/rsc-router.tsx +310 -0
  29. package/src/browser/scroll-restoration.ts +324 -0
  30. package/src/browser/server-action-bridge.ts +747 -0
  31. package/src/browser/shallow.ts +35 -0
  32. package/src/browser/types.ts +443 -0
  33. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  34. package/src/cache/__tests__/memory-store.test.ts +484 -0
  35. package/src/cache/cache-scope.ts +565 -0
  36. package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
  37. package/src/cache/cf/cf-cache-store.ts +274 -0
  38. package/src/cache/cf/index.ts +19 -0
  39. package/src/cache/index.ts +52 -0
  40. package/src/cache/memory-segment-store.ts +150 -0
  41. package/src/cache/memory-store.ts +253 -0
  42. package/src/cache/types.ts +366 -0
  43. package/src/client.rsc.tsx +88 -0
  44. package/src/client.tsx +609 -0
  45. package/src/components/DefaultDocument.tsx +20 -0
  46. package/src/default-error-boundary.tsx +88 -0
  47. package/src/deps/browser.ts +8 -0
  48. package/src/deps/html-stream-client.ts +2 -0
  49. package/src/deps/html-stream-server.ts +2 -0
  50. package/src/deps/rsc.ts +10 -0
  51. package/src/deps/ssr.ts +2 -0
  52. package/src/errors.ts +259 -0
  53. package/src/handle.ts +120 -0
  54. package/src/handles/MetaTags.tsx +178 -0
  55. package/src/handles/index.ts +6 -0
  56. package/src/handles/meta.ts +247 -0
  57. package/src/href-client.ts +128 -0
  58. package/src/href.ts +139 -0
  59. package/src/index.rsc.ts +69 -0
  60. package/src/index.ts +84 -0
  61. package/src/loader.rsc.ts +204 -0
  62. package/src/loader.ts +47 -0
  63. package/src/network-error-thrower.tsx +21 -0
  64. package/src/outlet-context.ts +15 -0
  65. package/src/root-error-boundary.tsx +277 -0
  66. package/src/route-content-wrapper.tsx +198 -0
  67. package/src/route-definition.ts +1333 -0
  68. package/src/route-map-builder.ts +140 -0
  69. package/src/route-types.ts +148 -0
  70. package/src/route-utils.ts +89 -0
  71. package/src/router/__tests__/match-context.test.ts +104 -0
  72. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  73. package/src/router/__tests__/match-result.test.ts +566 -0
  74. package/src/router/__tests__/on-error.test.ts +935 -0
  75. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  76. package/src/router/error-handling.ts +287 -0
  77. package/src/router/handler-context.ts +60 -0
  78. package/src/router/loader-resolution.ts +326 -0
  79. package/src/router/manifest.ts +116 -0
  80. package/src/router/match-context.ts +261 -0
  81. package/src/router/match-middleware/background-revalidation.ts +236 -0
  82. package/src/router/match-middleware/cache-lookup.ts +261 -0
  83. package/src/router/match-middleware/cache-store.ts +250 -0
  84. package/src/router/match-middleware/index.ts +81 -0
  85. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  86. package/src/router/match-middleware/segment-resolution.ts +174 -0
  87. package/src/router/match-pipelines.ts +214 -0
  88. package/src/router/match-result.ts +212 -0
  89. package/src/router/metrics.ts +62 -0
  90. package/src/router/middleware.test.ts +1355 -0
  91. package/src/router/middleware.ts +748 -0
  92. package/src/router/pattern-matching.ts +271 -0
  93. package/src/router/revalidation.ts +190 -0
  94. package/src/router/router-context.ts +299 -0
  95. package/src/router/types.ts +96 -0
  96. package/src/router.ts +3484 -0
  97. package/src/rsc/__tests__/helpers.test.ts +175 -0
  98. package/src/rsc/handler.ts +942 -0
  99. package/src/rsc/helpers.ts +64 -0
  100. package/src/rsc/index.ts +56 -0
  101. package/src/rsc/nonce.ts +18 -0
  102. package/src/rsc/types.ts +225 -0
  103. package/src/segment-system.tsx +405 -0
  104. package/src/server/__tests__/request-context.test.ts +171 -0
  105. package/src/server/context.ts +340 -0
  106. package/src/server/handle-store.ts +230 -0
  107. package/src/server/loader-registry.ts +174 -0
  108. package/src/server/request-context.ts +470 -0
  109. package/src/server/root-layout.tsx +10 -0
  110. package/src/server/tsconfig.json +14 -0
  111. package/src/server.ts +126 -0
  112. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  113. package/src/ssr/index.tsx +215 -0
  114. package/src/types.ts +1473 -0
  115. package/src/use-loader.tsx +346 -0
  116. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  117. package/src/vite/expose-action-id.ts +344 -0
  118. package/src/vite/expose-handle-id.ts +209 -0
  119. package/src/vite/expose-loader-id.ts +357 -0
  120. package/src/vite/expose-location-state-id.ts +177 -0
  121. package/src/vite/index.ts +608 -0
  122. package/src/vite/version.d.ts +12 -0
  123. package/src/vite/virtual-entries.ts +109 -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.href())
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.href() for type-safe URLs:
138
+ * ```tsx
139
+ * <Link to={router.href("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,228 @@
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
+
25
+ /**
26
+ * Process handles from an async generator, updating the event controller
27
+ * and cache as data streams in.
28
+ *
29
+ * This handles:
30
+ * 1. Consuming the async generator and calling setHandleData on each yield
31
+ * 2. Stopping early if user navigates away (historyKey changes)
32
+ * 3. Cleaning up stale data when generator yields nothing
33
+ * 4. Updating the cache after processing completes (if still on same page)
34
+ */
35
+ async function processHandles(
36
+ handlesGenerator: AsyncGenerator<HandleData>,
37
+ opts: {
38
+ eventController: EventController;
39
+ store: NavigationStore;
40
+ matched?: string[];
41
+ isPartial?: boolean;
42
+ historyKey: string;
43
+ }
44
+ ): Promise<void> {
45
+ const { eventController, store, matched, isPartial, historyKey } = opts;
46
+
47
+ let yieldCount = 0;
48
+ for await (const handleData of handlesGenerator) {
49
+ // Check if user navigated away before each update.
50
+ // This prevents handle data from cancelled navigations polluting
51
+ // the current route's breadcrumbs (e.g., quick popstate after clicking a link).
52
+ if (historyKey !== store.getHistoryKey()) {
53
+ console.log(
54
+ "[NavigationProvider] Stopping handle processing - user navigated away"
55
+ );
56
+ return;
57
+ }
58
+
59
+ yieldCount++;
60
+ eventController.setHandleData(handleData, matched, isPartial);
61
+ }
62
+
63
+ // Check again before final updates
64
+ if (historyKey !== store.getHistoryKey()) {
65
+ return;
66
+ }
67
+
68
+ // For partial updates where the generator yielded nothing (cached handlers),
69
+ // we still need to update the segment order to clean up stale handle data.
70
+ // This happens when navigating away from a route - the handlers for the new
71
+ // route might not push any breadcrumbs, but we still need to remove the old ones.
72
+ if (yieldCount === 0 && matched) {
73
+ eventController.setHandleData({}, matched, true);
74
+ }
75
+
76
+ // After handles processing completes, update the cache's handleData.
77
+ // This fixes a race condition where commit() caches stale handleData before
78
+ // the async handles processing completes.
79
+ // Only update if we're still on the same page (historyKey matches).
80
+ if (historyKey === store.getHistoryKey()) {
81
+ const finalHandleData = eventController.getHandleState().data;
82
+ store.updateCacheHandleData(historyKey, finalHandleData);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Props for NavigationProvider
88
+ */
89
+ export interface NavigationProviderProps {
90
+ /**
91
+ * Navigation store instance (for cache/segment management)
92
+ */
93
+ store: NavigationStore;
94
+
95
+ /**
96
+ * Event controller instance (for navigation/action state)
97
+ */
98
+ eventController: EventController;
99
+
100
+ /**
101
+ * Initial RSC payload from server
102
+ */
103
+ initialPayload: RscPayload;
104
+
105
+ /**
106
+ * Navigation bridge for handling navigation
107
+ */
108
+ bridge: NavigationBridge;
109
+ }
110
+
111
+ /**
112
+ * Navigation provider component
113
+ *
114
+ * Provides navigation context to the component tree and handles:
115
+ * - Providing stable store and event controller references (never re-renders consumers)
116
+ * - Subscribing to UI updates to re-render the tree
117
+ * - Providing navigate/refresh methods (delegated to bridge)
118
+ *
119
+ * State subscriptions happen via useNavigation hook (via event controller), not via context.
120
+ * This means context consumers don't re-render on state changes.
121
+ *
122
+ * @example
123
+ * ```tsx
124
+ * <NavigationProvider
125
+ * store={store}
126
+ * eventController={eventController}
127
+ * initialPayload={payload}
128
+ * bridge={navigationBridge}
129
+ * />
130
+ * ```
131
+ */
132
+ export function NavigationProvider({
133
+ store,
134
+ eventController,
135
+ initialPayload,
136
+ bridge,
137
+ }: NavigationProviderProps): ReactNode {
138
+ // Track current payload for rendering (this triggers re-renders)
139
+ const [payload, setPayload] = useState(initialPayload);
140
+
141
+ /**
142
+ * Navigate to a URL (delegates to bridge)
143
+ */
144
+ const navigate = useCallback(
145
+ async (url: string, options?: NavigateOptions): Promise<void> => {
146
+ await bridge.navigate(url, options);
147
+ },
148
+ []
149
+ );
150
+
151
+ /**
152
+ * Refresh current route (delegates to bridge)
153
+ */
154
+ const refresh = useCallback(async (): Promise<void> => {
155
+ await bridge.refresh();
156
+ }, []);
157
+
158
+ // Context value is stable (store, eventController, navigate, refresh never change)
159
+ const contextValue = useMemo<NavigationStoreContextValue>(
160
+ () => ({
161
+ store,
162
+ eventController,
163
+ navigate,
164
+ refresh,
165
+ }),
166
+ []
167
+ );
168
+
169
+ // Subscribe to UI updates (for re-rendering the tree)
170
+ useEffect(() => {
171
+ const unsubscribe = store.onUpdate((update) => {
172
+ setPayload({
173
+ root: update.root,
174
+ metadata: update.metadata,
175
+ });
176
+
177
+ // Update handle data progressively as it streams in
178
+ if (update.metadata.handles) {
179
+ // Capture historyKey now - by the time async processing completes,
180
+ // the user might have navigated elsewhere
181
+ const historyKey = store.getHistoryKey();
182
+
183
+ processHandles(update.metadata.handles, {
184
+ eventController,
185
+ store,
186
+ matched: update.metadata.matched,
187
+ isPartial: update.metadata.isPartial,
188
+ historyKey,
189
+ }).catch((err) =>
190
+ console.error("[NavigationProvider] Error consuming handles:", err)
191
+ );
192
+ } else if (update.metadata.cachedHandleData) {
193
+ // For back/forward navigation from cache, restore the cached handleData
194
+ // This restores breadcrumbs to the exact state they were when the page was cached
195
+ eventController.setHandleData(
196
+ update.metadata.cachedHandleData,
197
+ update.metadata.matched,
198
+ false // full replace - restore entire cached state
199
+ );
200
+ } else if (update.metadata.matched) {
201
+ // For cached navigations without handleData, update segmentOrder to clean up stale data
202
+ eventController.setHandleData(
203
+ {}, // Empty data - all existing data not in matched will be cleaned up
204
+ update.metadata.matched,
205
+ true // partial update - will clean up segments not in matched
206
+ );
207
+ }
208
+ });
209
+
210
+ return unsubscribe;
211
+ }, []);
212
+
213
+ // Handle promise case - use() will suspend until resolved
214
+ const root =
215
+ payload.root instanceof Promise ? use(payload.root) : payload.root;
216
+
217
+ // Wrap content in RootErrorBoundary to catch:
218
+ // 1. Errors from NetworkErrorThrower (rendered during network failures)
219
+ // 2. Client component errors that occur before/outside the segment tree's error boundary
220
+ // 3. Errors during promise resolution or navigation state updates
221
+ // This acts as a safety net - the segment tree has its own RootErrorBoundary that
222
+ // catches most errors, but this outer boundary catches anything that slips through.
223
+ return (
224
+ <NavigationStoreContext.Provider value={contextValue}>
225
+ <RootErrorBoundary>{root}</RootErrorBoundary>
226
+ </NavigationStoreContext.Provider>
227
+ );
228
+ }
@@ -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
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { createContext, type Context } from "react";
4
+ import type { NavigationStore, NavigateOptions } from "../types.js";
5
+ import type { EventController } from "../event-controller.js";
6
+
7
+ /**
8
+ * Navigation context value provided by NavigationProvider
9
+ *
10
+ * This context provides a STABLE reference to the store, event controller, and methods.
11
+ * The store itself never changes, so context consumers don't re-render
12
+ * when navigation state changes.
13
+ *
14
+ * Components subscribe to state changes via eventController.subscribe() in useNavigation.
15
+ */
16
+ export interface NavigationStoreContextValue {
17
+ /**
18
+ * The navigation store instance (stable reference)
19
+ * Used for cache/segment management
20
+ */
21
+ store: NavigationStore;
22
+
23
+ /**
24
+ * The event controller instance (stable reference)
25
+ * Used for navigation/action state
26
+ */
27
+ eventController: EventController;
28
+
29
+ /**
30
+ * Navigate to a new URL
31
+ *
32
+ * @param url - The URL to navigate to
33
+ * @param options - Navigation options (replace, scroll)
34
+ * @returns Promise that resolves when navigation is complete
35
+ */
36
+ navigate: (url: string, options?: NavigateOptions) => Promise<void>;
37
+
38
+ /**
39
+ * Refresh the current route
40
+ *
41
+ * @returns Promise that resolves when refresh is complete
42
+ */
43
+ refresh: () => Promise<void>;
44
+ }
45
+
46
+ /**
47
+ * React context for navigation store
48
+ *
49
+ * Provides stable reference to the store - does NOT re-render on state changes.
50
+ * Use useNavigation hook for reactive state access.
51
+ */
52
+ export const NavigationStoreContext: Context<NavigationStoreContextValue | null> =
53
+ createContext<NavigationStoreContextValue | null>(null);
@@ -0,0 +1,52 @@
1
+ // React exports for browser navigation
2
+
3
+ // Hook with Zustand-style selectors
4
+ export {
5
+ useNavigation,
6
+ type NavigationMethods,
7
+ type NavigationValue,
8
+ } from "./use-navigation.js";
9
+
10
+ // Action state tracking hook
11
+ export { useAction, type TrackedActionState } from "./use-action.js";
12
+
13
+ // Segments state hook
14
+ export { useSegments, initSegmentsSync, type SegmentsState } from "./use-segments.js";
15
+
16
+ // Handle data hook
17
+ export { useHandle, initHandleDataSync } from "./use-handle.js";
18
+
19
+ // Client cache controls hook
20
+ export {
21
+ useClientCache,
22
+ type ClientCacheControls,
23
+ } from "./use-client-cache.js";
24
+
25
+ // Provider
26
+ export {
27
+ NavigationProvider,
28
+ type NavigationProviderProps,
29
+ } from "./NavigationProvider.js";
30
+
31
+ // Context (for advanced usage)
32
+ export {
33
+ NavigationStoreContext,
34
+ type NavigationStoreContextValue,
35
+ } from "./context.js";
36
+
37
+ // Link component
38
+ export {
39
+ Link,
40
+ type LinkProps,
41
+ type PrefetchStrategy,
42
+ } from "./Link.js";
43
+
44
+ // Link status hook
45
+ export { useLinkStatus, type LinkStatus } from "./use-link-status.js";
46
+
47
+ // Scroll restoration
48
+ export {
49
+ ScrollRestoration,
50
+ useScrollRestoration,
51
+ type ScrollRestorationProps,
52
+ } from "./ScrollRestoration.js";