@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.
- package/README.md +19 -0
- package/package.json +131 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +891 -0
- package/src/browser/navigation-client.ts +155 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +545 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +228 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +149 -0
- package/src/browser/rsc-router.tsx +310 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +443 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
- package/src/cache/cf/cf-cache-store.ts +274 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/index.ts +52 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +366 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +609 -0
- package/src/components/DefaultDocument.tsx +20 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +178 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href.ts +139 -0
- package/src/index.rsc.ts +69 -0
- package/src/index.ts +84 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1333 -0
- package/src/route-map-builder.ts +140 -0
- package/src/route-types.ts +148 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +60 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +116 -0
- package/src/router/match-context.ts +261 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +250 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +212 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +271 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3484 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +942 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +225 -0
- package/src/segment-system.tsx +405 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +340 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +470 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +126 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +215 -0
- package/src/types.ts +1473 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +608 -0
- package/src/vite/version.d.ts +12 -0
- 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";
|