Skip to content

Commit 23d3281

Browse files
authored
feat: support same page navigation in router.go (#4511)
1 parent 4f77b4f commit 23d3281

File tree

2 files changed

+107
-94
lines changed

2 files changed

+107
-94
lines changed

src/client/app/index.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,19 +159,14 @@ function newRouter(): Router {
159159
if (inBrowser) {
160160
createApp().then(({ app, router, data }) => {
161161
// wait until page component is fetched before mounting
162-
router.go().then(() => {
162+
router.go(location.href, { initialLoad: true }).then(() => {
163163
// dynamically update head tags
164164
useUpdateHead(router.route, data.site)
165165
app.mount('#app')
166166

167167
// scroll to hash on new tab during dev
168168
if (import.meta.env.DEV && location.hash) {
169-
const target = document.getElementById(
170-
decodeURIComponent(location.hash).slice(1)
171-
)
172-
if (target) {
173-
scrollTo(target, location.hash)
174-
}
169+
scrollTo(location.hash)
175170
}
176171
})
177172
})

src/client/app/router.ts

Lines changed: 105 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { getScrollOffset, inBrowser, withBase } from './utils'
77

88
export interface Route {
99
path: string
10+
hash: string
11+
query: string
1012
data: PageData
1113
component: Component | null
1214
}
@@ -19,7 +21,15 @@ export interface Router {
1921
/**
2022
* Navigate to a new URL.
2123
*/
22-
go: (to?: string) => Promise<void>
24+
go: (
25+
to: string,
26+
options?: {
27+
// @internal
28+
initialLoad?: boolean
29+
// Whether to smoothly scroll to the target position.
30+
smoothScroll?: boolean
31+
}
32+
) => Promise<void>
2333
/**
2434
* Called before the route changes. Return `false` to cancel the navigation.
2535
*/
@@ -37,10 +47,6 @@ export interface Router {
3747
* Called after the route changes.
3848
*/
3949
onAfterRouteChange?: (to: string) => Awaitable<void>
40-
/**
41-
* @deprecated use `onAfterRouteChange` instead
42-
*/
43-
onAfterRouteChanged?: (to: string) => Awaitable<void>
4450
}
4551

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

5258
const getDefaultRoute = (): Route => ({
5359
path: '/',
60+
hash: '',
61+
query: '',
5462
component: null,
5563
data: notFoundPageData
5664
})
@@ -68,39 +76,32 @@ export function createRouter(
6876

6977
const router: Router = {
7078
route,
71-
go
72-
}
73-
74-
async function go(href: string = inBrowser ? location.href : '/') {
75-
href = normalizeHref(href)
76-
if ((await router.onBeforeRouteChange?.(href)) === false) return
77-
if (inBrowser && href !== normalizeHref(location.href)) {
78-
// save scroll position before changing url
79-
history.replaceState({ scrollPosition: window.scrollY }, '')
80-
history.pushState({}, '', href)
79+
async go(href, options) {
80+
href = normalizeHref(href)
81+
if ((await router.onBeforeRouteChange?.(href)) === false) return
82+
if (!inBrowser || (await changeRoute(href, options))) await loadPage(href)
83+
syncRouteQueryAndHash()
84+
await router.onAfterRouteChange?.(href)
8185
}
82-
await loadPage(href)
83-
await (router.onAfterRouteChange ?? router.onAfterRouteChanged)?.(href)
8486
}
8587

8688
let latestPendingPath: string | null = null
8789

8890
async function loadPage(href: string, scrollPosition = 0, isRetry = false) {
8991
if ((await router.onBeforePageLoad?.(href)) === false) return
92+
9093
const targetLoc = new URL(href, fakeHost)
9194
const pendingPath = (latestPendingPath = targetLoc.pathname)
95+
9296
try {
9397
let page = await loadPageModule(pendingPath)
94-
if (!page) {
95-
throw new Error(`Page not found: ${pendingPath}`)
96-
}
98+
if (!page) throw new Error(`Page not found: ${pendingPath}`)
99+
97100
if (latestPendingPath === pendingPath) {
98101
latestPendingPath = null
99102

100103
const { default: comp, __pageData } = page
101-
if (!comp) {
102-
throw new Error(`Invalid route component: ${comp}`)
103-
}
104+
if (!comp) throw new Error(`Invalid route component: ${comp}`)
104105

105106
await router.onAfterPageLoad?.(href)
106107

@@ -109,36 +110,25 @@ export function createRouter(
109110
route.data = import.meta.env.PROD
110111
? markRaw(__pageData)
111112
: (readonly(__pageData) as PageData)
113+
syncRouteQueryAndHash(targetLoc)
112114

113115
if (inBrowser) {
114116
nextTick(() => {
115117
let actualPathname =
116118
siteDataRef.value.base +
117119
__pageData.relativePath.replace(/(?:(^|\/)index)?\.md$/, '$1')
120+
118121
if (!siteDataRef.value.cleanUrls && !actualPathname.endsWith('/')) {
119122
actualPathname += '.html'
120123
}
124+
121125
if (actualPathname !== targetLoc.pathname) {
122126
targetLoc.pathname = actualPathname
123127
href = actualPathname + targetLoc.search + targetLoc.hash
124128
history.replaceState({}, '', href)
125129
}
126130

127-
if (targetLoc.hash && !scrollPosition) {
128-
let target: HTMLElement | null = null
129-
try {
130-
target = document.getElementById(
131-
decodeURIComponent(targetLoc.hash).slice(1)
132-
)
133-
} catch (e) {
134-
console.warn(e)
135-
}
136-
if (target) {
137-
scrollTo(target, targetLoc.hash)
138-
return
139-
}
140-
}
141-
window.scrollTo(0, scrollPosition)
131+
return scrollTo(targetLoc.hash, false, scrollPosition)
142132
})
143133
}
144134
}
@@ -173,14 +163,22 @@ export function createRouter(
173163
.replace(/^\//, '')
174164
: '404.md'
175165
route.data = { ...notFoundPageData, relativePath }
166+
syncRouteQueryAndHash(targetLoc)
176167
}
177168
}
178169
}
179170

171+
function syncRouteQueryAndHash(
172+
loc: { search: string; hash: string } = inBrowser
173+
? location
174+
: { search: '', hash: '' }
175+
) {
176+
route.query = loc.search
177+
route.hash = decodeURIComponent(loc.hash)
178+
}
179+
180180
if (inBrowser) {
181-
if (history.state === null) {
182-
history.replaceState({}, '')
183-
}
181+
if (history.state === null) history.replaceState({}, '')
184182
window.addEventListener(
185183
'click',
186184
(e) => {
@@ -193,56 +191,34 @@ export function createRouter(
193191
e.shiftKey ||
194192
e.altKey ||
195193
e.metaKey
196-
)
194+
) {
197195
return
196+
}
198197

199198
const link = e.target.closest<HTMLAnchorElement | SVGAElement>('a')
200199
if (
201200
!link ||
202201
link.closest('.vp-raw') ||
203202
link.hasAttribute('download') ||
204203
link.hasAttribute('target')
205-
)
204+
) {
206205
return
206+
}
207207

208208
const linkHref =
209209
link.getAttribute('href') ??
210210
(link instanceof SVGAElement ? link.getAttribute('xlink:href') : null)
211211
if (linkHref == null) return
212212

213-
const { href, origin, pathname, hash, search } = new URL(
214-
linkHref,
215-
link.baseURI
216-
)
217-
const currentUrl = new URL(location.href) // copy to keep old data
213+
const { href, origin, pathname } = new URL(linkHref, link.baseURI)
214+
const currentLoc = new URL(location.href) // copy to keep old data
218215
// only intercept inbound html links
219-
if (origin === currentUrl.origin && treatAsHtml(pathname)) {
216+
if (origin === currentLoc.origin && treatAsHtml(pathname)) {
220217
e.preventDefault()
221-
if (
222-
pathname === currentUrl.pathname &&
223-
search === currentUrl.search
224-
) {
225-
// scroll between hash anchors in the same page
226-
// avoid duplicate history entries when the hash is same
227-
if (hash !== currentUrl.hash) {
228-
history.pushState({}, '', href)
229-
// still emit the event so we can listen to it in themes
230-
window.dispatchEvent(
231-
new HashChangeEvent('hashchange', {
232-
oldURL: currentUrl.href,
233-
newURL: href
234-
})
235-
)
236-
}
237-
if (hash) {
238-
// use smooth scroll when clicking on header anchor links
239-
scrollTo(link, hash, link.classList.contains('header-anchor'))
240-
} else {
241-
window.scrollTo(0, 0)
242-
}
243-
} else {
244-
go(href)
245-
}
218+
router.go(href, {
219+
// use smooth scroll when clicking on header anchor links
220+
smoothScroll: link.classList.contains('header-anchor')
221+
})
246222
}
247223
},
248224
{ capture: true }
@@ -252,11 +228,13 @@ export function createRouter(
252228
if (e.state === null) return
253229
const href = normalizeHref(location.href)
254230
await loadPage(href, (e.state && e.state.scrollPosition) || 0)
255-
await (router.onAfterRouteChange ?? router.onAfterRouteChanged)?.(href)
231+
syncRouteQueryAndHash()
232+
await router.onAfterRouteChange?.(href)
256233
})
257234

258235
window.addEventListener('hashchange', (e) => {
259236
e.preventDefault()
237+
syncRouteQueryAndHash()
260238
})
261239
}
262240

@@ -267,23 +245,24 @@ export function createRouter(
267245

268246
export function useRouter(): Router {
269247
const router = inject(RouterSymbol)
270-
if (!router) {
271-
throw new Error('useRouter() is called without provider.')
272-
}
248+
if (!router) throw new Error('useRouter() is called without provider.')
273249
return router
274250
}
275251

276252
export function useRoute(): Route {
277253
return useRouter().route
278254
}
279255

280-
export function scrollTo(el: Element, hash: string, smooth = false) {
256+
export function scrollTo(hash: string, smooth = false, scrollPosition = 0) {
257+
if (!hash || scrollPosition) {
258+
window.scrollTo(0, scrollPosition)
259+
return
260+
}
261+
281262
let target: Element | null = null
282263

283264
try {
284-
target = el.classList.contains('header-anchor')
285-
? el
286-
: document.getElementById(decodeURIComponent(hash).slice(1))
265+
target = document.getElementById(decodeURIComponent(hash).slice(1))
287266
} catch (e) {
288267
console.warn(e)
289268
}
@@ -293,17 +272,20 @@ export function scrollTo(el: Element, hash: string, smooth = false) {
293272
window.getComputedStyle(target).paddingTop,
294273
10
295274
)
275+
296276
const targetTop =
297277
window.scrollY +
298278
target.getBoundingClientRect().top -
299279
getScrollOffset() +
300280
targetPadding
281+
301282
function scrollToTarget() {
302283
// only smooth scroll if distance is smaller than screen height.
303284
if (!smooth || Math.abs(targetTop - window.scrollY) > window.innerHeight)
304285
window.scrollTo(0, targetTop)
305286
else window.scrollTo({ left: 0, top: targetTop, behavior: 'smooth' })
306287
}
288+
307289
requestAnimationFrame(scrollToTarget)
308290
}
309291
}
@@ -313,9 +295,7 @@ function handleHMR(route: Route): void {
313295
if (import.meta.hot) {
314296
// hot reload pageData
315297
import.meta.hot.on('vitepress:pageData', (payload: PageDataPayload) => {
316-
if (shouldHotReload(payload)) {
317-
route.data = payload.pageData
318-
}
298+
if (shouldHotReload(payload)) route.data = payload.pageData
319299
})
320300
}
321301
}
@@ -332,9 +312,47 @@ function normalizeHref(href: string): string {
332312
const url = new URL(href, fakeHost)
333313
url.pathname = url.pathname.replace(/(^|\/)index(\.html)?$/, '$1')
334314
// ensure correct deep link so page refresh lands on correct files.
335-
if (siteDataRef.value.cleanUrls)
315+
if (siteDataRef.value.cleanUrls) {
336316
url.pathname = url.pathname.replace(/\.html$/, '')
337-
else if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html'))
317+
} else if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) {
338318
url.pathname += '.html'
319+
}
339320
return url.pathname + url.search + url.hash
340321
}
322+
323+
async function changeRoute(
324+
href: string,
325+
{ smoothScroll = false, initialLoad = false } = {}
326+
): Promise<boolean> {
327+
const loc = normalizeHref(location.href)
328+
const { pathname, hash } = new URL(href, fakeHost)
329+
const currentLoc = new URL(loc, fakeHost)
330+
331+
if (href === loc) {
332+
if (!initialLoad) {
333+
scrollTo(hash, smoothScroll)
334+
return false
335+
}
336+
} else {
337+
// save scroll position before changing URL
338+
history.replaceState({ scrollPosition: window.scrollY }, '')
339+
history.pushState({}, '', href)
340+
341+
if (pathname === currentLoc.pathname) {
342+
// scroll between hash anchors on the same page, avoid duplicate entries
343+
if (hash !== currentLoc.hash) {
344+
window.dispatchEvent(
345+
new HashChangeEvent('hashchange', {
346+
oldURL: currentLoc.href,
347+
newURL: href
348+
})
349+
)
350+
scrollTo(hash, smoothScroll)
351+
}
352+
353+
return false
354+
}
355+
}
356+
357+
return true
358+
}

0 commit comments

Comments
 (0)