@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,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
+ }