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 'animal-island-ui/style';
import { ConsoleAuthGate } from './auth/console-auth.js'; 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 { useBrowserPathname } from './navigation/use-browser-pathname.js';
import './styles.css'; import './styles.css';
const root = document.querySelector<HTMLElement>('#root'); const root = document.querySelector<HTMLElement>('#root');
if (!root) throw new Error('Missing application 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() { function BrowserApp() {
const pathname = useBrowserPathname(); const pathname = useBrowserPathname(resolveBrowserPathname);
return ( return (
<ConsoleAuthGate> <ConsoleAuthGate>
<AppShell pathname={pathname} /> <AppShell pathname={pathname} />
@@ -4,12 +4,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { useBrowserPathname } from './use-browser-pathname.js'; import { useBrowserPathname } from './use-browser-pathname.js';
function Harness({ link = <a href="/automation">Automation</a> }: { link?: React.ReactNode }) { function Harness({
const pathname = useBrowserPathname(); link = <a href="/automation">Automation</a>,
onMainFocus,
resolvePathname,
}: {
link?: React.ReactNode;
onMainFocus?: (pathname: string) => void;
resolvePathname?: (pathname: string) => string;
}) {
const pathname = useBrowserPathname(resolvePathname);
return ( return (
<> <>
{link} {link}
<main id="main-content" tabIndex={-1}> <main id="main-content" tabIndex={-1} onFocus={() => onMainFocus?.(pathname)}>
{pathname} {pathname}
</main> </main>
</> </>
@@ -27,7 +35,8 @@ describe('useBrowserPathname', () => {
window.history.replaceState(null, '', '/fleet'); window.history.replaceState(null, '', '/fleet');
const pushState = vi.spyOn(window.history, 'pushState'); const pushState = vi.spyOn(window.history, 'pushState');
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined); 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 link = screen.getByRole('link', { name: 'Automation' });
const accepted = fireEvent.click(link); const accepted = fireEvent.click(link);
@@ -36,9 +45,39 @@ describe('useBrowserPathname', () => {
expect(pushState).toHaveBeenCalledWith(null, '', '/automation'); expect(pushState).toHaveBeenCalledWith(null, '', '/automation');
expect(screen.getByRole('main').textContent).toBe('/automation'); expect(screen.getByRole('main').textContent).toBe('/automation');
expect(document.activeElement).toBe(screen.getByRole('main')); expect(document.activeElement).toBe(screen.getByRole('main'));
expect(focusedPathnames).toEqual(['/automation']);
expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' }); 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', () => { it('tracks browser history traversal and restores the page start', () => {
window.history.replaceState(null, '', '/automation'); window.history.replaceState(null, '', '/automation');
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined); 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 { function restorePageStart(): void {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); 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; return event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
} }
export function useBrowserPathname(): string { export function useBrowserPathname(resolvePathname: PathnameResolver = keepPathname): string {
const [pathname, setPathname] = useState(() => window.location.pathname); 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(() => { useEffect(() => {
function handleClick(event: MouseEvent): void { function handleClick(event: MouseEvent): void {
@@ -23,6 +51,11 @@ export function useBrowserPathname(): string {
const url = new URL(anchor.href, window.location.href); const url = new URL(anchor.href, window.location.href);
if (url.origin !== window.location.origin) return; if (url.origin !== window.location.origin) return;
const nextLocation = `${url.pathname}${url.search}${url.hash}`;
if (nextLocation === currentRelativeLocation()) {
event.preventDefault();
return;
}
if ( if (
url.pathname === window.location.pathname && url.pathname === window.location.pathname &&
url.search === window.location.search && url.search === window.location.search &&
@@ -31,14 +64,14 @@ export function useBrowserPathname(): string {
return; return;
event.preventDefault(); event.preventDefault();
window.history.pushState(null, '', `${url.pathname}${url.search}${url.hash}`); window.history.pushState(null, '', nextLocation);
setPathname(window.location.pathname); restoreAfterCommit.current = true;
restorePageStart(); setLocation(currentRelativeLocation());
} }
function handlePopState(): void { function handlePopState(): void {
setPathname(window.location.pathname); restoreAfterCommit.current = true;
restorePageStart(); setLocation(currentRelativeLocation());
} }
document.addEventListener('click', handleClick); document.addEventListener('click', handleClick);
+12 -2
View File
@@ -747,7 +747,7 @@ try {
`${viewport.width}px phone and temperature geometry overlaps: ${JSON.stringify(layout)}`, `${viewport.width}px phone and temperature geometry overlaps: ${JSON.stringify(layout)}`,
); );
assert.equal( assert.equal(
layout.hardwareEntries.length === FLEET_FIXTURE_ITEMS.length * 2 && layout.hardwareEntries.length === FLEET_FIXTURE_ITEMS.length * 2 &&
layout.hardwareEntries.every((entry) => entry.labelFits && entry.valueFits), layout.hardwareEntries.every((entry) => entry.labelFits && entry.valueFits),
true, true,
`${viewport.width}px hardware facts overflow: ${JSON.stringify(layout.hardwareEntries)}`, `${viewport.width}px hardware facts overflow: ${JSON.stringify(layout.hardwareEntries)}`,
@@ -1034,7 +1034,17 @@ try {
`document.querySelector('[role="tab"][aria-selected="true"]')?.textContent.trim() === '操作审计'`, `document.querySelector('[role="tab"][aria-selected="true"]')?.textContent.trim() === '操作审计'`,
'/audit alias did not select operation audit', '/audit alias did not select operation audit',
); );
await keyboardNavigate(cdp, '设置', '/settings/system', '密码保护'); await navigate(cdp, `${origin}/settings/instances`);
await eventually(
cdp,
`location.pathname === '/settings/system'`,
'Legacy settings route did not replace the browser pathname',
);
await eventually(
cdp,
`document.querySelector('h1')?.textContent.trim() === '密码保护'`,
'Legacy settings route did not render password protection',
);
assert.deepEqual(failures, [], `Browser failures detected:\n${failures.join('\n')}`); assert.deepEqual(failures, [], `Browser failures detected:\n${failures.join('\n')}`);
console.log(`PASS real Chrome E2E (${chromeBinary})`); console.log(`PASS real Chrome E2E (${chromeBinary})`);