@rangojs/router 0.0.0-experimental.002d056c
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 +899 -0
- package/dist/bin/rango.js +1606 -0
- package/dist/vite/index.js +5153 -0
- package/package.json +177 -0
- package/skills/breadcrumbs/SKILL.md +250 -0
- package/skills/cache-guide/SKILL.md +262 -0
- package/skills/caching/SKILL.md +253 -0
- package/skills/composability/SKILL.md +172 -0
- package/skills/debug-manifest/SKILL.md +112 -0
- package/skills/document-cache/SKILL.md +182 -0
- package/skills/fonts/SKILL.md +167 -0
- package/skills/hooks/SKILL.md +704 -0
- package/skills/host-router/SKILL.md +218 -0
- package/skills/intercept/SKILL.md +313 -0
- package/skills/layout/SKILL.md +310 -0
- package/skills/links/SKILL.md +239 -0
- package/skills/loader/SKILL.md +596 -0
- package/skills/middleware/SKILL.md +339 -0
- package/skills/mime-routes/SKILL.md +128 -0
- package/skills/parallel/SKILL.md +305 -0
- package/skills/prerender/SKILL.md +643 -0
- package/skills/rango/SKILL.md +118 -0
- package/skills/response-routes/SKILL.md +411 -0
- package/skills/route/SKILL.md +385 -0
- package/skills/router-setup/SKILL.md +439 -0
- package/skills/tailwind/SKILL.md +129 -0
- package/skills/theme/SKILL.md +79 -0
- package/skills/typesafety/SKILL.md +623 -0
- package/skills/use-cache/SKILL.md +324 -0
- package/src/__internal.ts +273 -0
- 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/event-controller.ts +899 -0
- package/src/browser/history-state.ts +80 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/intercept-utils.ts +52 -0
- package/src/browser/link-interceptor.ts +141 -0
- package/src/browser/logging.ts +55 -0
- package/src/browser/merge-segment-loaders.ts +134 -0
- package/src/browser/navigation-bridge.ts +638 -0
- package/src/browser/navigation-client.ts +261 -0
- package/src/browser/navigation-store.ts +806 -0
- package/src/browser/navigation-transaction.ts +297 -0
- package/src/browser/network-error-handler.ts +61 -0
- package/src/browser/partial-update.ts +582 -0
- package/src/browser/prefetch/cache.ts +206 -0
- package/src/browser/prefetch/fetch.ts +145 -0
- package/src/browser/prefetch/observer.ts +65 -0
- package/src/browser/prefetch/policy.ts +48 -0
- package/src/browser/prefetch/queue.ts +128 -0
- package/src/browser/rango-state.ts +112 -0
- package/src/browser/react/Link.tsx +368 -0
- package/src/browser/react/NavigationProvider.tsx +413 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +59 -0
- package/src/browser/react/filter-segment-order.ts +11 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +162 -0
- package/src/browser/react/location-state.ts +107 -0
- package/src/browser/react/mount-context.ts +37 -0
- 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 +218 -0
- package/src/browser/react/use-client-cache.ts +58 -0
- package/src/browser/react/use-handle.ts +162 -0
- package/src/browser/react/use-href.tsx +40 -0
- package/src/browser/react/use-link-status.ts +135 -0
- package/src/browser/react/use-mount.ts +31 -0
- package/src/browser/react/use-navigation.ts +99 -0
- package/src/browser/react/use-params.ts +65 -0
- package/src/browser/react/use-pathname.ts +47 -0
- package/src/browser/react/use-router.ts +63 -0
- package/src/browser/react/use-search-params.ts +56 -0
- package/src/browser/react/use-segments.ts +171 -0
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +464 -0
- package/src/browser/scroll-restoration.ts +397 -0
- package/src/browser/segment-reconciler.ts +216 -0
- package/src/browser/segment-structure-assert.ts +83 -0
- package/src/browser/server-action-bridge.ts +667 -0
- package/src/browser/shallow.ts +40 -0
- package/src/browser/types.ts +547 -0
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +438 -0
- package/src/build/generate-route-types.ts +36 -0
- package/src/build/index.ts +35 -0
- package/src/build/route-trie.ts +265 -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 +411 -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 +479 -0
- package/src/build/route-types/scan-filter.ts +78 -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 +338 -0
- package/src/cache/cache-scope.ts +382 -0
- package/src/cache/cf/cf-cache-store.ts +982 -0
- package/src/cache/cf/index.ts +29 -0
- package/src/cache/document-cache.ts +369 -0
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/handle-snapshot.ts +41 -0
- package/src/cache/index.ts +44 -0
- package/src/cache/memory-segment-store.ts +328 -0
- 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 +98 -0
- package/src/cache/types.ts +342 -0
- package/src/client.rsc.tsx +85 -0
- package/src/client.tsx +601 -0
- package/src/component-utils.ts +76 -0
- package/src/components/DefaultDocument.tsx +27 -0
- package/src/context-var.ts +86 -0
- package/src/debug.ts +243 -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 +365 -0
- package/src/handle.ts +135 -0
- package/src/handles/MetaTags.tsx +246 -0
- package/src/handles/breadcrumbs.ts +66 -0
- package/src/handles/index.ts +7 -0
- package/src/handles/meta.ts +264 -0
- package/src/host/cookie-handler.ts +165 -0
- package/src/host/errors.ts +97 -0
- package/src/host/index.ts +53 -0
- package/src/host/pattern-matcher.ts +214 -0
- package/src/host/router.ts +352 -0
- package/src/host/testing.ts +79 -0
- package/src/host/types.ts +146 -0
- package/src/host/utils.ts +25 -0
- package/src/href-client.ts +222 -0
- package/src/index.rsc.ts +233 -0
- package/src/index.ts +277 -0
- package/src/internal-debug.ts +11 -0
- package/src/loader.rsc.ts +89 -0
- package/src/loader.ts +64 -0
- package/src/network-error-thrower.tsx +23 -0
- package/src/outlet-context.ts +15 -0
- package/src/outlet-provider.tsx +45 -0
- package/src/prerender/param-hash.ts +37 -0
- package/src/prerender/store.ts +185 -0
- package/src/prerender.ts +463 -0
- package/src/reverse.ts +330 -0
- package/src/root-error-boundary.tsx +289 -0
- package/src/route-content-wrapper.tsx +196 -0
- package/src/route-definition/dsl-helpers.ts +934 -0
- package/src/route-definition/helper-factories.ts +200 -0
- package/src/route-definition/helpers-types.ts +430 -0
- package/src/route-definition/index.ts +52 -0
- package/src/route-definition/redirect.ts +93 -0
- package/src/route-definition.ts +1 -0
- package/src/route-map-builder.ts +281 -0
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +259 -0
- package/src/router/content-negotiation.ts +116 -0
- package/src/router/debug-manifest.ts +72 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/find-match.ts +160 -0
- package/src/router/handler-context.ts +451 -0
- package/src/router/intercept-resolution.ts +397 -0
- package/src/router/lazy-includes.ts +236 -0
- package/src/router/loader-resolution.ts +420 -0
- package/src/router/logging.ts +251 -0
- package/src/router/manifest.ts +269 -0
- package/src/router/match-api.ts +620 -0
- package/src/router/match-context.ts +266 -0
- package/src/router/match-handlers.ts +440 -0
- package/src/router/match-middleware/background-revalidation.ts +223 -0
- package/src/router/match-middleware/cache-lookup.ts +634 -0
- package/src/router/match-middleware/cache-store.ts +295 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +306 -0
- package/src/router/match-middleware/segment-resolution.ts +193 -0
- package/src/router/match-pipelines.ts +179 -0
- package/src/router/match-result.ts +219 -0
- package/src/router/metrics.ts +282 -0
- package/src/router/middleware-cookies.ts +55 -0
- package/src/router/middleware-types.ts +222 -0
- package/src/router/middleware.ts +749 -0
- package/src/router/pattern-matching.ts +563 -0
- package/src/router/prerender-match.ts +402 -0
- package/src/router/preview-match.ts +170 -0
- package/src/router/revalidation.ts +289 -0
- package/src/router/router-context.ts +320 -0
- package/src/router/router-interfaces.ts +452 -0
- package/src/router/router-options.ts +592 -0
- package/src/router/router-registry.ts +24 -0
- package/src/router/segment-resolution/fresh.ts +570 -0
- package/src/router/segment-resolution/helpers.ts +263 -0
- package/src/router/segment-resolution/loader-cache.ts +198 -0
- package/src/router/segment-resolution/revalidation.ts +1242 -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 +170 -0
- package/src/router.ts +1006 -0
- package/src/rsc/handler-context.ts +45 -0
- package/src/rsc/handler.ts +1089 -0
- package/src/rsc/helpers.ts +198 -0
- package/src/rsc/index.ts +36 -0
- package/src/rsc/loader-fetch.ts +209 -0
- package/src/rsc/manifest-init.ts +86 -0
- package/src/rsc/nonce.ts +32 -0
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +379 -0
- package/src/rsc/response-error.ts +37 -0
- package/src/rsc/response-route-handler.ts +347 -0
- package/src/rsc/rsc-rendering.ts +237 -0
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +348 -0
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/rsc/types.ts +263 -0
- package/src/search-params.ts +230 -0
- package/src/segment-system.tsx +454 -0
- package/src/server/context.ts +591 -0
- package/src/server/cookie-store.ts +190 -0
- package/src/server/fetchable-loader-store.ts +37 -0
- package/src/server/handle-store.ts +308 -0
- package/src/server/loader-registry.ts +133 -0
- package/src/server/request-context.ts +920 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +51 -0
- package/src/ssr/index.tsx +365 -0
- package/src/static-handler.ts +114 -0
- package/src/theme/ThemeProvider.tsx +297 -0
- package/src/theme/ThemeScript.tsx +61 -0
- package/src/theme/constants.ts +62 -0
- package/src/theme/index.ts +48 -0
- package/src/theme/theme-context.ts +44 -0
- package/src/theme/theme-script.ts +155 -0
- package/src/theme/types.ts +182 -0
- package/src/theme/use-theme.ts +44 -0
- 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 +687 -0
- package/src/types/index.ts +88 -0
- package/src/types/loader-types.ts +183 -0
- package/src/types/route-config.ts +170 -0
- package/src/types/route-entry.ts +109 -0
- package/src/types/segments.ts +148 -0
- package/src/types.ts +1 -0
- package/src/urls/include-helper.ts +197 -0
- package/src/urls/index.ts +53 -0
- package/src/urls/path-helper-types.ts +339 -0
- package/src/urls/path-helper.ts +329 -0
- package/src/urls/pattern-types.ts +95 -0
- package/src/urls/response-types.ts +106 -0
- package/src/urls/type-extraction.ts +372 -0
- package/src/urls/urls-function.ts +98 -0
- package/src/urls.ts +1 -0
- package/src/use-loader.tsx +354 -0
- package/src/vite/discovery/bundle-postprocess.ts +184 -0
- package/src/vite/discovery/discover-routers.ts +344 -0
- package/src/vite/discovery/prerender-collection.ts +385 -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 +108 -0
- package/src/vite/discovery/virtual-module-codegen.ts +203 -0
- package/src/vite/index.ts +16 -0
- package/src/vite/plugin-types.ts +48 -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/expose-action-id.ts +363 -0
- package/src/vite/plugins/expose-id-utils.ts +287 -0
- package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +179 -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 +569 -0
- package/src/vite/plugins/refresh-cmd.ts +65 -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/plugins/version.d.ts +12 -0
- package/src/vite/plugins/virtual-entries.ts +123 -0
- package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
- package/src/vite/rango.ts +445 -0
- package/src/vite/router-discovery.ts +777 -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/utils/package-resolution.ts +121 -0
- package/src/vite/utils/prerender-utils.ts +189 -0
- package/src/vite/utils/shared-utils.ts +169 -0
|
@@ -0,0 +1,397 @@
|
|
|
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
|
+
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
|
+
|
|
22
|
+
const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
|
|
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
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Interval for polling scroll restoration during streaming (ms).
|
|
34
|
+
* If content is still loading and we can't scroll to saved position,
|
|
35
|
+
* keep trying at this interval.
|
|
36
|
+
*/
|
|
37
|
+
const SCROLL_POLL_INTERVAL_MS = 50;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Maximum time to keep polling for scroll restoration (ms).
|
|
41
|
+
* After this timeout, stop trying even if streaming continues.
|
|
42
|
+
*/
|
|
43
|
+
const SCROLL_POLL_TIMEOUT_MS = 5000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* In-memory cache of scroll positions.
|
|
47
|
+
* Synced with sessionStorage on pagehide.
|
|
48
|
+
*/
|
|
49
|
+
let savedScrollPositions: Record<string, number> = {};
|
|
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
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether scroll restoration has been initialized
|
|
60
|
+
*/
|
|
61
|
+
let initialized = false;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Custom getKey function for determining scroll restoration key
|
|
65
|
+
*/
|
|
66
|
+
type GetScrollKeyFunction = (location: {
|
|
67
|
+
pathname: string;
|
|
68
|
+
search: string;
|
|
69
|
+
hash: string;
|
|
70
|
+
key: string;
|
|
71
|
+
}) => string;
|
|
72
|
+
|
|
73
|
+
let customGetKey: GetScrollKeyFunction | null = null;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Generate a unique key for the current history entry.
|
|
77
|
+
* Uses history.state.key if available, otherwise generates and stores a new one.
|
|
78
|
+
*/
|
|
79
|
+
export function getHistoryStateKey(): string {
|
|
80
|
+
const state = window.history.state;
|
|
81
|
+
if (state?.key) {
|
|
82
|
+
return state.key;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Generate a new key and store it in history.state
|
|
86
|
+
const key = Math.random().toString(36).slice(2, 10);
|
|
87
|
+
window.history.replaceState({ ...state, key }, "");
|
|
88
|
+
return key;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get the scroll restoration key for a location.
|
|
93
|
+
* Uses custom getKey function if set, otherwise uses history state key.
|
|
94
|
+
*/
|
|
95
|
+
export function getScrollKey(): string {
|
|
96
|
+
if (customGetKey) {
|
|
97
|
+
const loc = window.location;
|
|
98
|
+
return customGetKey({
|
|
99
|
+
pathname: loc.pathname,
|
|
100
|
+
search: loc.search,
|
|
101
|
+
hash: loc.hash,
|
|
102
|
+
key: getHistoryStateKey(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return getHistoryStateKey();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Initialize scroll restoration.
|
|
110
|
+
* Sets manual scroll restoration mode and loads saved positions from sessionStorage.
|
|
111
|
+
*/
|
|
112
|
+
export function initScrollRestoration(options?: {
|
|
113
|
+
getKey?: GetScrollKeyFunction;
|
|
114
|
+
}): () => void {
|
|
115
|
+
if (initialized) {
|
|
116
|
+
console.warn("[Scroll] Already initialized");
|
|
117
|
+
return () => {};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
initialized = true;
|
|
121
|
+
customGetKey = options?.getKey ?? null;
|
|
122
|
+
|
|
123
|
+
// Set manual scroll restoration to prevent browser's default behavior
|
|
124
|
+
window.history.scrollRestoration = "manual";
|
|
125
|
+
|
|
126
|
+
// Load saved positions from sessionStorage
|
|
127
|
+
try {
|
|
128
|
+
const stored = sessionStorage.getItem(SCROLL_STORAGE_KEY);
|
|
129
|
+
if (stored) {
|
|
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);
|
|
135
|
+
}
|
|
136
|
+
} catch (e) {
|
|
137
|
+
// Ignore parse errors, start with empty state
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Ensure current history entry has a key
|
|
141
|
+
getHistoryStateKey();
|
|
142
|
+
|
|
143
|
+
// Save scroll positions on pagehide (before leaving/refreshing)
|
|
144
|
+
const handlePageHide = () => {
|
|
145
|
+
saveCurrentScrollPosition();
|
|
146
|
+
persistToSessionStorage();
|
|
147
|
+
// Reset to auto for browser to handle if page is restored from bfcache
|
|
148
|
+
window.history.scrollRestoration = "auto";
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
window.addEventListener("pagehide", handlePageHide);
|
|
152
|
+
|
|
153
|
+
debugLog(
|
|
154
|
+
"[Scroll] Initialized, loaded positions:",
|
|
155
|
+
Object.keys(savedScrollPositions).length,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
return () => {
|
|
159
|
+
cancelScrollRestorationPolling();
|
|
160
|
+
window.removeEventListener("pagehide", handlePageHide);
|
|
161
|
+
window.history.scrollRestoration = "auto";
|
|
162
|
+
initialized = false;
|
|
163
|
+
savedScrollPositions = {};
|
|
164
|
+
scrollKeyOrder = [];
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Save the current scroll position for the current history entry.
|
|
170
|
+
* Maintains bounded size by evicting oldest entries when the limit is exceeded.
|
|
171
|
+
*/
|
|
172
|
+
export function saveCurrentScrollPosition(): void {
|
|
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
|
+
|
|
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
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
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.
|
|
196
|
+
*/
|
|
197
|
+
function persistToSessionStorage(): void {
|
|
198
|
+
try {
|
|
199
|
+
sessionStorage.setItem(
|
|
200
|
+
SCROLL_STORAGE_KEY,
|
|
201
|
+
JSON.stringify(savedScrollPositions),
|
|
202
|
+
);
|
|
203
|
+
} catch (e) {
|
|
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
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get the saved scroll position for a history key
|
|
234
|
+
*/
|
|
235
|
+
export function getSavedScrollPosition(key?: string): number | undefined {
|
|
236
|
+
const lookupKey = key ?? getScrollKey();
|
|
237
|
+
return savedScrollPositions[lookupKey];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Pending poll interval for scroll restoration during streaming
|
|
242
|
+
*/
|
|
243
|
+
let pendingPollInterval: ReturnType<typeof setInterval> | null = null;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Cancel any pending scroll restoration polling
|
|
247
|
+
*/
|
|
248
|
+
export function cancelScrollRestorationPolling(): void {
|
|
249
|
+
if (pendingPollInterval) {
|
|
250
|
+
clearInterval(pendingPollInterval);
|
|
251
|
+
pendingPollInterval = null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Restore scroll position for the current history entry.
|
|
257
|
+
* Returns true if position was fully restored, false otherwise.
|
|
258
|
+
*
|
|
259
|
+
* @param options.retryIfStreaming - If true, poll while streaming until we can scroll to target
|
|
260
|
+
* @param options.isStreaming - Function to check if streaming is in progress
|
|
261
|
+
*/
|
|
262
|
+
export function restoreScrollPosition(options?: {
|
|
263
|
+
retryIfStreaming?: boolean;
|
|
264
|
+
isStreaming?: () => boolean;
|
|
265
|
+
}): boolean {
|
|
266
|
+
// Clear any pending polling
|
|
267
|
+
cancelScrollRestorationPolling();
|
|
268
|
+
|
|
269
|
+
const key = getScrollKey();
|
|
270
|
+
const savedY = savedScrollPositions[key];
|
|
271
|
+
|
|
272
|
+
if (typeof savedY !== "number") {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// If streaming, poll until streaming ends then scroll to saved position
|
|
277
|
+
if (options?.retryIfStreaming && options?.isStreaming?.()) {
|
|
278
|
+
const startTime = Date.now();
|
|
279
|
+
|
|
280
|
+
pendingPollInterval = setInterval(() => {
|
|
281
|
+
if (Date.now() - startTime > SCROLL_POLL_TIMEOUT_MS) {
|
|
282
|
+
debugLog("[Scroll] Polling timeout, giving up");
|
|
283
|
+
cancelScrollRestorationPolling();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!options.isStreaming?.()) {
|
|
288
|
+
window.scrollTo(0, savedY);
|
|
289
|
+
debugLog("[Scroll] Restored after streaming:", savedY);
|
|
290
|
+
cancelScrollRestorationPolling();
|
|
291
|
+
}
|
|
292
|
+
}, SCROLL_POLL_INTERVAL_MS);
|
|
293
|
+
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
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;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Handle hash link scrolling.
|
|
309
|
+
* Scrolls to element with matching ID if hash is present.
|
|
310
|
+
* Returns true if scrolled to element, false otherwise.
|
|
311
|
+
*/
|
|
312
|
+
export function scrollToHash(): boolean {
|
|
313
|
+
const hash = window.location.hash;
|
|
314
|
+
if (!hash) return false;
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const id = decodeURIComponent(hash.slice(1));
|
|
318
|
+
const element = document.getElementById(id);
|
|
319
|
+
if (element) {
|
|
320
|
+
element.scrollIntoView();
|
|
321
|
+
debugLog("[Scroll] Scrolled to hash element:", id);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
} catch (e) {
|
|
325
|
+
console.warn("[Scroll] Failed to decode hash:", hash);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Scroll to top of page
|
|
333
|
+
*/
|
|
334
|
+
export function scrollToTop(): void {
|
|
335
|
+
window.scrollTo(0, 0);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Handle scroll for a new navigation.
|
|
340
|
+
* - Saves current position before navigating
|
|
341
|
+
* - Ensures new history entry has a key
|
|
342
|
+
*/
|
|
343
|
+
export function handleNavigationStart(): void {
|
|
344
|
+
if (!initialized) return;
|
|
345
|
+
saveCurrentScrollPosition();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Handle scroll after navigation completes.
|
|
350
|
+
* @param options.restore - If true, restore saved position (for popstate)
|
|
351
|
+
* @param options.scroll - If false, don't scroll at all
|
|
352
|
+
* @param options.isStreaming - Function to check if streaming is in progress (for retry logic)
|
|
353
|
+
*/
|
|
354
|
+
export function handleNavigationEnd(options: {
|
|
355
|
+
restore?: boolean;
|
|
356
|
+
scroll?: boolean;
|
|
357
|
+
isStreaming?: () => boolean;
|
|
358
|
+
}): void {
|
|
359
|
+
if (!initialized) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const { restore = false, scroll = true, isStreaming } = options;
|
|
364
|
+
|
|
365
|
+
// Don't scroll if explicitly disabled
|
|
366
|
+
if (scroll === false) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// For back/forward (restore), try to restore saved position
|
|
371
|
+
if (restore) {
|
|
372
|
+
if (restoreScrollPosition({ retryIfStreaming: true, isStreaming })) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// Fall through to hash or top if no saved position
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Defer hash and scroll-to-top to after React paints the new content,
|
|
379
|
+
// so the user doesn't see the current page jump before the new route appears.
|
|
380
|
+
deferToNextPaint(() => {
|
|
381
|
+
// Try hash scrolling first
|
|
382
|
+
if (scrollToHash()) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Default: scroll to top
|
|
387
|
+
scrollToTop();
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Update the history state key after pushState/replaceState.
|
|
393
|
+
* Call this after changing history to ensure new entry has a key.
|
|
394
|
+
*/
|
|
395
|
+
export function ensureHistoryKey(): void {
|
|
396
|
+
getHistoryStateKey();
|
|
397
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { ResolvedSegment } from "./types.js";
|
|
2
|
+
import {
|
|
3
|
+
mergeSegmentLoaders,
|
|
4
|
+
needsLoaderMerge,
|
|
5
|
+
insertMissingDiffSegments,
|
|
6
|
+
} from "./merge-segment-loaders.js";
|
|
7
|
+
import { assertSegmentStructure } from "./segment-structure-assert.js";
|
|
8
|
+
import { splitInterceptSegments } from "./intercept-utils.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Determines the merging behavior for segment reconciliation.
|
|
12
|
+
*
|
|
13
|
+
* - 'action': From server-action-bridge's own merge. Always merges loaders,
|
|
14
|
+
* always preserves cached loading (even undefined), never clears cached
|
|
15
|
+
* segment loading.
|
|
16
|
+
* - 'navigation': From partial-update during normal navigation. Does NOT merge
|
|
17
|
+
* loaders, preserves cached loading only when defined, clears truthy loading
|
|
18
|
+
* on cached segments not in server diff.
|
|
19
|
+
* - 'stale-revalidation': From partial-update during stale revalidation or
|
|
20
|
+
* action-triggered refetch. Merges loaders, always preserves cached loading
|
|
21
|
+
* (same as action), clears truthy loading on cached segments not in server diff.
|
|
22
|
+
*/
|
|
23
|
+
export type ReconcileActor = "navigation" | "action" | "stale-revalidation";
|
|
24
|
+
|
|
25
|
+
export interface ReconcileInput {
|
|
26
|
+
actor: ReconcileActor;
|
|
27
|
+
/** All segment IDs the server expects the client to have (matched array) */
|
|
28
|
+
matched: string[];
|
|
29
|
+
/** Segment IDs that changed (diff array) */
|
|
30
|
+
diff: string[];
|
|
31
|
+
/** Segments returned from server (raw array, keyed internally by ID) */
|
|
32
|
+
serverSegments: ResolvedSegment[];
|
|
33
|
+
/** Cached segments from current page (raw array, keyed internally by ID) */
|
|
34
|
+
cachedSegments: ResolvedSegment[];
|
|
35
|
+
/** When true, diff segments not in matched are inserted after their parent
|
|
36
|
+
* layout. Used during navigation when consolidation fetch returns loader
|
|
37
|
+
* segments that aren't in the matched array. */
|
|
38
|
+
insertMissingDiff?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ReconcileResult {
|
|
42
|
+
/** All merged segments in matched order (for caching and committing) */
|
|
43
|
+
segments: ResolvedSegment[];
|
|
44
|
+
/** Main segments excluding intercepts (for rendering) */
|
|
45
|
+
mainSegments: ResolvedSegment[];
|
|
46
|
+
/** Intercept segments only (passed via render options) */
|
|
47
|
+
interceptSegments: ResolvedSegment[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Single source of truth for merging server segments with cached segments.
|
|
52
|
+
*
|
|
53
|
+
* Replaces the duplicated merge loops in server-action-bridge.ts and
|
|
54
|
+
* partial-update.ts. The actor parameter controls the subtle behavioral
|
|
55
|
+
* differences between action and navigation merging:
|
|
56
|
+
*
|
|
57
|
+
* Loading preservation:
|
|
58
|
+
* - action/stale-revalidation: Always preserves cached loading value when it
|
|
59
|
+
* differs from server (even when cached is undefined). This prevents tree
|
|
60
|
+
* structure changes that would remount components and destroy useActionState
|
|
61
|
+
* during action revalidation or action-triggered refetch.
|
|
62
|
+
* - navigation: Preserves cached loading only when the cached value is defined
|
|
63
|
+
* (not undefined). When cached is undefined, lets server value through
|
|
64
|
+
* because we're building a new tree.
|
|
65
|
+
*
|
|
66
|
+
* Loader merging:
|
|
67
|
+
* - action/stale-revalidation: Merges partial loader data when server returns
|
|
68
|
+
* fewer loaders than cached (revalidation only updated some loaders).
|
|
69
|
+
* - navigation: Does not merge (full navigation fetches complete data).
|
|
70
|
+
*
|
|
71
|
+
* Cached segment handling (segments in matched but not in server response):
|
|
72
|
+
* - action: Returns cached segment as-is (preserve tree structure).
|
|
73
|
+
* - navigation/stale-revalidation: Clears truthy loading to undefined
|
|
74
|
+
* (prevents showing stale skeletons), but preserves loading=false
|
|
75
|
+
* (suppressed boundary is structural).
|
|
76
|
+
*/
|
|
77
|
+
export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
78
|
+
const { actor, matched, diff, insertMissingDiff } = input;
|
|
79
|
+
const shouldMergeLoaders = actor !== "navigation";
|
|
80
|
+
const context = actor === "action" ? "action-bridge" : "partial-update";
|
|
81
|
+
|
|
82
|
+
// Build lookup maps from arrays
|
|
83
|
+
const serverSegments = new Map<string, ResolvedSegment>();
|
|
84
|
+
input.serverSegments.forEach((s) => serverSegments.set(s.id, s));
|
|
85
|
+
const cachedSegments = new Map<string, ResolvedSegment>();
|
|
86
|
+
input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
|
|
87
|
+
|
|
88
|
+
const segments = matched
|
|
89
|
+
.map((segId: string) => {
|
|
90
|
+
const fromServer = serverSegments.get(segId);
|
|
91
|
+
const fromCache = cachedSegments.get(segId);
|
|
92
|
+
|
|
93
|
+
if (fromServer) {
|
|
94
|
+
// Merge partial loader data when server returns fewer loaders than cached
|
|
95
|
+
if (shouldMergeLoaders && needsLoaderMerge(fromServer, fromCache)) {
|
|
96
|
+
return mergeSegmentLoaders(fromServer, fromCache);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Preserve cached structural properties to maintain consistent React tree.
|
|
100
|
+
// Changing these between renders alters the element nesting
|
|
101
|
+
// (with/without RouteContentWrapper, MountContextProvider, etc.),
|
|
102
|
+
// causing React to remount components and destroy useActionState.
|
|
103
|
+
if (fromCache) {
|
|
104
|
+
let merged = fromServer;
|
|
105
|
+
|
|
106
|
+
// When server returns component: null for a layout segment, it means
|
|
107
|
+
// "this segment doesn't need re-rendering" - preserve the cached component
|
|
108
|
+
// to maintain the outlet chain and prevent React tree changes
|
|
109
|
+
if (
|
|
110
|
+
fromServer.component === null &&
|
|
111
|
+
fromServer.type === "layout" &&
|
|
112
|
+
fromCache.component != null
|
|
113
|
+
) {
|
|
114
|
+
merged = { ...merged, component: fromCache.component };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Loading preservation is actor-aware:
|
|
118
|
+
// - action/stale-revalidation: always preserve cached value to prevent
|
|
119
|
+
// tree remount (even when cached is undefined, to avoid adding a
|
|
120
|
+
// Suspense boundary that wasn't there before)
|
|
121
|
+
// - navigation: only when cached is defined (building a new tree)
|
|
122
|
+
if (actor !== "navigation") {
|
|
123
|
+
if (fromServer.loading !== fromCache.loading) {
|
|
124
|
+
merged = { ...merged, loading: fromCache.loading };
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
if (
|
|
128
|
+
fromCache.loading !== undefined &&
|
|
129
|
+
fromServer.loading !== fromCache.loading
|
|
130
|
+
) {
|
|
131
|
+
merged = { ...merged, loading: fromCache.loading };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// mountPath: SSR segments may lack mountPath while revalidated segments
|
|
136
|
+
// include it. The conditional MountContextProvider wrapper changes tree depth.
|
|
137
|
+
if (fromServer.mountPath !== fromCache.mountPath) {
|
|
138
|
+
merged = { ...merged, mountPath: fromCache.mountPath };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Dev-mode assertion: warn if the merged result still differs from cache
|
|
142
|
+
// in tree-structural properties. This catches bugs where the merge code
|
|
143
|
+
// above fails to preserve a value it should have.
|
|
144
|
+
assertSegmentStructure(fromCache, merged, context);
|
|
145
|
+
|
|
146
|
+
return merged;
|
|
147
|
+
}
|
|
148
|
+
return fromServer;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Fall back to cached segment (server expects client to already have it)
|
|
152
|
+
if (!fromCache) {
|
|
153
|
+
if (actor === "action") {
|
|
154
|
+
console.error(`[Browser] MISSING SEGMENT: ${segId} not in cache!`);
|
|
155
|
+
} else {
|
|
156
|
+
console.warn(`[Browser] Missing segment: ${segId}`);
|
|
157
|
+
}
|
|
158
|
+
return fromCache;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// For non-action actors: cached segments the server decided not to re-render.
|
|
162
|
+
// - Preserve loading=false (suppressed boundary) to maintain tree structure
|
|
163
|
+
// - Clear truthy loading (active skeleton) to prevent suspense on cached content
|
|
164
|
+
if (actor !== "action") {
|
|
165
|
+
if (fromCache.loading !== undefined && fromCache.loading !== false) {
|
|
166
|
+
return { ...fromCache, loading: undefined };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return fromCache;
|
|
171
|
+
})
|
|
172
|
+
.filter(Boolean) as ResolvedSegment[];
|
|
173
|
+
|
|
174
|
+
// Insert diff segments not in matched (e.g., loader segments from consolidation fetch).
|
|
175
|
+
// Only needed during navigation - action bridge doesn't use this.
|
|
176
|
+
if (insertMissingDiff) {
|
|
177
|
+
const matchedIdSet = new Set(matched);
|
|
178
|
+
insertMissingDiffSegments(segments, diff, matchedIdSet, serverSegments);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const { main, intercept } = splitInterceptSegments(segments);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
segments,
|
|
185
|
+
mainSegments: main,
|
|
186
|
+
interceptSegments: intercept,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Reconcile error segments with cached segments.
|
|
192
|
+
*
|
|
193
|
+
* For error responses, the server returns the error boundary segment.
|
|
194
|
+
* This function overlays error segments onto the full cached tree,
|
|
195
|
+
* preserving sibling layouts that aren't in the error parent chain.
|
|
196
|
+
*/
|
|
197
|
+
export function reconcileErrorSegments(
|
|
198
|
+
cachedSegments: ResolvedSegment[],
|
|
199
|
+
errorSegments: ResolvedSegment[],
|
|
200
|
+
): ReconcileResult {
|
|
201
|
+
const errorMap = new Map<string, ResolvedSegment>();
|
|
202
|
+
errorSegments.forEach((s) => errorMap.set(s.id, s));
|
|
203
|
+
|
|
204
|
+
const segments = cachedSegments.map((cached) => {
|
|
205
|
+
const fromServer = errorMap.get(cached.id);
|
|
206
|
+
return fromServer || cached;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const { main, intercept } = splitInterceptSegments(segments);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
segments,
|
|
213
|
+
mainSegments: main,
|
|
214
|
+
interceptSegments: intercept,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ResolvedSegment } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tree structure categories for the `loading` property.
|
|
5
|
+
*
|
|
6
|
+
* The `loading` value on a segment determines the React element tree
|
|
7
|
+
* that renderSegments() produces. Different categories produce different
|
|
8
|
+
* element nesting, which causes React to remount components when the
|
|
9
|
+
* category changes between renders (SSR -> navigation -> action).
|
|
10
|
+
*
|
|
11
|
+
* "none" -> OutletProvider (no boundary wrapping)
|
|
12
|
+
* "suppressed" -> LoaderBoundary > Suspense > LoaderResolver > OutletProvider
|
|
13
|
+
* (boundary present, but no RouteContentWrapper for children)
|
|
14
|
+
* "active" -> LoaderBoundary > Suspense > LoaderResolver > OutletProvider
|
|
15
|
+
* > RouteContentWrapper > Suspense > Suspender > component
|
|
16
|
+
*/
|
|
17
|
+
type LoadingCategory = "none" | "suppressed" | "active";
|
|
18
|
+
|
|
19
|
+
function getLoadingCategory(loading: unknown): LoadingCategory {
|
|
20
|
+
if (loading === undefined || loading === null) return "none";
|
|
21
|
+
if (loading === false) return "suppressed";
|
|
22
|
+
return "active";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Assert that merging a server segment with a cached segment won't change
|
|
27
|
+
* the React tree structure. Logs a warning in development when a mismatch
|
|
28
|
+
* is detected.
|
|
29
|
+
*
|
|
30
|
+
* This catches the class of bugs where `loading()` with `{ ssr: false }`
|
|
31
|
+
* produces `loading=false` on SSR but `loading=<skeleton>` on actions,
|
|
32
|
+
* causing React to remount LoaderBoundary/RouteContentWrapper and destroy
|
|
33
|
+
* client state (useActionState, refs, etc.).
|
|
34
|
+
*
|
|
35
|
+
* @param cached - The currently cached segment
|
|
36
|
+
* @param incoming - The new segment from server
|
|
37
|
+
* @param context - Where this merge is happening (for the warning message)
|
|
38
|
+
*/
|
|
39
|
+
export function assertSegmentStructure(
|
|
40
|
+
cached: ResolvedSegment,
|
|
41
|
+
incoming: ResolvedSegment,
|
|
42
|
+
context: string,
|
|
43
|
+
): void {
|
|
44
|
+
if (process.env.NODE_ENV === "production") return;
|
|
45
|
+
|
|
46
|
+
const cachedCategory = getLoadingCategory(cached.loading);
|
|
47
|
+
const incomingCategory = getLoadingCategory(incoming.loading);
|
|
48
|
+
|
|
49
|
+
if (cachedCategory !== incomingCategory) {
|
|
50
|
+
console.warn(
|
|
51
|
+
`[RSC Router] Tree structure mismatch detected in ${context} ` +
|
|
52
|
+
`for segment "${cached.id}": loading category changed from ` +
|
|
53
|
+
`"${cachedCategory}" (${describeLoading(cached.loading)}) to ` +
|
|
54
|
+
`"${incomingCategory}" (${describeLoading(incoming.loading)}). ` +
|
|
55
|
+
`This will cause React to remount the component, destroying ` +
|
|
56
|
+
`useActionState and other client state. ` +
|
|
57
|
+
`The merge code should preserve the cached loading value.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check mountPath consistency. MountContextProvider is conditionally added
|
|
62
|
+
// in renderSegments() when mountPath is truthy, changing tree depth.
|
|
63
|
+
const cachedHasMount = !!cached.mountPath;
|
|
64
|
+
const incomingHasMount = !!incoming.mountPath;
|
|
65
|
+
if (cachedHasMount !== incomingHasMount) {
|
|
66
|
+
console.warn(
|
|
67
|
+
`[RSC Router] MountContextProvider mismatch detected in ${context} ` +
|
|
68
|
+
`for segment "${cached.id}": mountPath changed from ` +
|
|
69
|
+
`${cachedHasMount ? `"${cached.mountPath}"` : "undefined"} to ` +
|
|
70
|
+
`${incomingHasMount ? `"${incoming.mountPath}"` : "undefined"}. ` +
|
|
71
|
+
`This will cause React to remount the component, destroying ` +
|
|
72
|
+
`useActionState and other client state. ` +
|
|
73
|
+
`The merge code should preserve the cached mountPath value.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function describeLoading(loading: unknown): string {
|
|
79
|
+
if (loading === undefined) return "undefined";
|
|
80
|
+
if (loading === null) return "null";
|
|
81
|
+
if (loading === false) return "false";
|
|
82
|
+
return "ReactNode";
|
|
83
|
+
}
|