fix: canonicalize browser navigation after commit

This commit is contained in:
Codex
2026-07-30 22:35:37 +08:00
parent 95abd09c39
commit 9bc0f27f12
4 changed files with 103 additions and 16 deletions
+7 -2
View File
@@ -3,15 +3,20 @@ import { createRoot } from 'react-dom/client';
import 'animal-island-ui/style';
import { ConsoleAuthGate } from './auth/console-auth.js';
import { AppShell } from './app-shell.js';
import { AppShell, resolveRoute } from './app-shell.js';
import { useBrowserPathname } from './navigation/use-browser-pathname.js';
import './styles.css';
const root = document.querySelector<HTMLElement>('#root');
if (!root) throw new Error('Missing application root');
function resolveBrowserPathname(pathname: string): string {
const route = resolveRoute(pathname);
return route.kind === 'redirect' ? (route.to ?? pathname) : pathname;
}
function BrowserApp() {
const pathname = useBrowserPathname();
const pathname = useBrowserPathname(resolveBrowserPathname);
return (
<ConsoleAuthGate>
<AppShell pathname={pathname} />
@@ -4,12 +4,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { useBrowserPathname } from './use-browser-pathname.js';
function Harness({ link = <a href="/automation">Automation</a> }: { link?: React.ReactNode }) {
const pathname = useBrowserPathname();
function Harness({
link = <a href="/automation">Automation</a>,
onMainFocus,
resolvePathname,
}: {
link?: React.ReactNode;
onMainFocus?: (pathname: string) => void;
resolvePathname?: (pathname: string) => string;
}) {
const pathname = useBrowserPathname(resolvePathname);
return (
<>
{link}
<main id="main-content" tabIndex={-1}>
<main id="main-content" tabIndex={-1} onFocus={() => onMainFocus?.(pathname)}>
{pathname}
</main>
</>
@@ -27,7 +35,8 @@ describe('useBrowserPathname', () => {
window.history.replaceState(null, '', '/fleet');
const pushState = vi.spyOn(window.history, 'pushState');
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined);
render(<Harness />);
const focusedPathnames: string[] = [];
render(<Harness onMainFocus={(pathname) => focusedPathnames.push(pathname)} />);
const link = screen.getByRole('link', { name: 'Automation' });
const accepted = fireEvent.click(link);
@@ -36,9 +45,39 @@ describe('useBrowserPathname', () => {
expect(pushState).toHaveBeenCalledWith(null, '', '/automation');
expect(screen.getByRole('main').textContent).toBe('/automation');
expect(document.activeElement).toBe(screen.getByRole('main'));
expect(focusedPathnames).toEqual(['/automation']);
expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' });
});
it('replaces a legacy pathname with its canonical destination', () => {
window.history.replaceState(null, '', '/settings/instances');
const replaceState = vi.spyOn(window.history, 'replaceState');
render(
<Harness
resolvePathname={(pathname) =>
pathname === '/settings/instances' ? '/settings/system' : pathname
}
/>,
);
expect(replaceState).toHaveBeenCalledWith(null, '', '/settings/system');
expect(window.location.pathname).toBe('/settings/system');
expect(screen.getByRole('main').textContent).toBe('/settings/system');
});
it('makes an exact-current link a no-op without adding history', () => {
window.history.replaceState(null, '', '/fleet');
const pushState = vi.spyOn(window.history, 'pushState');
render(<Harness link={<a href="/fleet">Current</a>} />);
const accepted = fireEvent.click(screen.getByRole('link', { name: 'Current' }));
expect(accepted).toBe(false);
expect(pushState).not.toHaveBeenCalled();
expect(window.location.pathname).toBe('/fleet');
});
it('tracks browser history traversal and restores the page start', () => {
window.history.replaceState(null, '', '/automation');
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined);
@@ -1,4 +1,18 @@
import { useEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
export type PathnameResolver = (pathname: string) => string;
const keepPathname: PathnameResolver = (pathname) => pathname;
function currentRelativeLocation(): string {
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
function resolvedLocation(location: string, resolvePathname: PathnameResolver): string {
const url = new URL(location, window.location.origin);
const pathname = resolvePathname(url.pathname);
return `${pathname}${url.search}${url.hash}`;
}
function restorePageStart(): void {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
@@ -9,8 +23,22 @@ function isModifiedClick(event: MouseEvent): boolean {
return event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
}
export function useBrowserPathname(): string {
const [pathname, setPathname] = useState(() => window.location.pathname);
export function useBrowserPathname(resolvePathname: PathnameResolver = keepPathname): string {
const [location, setLocation] = useState(currentRelativeLocation);
const restoreAfterCommit = useRef(false);
const pathname = new URL(location, window.location.origin).pathname;
useLayoutEffect(() => {
const canonicalLocation = resolvedLocation(location, resolvePathname);
if (canonicalLocation !== location) {
window.history.replaceState(window.history.state, '', canonicalLocation);
setLocation(canonicalLocation);
return;
}
if (!restoreAfterCommit.current) return;
restoreAfterCommit.current = false;
restorePageStart();
}, [location, resolvePathname]);
useEffect(() => {
function handleClick(event: MouseEvent): void {
@@ -23,6 +51,11 @@ export function useBrowserPathname(): string {
const url = new URL(anchor.href, window.location.href);
if (url.origin !== window.location.origin) return;
const nextLocation = `${url.pathname}${url.search}${url.hash}`;
if (nextLocation === currentRelativeLocation()) {
event.preventDefault();
return;
}
if (
url.pathname === window.location.pathname &&
url.search === window.location.search &&
@@ -31,14 +64,14 @@ export function useBrowserPathname(): string {
return;
event.preventDefault();
window.history.pushState(null, '', `${url.pathname}${url.search}${url.hash}`);
setPathname(window.location.pathname);
restorePageStart();
window.history.pushState(null, '', nextLocation);
restoreAfterCommit.current = true;
setLocation(currentRelativeLocation());
}
function handlePopState(): void {
setPathname(window.location.pathname);
restorePageStart();
restoreAfterCommit.current = true;
setLocation(currentRelativeLocation());
}
document.addEventListener('click', handleClick);