Skip to content

Commit 626f193

Browse files
authored
Migrate browser URL state to the Navigation API (#168)
Replace the History API with the newer Navigation API. This is mostly a modernization, but as a side-effect this also fixes a duplicated history entry when opening a query plan.
1 parent 7974abd commit 626f193

1 file changed

Lines changed: 56 additions & 24 deletions

File tree

standalone-app/src/browserUrlHooks.ts

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,69 @@
11
// `prettier` does not yet support `import type`
22

33
import type React from "react";
4-
import {useCallback, useEffect, useState} from "react";
4+
import {useCallback, useEffect, useMemo, useRef, useSyncExternalStore} from "react";
55

66
type URLState = [URL, (value: React.SetStateAction<URL>, noHistoryEntry?: boolean) => void];
77

8+
interface PendingUrlUpdate {
9+
url: URL;
10+
shouldPush: boolean;
11+
}
12+
13+
// `info` is not persisted in history; it only identifies the navigate event
14+
// synchronously generated by this hook.
15+
const browserUrlNavigationInfo = {};
16+
17+
function getBrowserUrl(): string {
18+
return window.navigation.currentEntry?.url ?? window.location.href;
19+
}
20+
21+
function subscribeToBrowserUrl(onStoreChange: () => void): () => void {
22+
window.navigation.addEventListener("currententrychange", onStoreChange);
23+
return () => window.navigation.removeEventListener("currententrychange", onStoreChange);
24+
}
25+
826
export function useBrowserUrl(): URLState {
9-
const [url, setUrlInternal] = useState<URL>(() => new URL(window.location.toString()));
27+
const href = useSyncExternalStore(subscribeToBrowserUrl, getBrowserUrl);
28+
const url = useMemo(() => new URL(href), [href]);
29+
const pendingUrlUpdate = useRef<PendingUrlUpdate | undefined>(undefined);
30+
1031
useEffect(() => {
11-
const listener = (_e: PopStateEvent) => {
12-
setUrlInternal(new URL(window.location.toString()));
32+
const listener = (event: NavigateEvent) => {
33+
if (event.canIntercept && event.info === browserUrlNavigationInfo) {
34+
// These navigations only update URL-backed state. Preserve the user's
35+
// current focus and scroll position while making them same-document.
36+
event.intercept({
37+
focusReset: "manual",
38+
scroll: "manual",
39+
});
40+
}
1341
};
14-
window.addEventListener("popstate", listener);
15-
return () => window.removeEventListener("popstate", listener);
16-
}, [setUrlInternal]);
17-
const setUrl = useCallback(
18-
(action: React.SetStateAction<URL>, noHistoryEntry?: boolean) => {
19-
let newUrl: URL;
20-
if (action instanceof Function) newUrl = action(url);
21-
else newUrl = action;
22-
setUrlInternal(newUrl);
23-
// Don't change the URL if the parameter didn't change.
24-
// This is important to ensure the history stays intact.
25-
if (newUrl.toString() != new URL(window.location.toString()).toString()) {
26-
if (noHistoryEntry) {
27-
window.history.replaceState(null, "", newUrl);
28-
} else {
29-
window.history.pushState(null, "", newUrl);
42+
window.navigation.addEventListener("navigate", listener);
43+
return () => window.navigation.removeEventListener("navigate", listener);
44+
}, []);
45+
const setUrl = useCallback((action: React.SetStateAction<URL>, noHistoryEntry?: boolean) => {
46+
let pending = pendingUrlUpdate.current;
47+
if (!pending) {
48+
pending = {
49+
url: new URL(getBrowserUrl()),
50+
shouldPush: false,
51+
};
52+
pendingUrlUpdate.current = pending;
53+
queueMicrotask(() => {
54+
const update = pendingUrlUpdate.current;
55+
pendingUrlUpdate.current = undefined;
56+
if (update && update.url.href !== getBrowserUrl()) {
57+
window.navigation.navigate(update.url.toString(), {
58+
history: update.shouldPush ? "push" : "replace",
59+
info: browserUrlNavigationInfo,
60+
});
3061
}
31-
}
32-
},
33-
[url, setUrlInternal],
34-
);
62+
});
63+
}
64+
pending.url = typeof action === "function" ? action(pending.url) : action;
65+
pending.shouldPush ||= !noHistoryEntry;
66+
}, []);
3567
return [url, setUrl];
3668
}
3769

0 commit comments

Comments
 (0)