@rangojs/router 0.0.0-experimental.8 → 0.0.0-experimental.81
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/AGENTS.md +9 -0
- package/README.md +942 -4
- package/dist/bin/rango.js +1689 -0
- package/dist/vite/index.js +5091 -941
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +61 -52
- package/skills/breadcrumbs/SKILL.md +250 -0
- package/skills/cache-guide/SKILL.md +294 -0
- package/skills/caching/SKILL.md +93 -23
- package/skills/composability/SKILL.md +172 -0
- package/skills/debug-manifest/SKILL.md +12 -8
- package/skills/document-cache/SKILL.md +18 -16
- package/skills/fonts/SKILL.md +167 -0
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/hooks/SKILL.md +340 -72
- package/skills/host-router/SKILL.md +218 -0
- package/skills/intercept/SKILL.md +151 -8
- package/skills/layout/SKILL.md +122 -3
- package/skills/links/SKILL.md +92 -31
- package/skills/loader/SKILL.md +404 -44
- package/skills/middleware/SKILL.md +205 -37
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +765 -0
- package/skills/mime-routes/SKILL.md +128 -0
- package/skills/parallel/SKILL.md +263 -1
- package/skills/prerender/SKILL.md +685 -0
- package/skills/rango/SKILL.md +87 -16
- package/skills/response-routes/SKILL.md +411 -0
- package/skills/route/SKILL.md +281 -14
- package/skills/router-setup/SKILL.md +210 -32
- package/skills/tailwind/SKILL.md +129 -0
- package/skills/theme/SKILL.md +9 -8
- package/skills/typesafety/SKILL.md +328 -89
- package/skills/use-cache/SKILL.md +324 -0
- package/src/__internal.ts +102 -4
- package/src/bin/rango.ts +321 -0
- package/src/browser/action-coordinator.ts +97 -0
- package/src/browser/action-response-classifier.ts +99 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +92 -64
- package/src/browser/history-state.ts +80 -0
- package/src/browser/intercept-utils.ts +52 -0
- package/src/browser/link-interceptor.ts +24 -4
- package/src/browser/logging.ts +55 -0
- package/src/browser/merge-segment-loaders.ts +20 -12
- package/src/browser/navigation-bridge.ts +317 -560
- package/src/browser/navigation-client.ts +206 -68
- package/src/browser/navigation-store.ts +73 -55
- package/src/browser/navigation-transaction.ts +297 -0
- package/src/browser/network-error-handler.ts +61 -0
- package/src/browser/partial-update.ts +343 -316
- package/src/browser/prefetch/cache.ts +216 -0
- package/src/browser/prefetch/fetch.ts +206 -0
- package/src/browser/prefetch/observer.ts +65 -0
- package/src/browser/prefetch/policy.ts +48 -0
- package/src/browser/prefetch/queue.ts +160 -0
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/rango-state.ts +112 -0
- package/src/browser/react/Link.tsx +253 -74
- package/src/browser/react/NavigationProvider.tsx +91 -11
- package/src/browser/react/context.ts +11 -0
- package/src/browser/react/filter-segment-order.ts +11 -0
- package/src/browser/react/index.ts +12 -12
- package/src/browser/react/location-state-shared.ts +95 -53
- package/src/browser/react/location-state.ts +60 -15
- package/src/browser/react/mount-context.ts +6 -1
- package/src/browser/react/nonce-context.ts +23 -0
- package/src/browser/react/shallow-equal.ts +27 -0
- package/src/browser/react/use-action.ts +29 -51
- package/src/browser/react/use-client-cache.ts +5 -3
- package/src/browser/react/use-handle.ts +30 -126
- package/src/browser/react/use-href.tsx +2 -2
- package/src/browser/react/use-link-status.ts +6 -5
- package/src/browser/react/use-navigation.ts +44 -65
- package/src/browser/react/use-params.ts +75 -0
- package/src/browser/react/use-pathname.ts +47 -0
- package/src/browser/react/use-router.ts +76 -0
- package/src/browser/react/use-search-params.ts +56 -0
- package/src/browser/react/use-segments.ts +80 -97
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +214 -58
- package/src/browser/scroll-restoration.ts +127 -52
- package/src/browser/segment-reconciler.ts +243 -0
- package/src/browser/segment-structure-assert.ts +16 -0
- package/src/browser/server-action-bridge.ts +510 -603
- package/src/browser/shallow.ts +6 -1
- package/src/browser/types.ts +141 -48
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +235 -24
- package/src/build/generate-route-types.ts +39 -0
- package/src/build/index.ts +13 -0
- package/src/build/route-trie.ts +291 -0
- package/src/build/route-types/ast-helpers.ts +25 -0
- package/src/build/route-types/ast-route-extraction.ts +98 -0
- package/src/build/route-types/codegen.ts +102 -0
- package/src/build/route-types/include-resolution.ts +418 -0
- package/src/build/route-types/param-extraction.ts +48 -0
- package/src/build/route-types/per-module-writer.ts +128 -0
- package/src/build/route-types/router-processing.ts +618 -0
- package/src/build/route-types/scan-filter.ts +85 -0
- package/src/build/runtime-discovery.ts +231 -0
- package/src/cache/background-task.ts +34 -0
- package/src/cache/cache-key-utils.ts +44 -0
- package/src/cache/cache-policy.ts +125 -0
- package/src/cache/cache-runtime.ts +342 -0
- package/src/cache/cache-scope.ts +167 -309
- package/src/cache/cf/cf-cache-store.ts +571 -17
- package/src/cache/cf/index.ts +13 -3
- package/src/cache/document-cache.ts +116 -77
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/handle-snapshot.ts +41 -0
- package/src/cache/index.ts +1 -15
- package/src/cache/memory-segment-store.ts +191 -13
- package/src/cache/profile-registry.ts +73 -0
- package/src/cache/read-through-swr.ts +134 -0
- package/src/cache/segment-codec.ts +256 -0
- package/src/cache/taint.ts +153 -0
- package/src/cache/types.ts +72 -122
- package/src/client.rsc.tsx +3 -1
- package/src/client.tsx +135 -301
- package/src/component-utils.ts +4 -4
- package/src/components/DefaultDocument.tsx +5 -1
- package/src/context-var.ts +156 -0
- package/src/debug.ts +19 -9
- package/src/errors.ts +108 -2
- package/src/handle.ts +55 -29
- package/src/handles/MetaTags.tsx +73 -20
- package/src/handles/breadcrumbs.ts +66 -0
- package/src/handles/index.ts +1 -0
- package/src/handles/meta.ts +30 -13
- package/src/host/cookie-handler.ts +21 -15
- package/src/host/errors.ts +8 -8
- package/src/host/index.ts +4 -7
- package/src/host/pattern-matcher.ts +27 -27
- package/src/host/router.ts +61 -39
- package/src/host/testing.ts +8 -8
- package/src/host/types.ts +15 -7
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +119 -29
- package/src/index.rsc.ts +155 -19
- package/src/index.ts +251 -30
- package/src/internal-debug.ts +11 -0
- package/src/loader.rsc.ts +26 -157
- package/src/loader.ts +27 -10
- package/src/network-error-thrower.tsx +3 -1
- package/src/outlet-provider.tsx +45 -0
- package/src/prerender/param-hash.ts +37 -0
- package/src/prerender/store.ts +186 -0
- package/src/prerender.ts +524 -0
- package/src/reverse.ts +354 -0
- package/src/root-error-boundary.tsx +41 -29
- package/src/route-content-wrapper.tsx +7 -4
- package/src/route-definition/dsl-helpers.ts +1121 -0
- package/src/route-definition/helper-factories.ts +200 -0
- package/src/route-definition/helpers-types.ts +478 -0
- package/src/route-definition/index.ts +55 -0
- package/src/route-definition/redirect.ts +101 -0
- package/src/route-definition/resolve-handler-use.ts +149 -0
- package/src/route-definition.ts +1 -1428
- package/src/route-map-builder.ts +217 -123
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +77 -8
- package/src/router/content-negotiation.ts +215 -0
- package/src/router/debug-manifest.ts +72 -0
- package/src/router/error-handling.ts +9 -9
- package/src/router/find-match.ts +160 -0
- package/src/router/handler-context.ts +438 -86
- package/src/router/intercept-resolution.ts +402 -0
- package/src/router/lazy-includes.ts +237 -0
- package/src/router/loader-resolution.ts +356 -128
- package/src/router/logging.ts +251 -0
- package/src/router/manifest.ts +163 -35
- package/src/router/match-api.ts +555 -0
- package/src/router/match-context.ts +5 -3
- package/src/router/match-handlers.ts +440 -0
- package/src/router/match-middleware/background-revalidation.ts +108 -93
- package/src/router/match-middleware/cache-lookup.ts +460 -10
- package/src/router/match-middleware/cache-store.ts +98 -26
- package/src/router/match-middleware/intercept-resolution.ts +57 -17
- package/src/router/match-middleware/segment-resolution.ts +80 -6
- package/src/router/match-pipelines.ts +10 -45
- package/src/router/match-result.ts +135 -35
- package/src/router/metrics.ts +240 -15
- package/src/router/middleware-cookies.ts +55 -0
- package/src/router/middleware-types.ts +220 -0
- package/src/router/middleware.ts +324 -369
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/pattern-matching.ts +211 -43
- package/src/router/prerender-match.ts +502 -0
- package/src/router/preview-match.ts +98 -0
- package/src/router/request-classification.ts +310 -0
- package/src/router/revalidation.ts +137 -38
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-context.ts +41 -21
- package/src/router/router-interfaces.ts +484 -0
- package/src/router/router-options.ts +618 -0
- package/src/router/router-registry.ts +24 -0
- package/src/router/segment-resolution/fresh.ts +748 -0
- package/src/router/segment-resolution/helpers.ts +268 -0
- package/src/router/segment-resolution/loader-cache.ts +199 -0
- package/src/router/segment-resolution/revalidation.ts +1379 -0
- package/src/router/segment-resolution/static-store.ts +67 -0
- package/src/router/segment-resolution.ts +21 -0
- package/src/router/segment-wrappers.ts +291 -0
- package/src/router/telemetry-otel.ts +299 -0
- package/src/router/telemetry.ts +300 -0
- package/src/router/timeout.ts +148 -0
- package/src/router/trie-matching.ts +239 -0
- package/src/router/types.ts +78 -3
- package/src/router.ts +740 -4252
- package/src/rsc/handler-context.ts +45 -0
- package/src/rsc/handler.ts +907 -797
- package/src/rsc/helpers.ts +140 -6
- package/src/rsc/index.ts +0 -20
- package/src/rsc/loader-fetch.ts +229 -0
- package/src/rsc/manifest-init.ts +90 -0
- package/src/rsc/nonce.ts +14 -0
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +393 -0
- package/src/rsc/response-error.ts +37 -0
- package/src/rsc/response-route-handler.ts +347 -0
- package/src/rsc/rsc-rendering.ts +246 -0
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +358 -0
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/rsc/types.ts +46 -11
- package/src/search-params.ts +230 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +134 -36
- package/src/server/context.ts +341 -61
- package/src/server/cookie-store.ts +190 -0
- package/src/server/fetchable-loader-store.ts +37 -0
- package/src/server/handle-store.ts +113 -15
- package/src/server/loader-registry.ts +24 -64
- package/src/server/request-context.ts +607 -81
- package/src/server.ts +35 -130
- package/src/ssr/index.tsx +103 -30
- package/src/static-handler.ts +126 -0
- package/src/theme/ThemeProvider.tsx +21 -15
- package/src/theme/ThemeScript.tsx +5 -5
- package/src/theme/constants.ts +5 -2
- package/src/theme/index.ts +4 -14
- package/src/theme/theme-context.ts +4 -30
- package/src/theme/theme-script.ts +21 -18
- package/src/types/boundaries.ts +158 -0
- package/src/types/cache-types.ts +198 -0
- package/src/types/error-types.ts +192 -0
- package/src/types/global-namespace.ts +100 -0
- package/src/types/handler-context.ts +791 -0
- package/src/types/index.ts +88 -0
- package/src/types/loader-types.ts +210 -0
- package/src/types/route-config.ts +170 -0
- package/src/types/route-entry.ts +120 -0
- package/src/types/segments.ts +150 -0
- package/src/types.ts +1 -1623
- package/src/urls/include-helper.ts +207 -0
- package/src/urls/index.ts +53 -0
- package/src/urls/path-helper-types.ts +372 -0
- package/src/urls/path-helper.ts +364 -0
- package/src/urls/pattern-types.ts +107 -0
- package/src/urls/response-types.ts +116 -0
- package/src/urls/type-extraction.ts +372 -0
- package/src/urls/urls-function.ts +98 -0
- package/src/urls.ts +1 -802
- package/src/use-loader.tsx +161 -81
- package/src/vite/discovery/bundle-postprocess.ts +181 -0
- package/src/vite/discovery/discover-routers.ts +348 -0
- package/src/vite/discovery/prerender-collection.ts +439 -0
- package/src/vite/discovery/route-types-writer.ts +258 -0
- package/src/vite/discovery/self-gen-tracking.ts +47 -0
- package/src/vite/discovery/state.ts +117 -0
- package/src/vite/discovery/virtual-module-codegen.ts +203 -0
- package/src/vite/index.ts +15 -1133
- package/src/vite/plugin-types.ts +103 -0
- package/src/vite/plugins/cjs-to-esm.ts +93 -0
- package/src/vite/plugins/client-ref-dedup.ts +115 -0
- package/src/vite/plugins/client-ref-hashing.ts +105 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/{expose-action-id.ts → plugins/expose-action-id.ts} +72 -53
- package/src/vite/plugins/expose-id-utils.ts +299 -0
- package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +209 -0
- package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
- package/src/vite/plugins/expose-ids/router-transform.ts +110 -0
- package/src/vite/plugins/expose-ids/types.ts +45 -0
- package/src/vite/plugins/expose-internal-ids.ts +786 -0
- package/src/vite/plugins/performance-tracks.ts +88 -0
- package/src/vite/plugins/refresh-cmd.ts +127 -0
- package/src/vite/plugins/use-cache-transform.ts +323 -0
- package/src/vite/plugins/version-injector.ts +83 -0
- package/src/vite/plugins/version-plugin.ts +266 -0
- package/src/vite/{virtual-entries.ts → plugins/virtual-entries.ts} +23 -14
- package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
- package/src/vite/rango.ts +462 -0
- package/src/vite/router-discovery.ts +977 -0
- package/src/vite/utils/ast-handler-extract.ts +517 -0
- package/src/vite/utils/banner.ts +36 -0
- package/src/vite/utils/bundle-analysis.ts +137 -0
- package/src/vite/utils/manifest-utils.ts +70 -0
- package/src/vite/{package-resolution.ts → utils/package-resolution.ts} +25 -29
- package/src/vite/utils/prerender-utils.ts +221 -0
- package/src/vite/utils/shared-utils.ts +170 -0
- package/CLAUDE.md +0 -43
- package/src/browser/lru-cache.ts +0 -69
- package/src/browser/request-controller.ts +0 -164
- package/src/cache/memory-store.ts +0 -253
- package/src/href-context.ts +0 -33
- package/src/href.ts +0 -255
- package/src/server/route-manifest-cache.ts +0 -173
- package/src/vite/expose-handle-id.ts +0 -209
- package/src/vite/expose-loader-id.ts +0 -426
- package/src/vite/expose-location-state-id.ts +0 -177
- /package/src/vite/{version.d.ts → plugins/version.d.ts} +0 -0
|
@@ -11,8 +11,7 @@ import { createEventController } from "./event-controller.js";
|
|
|
11
11
|
import { createNavigationClient } from "./navigation-client.js";
|
|
12
12
|
import { createServerActionBridge } from "./server-action-bridge.js";
|
|
13
13
|
import { createNavigationBridge } from "./navigation-bridge.js";
|
|
14
|
-
import { NavigationProvider
|
|
15
|
-
import { initThemeConfigSync } from "../theme/theme-context.js";
|
|
14
|
+
import { NavigationProvider } from "./react/index.js";
|
|
16
15
|
import type {
|
|
17
16
|
RscPayload,
|
|
18
17
|
RscBrowserDependencies,
|
|
@@ -22,6 +21,13 @@ import type {
|
|
|
22
21
|
} from "./types.js";
|
|
23
22
|
import type { EventController } from "./event-controller.js";
|
|
24
23
|
import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
|
|
24
|
+
import { initRangoState } from "./rango-state.js";
|
|
25
|
+
import { initPrefetchCache } from "./prefetch/cache.js";
|
|
26
|
+
import { setAppVersion } from "./app-version.js";
|
|
27
|
+
import {
|
|
28
|
+
isInterceptSegment,
|
|
29
|
+
splitInterceptSegments,
|
|
30
|
+
} from "./intercept-utils.js";
|
|
25
31
|
|
|
26
32
|
// Vite HMR types are provided by vite/client
|
|
27
33
|
|
|
@@ -106,6 +112,8 @@ export interface BrowserAppContext {
|
|
|
106
112
|
initialTheme?: Theme;
|
|
107
113
|
/** Whether connection warmup is enabled */
|
|
108
114
|
warmupEnabled?: boolean;
|
|
115
|
+
/** App version for prefetch version mismatch detection */
|
|
116
|
+
version?: string;
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
// Module-level state for the initialized app
|
|
@@ -121,18 +129,26 @@ let browserAppContext: BrowserAppContext | null = null;
|
|
|
121
129
|
* - Configures HMR support
|
|
122
130
|
*/
|
|
123
131
|
export async function initBrowserApp(
|
|
124
|
-
options: InitBrowserAppOptions
|
|
132
|
+
options: InitBrowserAppOptions,
|
|
125
133
|
): Promise<BrowserAppContext> {
|
|
126
|
-
const {
|
|
134
|
+
const {
|
|
135
|
+
rscStream,
|
|
136
|
+
deps,
|
|
137
|
+
storeOptions,
|
|
138
|
+
linkInterception = true,
|
|
139
|
+
themeConfig,
|
|
140
|
+
initialTheme,
|
|
141
|
+
} = options;
|
|
127
142
|
|
|
128
|
-
// Load initial payload from SSR-injected __FLIGHT_DATA__
|
|
129
143
|
const initialPayload =
|
|
130
144
|
await deps.createFromReadableStream<RscPayload>(rscStream);
|
|
131
145
|
|
|
132
146
|
// Extract themeConfig and initialTheme from payload if not explicitly provided
|
|
133
147
|
// This allows virtual entries to work without importing the router
|
|
134
|
-
const effectiveThemeConfig =
|
|
135
|
-
|
|
148
|
+
const effectiveThemeConfig =
|
|
149
|
+
themeConfig ?? initialPayload.metadata?.themeConfig ?? null;
|
|
150
|
+
const effectiveInitialTheme =
|
|
151
|
+
initialTheme ?? initialPayload.metadata?.initialTheme;
|
|
136
152
|
|
|
137
153
|
// Get initial segments and compute history key from current URL
|
|
138
154
|
const initialSegments = (initialPayload.metadata?.segments ??
|
|
@@ -148,20 +164,23 @@ export async function initBrowserApp(
|
|
|
148
164
|
...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
|
|
149
165
|
});
|
|
150
166
|
|
|
167
|
+
// Seed router identity from the initial SSR payload so the first
|
|
168
|
+
// cross-app SPA navigation can detect the app switch.
|
|
169
|
+
if (initialPayload.metadata?.routerId) {
|
|
170
|
+
store.setRouterId?.(initialPayload.metadata.routerId);
|
|
171
|
+
}
|
|
172
|
+
|
|
151
173
|
// Create event controller for reactive state management
|
|
152
174
|
const eventController = createEventController({
|
|
153
175
|
initialLocation: new URL(window.location.href),
|
|
154
176
|
});
|
|
155
177
|
|
|
156
|
-
// Initialize segments state BEFORE hydration to avoid mismatch
|
|
157
|
-
initSegmentsSync(initialPayload.metadata?.matched, initialPayload.metadata?.pathname);
|
|
158
|
-
|
|
159
|
-
// Initialize theme config for MetaTags (must match SSR state)
|
|
160
|
-
initThemeConfigSync(effectiveThemeConfig);
|
|
161
|
-
|
|
162
178
|
// Initialize event controller with segment order (even without handles)
|
|
163
179
|
eventController.setHandleData({}, initialPayload.metadata?.matched);
|
|
164
180
|
|
|
181
|
+
// Initialize route params
|
|
182
|
+
eventController.setParams(initialPayload.metadata?.params ?? {});
|
|
183
|
+
|
|
165
184
|
// Initialize handle data from initial payload BEFORE hydration
|
|
166
185
|
// This ensures useHandle returns correct data during hydration to avoid mismatch
|
|
167
186
|
// The handles property is an async generator that yields on each push
|
|
@@ -171,16 +190,17 @@ export async function initBrowserApp(
|
|
|
171
190
|
for await (const handleData of handlesGenerator) {
|
|
172
191
|
lastHandleData = handleData;
|
|
173
192
|
}
|
|
174
|
-
// Initialize
|
|
175
|
-
eventController.setHandleData(
|
|
176
|
-
|
|
193
|
+
// Initialize event controller with initial handle state before hydration.
|
|
194
|
+
eventController.setHandleData(
|
|
195
|
+
lastHandleData,
|
|
196
|
+
initialPayload.metadata?.matched,
|
|
197
|
+
);
|
|
177
198
|
|
|
178
199
|
// Update the initial cache entry with the processed handleData
|
|
179
200
|
// The cache entry was created by createNavigationStore but without handleData
|
|
180
201
|
store.updateCacheHandleData(initialHistoryKey, lastHandleData);
|
|
181
202
|
}
|
|
182
203
|
|
|
183
|
-
|
|
184
204
|
// Create composable utilities
|
|
185
205
|
const client = createNavigationClient(deps);
|
|
186
206
|
|
|
@@ -188,12 +208,28 @@ export async function initBrowserApp(
|
|
|
188
208
|
const rootLayout = initialPayload.metadata?.rootLayout;
|
|
189
209
|
const version = initialPayload.metadata?.version;
|
|
190
210
|
|
|
211
|
+
// Initialize the localStorage state key for cache invalidation.
|
|
212
|
+
// Uses the build version so a new deploy automatically busts all cached prefetches.
|
|
213
|
+
initRangoState(version ?? "0");
|
|
214
|
+
setAppVersion(version);
|
|
215
|
+
|
|
216
|
+
// Initialize the in-memory prefetch cache TTL from server config.
|
|
217
|
+
// A value of 0 disables the cache; undefined falls back to the module default.
|
|
218
|
+
const prefetchCacheTTL = initialPayload.metadata?.prefetchCacheTTL;
|
|
219
|
+
if (prefetchCacheTTL !== undefined) {
|
|
220
|
+
initPrefetchCache(prefetchCacheTTL);
|
|
221
|
+
}
|
|
222
|
+
|
|
191
223
|
// Create a bound renderSegments that includes rootLayout
|
|
192
224
|
const renderSegments = (
|
|
193
225
|
segments: ResolvedSegment[],
|
|
194
|
-
options?: RenderSegmentsOptions
|
|
226
|
+
options?: RenderSegmentsOptions,
|
|
195
227
|
) => baseRenderSegments(segments, { ...options, rootLayout });
|
|
196
228
|
|
|
229
|
+
// Lazy reference for navigation bridge — the action bridge is created first
|
|
230
|
+
// but may need to trigger SPA navigation for action redirects.
|
|
231
|
+
let navigateFn: ((url: string, options?: any) => Promise<void>) | null = null;
|
|
232
|
+
|
|
197
233
|
// Setup server action bridge
|
|
198
234
|
const actionBridge = createServerActionBridge({
|
|
199
235
|
store,
|
|
@@ -202,7 +238,13 @@ export async function initBrowserApp(
|
|
|
202
238
|
deps,
|
|
203
239
|
onUpdate: (update) => store.emitUpdate(update),
|
|
204
240
|
renderSegments,
|
|
205
|
-
|
|
241
|
+
onNavigate: (url, options) => {
|
|
242
|
+
if (!navigateFn) {
|
|
243
|
+
window.location.href = url;
|
|
244
|
+
return Promise.resolve();
|
|
245
|
+
}
|
|
246
|
+
return navigateFn(url, options);
|
|
247
|
+
},
|
|
206
248
|
});
|
|
207
249
|
actionBridge.register();
|
|
208
250
|
|
|
@@ -213,9 +255,12 @@ export async function initBrowserApp(
|
|
|
213
255
|
client,
|
|
214
256
|
onUpdate: (update) => store.emitUpdate(update),
|
|
215
257
|
renderSegments,
|
|
216
|
-
version,
|
|
258
|
+
version: version,
|
|
217
259
|
});
|
|
218
260
|
|
|
261
|
+
// Connect action redirect → navigation bridge (now that both are initialized)
|
|
262
|
+
navigateFn = (url, options) => navigationBridge.navigate(url, options);
|
|
263
|
+
|
|
219
264
|
// Optionally enable global link interception
|
|
220
265
|
if (linkInterception) {
|
|
221
266
|
navigationBridge.registerLinkInterception();
|
|
@@ -224,47 +269,139 @@ export async function initBrowserApp(
|
|
|
224
269
|
// Build initial tree with rootLayout
|
|
225
270
|
const initialTree = renderSegments(initialPayload.metadata!.segments);
|
|
226
271
|
|
|
227
|
-
// Setup HMR
|
|
272
|
+
// Setup HMR with debounce — burst saves (format-on-save, rapid edits)
|
|
273
|
+
// fire many rsc:update events in quick succession. Without debouncing,
|
|
274
|
+
// each event triggers a fetchPartial() which on slow routes can pile up
|
|
275
|
+
// and overwhelm the worker (cross-request promise issues, 500s).
|
|
228
276
|
if (import.meta.hot) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const handle = eventController.startNavigation(window.location.href, {
|
|
233
|
-
replace: true,
|
|
234
|
-
});
|
|
235
|
-
const streamingToken = handle.startStreaming();
|
|
236
|
-
|
|
237
|
-
try {
|
|
238
|
-
const { payload, streamComplete } = await client.fetchPartial({
|
|
239
|
-
targetUrl: window.location.href,
|
|
240
|
-
segmentIds: [],
|
|
241
|
-
previousUrl: store.getSegmentState().currentUrl,
|
|
242
|
-
});
|
|
277
|
+
let hmrTimer: ReturnType<typeof setTimeout> | null = null;
|
|
278
|
+
let hmrAbort: AbortController | null = null;
|
|
243
279
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
280
|
+
import.meta.hot.on("rsc:update", () => {
|
|
281
|
+
// Cancel any pending debounce timer
|
|
282
|
+
if (hmrTimer !== null) {
|
|
283
|
+
clearTimeout(hmrTimer);
|
|
284
|
+
}
|
|
247
285
|
|
|
248
|
-
|
|
249
|
-
|
|
286
|
+
// Abort any in-flight HMR fetch so it doesn't race with the next one
|
|
287
|
+
if (hmrAbort) {
|
|
288
|
+
hmrAbort.abort();
|
|
289
|
+
hmrAbort = null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Debounce: wait 200ms of quiet before fetching
|
|
293
|
+
hmrTimer = setTimeout(async () => {
|
|
294
|
+
hmrTimer = null;
|
|
295
|
+
|
|
296
|
+
// Don't interrupt an active user navigation — startNavigation()
|
|
297
|
+
// would abort it and refetch the old URL (window.location.href
|
|
298
|
+
// hasn't updated yet). The user's navigation will pick up the
|
|
299
|
+
// new server code when it completes. isNavigating covers the
|
|
300
|
+
// full lifecycle (fetching + streaming, before commit) without
|
|
301
|
+
// blocking on server actions.
|
|
302
|
+
if (eventController.getState().isNavigating) {
|
|
303
|
+
console.log("[RSCRouter] HMR: Skipping — navigation in progress");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
250
306
|
|
|
251
|
-
|
|
252
|
-
store.setHistoryKey(historyKey);
|
|
253
|
-
const currentHandleData = eventController.getHandleState().data;
|
|
254
|
-
store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
|
|
307
|
+
console.log("[RSCRouter] HMR: Server update, refetching RSC");
|
|
255
308
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
309
|
+
const abort = new AbortController();
|
|
310
|
+
hmrAbort = abort;
|
|
311
|
+
|
|
312
|
+
const handle = eventController.startNavigation(window.location.href, {
|
|
313
|
+
replace: true,
|
|
314
|
+
});
|
|
315
|
+
const streamingToken = handle.startStreaming();
|
|
316
|
+
|
|
317
|
+
const interceptSourceUrl = store.getInterceptSourceUrl();
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const { payload, streamComplete } = await client.fetchPartial({
|
|
321
|
+
targetUrl: window.location.href,
|
|
322
|
+
segmentIds: [],
|
|
323
|
+
previousUrl: store.getSegmentState().currentUrl,
|
|
324
|
+
interceptSourceUrl: interceptSourceUrl || undefined,
|
|
325
|
+
routerId: store.getRouterId?.(),
|
|
326
|
+
hmr: true,
|
|
327
|
+
signal: abort.signal,
|
|
259
328
|
});
|
|
260
|
-
}
|
|
261
329
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
330
|
+
if (abort.signal.aborted) return;
|
|
331
|
+
|
|
332
|
+
// If the server returned a non-RSC response (404, 500 without
|
|
333
|
+
// error boundary), the payload won't have valid metadata.
|
|
334
|
+
// Reload to recover rather than leaving the page stale.
|
|
335
|
+
if (!payload.metadata) {
|
|
336
|
+
throw new Error("HMR refetch returned invalid payload");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Update version BEFORE rebuilding state so that
|
|
340
|
+
// clearHistoryCache() runs first, then the fresh segment
|
|
341
|
+
// cache entry we create below survives.
|
|
342
|
+
const newVersion = payload.metadata.version;
|
|
343
|
+
if (newVersion && newVersion !== version) {
|
|
344
|
+
console.log(
|
|
345
|
+
"[RSCRouter] HMR: version changed",
|
|
346
|
+
version,
|
|
347
|
+
"→",
|
|
348
|
+
newVersion,
|
|
349
|
+
"clearing caches",
|
|
350
|
+
);
|
|
351
|
+
navigationBridge.updateVersion(newVersion);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (payload.metadata?.isPartial) {
|
|
355
|
+
const segments = payload.metadata.segments || [];
|
|
356
|
+
const matched = payload.metadata.matched || [];
|
|
357
|
+
|
|
358
|
+
// Derive intercept state from the returned payload, not the
|
|
359
|
+
// pre-fetch store snapshot. If the HMR edit removed intercept
|
|
360
|
+
// behavior, the response won't contain intercept segments.
|
|
361
|
+
const responseIsIntercept = segments.some(isInterceptSegment);
|
|
362
|
+
|
|
363
|
+
// Sync store intercept state with what the server returned
|
|
364
|
+
if (!responseIsIntercept && interceptSourceUrl) {
|
|
365
|
+
store.setInterceptSourceUrl(null);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
store.setSegmentIds(matched);
|
|
369
|
+
store.setCurrentUrl(window.location.href);
|
|
370
|
+
|
|
371
|
+
const historyKey = generateHistoryKey(window.location.href, {
|
|
372
|
+
intercept: responseIsIntercept,
|
|
373
|
+
});
|
|
374
|
+
store.setHistoryKey(historyKey);
|
|
375
|
+
const currentHandleData = eventController.getHandleState().data;
|
|
376
|
+
store.cacheSegmentsForHistory(
|
|
377
|
+
historyKey,
|
|
378
|
+
segments,
|
|
379
|
+
currentHandleData,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const { main, intercept } = splitInterceptSegments(segments);
|
|
383
|
+
store.emitUpdate({
|
|
384
|
+
root: renderSegments(main, {
|
|
385
|
+
interceptSegments: intercept.length > 0 ? intercept : undefined,
|
|
386
|
+
}),
|
|
387
|
+
metadata: payload.metadata,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
await streamComplete;
|
|
392
|
+
handle.complete(new URL(window.location.href));
|
|
393
|
+
console.log("[RSCRouter] HMR: RSC stream complete");
|
|
394
|
+
} catch (err) {
|
|
395
|
+
if (abort.signal.aborted) return;
|
|
396
|
+
console.warn("[RSCRouter] HMR: Refetch failed, reloading page", err);
|
|
397
|
+
window.location.reload();
|
|
398
|
+
return;
|
|
399
|
+
} finally {
|
|
400
|
+
if (hmrAbort === abort) hmrAbort = null;
|
|
401
|
+
streamingToken.end();
|
|
402
|
+
handle[Symbol.dispose]();
|
|
403
|
+
}
|
|
404
|
+
}, 200);
|
|
268
405
|
});
|
|
269
406
|
}
|
|
270
407
|
|
|
@@ -278,6 +415,7 @@ export async function initBrowserApp(
|
|
|
278
415
|
themeConfig: effectiveThemeConfig,
|
|
279
416
|
initialTheme: effectiveInitialTheme,
|
|
280
417
|
warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
|
|
418
|
+
version,
|
|
281
419
|
};
|
|
282
420
|
browserAppContext = context;
|
|
283
421
|
|
|
@@ -290,7 +428,7 @@ export async function initBrowserApp(
|
|
|
290
428
|
export function getBrowserAppContext(): BrowserAppContext {
|
|
291
429
|
if (!browserAppContext) {
|
|
292
430
|
throw new Error(
|
|
293
|
-
"RSCRouter: initBrowserApp() must be called before rendering RSCRouter"
|
|
431
|
+
"RSCRouter: initBrowserApp() must be called before rendering RSCRouter",
|
|
294
432
|
);
|
|
295
433
|
}
|
|
296
434
|
return browserAppContext;
|
|
@@ -333,18 +471,36 @@ export interface RSCRouterProps {}
|
|
|
333
471
|
* ```
|
|
334
472
|
*/
|
|
335
473
|
export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
|
|
336
|
-
const {
|
|
337
|
-
|
|
474
|
+
const {
|
|
475
|
+
store,
|
|
476
|
+
eventController,
|
|
477
|
+
bridge,
|
|
478
|
+
initialPayload,
|
|
479
|
+
initialTree,
|
|
480
|
+
themeConfig,
|
|
481
|
+
initialTheme,
|
|
482
|
+
warmupEnabled,
|
|
483
|
+
version,
|
|
484
|
+
} = getBrowserAppContext();
|
|
485
|
+
|
|
486
|
+
// Signal that the React tree has hydrated. useEffect only fires after
|
|
487
|
+
// hydration completes, so this attribute is a stable readiness marker
|
|
488
|
+
// that does not depend on React internals like __reactFiber.
|
|
489
|
+
React.useEffect(() => {
|
|
490
|
+
document.documentElement.dataset.hydrated = "";
|
|
491
|
+
}, []);
|
|
338
492
|
|
|
339
493
|
return (
|
|
340
494
|
<NavigationProvider
|
|
341
495
|
store={store}
|
|
342
496
|
eventController={eventController}
|
|
343
|
-
initialPayload={{
|
|
497
|
+
initialPayload={{ root: initialTree, metadata: initialPayload.metadata! }}
|
|
344
498
|
bridge={bridge}
|
|
345
499
|
themeConfig={themeConfig}
|
|
346
500
|
initialTheme={initialTheme}
|
|
347
501
|
warmupEnabled={warmupEnabled}
|
|
502
|
+
version={version}
|
|
503
|
+
basename={initialPayload.metadata?.basename}
|
|
348
504
|
/>
|
|
349
505
|
);
|
|
350
506
|
}
|
|
@@ -8,8 +8,27 @@
|
|
|
8
8
|
* - Supports hash link scrolling
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { debugLog } from "./logging.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Defers a callback to the next animation frame.
|
|
15
|
+
* Falls back to setTimeout(0) in environments without requestAnimationFrame.
|
|
16
|
+
*/
|
|
17
|
+
const deferToNextPaint: (fn: () => void) => void =
|
|
18
|
+
typeof requestAnimationFrame === "function"
|
|
19
|
+
? requestAnimationFrame
|
|
20
|
+
: (fn) => setTimeout(fn, 0);
|
|
21
|
+
|
|
11
22
|
const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
|
|
12
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Maximum number of scroll position entries to retain.
|
|
26
|
+
* When exceeded, the oldest entries (by insertion order) are evicted.
|
|
27
|
+
* 200 entries is well within sessionStorage limits while covering
|
|
28
|
+
* realistic back/forward navigation depth.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_SCROLL_ENTRIES = 200;
|
|
31
|
+
|
|
13
32
|
/**
|
|
14
33
|
* Interval for polling scroll restoration during streaming (ms).
|
|
15
34
|
* If content is still loading and we can't scroll to saved position,
|
|
@@ -29,6 +48,13 @@ const SCROLL_POLL_TIMEOUT_MS = 5000;
|
|
|
29
48
|
*/
|
|
30
49
|
let savedScrollPositions: Record<string, number> = {};
|
|
31
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Tracks insertion order of scroll position keys for LRU eviction.
|
|
53
|
+
* Most recent entries are at the end of the array.
|
|
54
|
+
* When a key is updated, it is moved to the end.
|
|
55
|
+
*/
|
|
56
|
+
let scrollKeyOrder: string[] = [];
|
|
57
|
+
|
|
32
58
|
/**
|
|
33
59
|
* Whether scroll restoration has been initialized
|
|
34
60
|
*/
|
|
@@ -37,9 +63,12 @@ let initialized = false;
|
|
|
37
63
|
/**
|
|
38
64
|
* Custom getKey function for determining scroll restoration key
|
|
39
65
|
*/
|
|
40
|
-
type GetScrollKeyFunction = (
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
type GetScrollKeyFunction = (location: {
|
|
67
|
+
pathname: string;
|
|
68
|
+
search: string;
|
|
69
|
+
hash: string;
|
|
70
|
+
key: string;
|
|
71
|
+
}) => string;
|
|
43
72
|
|
|
44
73
|
let customGetKey: GetScrollKeyFunction | null = null;
|
|
45
74
|
|
|
@@ -99,9 +128,13 @@ export function initScrollRestoration(options?: {
|
|
|
99
128
|
const stored = sessionStorage.getItem(SCROLL_STORAGE_KEY);
|
|
100
129
|
if (stored) {
|
|
101
130
|
savedScrollPositions = JSON.parse(stored);
|
|
131
|
+
// Rebuild key order from loaded positions.
|
|
132
|
+
// Exact original order is lost across page loads, but this is
|
|
133
|
+
// acceptable -- the important invariant is bounded size.
|
|
134
|
+
scrollKeyOrder = Object.keys(savedScrollPositions);
|
|
102
135
|
}
|
|
103
136
|
} catch (e) {
|
|
104
|
-
// Ignore parse errors
|
|
137
|
+
// Ignore parse errors, start with empty state
|
|
105
138
|
}
|
|
106
139
|
|
|
107
140
|
// Ensure current history entry has a key
|
|
@@ -117,31 +150,82 @@ export function initScrollRestoration(options?: {
|
|
|
117
150
|
|
|
118
151
|
window.addEventListener("pagehide", handlePageHide);
|
|
119
152
|
|
|
120
|
-
|
|
153
|
+
debugLog(
|
|
154
|
+
"[Scroll] Initialized, loaded positions:",
|
|
155
|
+
Object.keys(savedScrollPositions).length,
|
|
156
|
+
);
|
|
121
157
|
|
|
122
158
|
return () => {
|
|
159
|
+
cancelScrollRestorationPolling();
|
|
123
160
|
window.removeEventListener("pagehide", handlePageHide);
|
|
124
161
|
window.history.scrollRestoration = "auto";
|
|
125
162
|
initialized = false;
|
|
163
|
+
savedScrollPositions = {};
|
|
164
|
+
scrollKeyOrder = [];
|
|
126
165
|
};
|
|
127
166
|
}
|
|
128
167
|
|
|
129
168
|
/**
|
|
130
|
-
* Save the current scroll position for the current history entry
|
|
169
|
+
* Save the current scroll position for the current history entry.
|
|
170
|
+
* Maintains bounded size by evicting oldest entries when the limit is exceeded.
|
|
131
171
|
*/
|
|
132
172
|
export function saveCurrentScrollPosition(): void {
|
|
133
173
|
const key = getScrollKey();
|
|
174
|
+
|
|
175
|
+
// If this key already exists, remove it from its current position
|
|
176
|
+
// in the order array so it can be re-appended at the end (most recent).
|
|
177
|
+
const existingIndex = scrollKeyOrder.indexOf(key);
|
|
178
|
+
if (existingIndex !== -1) {
|
|
179
|
+
scrollKeyOrder.splice(existingIndex, 1);
|
|
180
|
+
}
|
|
181
|
+
|
|
134
182
|
savedScrollPositions[key] = window.scrollY;
|
|
183
|
+
scrollKeyOrder.push(key);
|
|
184
|
+
|
|
185
|
+
// Evict oldest entries if we exceed the limit
|
|
186
|
+
while (scrollKeyOrder.length > MAX_SCROLL_ENTRIES) {
|
|
187
|
+
const oldestKey = scrollKeyOrder.shift()!;
|
|
188
|
+
delete savedScrollPositions[oldestKey];
|
|
189
|
+
}
|
|
135
190
|
}
|
|
136
191
|
|
|
137
192
|
/**
|
|
138
|
-
* Persist scroll positions to sessionStorage
|
|
193
|
+
* Persist scroll positions to sessionStorage.
|
|
194
|
+
* If the write fails due to quota exceeded, progressively evict the oldest
|
|
195
|
+
* entries and retry until it succeeds or the store is empty.
|
|
139
196
|
*/
|
|
140
197
|
function persistToSessionStorage(): void {
|
|
141
198
|
try {
|
|
142
|
-
sessionStorage.setItem(
|
|
199
|
+
sessionStorage.setItem(
|
|
200
|
+
SCROLL_STORAGE_KEY,
|
|
201
|
+
JSON.stringify(savedScrollPositions),
|
|
202
|
+
);
|
|
143
203
|
} catch (e) {
|
|
144
|
-
|
|
204
|
+
// Likely QuotaExceededError. Evict oldest entries and retry.
|
|
205
|
+
const evictCount = Math.max(1, Math.floor(scrollKeyOrder.length / 4));
|
|
206
|
+
for (let i = 0; i < evictCount && scrollKeyOrder.length > 0; i++) {
|
|
207
|
+
const oldestKey = scrollKeyOrder.shift()!;
|
|
208
|
+
delete savedScrollPositions[oldestKey];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
sessionStorage.setItem(
|
|
213
|
+
SCROLL_STORAGE_KEY,
|
|
214
|
+
JSON.stringify(savedScrollPositions),
|
|
215
|
+
);
|
|
216
|
+
} catch (retryErr) {
|
|
217
|
+
// Storage still full after eviction. Clear our key entirely so we
|
|
218
|
+
// don't block other sessionStorage consumers.
|
|
219
|
+
console.warn(
|
|
220
|
+
"[Scroll] Failed to persist to sessionStorage after eviction, clearing scroll data:",
|
|
221
|
+
retryErr,
|
|
222
|
+
);
|
|
223
|
+
try {
|
|
224
|
+
sessionStorage.removeItem(SCROLL_STORAGE_KEY);
|
|
225
|
+
} catch {
|
|
226
|
+
// Nothing more we can do
|
|
227
|
+
}
|
|
228
|
+
}
|
|
145
229
|
}
|
|
146
230
|
}
|
|
147
231
|
|
|
@@ -189,50 +273,35 @@ export function restoreScrollPosition(options?: {
|
|
|
189
273
|
return false;
|
|
190
274
|
}
|
|
191
275
|
|
|
192
|
-
//
|
|
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
|
|
276
|
+
// If streaming, poll until streaming ends then scroll to saved position
|
|
207
277
|
if (options?.retryIfStreaming && options?.isStreaming?.()) {
|
|
208
278
|
const startTime = Date.now();
|
|
209
279
|
|
|
210
280
|
pendingPollInterval = setInterval(() => {
|
|
211
|
-
// Stop if we've exceeded the timeout
|
|
212
281
|
if (Date.now() - startTime > SCROLL_POLL_TIMEOUT_MS) {
|
|
213
|
-
|
|
282
|
+
debugLog("[Scroll] Polling timeout, giving up");
|
|
214
283
|
cancelScrollRestorationPolling();
|
|
215
284
|
return;
|
|
216
285
|
}
|
|
217
286
|
|
|
218
|
-
// Stop if streaming ended
|
|
219
287
|
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
288
|
window.scrollTo(0, savedY);
|
|
229
|
-
|
|
289
|
+
debugLog("[Scroll] Restored after streaming:", savedY);
|
|
230
290
|
cancelScrollRestorationPolling();
|
|
231
291
|
}
|
|
232
292
|
}, SCROLL_POLL_INTERVAL_MS);
|
|
293
|
+
|
|
294
|
+
return true;
|
|
233
295
|
}
|
|
234
296
|
|
|
235
|
-
|
|
297
|
+
// Not streaming — scroll after React commits and browser paints.
|
|
298
|
+
// startTransition defers the DOM commit, so scrolling synchronously
|
|
299
|
+
// would be overwritten when React replaces the content.
|
|
300
|
+
deferToNextPaint(() => {
|
|
301
|
+
window.scrollTo(0, savedY);
|
|
302
|
+
debugLog("[Scroll] Restored position:", savedY, "for key:", key);
|
|
303
|
+
});
|
|
304
|
+
return true;
|
|
236
305
|
}
|
|
237
306
|
|
|
238
307
|
/**
|
|
@@ -249,7 +318,7 @@ export function scrollToHash(): boolean {
|
|
|
249
318
|
const element = document.getElementById(id);
|
|
250
319
|
if (element) {
|
|
251
320
|
element.scrollIntoView();
|
|
252
|
-
|
|
321
|
+
debugLog("[Scroll] Scrolled to hash element:", id);
|
|
253
322
|
return true;
|
|
254
323
|
}
|
|
255
324
|
} catch (e) {
|
|
@@ -287,32 +356,38 @@ export function handleNavigationEnd(options: {
|
|
|
287
356
|
scroll?: boolean;
|
|
288
357
|
isStreaming?: () => boolean;
|
|
289
358
|
}): void {
|
|
290
|
-
if (!initialized) {
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
359
|
const { restore = false, scroll = true, isStreaming } = options;
|
|
295
360
|
|
|
296
|
-
// Don't scroll if explicitly disabled
|
|
297
|
-
if (scroll === false) {
|
|
361
|
+
// Don't scroll if explicitly disabled or not in a browser
|
|
362
|
+
if (scroll === false || typeof window === "undefined") {
|
|
298
363
|
return;
|
|
299
364
|
}
|
|
300
365
|
|
|
301
|
-
//
|
|
302
|
-
|
|
366
|
+
// Save/restore requires initialization (sessionStorage, history state).
|
|
367
|
+
// But basic scroll-to-top and hash scrolling work without it — this
|
|
368
|
+
// matters during cross-app navigation where ScrollRestoration unmounts
|
|
369
|
+
// and remounts, creating a brief window where initialized is false.
|
|
370
|
+
if (restore && initialized) {
|
|
303
371
|
if (restoreScrollPosition({ retryIfStreaming: true, isStreaming })) {
|
|
304
372
|
return;
|
|
305
373
|
}
|
|
306
374
|
// Fall through to hash or top if no saved position
|
|
307
375
|
}
|
|
308
376
|
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
377
|
+
// Defer hash and scroll-to-top to after React paints the new content,
|
|
378
|
+
// so the user doesn't see the current page jump before the new route appears.
|
|
379
|
+
deferToNextPaint(() => {
|
|
380
|
+
// Re-check: the deferred callback may fire after environment teardown
|
|
381
|
+
if (typeof window === "undefined") return;
|
|
382
|
+
|
|
383
|
+
// Try hash scrolling first
|
|
384
|
+
if (scrollToHash()) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
313
387
|
|
|
314
|
-
|
|
315
|
-
|
|
388
|
+
// Default: scroll to top
|
|
389
|
+
scrollToTop();
|
|
390
|
+
});
|
|
316
391
|
}
|
|
317
392
|
|
|
318
393
|
/**
|