Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support same page navigation in router.go #4511

Merged
merged 19 commits into from
Jan 23, 2025
9 changes: 2 additions & 7 deletions src/client/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
})
Expand Down
192 changes: 105 additions & 87 deletions src/client/app/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { getScrollOffset, inBrowser, withBase } from './utils'

export interface Route {
path: string
hash: string
query: string
data: PageData
component: Component | null
}
Expand All @@ -19,7 +21,15 @@ export interface Router {
/**
* Navigate to a new URL.
*/
go: (to?: string) => Promise<void>
go: (
to: string,
options?: {
// @internal
initialLoad?: boolean
// Whether to smoothly scroll to the target position.
smoothScroll?: boolean
}
) => Promise<void>
/**
* Called before the route changes. Return `false` to cancel the navigation.
*/
Expand All @@ -37,10 +47,6 @@ export interface Router {
* Called after the route changes.
*/
onAfterRouteChange?: (to: string) => Awaitable<void>
/**
* @deprecated use `onAfterRouteChange` instead
*/
onAfterRouteChanged?: (to: string) => Awaitable<void>
}

export const RouterSymbol: InjectionKey<Router> = Symbol()
Expand All @@ -51,6 +57,8 @@ const fakeHost = 'http://a.com'

const getDefaultRoute = (): Route => ({
path: '/',
hash: '',
query: '',
component: null,
data: notFoundPageData
})
Expand All @@ -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)

Expand All @@ -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)
})
}
}
Expand Down Expand Up @@ -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) => {
Expand All @@ -193,56 +191,34 @@ export function createRouter(
e.shiftKey ||
e.altKey ||
e.metaKey
)
) {
return
}

const link = e.target.closest<HTMLAnchorElement | SVGAElement>('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, 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 }
Expand All @@ -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()
})
}

Expand All @@ -267,23 +245,24 @@ 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
}

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)
}
Expand All @@ -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)
}
}
Expand All @@ -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
})
}
}
Expand All @@ -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<boolean> {
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
}
Loading