@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,310 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
renderSegments as baseRenderSegments,
|
|
4
|
+
type RenderSegmentsOptions,
|
|
5
|
+
} from "../segment-system.js";
|
|
6
|
+
import {
|
|
7
|
+
createNavigationStore,
|
|
8
|
+
generateHistoryKey,
|
|
9
|
+
} from "./navigation-store.js";
|
|
10
|
+
import { createEventController } from "./event-controller.js";
|
|
11
|
+
import { createNavigationClient } from "./navigation-client.js";
|
|
12
|
+
import { createServerActionBridge } from "./server-action-bridge.js";
|
|
13
|
+
import { createNavigationBridge } from "./navigation-bridge.js";
|
|
14
|
+
import { NavigationProvider, initHandleDataSync, initSegmentsSync } from "./react/index.js";
|
|
15
|
+
import type {
|
|
16
|
+
RscPayload,
|
|
17
|
+
RscBrowserDependencies,
|
|
18
|
+
ResolvedSegment,
|
|
19
|
+
NavigationStore,
|
|
20
|
+
NavigationBridge,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
import type { EventController } from "./event-controller.js";
|
|
23
|
+
|
|
24
|
+
// Vite HMR types
|
|
25
|
+
declare global {
|
|
26
|
+
interface ImportMeta {
|
|
27
|
+
hot?: {
|
|
28
|
+
on(event: string, callback: () => void): void;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Options for initializing the browser app
|
|
35
|
+
*/
|
|
36
|
+
export interface InitBrowserAppOptions {
|
|
37
|
+
/**
|
|
38
|
+
* RSC stream containing the initial payload (from rsc-html-stream/client)
|
|
39
|
+
*/
|
|
40
|
+
rscStream: ReadableStream<Uint8Array>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* RSC browser dependencies from @vitejs/plugin-rsc/browser
|
|
44
|
+
*/
|
|
45
|
+
deps: RscBrowserDependencies;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Optional store configuration
|
|
49
|
+
*/
|
|
50
|
+
storeOptions?: {
|
|
51
|
+
/**
|
|
52
|
+
* Maximum number of history entries to cache
|
|
53
|
+
* @default 10
|
|
54
|
+
*/
|
|
55
|
+
cacheSize?: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Enable global link interception for SPA navigation.
|
|
60
|
+
* When enabled, clicks on same-origin anchor elements are intercepted
|
|
61
|
+
* and handled via client-side navigation instead of full page loads.
|
|
62
|
+
*
|
|
63
|
+
* Links rendered with the Link component handle their own navigation
|
|
64
|
+
* regardless of this setting.
|
|
65
|
+
*
|
|
66
|
+
* Set to false to disable global interception and rely solely on
|
|
67
|
+
* Link components for SPA navigation.
|
|
68
|
+
*
|
|
69
|
+
* @default true
|
|
70
|
+
*/
|
|
71
|
+
linkInterception?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Result from initializing the browser app
|
|
76
|
+
*/
|
|
77
|
+
export interface BrowserAppContext {
|
|
78
|
+
store: NavigationStore;
|
|
79
|
+
eventController: EventController;
|
|
80
|
+
bridge: NavigationBridge;
|
|
81
|
+
initialPayload: RscPayload;
|
|
82
|
+
initialTree: React.ReactNode | Promise<React.ReactNode>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Module-level state for the initialized app
|
|
86
|
+
let browserAppContext: BrowserAppContext | null = null;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Initialize the browser app. Must be called before rendering RSCRouter.
|
|
90
|
+
*
|
|
91
|
+
* This function:
|
|
92
|
+
* - Loads the initial RSC payload from the stream
|
|
93
|
+
* - Creates the navigation store and event controller
|
|
94
|
+
* - Sets up action and navigation bridges
|
|
95
|
+
* - Configures HMR support
|
|
96
|
+
*/
|
|
97
|
+
export async function initBrowserApp(
|
|
98
|
+
options: InitBrowserAppOptions
|
|
99
|
+
): Promise<BrowserAppContext> {
|
|
100
|
+
const { rscStream, deps, storeOptions, linkInterception = true } = options;
|
|
101
|
+
|
|
102
|
+
// Load initial payload from SSR-injected __FLIGHT_DATA__
|
|
103
|
+
const initialPayload =
|
|
104
|
+
await deps.createFromReadableStream<RscPayload>(rscStream);
|
|
105
|
+
|
|
106
|
+
// Get initial segments and compute history key from current URL
|
|
107
|
+
const initialSegments = (initialPayload.metadata?.segments ??
|
|
108
|
+
[]) as ResolvedSegment[];
|
|
109
|
+
const initialHistoryKey = generateHistoryKey(window.location.href);
|
|
110
|
+
|
|
111
|
+
// Create navigation store with history-based caching
|
|
112
|
+
const store = createNavigationStore({
|
|
113
|
+
initialLocation: window.location,
|
|
114
|
+
initialSegmentIds: initialSegments.map((s) => s.id),
|
|
115
|
+
initialHistoryKey,
|
|
116
|
+
initialSegments,
|
|
117
|
+
...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Create event controller for reactive state management
|
|
121
|
+
const eventController = createEventController({
|
|
122
|
+
initialLocation: new URL(window.location.href),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Initialize segments state BEFORE hydration to avoid mismatch
|
|
126
|
+
initSegmentsSync(initialPayload.metadata?.matched, initialPayload.metadata?.pathname);
|
|
127
|
+
|
|
128
|
+
// Initialize event controller with segment order (even without handles)
|
|
129
|
+
eventController.setHandleData({}, initialPayload.metadata?.matched);
|
|
130
|
+
|
|
131
|
+
// Initialize handle data from initial payload BEFORE hydration
|
|
132
|
+
// This ensures useHandle returns correct data during hydration to avoid mismatch
|
|
133
|
+
// The handles property is an async generator that yields on each push
|
|
134
|
+
if (initialPayload.metadata?.handles) {
|
|
135
|
+
const handlesGenerator = initialPayload.metadata.handles;
|
|
136
|
+
let lastHandleData: Record<string, Record<string, unknown[]>> = {};
|
|
137
|
+
for await (const handleData of handlesGenerator) {
|
|
138
|
+
lastHandleData = handleData;
|
|
139
|
+
}
|
|
140
|
+
// Initialize both event controller AND module-level SSR state for hydration compatibility
|
|
141
|
+
eventController.setHandleData(lastHandleData, initialPayload.metadata?.matched);
|
|
142
|
+
initHandleDataSync(lastHandleData, initialPayload.metadata?.matched);
|
|
143
|
+
|
|
144
|
+
// Update the initial cache entry with the processed handleData
|
|
145
|
+
// The cache entry was created by createNavigationStore but without handleData
|
|
146
|
+
store.updateCacheHandleData(initialHistoryKey, lastHandleData);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
// Create composable utilities
|
|
151
|
+
const client = createNavigationClient(deps);
|
|
152
|
+
|
|
153
|
+
// Extract rootLayout and version from metadata for browser-side re-renders
|
|
154
|
+
const rootLayout = initialPayload.metadata?.rootLayout;
|
|
155
|
+
const version = initialPayload.metadata?.version;
|
|
156
|
+
|
|
157
|
+
// Create a bound renderSegments that includes rootLayout
|
|
158
|
+
const renderSegments = (
|
|
159
|
+
segments: ResolvedSegment[],
|
|
160
|
+
options?: RenderSegmentsOptions
|
|
161
|
+
) => baseRenderSegments(segments, { ...options, rootLayout });
|
|
162
|
+
|
|
163
|
+
// Setup server action bridge
|
|
164
|
+
const actionBridge = createServerActionBridge({
|
|
165
|
+
store,
|
|
166
|
+
eventController,
|
|
167
|
+
client,
|
|
168
|
+
deps,
|
|
169
|
+
onUpdate: (update) => store.emitUpdate(update),
|
|
170
|
+
renderSegments,
|
|
171
|
+
version,
|
|
172
|
+
});
|
|
173
|
+
actionBridge.register();
|
|
174
|
+
|
|
175
|
+
// Setup navigation bridge
|
|
176
|
+
const navigationBridge = createNavigationBridge({
|
|
177
|
+
store,
|
|
178
|
+
eventController,
|
|
179
|
+
client,
|
|
180
|
+
onUpdate: (update) => store.emitUpdate(update),
|
|
181
|
+
renderSegments,
|
|
182
|
+
version,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Optionally enable global link interception
|
|
186
|
+
if (linkInterception) {
|
|
187
|
+
navigationBridge.registerLinkInterception();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Build initial tree with rootLayout
|
|
191
|
+
const initialTree = renderSegments(initialPayload.metadata!.segments);
|
|
192
|
+
|
|
193
|
+
// Setup HMR
|
|
194
|
+
if (import.meta.hot) {
|
|
195
|
+
import.meta.hot.on("rsc:update", async () => {
|
|
196
|
+
console.log("[RSCRouter] HMR: Server update, refetching RSC");
|
|
197
|
+
|
|
198
|
+
const handle = eventController.startNavigation(window.location.href, {
|
|
199
|
+
replace: true,
|
|
200
|
+
});
|
|
201
|
+
const streamingToken = handle.startStreaming();
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const { payload, streamComplete } = await client.fetchPartial({
|
|
205
|
+
targetUrl: window.location.href,
|
|
206
|
+
segmentIds: [],
|
|
207
|
+
previousUrl: store.getSegmentState().currentUrl,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (payload.metadata?.isPartial) {
|
|
211
|
+
const segments = payload.metadata.segments || [];
|
|
212
|
+
const matched = payload.metadata.matched || [];
|
|
213
|
+
|
|
214
|
+
store.setSegmentIds(matched);
|
|
215
|
+
store.setCurrentUrl(window.location.href);
|
|
216
|
+
|
|
217
|
+
const historyKey = generateHistoryKey(window.location.href);
|
|
218
|
+
store.setHistoryKey(historyKey);
|
|
219
|
+
const currentHandleData = eventController.getHandleState().data;
|
|
220
|
+
store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
|
|
221
|
+
|
|
222
|
+
store.emitUpdate({
|
|
223
|
+
root: renderSegments(segments),
|
|
224
|
+
metadata: payload.metadata,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await streamComplete;
|
|
229
|
+
} finally {
|
|
230
|
+
streamingToken.end();
|
|
231
|
+
}
|
|
232
|
+
handle.complete(new URL(window.location.href));
|
|
233
|
+
console.log("[RSCRouter] HMR: RSC stream complete");
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Store context for RSCRouter component
|
|
238
|
+
const context: BrowserAppContext = {
|
|
239
|
+
store,
|
|
240
|
+
eventController,
|
|
241
|
+
bridge: navigationBridge,
|
|
242
|
+
initialPayload,
|
|
243
|
+
initialTree,
|
|
244
|
+
};
|
|
245
|
+
browserAppContext = context;
|
|
246
|
+
|
|
247
|
+
return context;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get the browser app context. Throws if initBrowserApp hasn't been called.
|
|
252
|
+
*/
|
|
253
|
+
export function getBrowserAppContext(): BrowserAppContext {
|
|
254
|
+
if (!browserAppContext) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
"RSCRouter: initBrowserApp() must be called before rendering RSCRouter"
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return browserAppContext;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Reset the browser app context (for testing)
|
|
264
|
+
*/
|
|
265
|
+
export function resetBrowserAppContext(): void {
|
|
266
|
+
browserAppContext = null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Props for the RSCRouter component
|
|
271
|
+
*/
|
|
272
|
+
export interface RSCRouterProps {}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* RSCRouter component - renders the RSC router with all internal wiring.
|
|
276
|
+
*
|
|
277
|
+
* Must be called after initBrowserApp() has completed.
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* ```tsx
|
|
281
|
+
* import { initBrowserApp, RSCRouter } from "rsc-router/browser";
|
|
282
|
+
* import { rscStream } from "rsc-html-stream/client";
|
|
283
|
+
* import * as rscBrowser from "@vitejs/plugin-rsc/browser";
|
|
284
|
+
*
|
|
285
|
+
* async function main() {
|
|
286
|
+
* await initBrowserApp({ rscStream, deps: rscBrowser });
|
|
287
|
+
*
|
|
288
|
+
* hydrateRoot(
|
|
289
|
+
* document,
|
|
290
|
+
* <React.StrictMode>
|
|
291
|
+
* <RSCRouter />
|
|
292
|
+
* </React.StrictMode>
|
|
293
|
+
* );
|
|
294
|
+
* }
|
|
295
|
+
* main();
|
|
296
|
+
* ```
|
|
297
|
+
*/
|
|
298
|
+
export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
|
|
299
|
+
const { store, eventController, bridge, initialPayload, initialTree } =
|
|
300
|
+
getBrowserAppContext();
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<NavigationProvider
|
|
304
|
+
store={store}
|
|
305
|
+
eventController={eventController}
|
|
306
|
+
initialPayload={{ ...initialPayload, root: initialTree }}
|
|
307
|
+
bridge={bridge}
|
|
308
|
+
/>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll Restoration Module
|
|
3
|
+
*
|
|
4
|
+
* Provides scroll position persistence across navigations, following React Router v7 patterns:
|
|
5
|
+
* - Saves scroll positions to sessionStorage keyed by unique history entry key
|
|
6
|
+
* - Restores scroll on back/forward navigation
|
|
7
|
+
* - Scrolls to top on new navigation (unless scroll: false)
|
|
8
|
+
* - Supports hash link scrolling
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Interval for polling scroll restoration during streaming (ms).
|
|
15
|
+
* If content is still loading and we can't scroll to saved position,
|
|
16
|
+
* keep trying at this interval.
|
|
17
|
+
*/
|
|
18
|
+
const SCROLL_POLL_INTERVAL_MS = 50;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Maximum time to keep polling for scroll restoration (ms).
|
|
22
|
+
* After this timeout, stop trying even if streaming continues.
|
|
23
|
+
*/
|
|
24
|
+
const SCROLL_POLL_TIMEOUT_MS = 5000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* In-memory cache of scroll positions.
|
|
28
|
+
* Synced with sessionStorage on pagehide.
|
|
29
|
+
*/
|
|
30
|
+
let savedScrollPositions: Record<string, number> = {};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether scroll restoration has been initialized
|
|
34
|
+
*/
|
|
35
|
+
let initialized = false;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Custom getKey function for determining scroll restoration key
|
|
39
|
+
*/
|
|
40
|
+
type GetScrollKeyFunction = (
|
|
41
|
+
location: { pathname: string; search: string; hash: string; key: string }
|
|
42
|
+
) => string;
|
|
43
|
+
|
|
44
|
+
let customGetKey: GetScrollKeyFunction | null = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate a unique key for the current history entry.
|
|
48
|
+
* Uses history.state.key if available, otherwise generates and stores a new one.
|
|
49
|
+
*/
|
|
50
|
+
export function getHistoryStateKey(): string {
|
|
51
|
+
const state = window.history.state;
|
|
52
|
+
if (state?.key) {
|
|
53
|
+
return state.key;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Generate a new key and store it in history.state
|
|
57
|
+
const key = Math.random().toString(36).slice(2, 10);
|
|
58
|
+
window.history.replaceState({ ...state, key }, "");
|
|
59
|
+
return key;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get the scroll restoration key for a location.
|
|
64
|
+
* Uses custom getKey function if set, otherwise uses history state key.
|
|
65
|
+
*/
|
|
66
|
+
export function getScrollKey(): string {
|
|
67
|
+
if (customGetKey) {
|
|
68
|
+
const loc = window.location;
|
|
69
|
+
return customGetKey({
|
|
70
|
+
pathname: loc.pathname,
|
|
71
|
+
search: loc.search,
|
|
72
|
+
hash: loc.hash,
|
|
73
|
+
key: getHistoryStateKey(),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return getHistoryStateKey();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Initialize scroll restoration.
|
|
81
|
+
* Sets manual scroll restoration mode and loads saved positions from sessionStorage.
|
|
82
|
+
*/
|
|
83
|
+
export function initScrollRestoration(options?: {
|
|
84
|
+
getKey?: GetScrollKeyFunction;
|
|
85
|
+
}): () => void {
|
|
86
|
+
if (initialized) {
|
|
87
|
+
console.warn("[Scroll] Already initialized");
|
|
88
|
+
return () => {};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
initialized = true;
|
|
92
|
+
customGetKey = options?.getKey ?? null;
|
|
93
|
+
|
|
94
|
+
// Set manual scroll restoration to prevent browser's default behavior
|
|
95
|
+
window.history.scrollRestoration = "manual";
|
|
96
|
+
|
|
97
|
+
// Load saved positions from sessionStorage
|
|
98
|
+
try {
|
|
99
|
+
const stored = sessionStorage.getItem(SCROLL_STORAGE_KEY);
|
|
100
|
+
if (stored) {
|
|
101
|
+
savedScrollPositions = JSON.parse(stored);
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// Ignore parse errors
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Ensure current history entry has a key
|
|
108
|
+
getHistoryStateKey();
|
|
109
|
+
|
|
110
|
+
// Save scroll positions on pagehide (before leaving/refreshing)
|
|
111
|
+
const handlePageHide = () => {
|
|
112
|
+
saveCurrentScrollPosition();
|
|
113
|
+
persistToSessionStorage();
|
|
114
|
+
// Reset to auto for browser to handle if page is restored from bfcache
|
|
115
|
+
window.history.scrollRestoration = "auto";
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
window.addEventListener("pagehide", handlePageHide);
|
|
119
|
+
|
|
120
|
+
console.log("[Scroll] Initialized, loaded positions:", Object.keys(savedScrollPositions).length);
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
window.removeEventListener("pagehide", handlePageHide);
|
|
124
|
+
window.history.scrollRestoration = "auto";
|
|
125
|
+
initialized = false;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Save the current scroll position for the current history entry
|
|
131
|
+
*/
|
|
132
|
+
export function saveCurrentScrollPosition(): void {
|
|
133
|
+
const key = getScrollKey();
|
|
134
|
+
savedScrollPositions[key] = window.scrollY;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Persist scroll positions to sessionStorage
|
|
139
|
+
*/
|
|
140
|
+
function persistToSessionStorage(): void {
|
|
141
|
+
try {
|
|
142
|
+
sessionStorage.setItem(SCROLL_STORAGE_KEY, JSON.stringify(savedScrollPositions));
|
|
143
|
+
} catch (e) {
|
|
144
|
+
console.warn("[Scroll] Failed to persist to sessionStorage:", e);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get the saved scroll position for a history key
|
|
150
|
+
*/
|
|
151
|
+
export function getSavedScrollPosition(key?: string): number | undefined {
|
|
152
|
+
const lookupKey = key ?? getScrollKey();
|
|
153
|
+
return savedScrollPositions[lookupKey];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Pending poll interval for scroll restoration during streaming
|
|
158
|
+
*/
|
|
159
|
+
let pendingPollInterval: ReturnType<typeof setInterval> | null = null;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Cancel any pending scroll restoration polling
|
|
163
|
+
*/
|
|
164
|
+
export function cancelScrollRestorationPolling(): void {
|
|
165
|
+
if (pendingPollInterval) {
|
|
166
|
+
clearInterval(pendingPollInterval);
|
|
167
|
+
pendingPollInterval = null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Restore scroll position for the current history entry.
|
|
173
|
+
* Returns true if position was fully restored, false otherwise.
|
|
174
|
+
*
|
|
175
|
+
* @param options.retryIfStreaming - If true, poll while streaming until we can scroll to target
|
|
176
|
+
* @param options.isStreaming - Function to check if streaming is in progress
|
|
177
|
+
*/
|
|
178
|
+
export function restoreScrollPosition(options?: {
|
|
179
|
+
retryIfStreaming?: boolean;
|
|
180
|
+
isStreaming?: () => boolean;
|
|
181
|
+
}): boolean {
|
|
182
|
+
// Clear any pending polling
|
|
183
|
+
cancelScrollRestorationPolling();
|
|
184
|
+
|
|
185
|
+
const key = getScrollKey();
|
|
186
|
+
const savedY = savedScrollPositions[key];
|
|
187
|
+
|
|
188
|
+
if (typeof savedY !== "number") {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Check if page is tall enough to scroll to saved position
|
|
193
|
+
const maxScrollY = document.documentElement.scrollHeight - window.innerHeight;
|
|
194
|
+
const canScrollToPosition = savedY <= maxScrollY;
|
|
195
|
+
|
|
196
|
+
if (canScrollToPosition) {
|
|
197
|
+
window.scrollTo(0, savedY);
|
|
198
|
+
console.log("[Scroll] Restored position:", savedY, "for key:", key);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Scroll as far as we can for now
|
|
203
|
+
window.scrollTo(0, maxScrollY);
|
|
204
|
+
console.log("[Scroll] Partial restore to:", maxScrollY, "target:", savedY);
|
|
205
|
+
|
|
206
|
+
// Poll while streaming until we can scroll to target position
|
|
207
|
+
if (options?.retryIfStreaming && options?.isStreaming?.()) {
|
|
208
|
+
const startTime = Date.now();
|
|
209
|
+
|
|
210
|
+
pendingPollInterval = setInterval(() => {
|
|
211
|
+
// Stop if we've exceeded the timeout
|
|
212
|
+
if (Date.now() - startTime > SCROLL_POLL_TIMEOUT_MS) {
|
|
213
|
+
console.log("[Scroll] Polling timeout, giving up");
|
|
214
|
+
cancelScrollRestorationPolling();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Stop if streaming ended
|
|
219
|
+
if (!options.isStreaming?.()) {
|
|
220
|
+
console.log("[Scroll] Streaming ended, stopping poll");
|
|
221
|
+
cancelScrollRestorationPolling();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Check if we can now scroll to the target position
|
|
226
|
+
const currentMaxScrollY = document.documentElement.scrollHeight - window.innerHeight;
|
|
227
|
+
if (savedY <= currentMaxScrollY) {
|
|
228
|
+
window.scrollTo(0, savedY);
|
|
229
|
+
console.log("[Scroll] Poll restored position:", savedY);
|
|
230
|
+
cancelScrollRestorationPolling();
|
|
231
|
+
}
|
|
232
|
+
}, SCROLL_POLL_INTERVAL_MS);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Handle hash link scrolling.
|
|
240
|
+
* Scrolls to element with matching ID if hash is present.
|
|
241
|
+
* Returns true if scrolled to element, false otherwise.
|
|
242
|
+
*/
|
|
243
|
+
export function scrollToHash(): boolean {
|
|
244
|
+
const hash = window.location.hash;
|
|
245
|
+
if (!hash) return false;
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const id = decodeURIComponent(hash.slice(1));
|
|
249
|
+
const element = document.getElementById(id);
|
|
250
|
+
if (element) {
|
|
251
|
+
element.scrollIntoView();
|
|
252
|
+
console.log("[Scroll] Scrolled to hash element:", id);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
} catch (e) {
|
|
256
|
+
console.warn("[Scroll] Failed to decode hash:", hash);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Scroll to top of page
|
|
264
|
+
*/
|
|
265
|
+
export function scrollToTop(): void {
|
|
266
|
+
window.scrollTo(0, 0);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Handle scroll for a new navigation.
|
|
271
|
+
* - Saves current position before navigating
|
|
272
|
+
* - Ensures new history entry has a key
|
|
273
|
+
*/
|
|
274
|
+
export function handleNavigationStart(): void {
|
|
275
|
+
if (!initialized) return;
|
|
276
|
+
saveCurrentScrollPosition();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Handle scroll after navigation completes.
|
|
281
|
+
* @param options.restore - If true, restore saved position (for popstate)
|
|
282
|
+
* @param options.scroll - If false, don't scroll at all
|
|
283
|
+
* @param options.isStreaming - Function to check if streaming is in progress (for retry logic)
|
|
284
|
+
*/
|
|
285
|
+
export function handleNavigationEnd(options: {
|
|
286
|
+
restore?: boolean;
|
|
287
|
+
scroll?: boolean;
|
|
288
|
+
isStreaming?: () => boolean;
|
|
289
|
+
}): void {
|
|
290
|
+
if (!initialized) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const { restore = false, scroll = true, isStreaming } = options;
|
|
295
|
+
|
|
296
|
+
// Don't scroll if explicitly disabled
|
|
297
|
+
if (scroll === false) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// For back/forward (restore), try to restore saved position
|
|
302
|
+
if (restore) {
|
|
303
|
+
if (restoreScrollPosition({ retryIfStreaming: true, isStreaming })) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// Fall through to hash or top if no saved position
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Try hash scrolling first
|
|
310
|
+
if (scrollToHash()) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Default: scroll to top
|
|
315
|
+
scrollToTop();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Update the history state key after pushState/replaceState.
|
|
320
|
+
* Call this after changing history to ensure new entry has a key.
|
|
321
|
+
*/
|
|
322
|
+
export function ensureHistoryKey(): void {
|
|
323
|
+
getHistoryStateKey();
|
|
324
|
+
}
|