diff --git a/app/pages/compare.vue b/app/pages/compare.vue index 5582274a60..26a92862f9 100644 --- a/app/pages/compare.vue +++ b/app/pages/compare.vue @@ -5,6 +5,7 @@ import FacetBarChart from '~/components/Compare/FacetBarChart.vue' definePageMeta({ name: 'compare', + preserveScrollOnQuery: true, }) const { locale } = useI18n() diff --git a/app/router.options.ts b/app/router.options.ts index a6d9db7ccd..3d1ec07693 100644 --- a/app/router.options.ts +++ b/app/router.options.ts @@ -1,12 +1,19 @@ import type { RouterConfig } from 'nuxt/schema' export default { - scrollBehavior(to, _from, savedPosition) { + scrollBehavior(to, from, savedPosition) { // If the browser has a saved position (e.g. back/forward navigation), restore it if (savedPosition) { return savedPosition } + + // Preserve the current viewport for query-only updates on pages that opt in, + // such as compare where controls sync state to the URL in-place. + if (to.path === from.path && to.hash === from.hash && to.meta.preserveScrollOnQuery === true) { + return false + } + // If navigating to a hash anchor, scroll to it if (to.hash) { const { scrollMargin } = to.meta diff --git a/app/types/index.ts b/app/types/index.ts index 5058830705..f736f8b329 100644 --- a/app/types/index.ts +++ b/app/types/index.ts @@ -10,5 +10,9 @@ declare module '#app' { * @default 70 */ scrollMargin?: number + /** + * preserve scroll position when only query params change on same path/hash + */ + preserveScrollOnQuery?: boolean } } diff --git a/test/unit/app/router.options.spec.ts b/test/unit/app/router.options.spec.ts new file mode 100644 index 0000000000..1c6c6b4f74 --- /dev/null +++ b/test/unit/app/router.options.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import routerOptions from '../../../app/router.options' + +type ScrollBehavior = NonNullable +type RouteArg = Parameters[0] + +function createRoute(overrides: Partial = {}) { + return { + path: '/', + hash: '', + query: {}, + meta: {}, + ...overrides, + } as RouteArg +} + +describe('router scrollBehavior', () => { + it('restores saved position when available', () => { + const savedPosition = { left: 12, top: 345 } + + expect(routerOptions.scrollBehavior(createRoute(), createRoute(), savedPosition)).toEqual( + savedPosition, + ) + }) + + it('preserves scroll on query-only updates for pages that opt in', () => { + const to = createRoute({ + path: '/compare', + query: { packages: 'vue,nuxt', facets: 'downloads,license' }, + meta: { preserveScrollOnQuery: true }, + }) + const from = createRoute({ + path: '/compare', + query: { packages: 'vue', facets: 'downloads' }, + meta: { preserveScrollOnQuery: true }, + }) + + expect(routerOptions.scrollBehavior(to, from, null)).toBe(false) + }) + + it('does not preserve scroll on query-only updates without opt-in', () => { + const to = createRoute({ + path: '/compare', + query: { packages: 'vue,nuxt', facets: 'downloads,license' }, + }) + const from = createRoute({ + path: '/compare', + query: { packages: 'vue', facets: 'downloads' }, + }) + + expect(routerOptions.scrollBehavior(to, from, null)).toEqual({ left: 0, top: 0 }) + }) + + it('scrolls to hash anchors', () => { + const to = createRoute({ + hash: '#section-function', + meta: { scrollMargin: 96 }, + }) + + expect(routerOptions.scrollBehavior(to, createRoute(), null)).toEqual({ + el: '#section-function', + behavior: 'smooth', + top: 96, + }) + }) + + it('scrolls to top for regular navigations', () => { + const to = createRoute({ path: '/compare', meta: { preserveScrollOnQuery: true } }) + const from = createRoute({ path: '/search' }) + + expect(routerOptions.scrollBehavior(to, from, null)).toEqual({ left: 0, top: 0 }) + }) +})