fix: canonicalize browser navigation after commit
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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})`);
|
||||||
|
|||||||
Reference in New Issue
Block a user