diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 16c5490..4cbe88c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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('#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 ( diff --git a/apps/web/src/navigation/use-browser-pathname.test.tsx b/apps/web/src/navigation/use-browser-pathname.test.tsx index ee8a530..bf81515 100644 --- a/apps/web/src/navigation/use-browser-pathname.test.tsx +++ b/apps/web/src/navigation/use-browser-pathname.test.tsx @@ -4,12 +4,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { useBrowserPathname } from './use-browser-pathname.js'; -function Harness({ link = Automation }: { link?: React.ReactNode }) { - const pathname = useBrowserPathname(); +function Harness({ + link = Automation, + onMainFocus, + resolvePathname, +}: { + link?: React.ReactNode; + onMainFocus?: (pathname: string) => void; + resolvePathname?: (pathname: string) => string; +}) { + const pathname = useBrowserPathname(resolvePathname); return ( <> {link} -
+
onMainFocus?.(pathname)}> {pathname}
@@ -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(); + const focusedPathnames: string[] = []; + render( 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( + + 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(Current} />); + + 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); diff --git a/apps/web/src/navigation/use-browser-pathname.ts b/apps/web/src/navigation/use-browser-pathname.ts index 6a787f9..2d08d00 100644 --- a/apps/web/src/navigation/use-browser-pathname.ts +++ b/apps/web/src/navigation/use-browser-pathname.ts @@ -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); diff --git a/scripts/real-browser-e2e.mjs b/scripts/real-browser-e2e.mjs index 8e51d80..3902fb3 100644 --- a/scripts/real-browser-e2e.mjs +++ b/scripts/real-browser-e2e.mjs @@ -747,7 +747,7 @@ try { `${viewport.width}px phone and temperature geometry overlaps: ${JSON.stringify(layout)}`, ); 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), true, `${viewport.width}px hardware facts overflow: ${JSON.stringify(layout.hardwareEntries)}`, @@ -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})`);