diff --git a/__tests__/e2e/router/router.test.ts b/__tests__/e2e/router/router.test.ts new file mode 100644 index 000000000000..b46d42b524b5 --- /dev/null +++ b/__tests__/e2e/router/router.test.ts @@ -0,0 +1,548 @@ +import type { BrowserContext, Page } from 'playwright-chromium' +import type { Router } from 'vitepress' + +/** + * The same suite runs twice — once with the Navigation API strategy (the + * default in modern Chromium) and once with the legacy History-API + * strategy, forced on by setting `window.__VP_USE_LEGACY_ROUTER__` before + * the app boots. This gives us a single source of truth for user-facing + * router behaviour across both implementations. + * + * The default (nav-api) mode reuses the shared `page` / `goto` created by + * vitestSetup.ts; the legacy mode adds a second browser context on the + * same Playwright browser with the flag pre-injected via an init script. + */ + +// ---- Types shared between Node-side helpers and browser-side evaluate bodies. +// These types are erased at runtime; they only help TS check the callbacks +// passed to `page.evaluate`. + +interface VueAppInternals { + _context: { provides: Record } +} +type AppEl = HTMLElement & { __vue_app__?: VueAppInternals } + +declare global { + interface Window { + __VP_USE_LEGACY_ROUTER__?: boolean + __spaMarker?: number + __hookLog?: Array<['before' | 'after', string]> + resolveRouterOrThrow: () => Router + } +} + +// Browser-side helper: finds the router via the Vue app on `#app` so every +// `page.evaluate(...)` below can just call `window.resolveRouterOrThrow()` instead +// of duplicating the provides-walking logic. Registered via init script on +// both browser contexts so it runs before any client code. +const RESOLVE_ROUTER_INIT_SCRIPT = (): void => { + window.resolveRouterOrThrow = function () { + const app = (document.querySelector('#app') as AppEl | null)?.__vue_app__ + if (!app) throw new Error('[router test] Vue app not mounted on #app') + const provides = app._context.provides + for (const sym of Object.getOwnPropertySymbols(provides)) { + const v = provides[sym] + if (v && typeof (v as Router).go === 'function' && (v as Router).route) { + return v as Router + } + } + throw new Error('[router test] router not found in provides') + } +} + +// ---- Legacy-mode page, reusing the browser from the shared setup ---- + +let legacyContext: BrowserContext +let legacyPage: Page +let legacyGoto: (path: string) => Promise + +beforeAll(async () => { + const browser = page.context().browser() + if (!browser) throw new Error('shared browser is not available') + + // Default context (nav-api mode) — inject the resolver. + await page.context().addInitScript(RESOLVE_ROUTER_INIT_SCRIPT) + + // Legacy context — flag the router off, then inject the resolver. + legacyContext = await browser.newContext() + await legacyContext.addInitScript(() => { + window.__VP_USE_LEGACY_ROUTER__ = true + }) + await legacyContext.addInitScript(RESOLVE_ROUTER_INIT_SCRIPT) + legacyPage = await legacyContext.newPage() + legacyGoto = async (path) => { + await legacyPage.goto(`http://localhost:${process.env['PORT']}${path}`) + await legacyPage.waitForSelector('#app .Layout') + } +}) + +afterAll(async () => { + await legacyPage.close() + await legacyContext.close() +}) + +// ---- Same scenarios, parameterised over both strategies ---- + +interface Mode { + name: 'navigation-api' | 'legacy' + getPage: () => Page + getGoto: () => (path: string) => Promise +} + +const MODES: Mode[] = [ + { name: 'navigation-api', getPage: () => page, getGoto: () => goto }, + { name: 'legacy', getPage: () => legacyPage, getGoto: () => legacyGoto } +] + +for (const mode of MODES) { + describe(`router [${mode.name}]`, () => { + let p: Page + let visit: (path: string) => Promise + + beforeAll(() => { + p = mode.getPage() + visit = mode.getGoto() + }) + + test('selects the expected router strategy', async () => { + await visit('/home') + const isLegacy = await p.evaluate( + () => window.__VP_USE_LEGACY_ROUTER__ === true + ) + expect(isLegacy).toBe(mode.name === 'legacy') + + // Navigation API is present in this Chromium in both modes; the flag + // is the only thing distinguishing the two runs. + const hasNavigation = await p.evaluate(() => 'navigation' in window) + expect(hasNavigation).toBe(true) + }) + + test('link click performs a SPA navigation (no full reload)', async () => { + await visit('/home') + // Tag the window — a full document reload would clear this. + await p.evaluate(() => { + window.__spaMarker = 42 + }) + + await p.click( + '.VPSidebar a[href="/frontmatter/multiple-levels-outline.html"]' + ) + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await p.waitForSelector('h1#h1-1') + + const marker = await p.evaluate(() => window.__spaMarker) + expect(marker).toBe(42) + expect(await p.textContent('h1#h1-1')).toMatch('h1 - 1') + }) + + test('hash anchor click updates URL, scrolls and focuses the target', async () => { + await visit('/frontmatter/multiple-levels-outline') + await p.evaluate(() => window.scrollTo(0, 0)) + + await p.click('.VPDocAsideOutline a[href="#h3-2"]') + await p.waitForFunction(() => location.hash === '#h3-2') + // Focus is applied via requestAnimationFrame; wait for it explicitly. + await p.waitForFunction(() => document.activeElement?.id === 'h3-2') + + const state = await p.evaluate(() => ({ + hash: location.hash, + scrollY: window.scrollY, + focusId: document.activeElement?.id ?? '', + targetTop: + document.getElementById('h3-2')?.getBoundingClientRect().top ?? 0 + })) + + expect(state.hash).toBe('#h3-2') + expect(state.focusId).toBe('h3-2') + expect(state.scrollY).toBeGreaterThan(0) + // Both paths respect scroll-margin, so the target lands near the top. + expect(state.targetTop).toBeLessThan(200) + }) + + test('back and forward navigate between entries', async () => { + await visit('/home') + await p.click( + '.VPSidebar a[href="/frontmatter/multiple-levels-outline.html"]' + ) + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await waitForH1(p, /h1 - 1/) + + await p.goBack() + await p.waitForURL(/\/home(\.html)?$/) + await waitForH1(p, /Lorem Ipsum/) + + await p.goForward() + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await waitForH1(p, /h1 - 1/) + }) + + test('scroll position is restored on back traversal', async () => { + await visit('/frontmatter/multiple-levels-outline') + await p.evaluate(() => { + const max = document.documentElement.scrollHeight - window.innerHeight + window.scrollTo(0, Math.min(400, max)) + }) + await p.waitForFunction(() => window.scrollY > 100) + const scrollBefore = await p.evaluate(() => window.scrollY) + + await callRouterGo(p, '/home') + await p.waitForURL(/\/home(\.html)?$/) + await waitForH1(p, /Lorem Ipsum/) + + await p.goBack() + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await waitForH1(p, /h1 - 1/) + await p.waitForFunction( + (expected) => Math.abs(window.scrollY - expected) < 50, + scrollBefore, + { timeout: 5_000 } + ) + const scrollAfter = await p.evaluate(() => window.scrollY) + expect(Math.abs(scrollAfter - scrollBefore)).toBeLessThan(50) + }) + + test('onBeforeRouteChange / onAfterRouteChange fire for full navigations', async () => { + await visit('/home') + await installHookSpy(p) + + await p.click( + '.VPSidebar a[href="/frontmatter/multiple-levels-outline.html"]' + ) + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await p.waitForFunction(() => (window.__hookLog?.length ?? 0) >= 2) + + const log = await p.evaluate(() => window.__hookLog ?? []) + expect(log[0][0]).toBe('before') + expect(log[log.length - 1][0]).toBe('after') + expect(log[0][1]).toMatch(/multiple-levels-outline/) + }) + + test('onBeforeRouteChange / onAfterRouteChange fire for hash-only navs', async () => { + await visit('/frontmatter/multiple-levels-outline') + await installHookSpy(p) + + await p.click('.VPDocAsideOutline a[href="#h2-1"]') + await p.waitForFunction(() => location.hash === '#h2-1') + await p.waitForFunction(() => (window.__hookLog?.length ?? 0) >= 2) + + const log = await p.evaluate(() => window.__hookLog ?? []) + expect(log[0][0]).toBe('before') + expect(log[log.length - 1][0]).toBe('after') + expect(log[0][1]).toMatch(/#h2-1$/) + }) + + test('onBeforeRouteChange returning false cancels the page load', async () => { + await visit('/home') + await p.evaluate(() => { + window.resolveRouterOrThrow().onBeforeRouteChange = () => false + }) + + await p.click( + '.VPSidebar a[href="/frontmatter/multiple-levels-outline.html"]' + ) + // Give the click a chance to process; content should NOT change. + await p.waitForTimeout(400) + + expect(await p.textContent('.VPContent h1')).toMatch('Lorem Ipsum') + }) + + test('programmatic router.go navigates and updates route state', async () => { + await visit('/home') + await callRouterGo(p, '/frontmatter/multiple-levels-outline') + + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await waitForH1(p, /h1 - 1/) + }) + + test('router.go with { replace: true } replaces the history entry', async () => { + await visit('/home') + await visit('/multi-sidebar/') + const lenBefore = await p.evaluate(() => history.length) + + await callRouterGo(p, '/frontmatter/multiple-levels-outline', { + replace: true + }) + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await waitForH1(p, /h1 - 1/) + expect(await p.evaluate(() => history.length)).toBe(lenBefore) + + // /multi-sidebar/ was replaced by /frontmatter/... so goBack lands + // on /home (the entry before /multi-sidebar/), not on /multi-sidebar/. + await p.goBack() + await p.waitForURL(/\/home(\.html)?$/) + await waitForH1(p, /Lorem Ipsum/) + }) + + test('same-URL click scrolls to hash without creating a new history entry', async () => { + await visit('/frontmatter/multiple-levels-outline') + // Click once to seed a hash in the URL. + await p.click('.VPDocAsideOutline a[href="#h2-1"]') + await p.waitForFunction(() => location.hash === '#h2-1') + const lenAfterFirstClick = await p.evaluate(() => history.length) + + // Click again on the same hash — URL already matches, no new entry. + await p.click('.VPDocAsideOutline a[href="#h2-1"]') + await p.waitForTimeout(300) + expect(await p.evaluate(() => history.length)).toBe(lenAfterFirstClick) + expect(await p.evaluate(() => location.hash)).toBe('#h2-1') + }) + + test('onBeforePageLoad / onAfterPageLoad fire during loadPage', async () => { + await visit('/home') + await p.evaluate(() => { + const router = window.resolveRouterOrThrow() + window.__hookLog = [] + router.onBeforePageLoad = (to: string) => { + window.__hookLog!.push(['before', to]) + } + router.onAfterPageLoad = (to: string) => { + window.__hookLog!.push(['after', to]) + } + }) + + await p.click( + '.VPSidebar a[href="/frontmatter/multiple-levels-outline.html"]' + ) + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await p.waitForFunction(() => (window.__hookLog?.length ?? 0) >= 2) + + const log = await p.evaluate(() => window.__hookLog ?? []) + expect(log[0][0]).toBe('before') + expect(log[log.length - 1][0]).toBe('after') + expect(log[0][1]).toMatch(/multiple-levels-outline/) + }) + + test('onBeforePageLoad returning false cancels page load (but still fires onAfterRouteChange)', async () => { + await visit('/home') + await p.evaluate(() => { + const router = window.resolveRouterOrThrow() + router.onBeforePageLoad = () => false + }) + + await p.click( + '.VPSidebar a[href="/frontmatter/multiple-levels-outline.html"]' + ) + await p.waitForTimeout(400) + // Content should stay on /home since loadPage bailed out. + expect(await p.textContent('.VPContent h1')).toMatch('Lorem Ipsum') + }) + + test('links inside .vp-raw are not intercepted (full page reload)', async () => { + await visit('/home') + // Inject a .vp-raw-wrapped link and trigger its click via the DOM + // API — sidebar overlays would otherwise block a real click. A full + // document reload will clear the window tag we set below. + await p.evaluate(() => { + window.__spaMarker = 99 + const wrapper = document.createElement('div') + wrapper.className = 'vp-raw' + wrapper.style.position = 'fixed' + wrapper.style.top = '0' + wrapper.style.left = '0' + wrapper.style.zIndex = '9999' + const link = document.createElement('a') + link.id = 'vp-raw-link' + link.href = '/frontmatter/multiple-levels-outline.html' + link.textContent = 'raw link' + wrapper.appendChild(link) + document.body.appendChild(wrapper) + ;(link as HTMLAnchorElement).click() + }) + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + const marker = await p.evaluate(() => window.__spaMarker) + expect(marker).toBeUndefined() + }) + + test('external links are not intercepted', async () => { + await visit('/home') + // Don't actually follow the link cross-origin; just verify the + // router's capture-phase handler did NOT call preventDefault, which + // would leave the anchor's default navigation intact. + const intercepted = await p.evaluate(() => { + const link = document.createElement('a') + link.href = 'https://example.com/' + link.textContent = 'ext' + document.body.appendChild(link) + let defaultPrevented = false + link.addEventListener( + 'click', + (e) => { + defaultPrevented = e.defaultPrevented + e.preventDefault() // stop the actual navigation away + }, + // Runs after the router's capture-phase handler. + { capture: false } + ) + link.click() + link.remove() + return defaultPrevented + }) + expect(intercepted).toBe(false) + }) + + test('links with download attribute are not intercepted', async () => { + await visit('/home') + const intercepted = await p.evaluate(() => { + const link = document.createElement('a') + link.href = '/frontmatter/multiple-levels-outline.html' + link.setAttribute('download', 'x.html') + link.textContent = 'dl' + document.body.appendChild(link) + let defaultPrevented = false + link.addEventListener('click', (e) => { + defaultPrevented = e.defaultPrevented + e.preventDefault() + }) + link.click() + link.remove() + return defaultPrevented + }) + expect(intercepted).toBe(false) + }) + + test('links with target attribute are not intercepted', async () => { + await visit('/home') + const intercepted = await p.evaluate(() => { + const link = document.createElement('a') + link.href = '/frontmatter/multiple-levels-outline.html' + link.target = '_self' + link.textContent = 't' + document.body.appendChild(link) + let defaultPrevented = false + link.addEventListener('click', (e) => { + defaultPrevented = e.defaultPrevented + e.preventDefault() + }) + link.click() + link.remove() + return defaultPrevented + }) + expect(intercepted).toBe(false) + }) + + test('links to non-HTML paths are not intercepted', async () => { + await visit('/home') + const intercepted = await p.evaluate(() => { + const link = document.createElement('a') + link.href = '/some-file.zip' + link.textContent = 'zip' + document.body.appendChild(link) + let defaultPrevented = false + link.addEventListener('click', (e) => { + defaultPrevented = e.defaultPrevented + e.preventDefault() + }) + link.click() + link.remove() + return defaultPrevented + }) + expect(intercepted).toBe(false) + }) + + test('clicks on buttons inside links are not intercepted', async () => { + // docsearch-style layout: ``. Clicking + // the inner button should run the button's handler, not navigate. + await visit('/home') + const { defaultPrevented, buttonHandled } = await p.evaluate(() => { + const link = document.createElement('a') + link.href = '/frontmatter/multiple-levels-outline.html' + const button = document.createElement('button') + button.textContent = 'action' + link.appendChild(button) + document.body.appendChild(link) + + let buttonHandled = false + button.addEventListener('click', () => { + buttonHandled = true + }) + // Read defaultPrevented after the router's capture-phase click + // listener (legacy mode) would have run. The button's own bubble + // listener above doesn't call preventDefault. + let defaultPrevented = false + link.addEventListener('click', (e) => { + defaultPrevented = e.defaultPrevented + e.preventDefault() // stop the actual navigation away + }) + + button.click() + link.remove() + return { defaultPrevented, buttonHandled } + }) + expect(buttonHandled).toBe(true) + expect(defaultPrevented).toBe(false) + }) + + test('route state (path, hash, query) stays in sync', async () => { + await visit('/home') + await callRouterGo(p, '/frontmatter/multiple-levels-outline?x=1#h3-2') + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await p.waitForFunction(() => location.hash === '#h3-2') + + const state = await p.evaluate(() => { + const router = window.resolveRouterOrThrow() + return { + path: router.route.path, + hash: router.route.hash, + query: router.route.query + } + }) + expect(state.path).toMatch(/\/frontmatter\/multiple-levels-outline/) + expect(state.hash).toBe('#h3-2') + expect(state.query).toBe('?x=1') + }) + + test('unknown paths fall back to the 404 page', async () => { + await visit('/definitely-not-a-real-page') + // Default theme's NotFound renders this copy. + await p.waitForFunction(() => + /PAGE NOT FOUND/i.test(document.body.textContent ?? '') + ) + expect(await p.textContent('body')).toMatch(/PAGE NOT FOUND/i) + }) + + test('route.data.relativePath is set from the loaded page', async () => { + await visit('/frontmatter/multiple-levels-outline') + const relPath = await p.evaluate( + () => window.resolveRouterOrThrow().route.data.relativePath + ) + expect(relPath).toBe('frontmatter/multiple-levels-outline.md') + }) + }) +} + +// ---- Node-side helpers ---- + +async function waitForH1(p: Page, pattern: RegExp): Promise { + await p.waitForFunction( + (src: string) => + new RegExp(src).test( + document.querySelector('.VPContent h1')?.textContent ?? '' + ), + pattern.source, + { timeout: 5_000 } + ) +} + +async function callRouterGo( + p: Page, + href: string, + options?: { replace?: boolean } +): Promise { + await p.evaluate( + ({ to, opts }) => window.resolveRouterOrThrow().go(to, opts), + { to: href, opts: options } + ) +} + +async function installHookSpy(p: Page): Promise { + await p.evaluate(() => { + const router = window.resolveRouterOrThrow() + window.__hookLog = [] + router.onBeforeRouteChange = (to: string) => { + window.__hookLog!.push(['before', to]) + } + router.onAfterRouteChange = (to: string) => { + window.__hookLog!.push(['after', to]) + } + }) +} diff --git a/src/client/app/router.ts b/src/client/app/router.ts deleted file mode 100644 index 5d3d86f148c9..000000000000 --- a/src/client/app/router.ts +++ /dev/null @@ -1,383 +0,0 @@ -import type { Component, InjectionKey } from 'vue' -import { inject, markRaw, nextTick, reactive, readonly } from 'vue' -import type { Awaitable, PageData, PageDataPayload } from '../shared' -import { notFoundPageData, treatAsHtml } from '../shared' -import { siteDataRef } from './data' -import { inBrowser, withBase } from './utils' - -export interface Route { - path: string - hash: string - query: string - data: PageData - component: Component | null -} - -export interface Router { - /** - * Current route. - */ - route: Route - /** - * Navigate to a new URL. - */ - go: ( - to: string, - options?: { - // @internal - initialLoad?: boolean - // Whether to replace the current history entry. - replace?: boolean - } - ) => Promise - /** - * Called before the route changes. Return `false` to cancel the navigation. - */ - onBeforeRouteChange?: (to: string) => Awaitable - /** - * Called before the page component is loaded (after the history state is - * updated). Return `false` to cancel the navigation. - */ - onBeforePageLoad?: (to: string) => Awaitable - /** - * Called after the page component is loaded (before the page component is updated). - */ - onAfterPageLoad?: (to: string) => Awaitable - /** - * Called after the route changes. - */ - onAfterRouteChange?: (to: string) => Awaitable -} - -export const RouterSymbol: InjectionKey = Symbol() - -// we are just using URL to parse the pathname and hash - the base doesn't -// matter and is only passed to support same-host hrefs -const fakeHost = 'http://a.com' - -const getDefaultRoute = (): Route => ({ - path: '/', - hash: '', - query: '', - component: null, - data: notFoundPageData -}) - -interface PageModule { - __pageData: PageData - default: Component -} - -export function createRouter( - loadPageModule: (path: string) => Awaitable, - fallbackComponent?: Component -): Router { - const route = reactive(getDefaultRoute()) - - const router: Router = { - route, - async go(href, options) { - const { hash } = new URL(href, fakeHost) - const hasTextFragment = - inBrowser && document.fragmentDirective && hash.includes(':~:') - href = normalizeHref(href) - if ((await router.onBeforeRouteChange?.(href)) === false) return - if ( - !inBrowser || - (await changeRoute(href, { ...options, hasTextFragment })) - ) { - await loadPage(href, { initialLoad: !!options?.initialLoad }) - } - if (hasTextFragment) { - // this will create a new history entry, but that's almost unavoidable - location.hash = hash - } - syncRouteQueryAndHash() - await router.onAfterRouteChange?.(href) - } - } - - let latestPendingPath: string | null = null - - async function loadPage( - href: string, - { scrollPosition = 0, isRetry = false, initialLoad = false } = {} - ) { - if ((await router.onBeforePageLoad?.(href)) === false) return - - const targetLoc = new URL(href, fakeHost) - const pendingPath = (latestPendingPath = targetLoc.pathname) - - try { - let page = await loadPageModule(pendingPath) - if (!page) throw new Error(`Page not found: ${pendingPath}`) - - if (latestPendingPath === pendingPath) { - latestPendingPath = null - - const { default: comp, __pageData } = page - if (!comp) throw new Error(`Invalid route component: ${comp}`) - - await router.onAfterPageLoad?.(href) - - route.path = inBrowser ? pendingPath : withBase(pendingPath) - route.component = markRaw(comp) - route.data = import.meta.env.PROD - ? markRaw(__pageData) - : (readonly(__pageData) as PageData) - syncRouteQueryAndHash(targetLoc) - - if (inBrowser) { - nextTick(() => { - let actualPathname = - siteDataRef.value.base + - __pageData.relativePath.replace(/(?:(^|\/)index)?\.md$/, '$1') - - if (!siteDataRef.value.cleanUrls && !actualPathname.endsWith('/')) { - actualPathname += '.html' - } - - if (actualPathname !== targetLoc.pathname) { - targetLoc.pathname = actualPathname - href = actualPathname + targetLoc.search + targetLoc.hash - history.replaceState({}, '', href) - } - - if (!initialLoad) scrollTo(targetLoc.hash, scrollPosition) - }) - } - } - } catch (err: any) { - if ( - !/fetch|Page not found/.test(err.message) && - !/^\/404(\.html|\/)?$/.test(href) - ) { - console.error(err) - } - - // retry on fetch fail: the page to hash map may have been invalidated - // because a new deploy happened while the page is open. Try to fetch - // the updated pageToHash map and fetch again. - if (!isRetry) { - try { - const res = await fetch(siteDataRef.value.base + 'hashmap.json') - ;(window as any).__VP_HASH_MAP__ = await res.json() - await loadPage(href, { scrollPosition, isRetry: true, initialLoad }) - return - } catch (e) {} - } - - if (latestPendingPath === pendingPath) { - latestPendingPath = null - route.path = inBrowser ? pendingPath : withBase(pendingPath) - route.component = fallbackComponent ? markRaw(fallbackComponent) : null - const relativePath = inBrowser - ? route.path - .replace(/(^|\/)$/, '$1index') - .replace(/(\.html)?$/, '.md') - .slice(siteDataRef.value.base.length) - : '404.md' - route.data = { ...notFoundPageData, relativePath } - syncRouteQueryAndHash(targetLoc) - } - } - } - - function syncRouteQueryAndHash( - loc: { search: string; hash: string } = inBrowser - ? location - : { search: '', hash: '' } - ) { - route.query = loc.search - route.hash = decodeURIComponent(loc.hash) - } - - if (inBrowser) { - if (history.state === null) history.replaceState({}, '') - window.addEventListener( - 'click', - (e) => { - if ( - e.defaultPrevented || - !(e.target instanceof Element) || - e.target.closest('button') || // temporary fix for docsearch action buttons - e.button !== 0 || - e.ctrlKey || - e.shiftKey || - e.altKey || - e.metaKey - ) { - return - } - - const link = e.target.closest('a') - if ( - !link || - link.closest('.vp-raw') || - link.hasAttribute('download') || - link.hasAttribute('target') - ) { - return - } - - const linkHref = - link.getAttribute('href') ?? - (link instanceof SVGAElement ? link.getAttribute('xlink:href') : null) - if (linkHref == null) return - - const { href, origin, pathname } = new URL(linkHref, link.baseURI) - const currentLoc = new URL(location.href) // copy to keep old data - // only intercept inbound html links - if (origin === currentLoc.origin && treatAsHtml(pathname)) { - e.preventDefault() - router.go(href) - } - }, - { capture: true } - ) - - window.addEventListener('popstate', async (e) => { - if (e.state === null) return - const href = normalizeHref(location.href) - await loadPage(href, { scrollPosition: e.state.scrollPosition || 0 }) - syncRouteQueryAndHash() - await router.onAfterRouteChange?.(href) - }) - - window.addEventListener('hashchange', (e) => { - e.preventDefault() - syncRouteQueryAndHash() - }) - } - - handleHMR(route) - - return router -} - -export function useRouter(): Router { - const router = inject(RouterSymbol) - if (!router) throw new Error('useRouter() is called without provider.') - return router -} - -export function useRoute(): Route { - return useRouter().route -} - -export function scrollTo(hash: string, scrollPosition = 0) { - if (!hash || scrollPosition) { - window.scrollTo(0, scrollPosition) - return - } - - let target: HTMLElement | null = null - try { - target = document.getElementById(decodeURIComponent(hash).slice(1)) - } catch (e) { - console.warn(e) - } - if (!target) return - - const scrollToTarget = () => { - target.scrollIntoView({ block: 'start' }) - - // focus the target element for better accessibility - target.focus({ preventScroll: true }) - - // return if focus worked - if (document.activeElement === target) return - - // element has tabindex already, likely not focusable - // because of some other reason, bail out - if (target.hasAttribute('tabindex')) return - - const restoreTabindex = () => { - target.removeAttribute('tabindex') - target.removeEventListener('blur', restoreTabindex) - } - - // temporarily make the target element focusable - target.setAttribute('tabindex', '-1') - target.addEventListener('blur', restoreTabindex) - - // try to focus again - target.focus({ preventScroll: true }) - - // remove tabindex and event listener if focus still not worked - if (document.activeElement !== target) restoreTabindex() - } - - requestAnimationFrame(scrollToTarget) -} - -function handleHMR(route: Route): void { - // update route.data on HMR updates of active page - if (import.meta.hot) { - // hot reload pageData - import.meta.hot.on('vitepress:pageData', (payload: PageDataPayload) => { - if (shouldHotReload(payload)) route.data = payload.pageData - }) - } -} - -function shouldHotReload(payload: PageDataPayload): boolean { - const payloadPath = payload.path.replace(/(?:(^|\/)index)?\.md$/, '$1') - const locationPath = location.pathname - .replace(/(?:(^|\/)index)?\.html$/, '') - .slice(siteDataRef.value.base.length - 1) - return payloadPath === locationPath -} - -function normalizeHref(href: string): string { - const url = new URL(href, fakeHost) - url.pathname = url.pathname.replace(/(^|\/)index(\.html)?$/, '$1') - // ensure correct deep link so page refresh lands on correct files - if (siteDataRef.value.cleanUrls) { - url.pathname = url.pathname.replace(/\.html$/, '') - } else if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) { - url.pathname += '.html' - } - return url.pathname + url.search + url.hash.split(':~:')[0] -} - -async function changeRoute( - href: string, - { initialLoad = false, replace = false, hasTextFragment = false } = {} -): Promise { - const loc = normalizeHref(location.href) - const nextUrl = new URL(href, location.origin) - const currentUrl = new URL(loc, location.origin) - - if (href === loc) { - if (!initialLoad) { - if (!hasTextFragment) scrollTo(nextUrl.hash) - return false - } - } else { - if (replace) { - history.replaceState({}, '', href) - } else { - // save scroll position before changing URL - history.replaceState({ scrollPosition: window.scrollY }, '') - history.pushState({}, '', href) - } - - if (nextUrl.pathname === currentUrl.pathname) { - // scroll between hash anchors on the same page, avoid duplicate entries - if (nextUrl.hash !== currentUrl.hash) { - window.dispatchEvent( - new HashChangeEvent('hashchange', { - oldURL: currentUrl.href, - newURL: nextUrl.href - }) - ) - if (!hasTextFragment) scrollTo(nextUrl.hash) - } - - return false - } - } - - return true -} diff --git a/src/client/app/router/index.ts b/src/client/app/router/index.ts new file mode 100644 index 000000000000..eabc733a5e14 --- /dev/null +++ b/src/client/app/router/index.ts @@ -0,0 +1,194 @@ +import type { Component, InjectionKey } from 'vue' +import { inject, markRaw, nextTick, reactive, readonly } from 'vue' +import type { Awaitable, PageData, PageDataPayload } from '../../shared' +import { notFoundPageData } from '../../shared' +import { siteDataRef } from '../data' +import { inBrowser, withBase } from '../utils' +import { createLegacyRouterStrategy } from './legacy' +import { + createNavigationApiRouterStrategy, + hasNavigationApi +} from './navigationApi' +import { + fakeHost, + scrollTo, + type LoadPage, + type Route, + type Router, + type RouterStrategy, + type RouterStrategyFactory +} from './shared' + +export { scrollTo } +export type { Route, Router } from './shared' + +export const RouterSymbol: InjectionKey = Symbol() + +const getDefaultRoute = (): Route => ({ + path: '/', + hash: '', + query: '', + component: null, + data: notFoundPageData +}) + +interface PageModule { + __pageData: PageData + default: Component +} + +export function createRouter( + loadPageModule: (path: string) => Awaitable, + fallbackComponent?: Component +): Router { + const route = reactive(getDefaultRoute()) + + function syncRouteQueryAndHash( + loc: { search: string; hash: string } = inBrowser + ? location + : { search: '', hash: '' } + ) { + route.query = loc.search + route.hash = decodeURIComponent(loc.hash) + } + + // Forward reference: strategies receive `loadPage` at setup time but call + // it only after setup returns, by which point `loadPageImpl` is bound. + let loadPageImpl: LoadPage + const loadPage: LoadPage = (href) => loadPageImpl(href) + + // `go` is populated once the strategy is built; until then we use a stub so + // the router object is usable for `router.route` / hook assignment. + const router: Router = { route, go: async () => {} } + + const strategyFactory: RouterStrategyFactory = + inBrowser && hasNavigationApi() + ? createNavigationApiRouterStrategy + : createLegacyRouterStrategy + + const strategy: RouterStrategy = strategyFactory({ + router, + loadPage, + syncRouteQueryAndHash + }) + + router.go = strategy.go + + let latestPendingPath: string | null = null + + async function loadPageInternal( + href: string, + { isRetry = false }: { isRetry?: boolean } = {} + ): Promise { + if ((await router.onBeforePageLoad?.(href)) === false) return + + const targetLoc = new URL(href, fakeHost) + const pendingPath = (latestPendingPath = targetLoc.pathname) + + try { + let page = await loadPageModule(pendingPath) + if (!page) throw new Error(`Page not found: ${pendingPath}`) + + if (latestPendingPath === pendingPath) { + latestPendingPath = null + + const { default: comp, __pageData } = page + if (!comp) throw new Error(`Invalid route component: ${comp}`) + + await router.onAfterPageLoad?.(href) + + route.path = inBrowser ? pendingPath : withBase(pendingPath) + route.component = markRaw(comp) + route.data = import.meta.env.PROD + ? markRaw(__pageData) + : (readonly(__pageData) as PageData) + syncRouteQueryAndHash(targetLoc) + + if (inBrowser) { + nextTick(() => { + let actualPathname = + siteDataRef.value.base + + __pageData.relativePath.replace(/(?:(^|\/)index)?\.md$/, '$1') + + if (!siteDataRef.value.cleanUrls && !actualPathname.endsWith('/')) { + actualPathname += '.html' + } + + if (actualPathname !== targetLoc.pathname) { + targetLoc.pathname = actualPathname + href = actualPathname + targetLoc.search + targetLoc.hash + strategy.replaceUrl(href) + } + }) + } + } + } catch (err: any) { + if ( + !/fetch|Page not found/.test(err.message) && + !/^\/404(\.html|\/)?$/.test(href) + ) { + console.error(err) + } + + // retry on fetch fail: the page to hash map may have been invalidated + // because a new deploy happened while the page is open. Try to fetch + // the updated pageToHash map and fetch again. + if (!isRetry) { + try { + const res = await fetch(siteDataRef.value.base + 'hashmap.json') + ;(window as any).__VP_HASH_MAP__ = await res.json() + await loadPageInternal(href, { isRetry: true }) + return + } catch (e) {} + } + + if (latestPendingPath === pendingPath) { + latestPendingPath = null + route.path = inBrowser ? pendingPath : withBase(pendingPath) + route.component = fallbackComponent ? markRaw(fallbackComponent) : null + const relativePath = inBrowser + ? route.path + .replace(/(^|\/)$/, '$1index') + .replace(/(\.html)?$/, '.md') + .slice(siteDataRef.value.base.length) + : '404.md' + route.data = { ...notFoundPageData, relativePath } + syncRouteQueryAndHash(targetLoc) + } + } + } + + loadPageImpl = loadPageInternal + + handleHMR(route) + + return router +} + +export function useRouter(): Router { + const router = inject(RouterSymbol) + if (!router) throw new Error('useRouter() is called without provider.') + return router +} + +export function useRoute(): Route { + return useRouter().route +} + +function handleHMR(route: Route): void { + // update route.data on HMR updates of active page + if (import.meta.hot) { + // hot reload pageData + import.meta.hot.on('vitepress:pageData', (payload: PageDataPayload) => { + if (shouldHotReload(payload)) route.data = payload.pageData + }) + } +} + +function shouldHotReload(payload: PageDataPayload): boolean { + const payloadPath = payload.path.replace(/(?:(^|\/)index)?\.md$/, '$1') + const locationPath = location.pathname + .replace(/(?:(^|\/)index)?\.html$/, '') + .slice(siteDataRef.value.base.length - 1) + return payloadPath === locationPath +} diff --git a/src/client/app/router/legacy.ts b/src/client/app/router/legacy.ts new file mode 100644 index 000000000000..618f7ffd4bac --- /dev/null +++ b/src/client/app/router/legacy.ts @@ -0,0 +1,155 @@ +import { nextTick } from 'vue' +import { treatAsHtml } from '../../shared' +import { inBrowser } from '../utils' +import { + fakeHost, + hasTextFragment, + normalizeHref, + scrollTo, + type GoOptions, + type RouterStrategyFactory +} from './shared' + +/** + * History API + DOM-event based router strategy. This is the fallback used + * when the Navigation API is unavailable, and it also serves the SSR path. + */ +export const createLegacyRouterStrategy: RouterStrategyFactory = (ctx) => { + const { router, loadPage, syncRouteQueryAndHash } = ctx + + async function go(href: string, options?: GoOptions): Promise { + const { hash } = new URL(href, fakeHost) + const textFrag = hasTextFragment(hash) + href = normalizeHref(href) + + if ((await router.onBeforeRouteChange?.(href)) === false) return + + if ( + !inBrowser || + (await changeRoute(href, { ...options, hasTextFragment: textFrag })) + ) { + await loadPage(href) + if (inBrowser && !options?.initialLoad) { + // wait for Vue to render the new component before scrolling so the + // hash target (if any) is in the DOM + await nextTick() + scrollTo(new URL(href, fakeHost).hash) + } + } + if (textFrag) { + // this will create a new history entry, but that's almost unavoidable + location.hash = hash + } + syncRouteQueryAndHash() + await router.onAfterRouteChange?.(href) + } + + if (inBrowser) { + if (history.state === null) history.replaceState({}, '') + + window.addEventListener( + 'click', + (e) => { + if ( + e.defaultPrevented || + !(e.target instanceof Element) || + e.target.closest('button') || // temporary fix for docsearch action buttons + e.button !== 0 || + e.ctrlKey || + e.shiftKey || + e.altKey || + e.metaKey + ) { + return + } + + const link = e.target.closest('a') + if ( + !link || + link.closest('.vp-raw') || + link.hasAttribute('download') || + link.hasAttribute('target') + ) { + return + } + + const linkHref = + link.getAttribute('href') ?? + (link instanceof SVGAElement ? link.getAttribute('xlink:href') : null) + if (linkHref == null) return + + const { href, origin, pathname } = new URL(linkHref, link.baseURI) + const currentLoc = new URL(location.href) // copy to keep old data + // only intercept inbound html links + if (origin === currentLoc.origin && treatAsHtml(pathname)) { + e.preventDefault() + router.go(href) + } + }, + { capture: true } + ) + + window.addEventListener('popstate', async (e) => { + if (e.state === null) return + const href = normalizeHref(location.href) + await loadPage(href) + await nextTick() + scrollTo(new URL(href, fakeHost).hash, e.state.scrollPosition || 0) + syncRouteQueryAndHash() + await router.onAfterRouteChange?.(href) + }) + + window.addEventListener('hashchange', (e) => { + e.preventDefault() + syncRouteQueryAndHash() + }) + } + + return { + go, + replaceUrl(href) { + history.replaceState({}, '', href) + } + } +} + +async function changeRoute( + href: string, + { initialLoad = false, replace = false, hasTextFragment = false } = {} +): Promise { + const loc = normalizeHref(location.href) + const nextUrl = new URL(href, location.origin) + const currentUrl = new URL(loc, location.origin) + + if (href === loc) { + if (!initialLoad) { + if (!hasTextFragment) scrollTo(nextUrl.hash) + return false + } + } else { + if (replace) { + history.replaceState({}, '', href) + } else { + // save scroll position before changing URL + history.replaceState({ scrollPosition: window.scrollY }, '') + history.pushState({}, '', href) + } + + if (nextUrl.pathname === currentUrl.pathname) { + // scroll between hash anchors on the same page, avoid duplicate entries + if (nextUrl.hash !== currentUrl.hash) { + window.dispatchEvent( + new HashChangeEvent('hashchange', { + oldURL: currentUrl.href, + newURL: nextUrl.href + }) + ) + if (!hasTextFragment) scrollTo(nextUrl.hash) + } + + return false + } + } + + return true +} diff --git a/src/client/app/router/navigationApi.ts b/src/client/app/router/navigationApi.ts new file mode 100644 index 000000000000..15db6921a6cd --- /dev/null +++ b/src/client/app/router/navigationApi.ts @@ -0,0 +1,196 @@ +import { nextTick } from 'vue' +import { treatAsHtml } from '../../shared' +import { + fakeHost, + focusHashTarget, + hasTextFragment, + normalizeHref, + scrollTo, + type GoOptions, + type RouterStrategyFactory +} from './shared' + +/** + * Feature-detect the Navigation API. We gate on `sourceElement` since that + * property is newer than `intercept()` itself — its presence gives us a + * sufficiently complete implementation to replace the legacy listeners. + * + * Setting `window.__VP_USE_LEGACY_ROUTER__ = true` before the app boots + * forces the legacy strategy; this is used by the e2e suite to run the + * same tests against both router implementations. + */ +export function hasNavigationApi(): boolean { + if (typeof window === 'undefined') return false + if ((window as any).__VP_USE_LEGACY_ROUTER__) return false + return ( + 'navigation' in window && + typeof NavigateEvent !== 'undefined' && + 'sourceElement' in NavigateEvent.prototype + ) +} + +/** + * Navigation API based router strategy. Requires a browser environment where + * {@link hasNavigationApi} returns true. + * + * We rely on the browser's built-in post-commit scroll (to the URL fragment + * on push/replace, or to the restored position on traverse) and only keep + * focus handling manual, so screen readers still land on the target heading. + * To make sure the browser scrolls into a rendered DOM we await `nextTick()` + * inside the intercept handler before resolving. + */ +export const createNavigationApiRouterStrategy: RouterStrategyFactory = ( + ctx +) => { + const { router, loadPage, syncRouteQueryAndHash } = ctx + + // Guard against re-entering the navigate listener for our own same-document + // URL fix-up via history.replaceState inside loadPage. + let skipInternalNavigate = false + + async function go(href: string, options?: GoOptions): Promise { + const { hash } = new URL(href, fakeHost) + const textFrag = hasTextFragment(hash) + href = normalizeHref(href) + + if (options?.initialLoad) { + // Initial load: the document is already at this URL; just render the + // page component without going through the Navigation API. + if ((await router.onBeforeRouteChange?.(href)) === false) return + await loadPage(href) + if (textFrag) location.hash = hash + syncRouteQueryAndHash() + await router.onAfterRouteChange?.(href) + return + } + + // Run the before-route-change hook here so we can cancel synchronously + // before committing the URL via navigation.navigate(). + if ((await router.onBeforeRouteChange?.(href)) === false) return + + const currentHref = normalizeHref(location.href) + if (href === currentHref) { + // No navigation needed; the browser won't scroll on its own, so we + // scroll + focus explicitly. + if (!textFrag) scrollTo(new URL(href, fakeHost).hash) + syncRouteQueryAndHash() + await router.onAfterRouteChange?.(href) + return + } + + try { + await navigation.navigate(href, { + history: options?.replace ? 'replace' : 'push', + info: { __vpFromGo: true } + }).finished + } catch { + // navigation was cancelled or errored; listener / navigateerror + // handles any observable side effects. + } + } + + navigation.addEventListener('navigate', (event) => { + if (skipInternalNavigate) return + if (!event.canIntercept) return + if (event.downloadRequest != null) return + if (event.formData) return + + const src = event.sourceElement + // Mirror the legacy click-handler filters when the navigation was + // element-initiated (e.g. link click). Programmatic navigations have a + // null sourceElement and are always considered eligible. `download` is + // handled by `event.downloadRequest` above. + if (src) { + if (src.closest('.vp-raw')) return + // covers docsearch action buttons and button-wrapped link content + if (src.closest('button') || src.querySelector('button')) return + if ( + (src instanceof HTMLAnchorElement || src instanceof SVGAElement) && + src.hasAttribute('target') + ) { + return + } + } + + const destUrl = new URL(event.destination.url) + if (destUrl.origin !== location.origin) return + if (!treatAsHtml(destUrl.pathname)) return + + const rawHash = destUrl.hash + const textFrag = hasTextFragment(rawHash) + const href = normalizeHref(event.destination.url) + const fromGo = !!event.info?.__vpFromGo + + if (event.hashChange) { + // Text-fragment navigations need the browser's native fragment + // directive processing; intercepting here would bypass it. + if (textFrag) return + // Same-document hash navigation: let the browser update the URL and + // scroll to the fragment. We still intercept with a minimal handler + // so the route-change hooks fire (consistent with the legacy path) + // and we move focus to the target for a11y. + event.intercept({ + focusReset: 'manual', + async handler() { + if (!fromGo) { + if ((await router.onBeforeRouteChange?.(href)) === false) { + throw new Error('Route change cancelled') + } + } + syncRouteQueryAndHash() + focusHashTarget(rawHash) + await router.onAfterRouteChange?.(href) + } + }) + return + } + + const isTraverse = event.navigationType === 'traverse' + + event.intercept({ + // Scroll is left on browser default: push/replace scrolls to the URL + // fragment (or top), traverse restores the previously-committed scroll + // position. Focus is kept manual so our hash-target focus logic wins. + focusReset: 'manual', + async handler() { + if (!fromGo) { + if ((await router.onBeforeRouteChange?.(href)) === false) { + throw new Error('Route change cancelled') + } + } + + await loadPage(href) + // Ensure Vue has applied the new component to the DOM so the + // browser's post-handler scroll lands on the right element. + await nextTick() + if (textFrag) { + location.hash = rawHash + } else if (!isTraverse && rawHash) { + // Focus the hash target for a11y; the browser will handle the + // scroll once the handler resolves. + focusHashTarget(rawHash) + } + + syncRouteQueryAndHash() + await router.onAfterRouteChange?.(href) + } + }) + }) + + // Text-fragment hash navigations are not intercepted above, so rely on + // the browser's native `hashchange` event to keep `route.hash` / + // `route.query` in sync. + window.addEventListener('hashchange', (e) => { + e.preventDefault() + syncRouteQueryAndHash() + }) + + return { + go, + replaceUrl(href) { + skipInternalNavigate = true + history.replaceState({}, '', href) + skipInternalNavigate = false + } + } +} diff --git a/src/client/app/router/shared.ts b/src/client/app/router/shared.ts new file mode 100644 index 000000000000..9433e1f2e57e --- /dev/null +++ b/src/client/app/router/shared.ts @@ -0,0 +1,158 @@ +import type { Component } from 'vue' +import type { Awaitable, PageData } from '../../shared' +import { siteDataRef } from '../data' + +export interface Route { + path: string + hash: string + query: string + data: PageData + component: Component | null +} + +export interface GoOptions { + // @internal + initialLoad?: boolean + // Whether to replace the current history entry. + replace?: boolean +} + +export interface Router { + /** + * Current route. + */ + route: Route + /** + * Navigate to a new URL. + */ + go: (to: string, options?: GoOptions) => Promise + /** + * Called before the route changes. Return `false` to cancel the navigation. + */ + onBeforeRouteChange?: (to: string) => Awaitable + /** + * Called before the page component is loaded (after the history state is + * updated). Return `false` to cancel the navigation. + */ + onBeforePageLoad?: (to: string) => Awaitable + /** + * Called after the page component is loaded (before the page component is updated). + */ + onAfterPageLoad?: (to: string) => Awaitable + /** + * Called after the route changes. + */ + onAfterRouteChange?: (to: string) => Awaitable +} + +export type LoadPage = (href: string) => Promise + +export type SyncRouteQueryAndHash = (loc?: { + search: string + hash: string +}) => void + +export interface RouterStrategyContext { + router: Router + loadPage: LoadPage + syncRouteQueryAndHash: SyncRouteQueryAndHash +} + +export interface RouterStrategy { + go: Router['go'] + /** + * Replace the current URL without triggering a new navigation through this + * strategy's listeners. Used by `loadPage` to fix up the URL to match the + * canonical path once the page component has been resolved. + */ + replaceUrl: (href: string) => void +} + +export type RouterStrategyFactory = ( + ctx: RouterStrategyContext +) => RouterStrategy + +// we are just using URL to parse the pathname and hash - the base doesn't +// matter and is only passed to support same-host hrefs +export const fakeHost = 'http://a.com' + +export function normalizeHref(href: string): string { + const url = new URL(href, fakeHost) + url.pathname = url.pathname.replace(/(^|\/)index(\.html)?$/, '$1') + // ensure correct deep link so page refresh lands on correct files + if (siteDataRef.value.cleanUrls) { + url.pathname = url.pathname.replace(/\.html$/, '') + } else if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) { + url.pathname += '.html' + } + return url.pathname + url.search + url.hash.split(':~:')[0] +} + +export function hasTextFragment(hash: string): boolean { + return ( + typeof document !== 'undefined' && + !!document.fragmentDirective && + hash.includes(':~:') + ) +} + +export function scrollTo(hash: string, scrollPosition = 0): void { + if (!hash || scrollPosition) { + window.scrollTo(0, scrollPosition) + return + } + const target = findHashTarget(hash) + if (!target) return + requestAnimationFrame(() => { + target.scrollIntoView({ block: 'start' }) + focusOnTarget(target) + }) +} + +/** + * Focus the element referenced by `hash` for screen-reader accessibility, + * without performing a scroll. Intended for the Navigation API strategy, + * which relies on the browser's built-in scrolling and just needs us to + * move focus after the page has rendered. + */ +export function focusHashTarget(hash: string): void { + const target = findHashTarget(hash) + if (!target) return + requestAnimationFrame(() => focusOnTarget(target)) +} + +function findHashTarget(hash: string): HTMLElement | null { + if (!hash) return null + try { + return document.getElementById(decodeURIComponent(hash).slice(1)) + } catch (e) { + console.warn(e) + return null + } +} + +function focusOnTarget(target: HTMLElement): void { + // focus the target element for better accessibility + target.focus({ preventScroll: true }) + + // return if focus worked + if (document.activeElement === target) return + + // element has tabindex already, likely not focusable for another reason + if (target.hasAttribute('tabindex')) return + + const restoreTabindex = () => { + target.removeAttribute('tabindex') + target.removeEventListener('blur', restoreTabindex) + } + + // temporarily make the target element focusable + target.setAttribute('tabindex', '-1') + target.addEventListener('blur', restoreTabindex) + + // try to focus again + target.focus({ preventScroll: true }) + + // remove tabindex and event listener if focus still not worked + if (document.activeElement !== target) restoreTabindex() +}