diff --git a/docs/content/3.api/4.advanced/1.hooks.md b/docs/content/3.api/4.advanced/1.hooks.md index b40319771ec..04829cdb81c 100644 --- a/docs/content/3.api/4.advanced/1.hooks.md +++ b/docs/content/3.api/4.advanced/1.hooks.md @@ -27,6 +27,7 @@ Hook | Arguments | Environment | Description `link:prefetch` | `to` | Client | Called when a `` is observed to be prefetched. `page:start` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) pending event. `page:finish` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) resolved event. +`page:transition:finish`| `pageComponent?` | Client | After page transition [onAfterLeave](https://vuejs.org/guide/built-ins/transition.html#javascript-hooks) event. # Nuxt Hooks (build time) diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index 1d0c419331f..228a8ce57fb 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -34,6 +34,7 @@ export interface RuntimeNuxtHooks { 'link:prefetch': (link: string) => HookResult 'page:start': (Component?: VNode) => HookResult 'page:finish': (Component?: VNode) => HookResult + 'page:transition:finish': (Component?: VNode) => HookResult 'vue:setup': () => void 'vue:error': (...args: Parameters[0]>) => HookResult } diff --git a/packages/nuxt/src/pages/module.ts b/packages/nuxt/src/pages/module.ts index 88bcfd6ffbb..26f7fc447de 100644 --- a/packages/nuxt/src/pages/module.ts +++ b/packages/nuxt/src/pages/module.ts @@ -139,11 +139,14 @@ export default defineNuxtModule({ addTemplate({ filename: 'router.options.mjs', getContents: async () => { - // Check for router options + // Scan and register app/router.options files const routerOptionsFiles = (await Promise.all(nuxt.options._layers.map( async layer => await findPath(resolve(layer.config.srcDir, 'app/router.options')) ))).filter(Boolean) as string[] + // Add default options + routerOptionsFiles.unshift(resolve(runtimeDir, 'router.options')) + const configRouterOptions = genObjectFromRawEntries(Object.entries(nuxt.options.router.options) .map(([key, value]) => [key, genString(value as string)])) diff --git a/packages/nuxt/src/pages/runtime/composables.ts b/packages/nuxt/src/pages/runtime/composables.ts index ca977dd23ed..6c517be0443 100644 --- a/packages/nuxt/src/pages/runtime/composables.ts +++ b/packages/nuxt/src/pages/runtime/composables.ts @@ -29,6 +29,8 @@ export interface PageMeta { layoutTransition?: boolean | TransitionProps key?: false | string | ((route: RouteLocationNormalizedLoaded) => string) keepalive?: boolean | KeepAliveProps + /** Set to `false` to avoid scrolling to top on page navigations */ + scrollToTop?: boolean } declare module 'vue-router' { diff --git a/packages/nuxt/src/pages/runtime/page.ts b/packages/nuxt/src/pages/runtime/page.ts index b5db98e17a3..22311d3f3fc 100644 --- a/packages/nuxt/src/pages/runtime/page.ts +++ b/packages/nuxt/src/pages/runtime/page.ts @@ -1,6 +1,7 @@ import { computed, defineComponent, h, provide, reactive, onMounted, nextTick, Suspense, Transition, KeepAliveProps, TransitionProps } from 'vue' import type { DefineComponent, VNode } from 'vue' import { RouterView } from 'vue-router' +import { defu } from 'defu' import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouteLocation } from 'vue-router' import { generateRouteKey, RouterViewSlotProps, wrapInKeepAlive } from './utils' @@ -34,22 +35,27 @@ export default defineComponent({ }, setup (props, { attrs }) { const nuxtApp = useNuxtApp() - return () => { return h(RouterView, { name: props.name, route: props.route, ...attrs }, { default: (routeProps: RouterViewSlotProps) => { if (!routeProps.Component) { return } const key = generateRouteKey(props.pageKey, routeProps) - const transitionProps = props.transition ?? routeProps.route.meta.pageTransition ?? (defaultPageTransition as TransitionProps) - const done = nuxtApp.deferHydration() - return _wrapIf(Transition, transitionProps, + const hasTransition = !!(props.transition ?? routeProps.route.meta.pageTransition ?? defaultPageTransition) + const transitionProps = hasTransition && _mergeTransitionProps([ + props.transition, + routeProps.route.meta.pageTransition, + defaultPageTransition, + { onAfterLeave: () => { nuxtApp.callHook('page:transition:finish', routeProps.Component) } } + ].filter(Boolean)) + + return _wrapIf(Transition, hasTransition && transitionProps, wrapInKeepAlive(props.keepalive ?? routeProps.route.meta.keepalive ?? (defaultKeepaliveConfig as KeepAliveProps), h(Suspense, { onPending: () => nuxtApp.callHook('page:start', routeProps.Component), - onResolve: () => nuxtApp.callHook('page:finish', routeProps.Component).finally(done) - }, { default: () => h(Component, { key, routeProps, pageKey: key, hasTransition: !!transitionProps } as {}) }) + onResolve: () => { nextTick(() => nuxtApp.callHook('page:finish', routeProps.Component).finally(done)) } + }, { default: () => h(Component, { key, routeProps, pageKey: key, hasTransition } as {}) }) )).default() } }) @@ -62,6 +68,19 @@ export default defineComponent({ [key: string]: any }> +function _toArray (val: any) { + return Array.isArray(val) ? val : (val ? [val] : []) +} + +function _mergeTransitionProps (routeProps: TransitionProps[]): TransitionProps { + const _props: TransitionProps[] = routeProps.map(prop => ({ + ...prop, + onAfterLeave: _toArray(prop.onAfterLeave) + })) + // @ts-ignore + return defu(..._props) +} + const Component = defineComponent({ // TODO: Type props // eslint-disable-next-line vue/require-prop-types diff --git a/packages/nuxt/src/pages/runtime/router.options.ts b/packages/nuxt/src/pages/runtime/router.options.ts new file mode 100644 index 00000000000..1137b346dde --- /dev/null +++ b/packages/nuxt/src/pages/runtime/router.options.ts @@ -0,0 +1,58 @@ +import type { RouterConfig } from '@nuxt/schema' +import type { RouterScrollBehavior } from 'vue-router' +import { nextTick } from 'vue' +import { useNuxtApp } from '#app' + +type ScrollPosition = Awaited> + +// Default router options +// https://router.vuejs.org/api/#routeroptions +export default { + scrollBehavior (to, from, savedPosition) { + const nuxtApp = useNuxtApp() + + // 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 + + // Scroll to top if route is changed by default + if ( + !position && + (from && to && from.matched[0] !== to.matched[0]) && + to.meta.scrollToTop !== false + ) { + position = { left: 0, top: 0 } + } + + // 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) } + } + } + + // Wait for `page:transition:finish` or `page:finish` depending on if transitions are enabled or not + const hasTransition = to.meta.pageTransition !== false && from.meta.pageTransition !== false + 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) } + } + resolve(position) + }) + }) + } +} + +function _getHashElementScrollMarginTop (selector: string): number { + const elem = document.querySelector(selector) + if (elem) { + return parseFloat(getComputedStyle(elem).scrollMarginTop) + } + return 0 +}