From 23d3281ed6f1111ab15708ca1fd86202674f8ef7 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Thu, 23 Jan 2025 21:06:11 +0530 Subject: [PATCH] feat: support same page navigation in router.go (#4511) --- src/client/app/index.ts | 9 +- src/client/app/router.ts | 192 +++++++++++++++++++++------------------ 2 files changed, 107 insertions(+), 94 deletions(-) diff --git a/src/client/app/index.ts b/src/client/app/index.ts index 5acb3be5..1f1e8069 100644 --- a/src/client/app/index.ts +++ b/src/client/app/index.ts @@ -159,19 +159,14 @@ function newRouter(): Router { if (inBrowser) { createApp().then(({ app, router, data }) => { // wait until page component is fetched before mounting - router.go().then(() => { + router.go(location.href, { initialLoad: true }).then(() => { // dynamically update head tags useUpdateHead(router.route, data.site) app.mount('#app') // scroll to hash on new tab during dev if (import.meta.env.DEV && location.hash) { - const target = document.getElementById( - decodeURIComponent(location.hash).slice(1) - ) - if (target) { - scrollTo(target, location.hash) - } + scrollTo(location.hash) } }) }) diff --git a/src/client/app/router.ts b/src/client/app/router.ts index 0cffc700..8b09339d 100644 --- a/src/client/app/router.ts +++ b/src/client/app/router.ts @@ -7,6 +7,8 @@ import { getScrollOffset, inBrowser, withBase } from './utils' export interface Route { path: string + hash: string + query: string data: PageData component: Component | null } @@ -19,7 +21,15 @@ export interface Router { /** * Navigate to a new URL. */ - go: (to?: string) => Promise + go: ( + to: string, + options?: { + // @internal + initialLoad?: boolean + // Whether to smoothly scroll to the target position. + smoothScroll?: boolean + } + ) => Promise /** * Called before the route changes. Return `false` to cancel the navigation. */ @@ -37,10 +47,6 @@ export interface Router { * Called after the route changes. */ onAfterRouteChange?: (to: string) => Awaitable - /** - * @deprecated use `onAfterRouteChange` instead - */ - onAfterRouteChanged?: (to: string) => Awaitable } export const RouterSymbol: InjectionKey = Symbol() @@ -51,6 +57,8 @@ const fakeHost = 'http://a.com' const getDefaultRoute = (): Route => ({ path: '/', + hash: '', + query: '', component: null, data: notFoundPageData }) @@ -68,39 +76,32 @@ export function createRouter( const router: Router = { route, - go - } - - async function go(href: string = inBrowser ? location.href : '/') { - href = normalizeHref(href) - if ((await router.onBeforeRouteChange?.(href)) === false) return - if (inBrowser && href !== normalizeHref(location.href)) { - // save scroll position before changing url - history.replaceState({ scrollPosition: window.scrollY }, '') - history.pushState({}, '', href) + async go(href, options) { + href = normalizeHref(href) + if ((await router.onBeforeRouteChange?.(href)) === false) return + if (!inBrowser || (await changeRoute(href, options))) await loadPage(href) + syncRouteQueryAndHash() + await router.onAfterRouteChange?.(href) } - await loadPage(href) - await (router.onAfterRouteChange ?? router.onAfterRouteChanged)?.(href) } let latestPendingPath: string | null = null async function loadPage(href: string, scrollPosition = 0, isRetry = 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 (!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}`) - } + if (!comp) throw new Error(`Invalid route component: ${comp}`) await router.onAfterPageLoad?.(href) @@ -109,36 +110,25 @@ export function createRouter( 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 (targetLoc.hash && !scrollPosition) { - let target: HTMLElement | null = null - try { - target = document.getElementById( - decodeURIComponent(targetLoc.hash).slice(1) - ) - } catch (e) { - console.warn(e) - } - if (target) { - scrollTo(target, targetLoc.hash) - return - } - } - window.scrollTo(0, scrollPosition) + return scrollTo(targetLoc.hash, false, scrollPosition) }) } } @@ -173,14 +163,22 @@ export function createRouter( .replace(/^\//, '') : '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({}, '') - } + if (history.state === null) history.replaceState({}, '') window.addEventListener( 'click', (e) => { @@ -193,8 +191,9 @@ export function createRouter( e.shiftKey || e.altKey || e.metaKey - ) + ) { return + } const link = e.target.closest('a') if ( @@ -202,47 +201,24 @@ export function createRouter( 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, hash, search } = new URL( - linkHref, - link.baseURI - ) - const currentUrl = new URL(location.href) // copy to keep old data + 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 === currentUrl.origin && treatAsHtml(pathname)) { + if (origin === currentLoc.origin && treatAsHtml(pathname)) { e.preventDefault() - if ( - pathname === currentUrl.pathname && - search === currentUrl.search - ) { - // scroll between hash anchors in the same page - // avoid duplicate history entries when the hash is same - if (hash !== currentUrl.hash) { - history.pushState({}, '', href) - // still emit the event so we can listen to it in themes - window.dispatchEvent( - new HashChangeEvent('hashchange', { - oldURL: currentUrl.href, - newURL: href - }) - ) - } - if (hash) { - // use smooth scroll when clicking on header anchor links - scrollTo(link, hash, link.classList.contains('header-anchor')) - } else { - window.scrollTo(0, 0) - } - } else { - go(href) - } + router.go(href, { + // use smooth scroll when clicking on header anchor links + smoothScroll: link.classList.contains('header-anchor') + }) } }, { capture: true } @@ -252,11 +228,13 @@ export function createRouter( if (e.state === null) return const href = normalizeHref(location.href) await loadPage(href, (e.state && e.state.scrollPosition) || 0) - await (router.onAfterRouteChange ?? router.onAfterRouteChanged)?.(href) + syncRouteQueryAndHash() + await router.onAfterRouteChange?.(href) }) window.addEventListener('hashchange', (e) => { e.preventDefault() + syncRouteQueryAndHash() }) } @@ -267,9 +245,7 @@ export function createRouter( export function useRouter(): Router { const router = inject(RouterSymbol) - if (!router) { - throw new Error('useRouter() is called without provider.') - } + if (!router) throw new Error('useRouter() is called without provider.') return router } @@ -277,13 +253,16 @@ export function useRoute(): Route { return useRouter().route } -export function scrollTo(el: Element, hash: string, smooth = false) { +export function scrollTo(hash: string, smooth = false, scrollPosition = 0) { + if (!hash || scrollPosition) { + window.scrollTo(0, scrollPosition) + return + } + let target: Element | null = null try { - target = el.classList.contains('header-anchor') - ? el - : document.getElementById(decodeURIComponent(hash).slice(1)) + target = document.getElementById(decodeURIComponent(hash).slice(1)) } catch (e) { console.warn(e) } @@ -293,17 +272,20 @@ export function scrollTo(el: Element, hash: string, smooth = false) { window.getComputedStyle(target).paddingTop, 10 ) + const targetTop = window.scrollY + target.getBoundingClientRect().top - getScrollOffset() + targetPadding + function scrollToTarget() { // only smooth scroll if distance is smaller than screen height. if (!smooth || Math.abs(targetTop - window.scrollY) > window.innerHeight) window.scrollTo(0, targetTop) else window.scrollTo({ left: 0, top: targetTop, behavior: 'smooth' }) } + requestAnimationFrame(scrollToTarget) } } @@ -313,9 +295,7 @@ function handleHMR(route: Route): void { if (import.meta.hot) { // hot reload pageData import.meta.hot.on('vitepress:pageData', (payload: PageDataPayload) => { - if (shouldHotReload(payload)) { - route.data = payload.pageData - } + if (shouldHotReload(payload)) route.data = payload.pageData }) } } @@ -332,9 +312,47 @@ 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) + if (siteDataRef.value.cleanUrls) { url.pathname = url.pathname.replace(/\.html$/, '') - else if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) + } else if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) { url.pathname += '.html' + } return url.pathname + url.search + url.hash } + +async function changeRoute( + href: string, + { smoothScroll = false, initialLoad = false } = {} +): Promise { + const loc = normalizeHref(location.href) + const { pathname, hash } = new URL(href, fakeHost) + const currentLoc = new URL(loc, fakeHost) + + if (href === loc) { + if (!initialLoad) { + scrollTo(hash, smoothScroll) + return false + } + } else { + // save scroll position before changing URL + history.replaceState({ scrollPosition: window.scrollY }, '') + history.pushState({}, '', href) + + if (pathname === currentLoc.pathname) { + // scroll between hash anchors on the same page, avoid duplicate entries + if (hash !== currentLoc.hash) { + window.dispatchEvent( + new HashChangeEvent('hashchange', { + oldURL: currentLoc.href, + newURL: href + }) + ) + scrollTo(hash, smoothScroll) + } + + return false + } + } + + return true +}