@rangojs/router 0.0.0-experimental.77 → 0.0.0-experimental.77ed8945
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -25
- package/dist/bin/rango.js +147 -57
- package/dist/vite/index.js +2103 -861
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +13 -8
- package/skills/api-client/SKILL.md +211 -0
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/cache-guide/SKILL.md +220 -30
- package/skills/caching/SKILL.md +116 -8
- package/skills/composability/SKILL.md +27 -2
- package/skills/css/SKILL.md +76 -0
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/handler-use/SKILL.md +3 -1
- package/skills/hooks/SKILL.md +229 -20
- package/skills/host-router/SKILL.md +66 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +26 -4
- package/skills/layout/SKILL.md +6 -7
- package/skills/links/SKILL.md +247 -17
- package/skills/loader/SKILL.md +219 -9
- package/skills/middleware/SKILL.md +47 -12
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/mime-routes/SKILL.md +27 -0
- package/skills/observability/SKILL.md +137 -0
- package/skills/parallel/SKILL.md +12 -6
- package/skills/prerender/SKILL.md +14 -33
- package/skills/rango/SKILL.md +238 -22
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/response-routes/SKILL.md +122 -47
- package/skills/route/SKILL.md +33 -4
- package/skills/router-setup/SKILL.md +3 -3
- package/skills/server-actions/SKILL.md +751 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/tailwind/SKILL.md +27 -3
- package/skills/typesafety/SKILL.md +319 -27
- package/skills/use-cache/SKILL.md +34 -5
- package/skills/view-transitions/SKILL.md +294 -0
- package/src/__augment-tests__/augment.ts +81 -0
- package/src/__augment-tests__/augmented.check.ts +116 -0
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/app-shell.ts +39 -0
- package/src/browser/event-controller.ts +86 -70
- package/src/browser/history-state.ts +21 -0
- package/src/browser/index.ts +3 -3
- package/src/browser/navigation-bridge.ts +29 -9
- package/src/browser/navigation-client.ts +99 -77
- package/src/browser/navigation-store.ts +7 -8
- package/src/browser/navigation-transaction.ts +10 -28
- package/src/browser/partial-update.ts +60 -40
- package/src/browser/prefetch/cache.ts +196 -49
- package/src/browser/prefetch/fetch.ts +203 -59
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +37 -13
- package/src/browser/react/Link.tsx +18 -13
- package/src/browser/react/NavigationProvider.tsx +75 -31
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/location-state-shared.ts +175 -4
- package/src/browser/react/location-state.ts +39 -13
- package/src/browser/react/use-handle.ts +17 -9
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +20 -8
- package/src/browser/react/use-reverse.ts +106 -0
- package/src/browser/react/use-router.ts +23 -2
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/response-adapter.ts +52 -1
- package/src/browser/rsc-router.tsx +71 -22
- package/src/browser/scroll-restoration.ts +22 -14
- package/src/browser/segment-reconciler.ts +10 -14
- package/src/browser/segment-structure-assert.ts +2 -2
- package/src/browser/server-action-bridge.ts +44 -30
- package/src/browser/types.ts +12 -2
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +60 -35
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/index.ts +8 -1
- package/src/build/prefix-tree-utils.ts +123 -0
- package/src/build/route-trie.ts +45 -1
- package/src/build/route-types/codegen.ts +4 -4
- package/src/build/route-types/include-resolution.ts +1 -1
- package/src/build/route-types/per-module-writer.ts +7 -4
- package/src/build/route-types/router-processing.ts +55 -14
- package/src/build/route-types/scan-filter.ts +1 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/build/runtime-discovery.ts +9 -20
- package/src/cache/cache-runtime.ts +17 -5
- package/src/cache/cache-scope.ts +51 -49
- package/src/cache/cf/cf-cache-store.ts +502 -32
- package/src/cache/cf/index.ts +3 -0
- package/src/cache/handle-snapshot.ts +103 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/memory-segment-store.ts +3 -2
- package/src/cache/types.ts +10 -6
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +96 -205
- package/src/context-var.ts +5 -5
- package/src/decode-loader-results.ts +36 -0
- package/src/errors.ts +30 -4
- package/src/handle.ts +4 -6
- package/src/host/index.ts +2 -2
- package/src/host/router.ts +129 -57
- package/src/host/types.ts +31 -2
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +140 -21
- package/src/index.rsc.ts +10 -6
- package/src/index.ts +17 -8
- package/src/loader-store.ts +500 -0
- package/src/loader.rsc.ts +2 -5
- package/src/loader.ts +3 -10
- package/src/missing-id-error.ts +68 -0
- package/src/outlet-context.ts +1 -1
- package/src/prerender/store.ts +9 -7
- package/src/prerender.ts +4 -4
- package/src/response-utils.ts +37 -0
- package/src/reverse.ts +65 -39
- package/src/route-content-wrapper.tsx +6 -28
- package/src/route-definition/dsl-helpers.ts +253 -265
- package/src/route-definition/helper-factories.ts +29 -139
- package/src/route-definition/helpers-types.ts +43 -15
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-definition/use-item-types.ts +32 -0
- package/src/route-types.ts +26 -41
- package/src/router/content-negotiation.ts +15 -2
- package/src/router/error-handling.ts +1 -1
- package/src/router/find-match.ts +54 -6
- package/src/router/handler-context.ts +21 -41
- package/src/router/intercept-resolution.ts +4 -18
- package/src/router/lazy-includes.ts +41 -22
- package/src/router/loader-resolution.ts +82 -36
- package/src/router/manifest.ts +41 -19
- package/src/router/match-api.ts +4 -3
- package/src/router/match-handlers.ts +1 -0
- package/src/router/match-middleware/cache-lookup.ts +57 -95
- package/src/router/match-middleware/cache-store.ts +3 -2
- package/src/router/match-result.ts +53 -32
- package/src/router/metrics.ts +1 -1
- package/src/router/middleware-types.ts +15 -26
- package/src/router/middleware.ts +99 -84
- package/src/router/pattern-matching.ts +116 -19
- package/src/router/prerender-match.ts +40 -15
- package/src/router/preview-match.ts +3 -1
- package/src/router/request-classification.ts +40 -37
- package/src/router/revalidation.ts +58 -2
- package/src/router/router-interfaces.ts +51 -35
- package/src/router/router-options.ts +25 -1
- package/src/router/router-registry.ts +2 -5
- package/src/router/segment-resolution/fresh.ts +27 -6
- package/src/router/segment-resolution/revalidation.ts +147 -106
- package/src/router/segment-resolution/static-store.ts +19 -5
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/trie-matching.ts +40 -16
- package/src/router/types.ts +8 -0
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +37 -25
- package/src/rsc/handler-context.ts +2 -2
- package/src/rsc/handler.ts +58 -77
- package/src/rsc/helpers.ts +72 -43
- package/src/rsc/index.ts +1 -1
- package/src/rsc/manifest-init.ts +28 -41
- package/src/rsc/origin-guard.ts +30 -10
- package/src/rsc/progressive-enhancement.ts +4 -0
- package/src/rsc/response-error.ts +79 -12
- package/src/rsc/response-route-handler.ts +76 -61
- package/src/rsc/rsc-rendering.ts +45 -51
- package/src/rsc/runtime-warnings.ts +9 -10
- package/src/rsc/server-action.ts +33 -39
- package/src/rsc/ssr-setup.ts +16 -0
- package/src/rsc/types.ts +8 -2
- package/src/search-params.ts +4 -4
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +132 -116
- package/src/serialize.ts +243 -0
- package/src/server/context.ts +175 -53
- package/src/server/cookie-store.ts +28 -4
- package/src/server/request-context.ts +57 -51
- package/src/ssr/index.tsx +5 -1
- package/src/static-handler.ts +1 -1
- package/src/types/global-namespace.ts +39 -26
- package/src/types/handler-context.ts +68 -50
- package/src/types/index.ts +1 -0
- package/src/types/loader-types.ts +11 -9
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +35 -2
- package/src/urls/include-helper.ts +34 -67
- package/src/urls/index.ts +1 -5
- package/src/urls/path-helper-types.ts +17 -3
- package/src/urls/path-helper.ts +17 -52
- package/src/urls/pattern-types.ts +36 -19
- package/src/urls/response-types.ts +22 -29
- package/src/urls/type-extraction.ts +58 -139
- package/src/urls/urls-function.ts +1 -5
- package/src/use-loader.tsx +413 -42
- package/src/vite/debug.ts +185 -0
- package/src/vite/discovery/bundle-postprocess.ts +6 -6
- package/src/vite/discovery/discover-routers.ts +106 -75
- package/src/vite/discovery/discovery-errors.ts +194 -0
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +72 -31
- package/src/vite/discovery/route-types-writer.ts +40 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/discovery/state.ts +33 -0
- package/src/vite/discovery/virtual-module-codegen.ts +13 -23
- package/src/vite/index.ts +2 -0
- package/src/vite/plugin-types.ts +67 -0
- package/src/vite/plugins/cjs-to-esm.ts +8 -7
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +28 -5
- 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/plugins/expose-action-id.ts +54 -30
- package/src/vite/plugins/expose-id-utils.ts +12 -8
- package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
- package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
- package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +496 -486
- package/src/vite/plugins/performance-tracks.ts +29 -25
- package/src/vite/plugins/use-cache-transform.ts +65 -50
- package/src/vite/plugins/version-injector.ts +39 -23
- package/src/vite/plugins/version-plugin.ts +59 -2
- package/src/vite/plugins/virtual-entries.ts +2 -2
- package/src/vite/rango.ts +116 -29
- package/src/vite/router-discovery.ts +753 -104
- package/src/vite/utils/ast-handler-extract.ts +15 -15
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/bundle-analysis.ts +4 -2
- package/src/vite/utils/client-chunks.ts +190 -0
- package/src/vite/utils/forward-user-plugins.ts +193 -0
- package/src/vite/utils/manifest-utils.ts +8 -59
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +5 -4
- package/src/vite/utils/shared-utils.ts +107 -26
- package/src/browser/action-response-classifier.ts +0 -99
|
@@ -53,6 +53,12 @@ export function useNavigation<T>(
|
|
|
53
53
|
});
|
|
54
54
|
const prevState = useRef(baseValue);
|
|
55
55
|
|
|
56
|
+
// Tracks whether the most recent setOptimisticValue call pinned the value
|
|
57
|
+
// to a non-idle state. Used to decide whether to emit a release update when
|
|
58
|
+
// returning to idle, so the optimistic store doesn't stay pinned if a
|
|
59
|
+
// parent transition (e.g. <Link> click) is still pending.
|
|
60
|
+
const optimisticPinnedRef = useRef(false);
|
|
61
|
+
|
|
56
62
|
// useOptimistic allows immediate updates during transitions/actions
|
|
57
63
|
const [value, setOptimisticValue] = useOptimistic(baseValue);
|
|
58
64
|
|
|
@@ -82,11 +88,25 @@ export function useNavigation<T>(
|
|
|
82
88
|
const hasInflightActions =
|
|
83
89
|
ctx.eventController.getInflightActions().size > 0;
|
|
84
90
|
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
const shouldPin = hasInflightActions || publicState.state !== "idle";
|
|
92
|
+
|
|
93
|
+
if (shouldPin) {
|
|
94
|
+
// Pin the optimistic store so the loading value shows immediately
|
|
95
|
+
// even if a parent transition (e.g. <Link> click) defers the
|
|
96
|
+
// urgent setBaseValue commit.
|
|
97
|
+
startTransition(() => {
|
|
98
|
+
setOptimisticValue(nextSelected);
|
|
99
|
+
});
|
|
100
|
+
optimisticPinnedRef.current = true;
|
|
101
|
+
} else if (optimisticPinnedRef.current) {
|
|
102
|
+
// Release a previously-pinned optimistic value. Without this,
|
|
103
|
+
// useOptimistic keeps returning the stale loading value while
|
|
104
|
+
// any parent transition is still pending, even after baseValue
|
|
105
|
+
// flipped to idle.
|
|
87
106
|
startTransition(() => {
|
|
88
107
|
setOptimisticValue(nextSelected);
|
|
89
108
|
});
|
|
109
|
+
optimisticPinnedRef.current = false;
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
// Always update base state so UI reflects current state
|
|
@@ -4,6 +4,8 @@ import { useContext, useState, useEffect, useRef } from "react";
|
|
|
4
4
|
import { NavigationStoreContext } from "./context.js";
|
|
5
5
|
import { shallowEqual } from "./shallow-equal.js";
|
|
6
6
|
|
|
7
|
+
const EMPTY_PARAMS: Record<string, string> = Object.freeze({});
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Hook to access the current route params.
|
|
9
11
|
*
|
|
@@ -16,24 +18,34 @@ import { shallowEqual } from "./shallow-equal.js";
|
|
|
16
18
|
* const params = useParams();
|
|
17
19
|
* // { productId: "123" }
|
|
18
20
|
*
|
|
21
|
+
* // Annotate the expected shape via a generic
|
|
22
|
+
* const { productId } = useParams<{ productId: string }>();
|
|
23
|
+
*
|
|
19
24
|
* // With selector
|
|
20
25
|
* const productId = useParams(p => p.productId);
|
|
21
26
|
* ```
|
|
22
27
|
*/
|
|
23
|
-
|
|
28
|
+
// `T extends object` (not `Record<string, string | undefined>`) so that
|
|
29
|
+
// interface shapes pass the constraint — interfaces lack an implicit
|
|
30
|
+
// index signature and would otherwise be rejected. The generic is a
|
|
31
|
+
// shape annotation, not a runtime check; the body always returns the
|
|
32
|
+
// underlying params map unchanged. The default and selector input use
|
|
33
|
+
// `string | undefined` because absent optional params are omitted from
|
|
34
|
+
// the params record at runtime — the type must reflect that so callers
|
|
35
|
+
// don't write `p.locale.length` and crash when the segment is absent.
|
|
36
|
+
export function useParams<
|
|
37
|
+
T extends object = Record<string, string | undefined>,
|
|
38
|
+
>(): Readonly<T>;
|
|
24
39
|
export function useParams<T>(
|
|
25
|
-
selector: (params: Record<string, string>) => T,
|
|
40
|
+
selector: (params: Record<string, string | undefined>) => T,
|
|
26
41
|
): T;
|
|
27
42
|
export function useParams<T>(
|
|
28
|
-
selector?: (params: Record<string, string>) => T,
|
|
29
|
-
): T | Record<string, string> {
|
|
43
|
+
selector?: (params: Record<string, string | undefined>) => T,
|
|
44
|
+
): T | Record<string, string | undefined> {
|
|
30
45
|
const ctx = useContext(NavigationStoreContext);
|
|
31
46
|
|
|
32
47
|
const [value, setValue] = useState<T | Record<string, string>>(() => {
|
|
33
|
-
|
|
34
|
-
return selector ? selector({}) : {};
|
|
35
|
-
}
|
|
36
|
-
const params = ctx.eventController.getParams();
|
|
48
|
+
const params = ctx ? ctx.eventController.getParams() : EMPTY_PARAMS;
|
|
37
49
|
return selector ? selector(params) : params;
|
|
38
50
|
});
|
|
39
51
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import type { LocalReverseFunction } from "../../reverse.js";
|
|
5
|
+
import { substitutePatternParams } from "../../router/substitute-pattern-params.js";
|
|
6
|
+
import { serializeSearchParams } from "../../search-params.js";
|
|
7
|
+
import { useMount } from "./use-mount.js";
|
|
8
|
+
import { useParams } from "./use-params.js";
|
|
9
|
+
|
|
10
|
+
type RouteEntry = string | { readonly path: string };
|
|
11
|
+
type LocalRouteMap = Readonly<Record<string, RouteEntry>>;
|
|
12
|
+
|
|
13
|
+
function getPattern(entry: RouteEntry | undefined): string | undefined {
|
|
14
|
+
if (entry === undefined) return undefined;
|
|
15
|
+
return typeof entry === "string" ? entry : entry.path;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Join an include mount prefix with a mount-relative pattern.
|
|
20
|
+
*
|
|
21
|
+
* `pattern === "/"` is the index of the local module — under a non-root
|
|
22
|
+
* mount it must collapse so `/` under `/blog` becomes `/blog`, not
|
|
23
|
+
* `/blog/`. This matches `ctx.reverse(".index")` on the server.
|
|
24
|
+
*/
|
|
25
|
+
function joinMount(mount: string, pattern: string): string {
|
|
26
|
+
if (pattern === "/") {
|
|
27
|
+
if (mount === "" || mount === "/") return "/";
|
|
28
|
+
return mount.endsWith("/") ? mount.slice(0, -1) : mount;
|
|
29
|
+
}
|
|
30
|
+
const normalizedMount = mount === "/" ? "" : mount.replace(/\/+$/, "");
|
|
31
|
+
return normalizedMount + pattern;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mount-aware reverse function for a locally-imported `routes` map.
|
|
36
|
+
*
|
|
37
|
+
* The `routes` map you pass IS the scope: `reverse("name")` looks the name up
|
|
38
|
+
* in that map (verbatim), prefixes the result with the surrounding `include()`
|
|
39
|
+
* mount path via `useMount()`, and substitutes params — auto-filling from the
|
|
40
|
+
* current matched route's params, with explicit params overriding. A module's
|
|
41
|
+
* components can therefore reverse their own routes without knowing where the
|
|
42
|
+
* module is mounted: include it under any prefix and the URLs resolve correctly.
|
|
43
|
+
*
|
|
44
|
+
* The leading dot is optional and cosmetic: `reverse("post")` and
|
|
45
|
+
* `reverse(".post")` resolve identically. The dot exists only as a readability
|
|
46
|
+
* convention and for parity with `ctx.reverse(".name")` on the server; here the
|
|
47
|
+
* passed map is the scope, so there is no separate global namespace to
|
|
48
|
+
* disambiguate and the dot carries no meaning.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```tsx
|
|
52
|
+
* "use client";
|
|
53
|
+
* import { Link, useReverse } from "@rangojs/router/client";
|
|
54
|
+
* import { routes as blogRoutes } from "../urls/blog.gen.js";
|
|
55
|
+
*
|
|
56
|
+
* function BlogNav() {
|
|
57
|
+
* const reverse = useReverse(blogRoutes);
|
|
58
|
+
* return (
|
|
59
|
+
* <>
|
|
60
|
+
* <Link to={reverse("index")}>Blog</Link>
|
|
61
|
+
* <Link to={reverse("post", { postId: "hello" })}>Post</Link>
|
|
62
|
+
* </>
|
|
63
|
+
* );
|
|
64
|
+
* }
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function useReverse<const TRoutes extends LocalRouteMap>(
|
|
68
|
+
routes: TRoutes,
|
|
69
|
+
): LocalReverseFunction<TRoutes> {
|
|
70
|
+
const mount = useMount();
|
|
71
|
+
const currentParams = useParams();
|
|
72
|
+
|
|
73
|
+
return useCallback(
|
|
74
|
+
((
|
|
75
|
+
name: string,
|
|
76
|
+
explicitParams?: Record<string, string | undefined>,
|
|
77
|
+
search?: Record<string, unknown>,
|
|
78
|
+
): string => {
|
|
79
|
+
// The leading dot is optional. The passed map IS the scope, so a dot to
|
|
80
|
+
// signal "local" is unnecessary — "detail" and ".detail" resolve the same.
|
|
81
|
+
// A dot is accepted (and stripped) for readability / ctx.reverse parity.
|
|
82
|
+
const lookupName = name.startsWith(".") ? name.slice(1) : name;
|
|
83
|
+
const entry = (routes as LocalRouteMap)[lookupName];
|
|
84
|
+
const pattern = getPattern(entry);
|
|
85
|
+
if (pattern === undefined) {
|
|
86
|
+
throw new Error(`Unknown route: "${name}"`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const joined = joinMount(mount, pattern);
|
|
90
|
+
|
|
91
|
+
const mergedParams = explicitParams
|
|
92
|
+
? { ...currentParams, ...explicitParams }
|
|
93
|
+
: currentParams;
|
|
94
|
+
|
|
95
|
+
const substituted = substitutePatternParams(joined, mergedParams, name);
|
|
96
|
+
|
|
97
|
+
if (search) {
|
|
98
|
+
const qs = serializeSearchParams(search);
|
|
99
|
+
if (qs) return `${substituted}?${qs}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return substituted;
|
|
103
|
+
}) as LocalReverseFunction<TRoutes>,
|
|
104
|
+
[routes, mount, currentParams],
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -13,6 +13,11 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
|
|
|
13
13
|
* useRouter() do not re-render on navigation state changes.
|
|
14
14
|
* For reactive navigation state, use useNavigation() instead.
|
|
15
15
|
*
|
|
16
|
+
* Methods read `basename` from the context on each call. It is set once from
|
|
17
|
+
* the initial payload and is stable within a session — a cross-app navigation
|
|
18
|
+
* is a full document load (X-RSC-Reload), so the target app mounts fresh with
|
|
19
|
+
* its own basename.
|
|
20
|
+
*
|
|
16
21
|
* @example
|
|
17
22
|
* ```tsx
|
|
18
23
|
* const router = useRouter();
|
|
@@ -29,7 +34,10 @@ export function useRouter(): RouterInstance {
|
|
|
29
34
|
throw new Error("useRouter must be used within NavigationProvider");
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
// Stable reference: ctx is
|
|
37
|
+
// Stable reference: ctx itself is stable, and reads on each method call
|
|
38
|
+
// pick up live basename values from the context (backed by a live ref
|
|
39
|
+
// in NavigationProvider), so app-switch transitions are reflected without
|
|
40
|
+
// recreating this object.
|
|
33
41
|
return useMemo<RouterInstance>(() => {
|
|
34
42
|
/** Prefix a root-relative path with basename if not already prefixed. */
|
|
35
43
|
function withBasename(url: string): string {
|
|
@@ -65,7 +73,20 @@ export function useRouter(): RouterInstance {
|
|
|
65
73
|
},
|
|
66
74
|
|
|
67
75
|
back(): void {
|
|
68
|
-
|
|
76
|
+
// Avoid escaping the host on the first entry of this session.
|
|
77
|
+
// Prefer the Navigation API; fall back to the router-stamped
|
|
78
|
+
// history.state.idx (set by pushHistoryWithIdx) for older browsers.
|
|
79
|
+
const nav = (window as { navigation?: { canGoBack: boolean } })
|
|
80
|
+
.navigation;
|
|
81
|
+
const canGoBack =
|
|
82
|
+
nav && typeof nav.canGoBack === "boolean"
|
|
83
|
+
? nav.canGoBack
|
|
84
|
+
: ((window.history.state as { idx?: number } | null)?.idx ?? 0) > 0;
|
|
85
|
+
if (canGoBack) {
|
|
86
|
+
window.history.back();
|
|
87
|
+
} else {
|
|
88
|
+
ctx.navigate(withBasename("/"), { replace: true });
|
|
89
|
+
}
|
|
69
90
|
},
|
|
70
91
|
|
|
71
92
|
forward(): void {
|
|
@@ -25,15 +25,18 @@ function parsePathname(pathname: string): string[] {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Build segments state from event controller
|
|
28
|
+
* Build segments state from event controller. `segmentIds` is the
|
|
29
|
+
* route-only list (parallels and loaders stripped) — distinct from the
|
|
30
|
+
* controller's `segmentOrder` which drives handle collection and includes
|
|
31
|
+
* parallel slot ids.
|
|
29
32
|
*/
|
|
30
33
|
function buildSegmentsState(
|
|
31
34
|
location: URL,
|
|
32
|
-
|
|
35
|
+
routeSegmentIds: string[],
|
|
33
36
|
): SegmentsState {
|
|
34
37
|
return {
|
|
35
38
|
path: parsePathname(location.pathname),
|
|
36
|
-
segmentIds:
|
|
39
|
+
segmentIds: routeSegmentIds,
|
|
37
40
|
location,
|
|
38
41
|
};
|
|
39
42
|
}
|
|
@@ -74,7 +77,7 @@ export function useSegments<T>(
|
|
|
74
77
|
const handleState = ctx.eventController.getHandleState();
|
|
75
78
|
const segmentsState = buildSegmentsState(
|
|
76
79
|
location as URL,
|
|
77
|
-
handleState.
|
|
80
|
+
handleState.routeSegmentIds,
|
|
78
81
|
);
|
|
79
82
|
return selector ? selector(segmentsState) : segmentsState;
|
|
80
83
|
});
|
|
@@ -94,7 +97,7 @@ export function useSegments<T>(
|
|
|
94
97
|
// render-time setState calls.
|
|
95
98
|
const segmentsCache = useRef<{
|
|
96
99
|
location: URL;
|
|
97
|
-
|
|
100
|
+
routeSegmentIds: string[];
|
|
98
101
|
state: SegmentsState;
|
|
99
102
|
} | null>(null);
|
|
100
103
|
|
|
@@ -113,17 +116,17 @@ export function useSegments<T>(
|
|
|
113
116
|
if (
|
|
114
117
|
cache &&
|
|
115
118
|
cache.location === location &&
|
|
116
|
-
cache.
|
|
119
|
+
cache.routeSegmentIds === handleState.routeSegmentIds
|
|
117
120
|
) {
|
|
118
121
|
segmentsState = cache.state;
|
|
119
122
|
} else {
|
|
120
123
|
segmentsState = buildSegmentsState(
|
|
121
124
|
location as URL,
|
|
122
|
-
handleState.
|
|
125
|
+
handleState.routeSegmentIds,
|
|
123
126
|
);
|
|
124
127
|
segmentsCache.current = {
|
|
125
128
|
location: location as URL,
|
|
126
|
-
|
|
129
|
+
routeSegmentIds: handleState.routeSegmentIds,
|
|
127
130
|
state: segmentsState,
|
|
128
131
|
};
|
|
129
132
|
}
|
|
@@ -24,6 +24,51 @@ export function emptyResponse(): Response {
|
|
|
24
24
|
return new Response(null, { status: 200 });
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Whether an RSC content response carries a server-stamped router identity
|
|
29
|
+
* (`X-RSC-Router-Id`) that DIFFERS from the id this client expects (its own
|
|
30
|
+
* routerId, also sent as `_rsc_rid`). Pre-decode integrity check: lets a caller
|
|
31
|
+
* refuse a foreign app's payload before `createFromFetch` imports its chunks.
|
|
32
|
+
*
|
|
33
|
+
* True ONLY when both the header and the expected id are present and differ. An
|
|
34
|
+
* absent header (control-only reload/redirect responses are not stamped) or an
|
|
35
|
+
* absent expected id (e.g. before the client is seeded) is a pass-through —
|
|
36
|
+
* never a false reject.
|
|
37
|
+
*/
|
|
38
|
+
export function isForeignRouterId(
|
|
39
|
+
response: Response,
|
|
40
|
+
expectedId: string | undefined,
|
|
41
|
+
): boolean {
|
|
42
|
+
const got = response.headers.get("X-RSC-Router-Id");
|
|
43
|
+
if (!got || !expectedId) return false;
|
|
44
|
+
return got !== expectedId;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Handle the X-RSC-Reload control header (server requests a full page reload on
|
|
49
|
+
* a version mismatch). Returns a short-circuit response when the header is
|
|
50
|
+
* present -- emptyResponse() if the URL was blocked by origin validation, or a
|
|
51
|
+
* never-resolving promise while the page reloads -- and null when absent, so
|
|
52
|
+
* the caller continues processing (e.g. the X-RSC-Redirect check). Scoped to
|
|
53
|
+
* X-RSC-Reload only; redirect handling differs between callers.
|
|
54
|
+
*/
|
|
55
|
+
export function handleReloadHeader(
|
|
56
|
+
response: Response,
|
|
57
|
+
opts: { onBlocked: () => void; onReload: (url: string) => void },
|
|
58
|
+
): Response | Promise<Response> | null {
|
|
59
|
+
const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
|
|
60
|
+
if (reload === "blocked") {
|
|
61
|
+
opts.onBlocked();
|
|
62
|
+
return emptyResponse();
|
|
63
|
+
}
|
|
64
|
+
if (reload) {
|
|
65
|
+
opts.onReload(reload.url);
|
|
66
|
+
window.location.href = reload.url;
|
|
67
|
+
return new Promise<Response>(() => {});
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
27
72
|
/**
|
|
28
73
|
* Tee a response body for RSC parsing and stream completion tracking.
|
|
29
74
|
* Returns a new Response with one branch; the other is consumed to detect
|
|
@@ -31,11 +76,17 @@ export function emptyResponse(): Response {
|
|
|
31
76
|
*
|
|
32
77
|
* If the response has no body, onComplete fires synchronously.
|
|
33
78
|
* If signal is provided, an abort cancels the tracking reader.
|
|
79
|
+
*
|
|
80
|
+
* `silent` suppresses the stream-error log. Prefetch passes it: a speculative,
|
|
81
|
+
* low-priority prefetch that is aborted or never consumed can error its stream
|
|
82
|
+
* benignly, which is not worth surfacing. The fresh-navigation path keeps the
|
|
83
|
+
* log (default), where a stream error reflects a real failed navigation.
|
|
34
84
|
*/
|
|
35
85
|
export function teeWithCompletion(
|
|
36
86
|
response: Response,
|
|
37
87
|
onComplete: () => void,
|
|
38
88
|
signal?: AbortSignal,
|
|
89
|
+
silent = false,
|
|
39
90
|
): Response {
|
|
40
91
|
if (!response.body) {
|
|
41
92
|
onComplete();
|
|
@@ -59,7 +110,7 @@ export function teeWithCompletion(
|
|
|
59
110
|
onComplete();
|
|
60
111
|
}
|
|
61
112
|
})().catch((error) => {
|
|
62
|
-
if (!signal?.aborted) {
|
|
113
|
+
if (!silent && !signal?.aborted) {
|
|
63
114
|
console.error("[Browser] Error reading tracking stream:", error);
|
|
64
115
|
}
|
|
65
116
|
onComplete();
|
|
@@ -23,11 +23,13 @@ import type { EventController } from "./event-controller.js";
|
|
|
23
23
|
import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
|
|
24
24
|
import { initRangoState } from "./rango-state.js";
|
|
25
25
|
import { initPrefetchCache } from "./prefetch/cache.js";
|
|
26
|
+
import { setPrefetchDecoder } from "./prefetch/fetch.js";
|
|
26
27
|
import { setAppVersion } from "./app-version.js";
|
|
27
28
|
import {
|
|
28
29
|
isInterceptSegment,
|
|
29
30
|
splitInterceptSegments,
|
|
30
31
|
} from "./intercept-utils.js";
|
|
32
|
+
import { createAppShellRef } from "./app-shell.js";
|
|
31
33
|
|
|
32
34
|
// Vite HMR types are provided by vite/client
|
|
33
35
|
|
|
@@ -114,13 +116,22 @@ export interface BrowserAppContext {
|
|
|
114
116
|
warmupEnabled?: boolean;
|
|
115
117
|
/** App version for prefetch version mismatch detection */
|
|
116
118
|
version?: string;
|
|
119
|
+
/**
|
|
120
|
+
* App-shell ref, read through on each render so renderSegments and the
|
|
121
|
+
* NavigationProvider see rootLayout/basename/version without closing over a
|
|
122
|
+
* stale snapshot. Set once from the initial payload and not swapped within a
|
|
123
|
+
* session: a cross-app navigation is a full document load (X-RSC-Reload), so
|
|
124
|
+
* the target app establishes its own shell on load. Theme, warmup, and
|
|
125
|
+
* prefetch TTL are document-lifetime too (see AppShell).
|
|
126
|
+
*/
|
|
127
|
+
appShellRef?: import("./app-shell.js").AppShellRef;
|
|
117
128
|
}
|
|
118
129
|
|
|
119
130
|
// Module-level state for the initialized app
|
|
120
131
|
let browserAppContext: BrowserAppContext | null = null;
|
|
121
132
|
|
|
122
133
|
/**
|
|
123
|
-
* Initialize the browser app. Must be called before rendering
|
|
134
|
+
* Initialize the browser app. Must be called before rendering Rango.
|
|
124
135
|
*
|
|
125
136
|
* This function:
|
|
126
137
|
* - Loads the initial RSC payload from the stream
|
|
@@ -204,13 +215,23 @@ export async function initBrowserApp(
|
|
|
204
215
|
// Create composable utilities
|
|
205
216
|
const client = createNavigationClient(deps);
|
|
206
217
|
|
|
207
|
-
//
|
|
208
|
-
|
|
218
|
+
// Capture the per-router app-shell. rootLayout, basename, and version live
|
|
219
|
+
// here and are read through the ref at call time rather than closed over.
|
|
220
|
+
// It is set once from the initial payload and not swapped within a session:
|
|
221
|
+
// a cross-app navigation is a full document load (X-RSC-Reload), so the
|
|
222
|
+
// target app establishes its own shell on load.
|
|
209
223
|
const version = initialPayload.metadata?.version;
|
|
224
|
+
const appShellRef = createAppShellRef({
|
|
225
|
+
routerId: initialPayload.metadata?.routerId,
|
|
226
|
+
rootLayout: initialPayload.metadata?.rootLayout,
|
|
227
|
+
basename: initialPayload.metadata?.basename,
|
|
228
|
+
version,
|
|
229
|
+
});
|
|
210
230
|
|
|
211
231
|
// Initialize the localStorage state key for cache invalidation.
|
|
212
|
-
//
|
|
213
|
-
|
|
232
|
+
// The build version busts cached prefetches on deploy; the routerId
|
|
233
|
+
// namespaces the key so sibling apps on the same origin don't collide.
|
|
234
|
+
initRangoState(version ?? "0", initialPayload.metadata?.routerId);
|
|
214
235
|
setAppVersion(version);
|
|
215
236
|
|
|
216
237
|
// Initialize the in-memory prefetch cache TTL from server config.
|
|
@@ -220,11 +241,22 @@ export async function initBrowserApp(
|
|
|
220
241
|
initPrefetchCache(prefetchCacheTTL);
|
|
221
242
|
}
|
|
222
243
|
|
|
223
|
-
//
|
|
244
|
+
// Wire the RSC decoder so prefetches decode eagerly and warm the route's
|
|
245
|
+
// client chunks (same createFromFetch the navigation client uses).
|
|
246
|
+
setPrefetchDecoder((response) => deps.createFromFetch<RscPayload>(response));
|
|
247
|
+
|
|
248
|
+
// Create a bound renderSegments that reads rootLayout through the shell ref.
|
|
249
|
+
// The shell is set once at init and not swapped within a session (a cross-app
|
|
250
|
+
// navigation is a full document load), so this always renders this app's
|
|
251
|
+
// Document; reading through the ref just avoids closing over a stale value.
|
|
224
252
|
const renderSegments = (
|
|
225
253
|
segments: ResolvedSegment[],
|
|
226
254
|
options?: RenderSegmentsOptions,
|
|
227
|
-
) =>
|
|
255
|
+
) =>
|
|
256
|
+
baseRenderSegments(segments, {
|
|
257
|
+
...options,
|
|
258
|
+
rootLayout: appShellRef.get().rootLayout,
|
|
259
|
+
});
|
|
228
260
|
|
|
229
261
|
// Lazy reference for navigation bridge — the action bridge is created first
|
|
230
262
|
// but may need to trigger SPA navigation for action redirects.
|
|
@@ -300,11 +332,11 @@ export async function initBrowserApp(
|
|
|
300
332
|
// full lifecycle (fetching + streaming, before commit) without
|
|
301
333
|
// blocking on server actions.
|
|
302
334
|
if (eventController.getState().isNavigating) {
|
|
303
|
-
console.log("[
|
|
335
|
+
console.log("[Rango] HMR: Skipping — navigation in progress");
|
|
304
336
|
return;
|
|
305
337
|
}
|
|
306
338
|
|
|
307
|
-
console.log("[
|
|
339
|
+
console.log("[Rango] HMR: Server update, refetching RSC");
|
|
308
340
|
|
|
309
341
|
const abort = new AbortController();
|
|
310
342
|
hmrAbort = abort;
|
|
@@ -339,11 +371,18 @@ export async function initBrowserApp(
|
|
|
339
371
|
// Update version BEFORE rebuilding state so that
|
|
340
372
|
// clearHistoryCache() runs first, then the fresh segment
|
|
341
373
|
// cache entry we create below survives.
|
|
374
|
+
//
|
|
375
|
+
// Compare against the bridge's live version, not the init-time
|
|
376
|
+
// `version` const: after the first HMR bump the const is stale, so a
|
|
377
|
+
// later update with an unchanged version would otherwise re-clear the
|
|
378
|
+
// cache and re-broadcast across tabs/apps. The live read fires only
|
|
379
|
+
// on a genuine version change.
|
|
342
380
|
const newVersion = payload.metadata.version;
|
|
343
|
-
|
|
381
|
+
const currentVersion = navigationBridge.getVersion();
|
|
382
|
+
if (newVersion && newVersion !== currentVersion) {
|
|
344
383
|
console.log(
|
|
345
|
-
"[
|
|
346
|
-
|
|
384
|
+
"[Rango] HMR: version changed",
|
|
385
|
+
currentVersion,
|
|
347
386
|
"→",
|
|
348
387
|
newVersion,
|
|
349
388
|
"clearing caches",
|
|
@@ -351,6 +390,13 @@ export async function initBrowserApp(
|
|
|
351
390
|
navigationBridge.updateVersion(newVersion);
|
|
352
391
|
}
|
|
353
392
|
|
|
393
|
+
// Apply only partial segment updates. A non-partial payload during
|
|
394
|
+
// HMR is transient: the worker route table is still rebuilding after
|
|
395
|
+
// the edit, so the URL momentarily resolves to not-found/catch-all.
|
|
396
|
+
// Skip it -- the debounced follow-up refetch returns the settled
|
|
397
|
+
// route's partial payload and renders it below. We never reload here:
|
|
398
|
+
// a paramless document GET would run the SSR path and surface the
|
|
399
|
+
// not-found page during that same transient.
|
|
354
400
|
if (payload.metadata?.isPartial) {
|
|
355
401
|
const segments = payload.metadata.segments || [];
|
|
356
402
|
const matched = payload.metadata.matched || [];
|
|
@@ -390,10 +436,10 @@ export async function initBrowserApp(
|
|
|
390
436
|
|
|
391
437
|
await streamComplete;
|
|
392
438
|
handle.complete(new URL(window.location.href));
|
|
393
|
-
console.log("[
|
|
439
|
+
console.log("[Rango] HMR: RSC stream complete");
|
|
394
440
|
} catch (err) {
|
|
395
441
|
if (abort.signal.aborted) return;
|
|
396
|
-
console.warn("[
|
|
442
|
+
console.warn("[Rango] HMR: Refetch failed, reloading page", err);
|
|
397
443
|
window.location.reload();
|
|
398
444
|
return;
|
|
399
445
|
} finally {
|
|
@@ -405,7 +451,7 @@ export async function initBrowserApp(
|
|
|
405
451
|
});
|
|
406
452
|
}
|
|
407
453
|
|
|
408
|
-
// Store context for
|
|
454
|
+
// Store context for Rango component
|
|
409
455
|
const context: BrowserAppContext = {
|
|
410
456
|
store,
|
|
411
457
|
eventController,
|
|
@@ -416,6 +462,7 @@ export async function initBrowserApp(
|
|
|
416
462
|
initialTheme: effectiveInitialTheme,
|
|
417
463
|
warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
|
|
418
464
|
version,
|
|
465
|
+
appShellRef,
|
|
419
466
|
};
|
|
420
467
|
browserAppContext = context;
|
|
421
468
|
|
|
@@ -428,7 +475,7 @@ export async function initBrowserApp(
|
|
|
428
475
|
export function getBrowserAppContext(): BrowserAppContext {
|
|
429
476
|
if (!browserAppContext) {
|
|
430
477
|
throw new Error(
|
|
431
|
-
"
|
|
478
|
+
"Rango: initBrowserApp() must be called before rendering Rango",
|
|
432
479
|
);
|
|
433
480
|
}
|
|
434
481
|
return browserAppContext;
|
|
@@ -442,18 +489,18 @@ export function resetBrowserAppContext(): void {
|
|
|
442
489
|
}
|
|
443
490
|
|
|
444
491
|
/**
|
|
445
|
-
* Props for the
|
|
492
|
+
* Props for the Rango component
|
|
446
493
|
*/
|
|
447
|
-
export interface
|
|
494
|
+
export interface RangoProps {}
|
|
448
495
|
|
|
449
496
|
/**
|
|
450
|
-
*
|
|
497
|
+
* Rango component - renders the RSC router with all internal wiring.
|
|
451
498
|
*
|
|
452
499
|
* Must be called after initBrowserApp() has completed.
|
|
453
500
|
*
|
|
454
501
|
* @example
|
|
455
502
|
* ```tsx
|
|
456
|
-
* import { initBrowserApp,
|
|
503
|
+
* import { initBrowserApp, Rango } from "rsc-router/browser";
|
|
457
504
|
* import { rscStream } from "rsc-html-stream/client";
|
|
458
505
|
* import * as rscBrowser from "@vitejs/plugin-rsc/browser";
|
|
459
506
|
*
|
|
@@ -463,14 +510,14 @@ export interface RSCRouterProps {}
|
|
|
463
510
|
* hydrateRoot(
|
|
464
511
|
* document,
|
|
465
512
|
* <React.StrictMode>
|
|
466
|
-
* <
|
|
513
|
+
* <Rango />
|
|
467
514
|
* </React.StrictMode>
|
|
468
515
|
* );
|
|
469
516
|
* }
|
|
470
517
|
* main();
|
|
471
518
|
* ```
|
|
472
519
|
*/
|
|
473
|
-
export function
|
|
520
|
+
export function Rango(_props: RangoProps): React.ReactElement {
|
|
474
521
|
const {
|
|
475
522
|
store,
|
|
476
523
|
eventController,
|
|
@@ -481,6 +528,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
|
|
|
481
528
|
initialTheme,
|
|
482
529
|
warmupEnabled,
|
|
483
530
|
version,
|
|
531
|
+
appShellRef,
|
|
484
532
|
} = getBrowserAppContext();
|
|
485
533
|
|
|
486
534
|
// Signal that the React tree has hydrated. useEffect only fires after
|
|
@@ -501,6 +549,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
|
|
|
501
549
|
warmupEnabled={warmupEnabled}
|
|
502
550
|
version={version}
|
|
503
551
|
basename={initialPayload.metadata?.basename}
|
|
552
|
+
appShellRef={appShellRef}
|
|
504
553
|
/>
|
|
505
554
|
);
|
|
506
555
|
}
|
|
@@ -332,6 +332,8 @@ export function scrollToHash(): boolean {
|
|
|
332
332
|
* Scroll to top of page
|
|
333
333
|
*/
|
|
334
334
|
export function scrollToTop(): void {
|
|
335
|
+
if (typeof window === "undefined") return;
|
|
336
|
+
if (typeof window.scrollTo !== "function") return;
|
|
335
337
|
window.scrollTo(0, 0);
|
|
336
338
|
}
|
|
337
339
|
|
|
@@ -374,20 +376,26 @@ export function handleNavigationEnd(options: {
|
|
|
374
376
|
// Fall through to hash or top if no saved position
|
|
375
377
|
}
|
|
376
378
|
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
379
|
+
// scrollToHash / scrollToTop run synchronously here.
|
|
380
|
+
// handleNavigationEnd is invoked from NavigationProvider's
|
|
381
|
+
// useLayoutEffect (post-commit, pre-paint), so a sync scrollTo is
|
|
382
|
+
// captured by the upcoming paint AND by startViewTransition's snapshot.
|
|
383
|
+
// Deferring via rAF here pushed the call past the snapshot capture,
|
|
384
|
+
// making forward navigations wrapped in a layout/route view transition
|
|
385
|
+
// skip scroll-to-top — the live DOM scrolled but the captured snapshot
|
|
386
|
+
// was at the previous scroll position, so the user-facing page stayed
|
|
387
|
+
// visually clamped at the source page's scrollY (often the new tree's
|
|
388
|
+
// max scroll for tall→short navs). Y=0 / a hash element are robust
|
|
389
|
+
// against unmeasured layout, so sync scroll is correct here even
|
|
390
|
+
// before the new tree's scrollHeight settles.
|
|
391
|
+
//
|
|
392
|
+
// (The restore branch above keeps deferToNextPaint because savedY
|
|
393
|
+
// depends on the new tree's max scroll; sync scrollTo against an
|
|
394
|
+
// unmeasured DOM would clamp savedY to whatever the old/zero max was.)
|
|
395
|
+
if (scrollToHash()) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
scrollToTop();
|
|
391
399
|
}
|
|
392
400
|
|
|
393
401
|
/**
|