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 { 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);
|
||||
|
||||
@@ -1034,7 +1034,17 @@ try {
|
||||
`document.querySelector('[role="tab"][aria-selected="true"]')?.textContent.trim() === '操作审计'`,
|
||||
'/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')}`);
|
||||
console.log(`PASS real Chrome E2E (${chromeBinary})`);
|
||||
|
||||
Reference in New Issue
Block a user