From a3658ee7cd7e8c2a95e9254609f544ea7ec48939 Mon Sep 17 00:00:00 2001 From: Bernhard Berger Date: Sun, 7 Jan 2024 22:42:50 +0100 Subject: [PATCH] fix: improved default router.options scrollBehaviour handle first page load and page refresh scenarios (restore scroll position without jumps), fixes hash-navigation when changing pages and browser back/forward positions, especiall when page transitions are involved. fixes #24941, #22487, #25030 and #19664 Signed-off-by: Bernhard Berger --- .../nuxt/src/pages/runtime/router.options.ts | 88 +++++++++++++------ 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/packages/nuxt/src/pages/runtime/router.options.ts b/packages/nuxt/src/pages/runtime/router.options.ts index 71eefaa4a1..f4fb0015e9 100644 --- a/packages/nuxt/src/pages/runtime/router.options.ts +++ b/packages/nuxt/src/pages/runtime/router.options.ts @@ -1,8 +1,8 @@ -import type { RouteLocationNormalized, RouterScrollBehavior } from '#vue-router' import { nextTick } from 'vue' import type { RouterConfig } from 'nuxt/schema' +import { START_LOCATION } from 'vue-router' +import type { RouteLocationNormalized, RouterScrollBehavior } from '#vue-router' import { useNuxtApp } from '#app/nuxt' -import { isChangingPage } from '#app/components/utils' import { useRouter } from '#app/composables/router' // @ts-expect-error virtual file import { appPageTransition as defaultPageTransition } from '#build/nuxt.config.mjs' @@ -11,43 +11,54 @@ type ScrollPosition = Awaited> // Default router options // https://router.vuejs.org/api/#routeroptions -export default { - scrollBehavior (to, from, savedPosition) { +export default { + scrollBehavior: ( + to: RouteLocationNormalized, + from: RouteLocationNormalized, + savedPosition: ScrollPosition | null + ) => { + // Check if the navigation is the first page load + const isFirstLoad = from === START_LOCATION const nuxtApp = useNuxtApp() // @ts-expect-error untyped, nuxt-injected option - const behavior = useRouter().options?.scrollBehaviorType ?? 'auto' + const scrollBehaviorType = useRouter().options?.scrollBehaviorType ?? 'auto' - // By default when the returned position is falsy or an empty object, vue-router will retain the current scroll position - // savedPosition is only available for popstate navigations (back button) - let position: ScrollPosition = savedPosition || undefined + // Handle page reload scenario where the saved position should be used + if (isFirstLoad && savedPosition) { + // Restore browser's default scroll behavior to auto (vue-router sets this when using custom ScrollBehavior) + // to avoid page jumps on load + window.history.scrollRestoration = 'auto' + return savedPosition + } + // For other navigations, ensure manual scrollRestoration + window.history.scrollRestoration = 'manual' + // Check if the route explicitly disables automatic scroll to top const routeAllowsScrollToTop = typeof to.meta.scrollToTop === 'function' ? to.meta.scrollToTop(to, from) : to.meta.scrollToTop - - // Scroll to top if route is changed by default - if (!position && from && to && routeAllowsScrollToTop !== false && isChangingPage(to, from)) { - position = { left: 0, top: 0 } + if (routeAllowsScrollToTop === false) { + return false // Do not scroll to top if the route disallows it } - // Hash routes on the same page, no page hook is fired so resolve here - if (to.path === from.path) { - if (from.hash && !to.hash) { - return { left: 0, top: 0 } - } - if (to.hash) { - return { el: to.hash, top: _getHashElementScrollMarginTop(to.hash), behavior } - } + // Handle same page navigation or first load + if (isFirstLoad || to.path === from.path) { + return _calculatePosition(to, savedPosition, to.path === from.path || isFirstLoad ? scrollBehaviorType : 'instant') } - // Wait for `page:transition:finish` or `page:finish` depending on if transitions are enabled or not - const hasTransition = (route: RouteLocationNormalized) => !!(route.meta.pageTransition ?? defaultPageTransition) - const hookToWait = (hasTransition(from) && hasTransition(to)) ? 'page:transition:finish' : 'page:finish' + // Check if the route change involves a page transition + const routeHasTransition = (route: RouteLocationNormalized): boolean => { + return !!(route.meta.pageTransition ?? defaultPageTransition) && !isFirstLoad + } + const hasTransition = (routeHasTransition(from) && routeHasTransition(to)) + + // Wait for page transition to finish before applying the scroll behavior + const hookToWait = hasTransition ? 'page:transition:finish' : 'page:finish' return new Promise((resolve) => { - nuxtApp.hooks.hookOnce(hookToWait, async () => { - await nextTick() - if (to.hash) { - position = { el: to.hash, top: _getHashElementScrollMarginTop(to.hash), behavior } - } - resolve(position) + nuxtApp.hooks.hookOnce(hookToWait, () => { + return nextTick(() => { + // Without this setTimeout, the scroll behaviour is not applied, this is however working reliably + // todo: figure out how we can solve this without setTimeout of 0ms + setTimeout(() => resolve(_calculatePosition(to, savedPosition, 'instant')), 0) + }) }) }) } @@ -64,3 +75,22 @@ function _getHashElementScrollMarginTop (selector: string): number { } return 0 } + +function _calculatePosition ( + to: RouteLocationNormalized, + savedPosition: ScrollPosition | null, + scrollBehaviorType: ScrollBehavior +): ScrollPosition { + // Handle saved position for backward/forward navigation + if (savedPosition) { + return savedPosition + } + + // Scroll to the element specified in the URL hash, if present + if (to.hash) { + return { el: to.hash, top: _getHashElementScrollMarginTop(to.hash), behavior: scrollBehaviorType } + } + + // Default scroll to the top left of the page + return { left: 0, top: 0, behavior: scrollBehaviorType } +}