diff --git a/.changeset/polite-ravens-serve.md b/.changeset/polite-ravens-serve.md new file mode 100644 index 000000000..0f5d28c92 --- /dev/null +++ b/.changeset/polite-ravens-serve.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Restore horizontal scroll position on history navigation (view transitions) diff --git a/packages/astro/components/ViewTransitions.astro b/packages/astro/components/ViewTransitions.astro index 4d615188d..6ae8f3592 100644 --- a/packages/astro/components/ViewTransitions.astro +++ b/packages/astro/components/ViewTransitions.astro @@ -15,6 +15,7 @@ const { fallback = 'animate' } = Astro.props as Props; type Direction = 'forward' | 'back'; type State = { index: number; + scrollX: number; scrollY: number; }; type Events = 'astro:page-load' | 'astro:after-swap'; @@ -37,9 +38,9 @@ const { fallback = 'animate' } = Astro.props as Props; // we reloaded a page with history state // (e.g. history navigation from non-transition page or browser reload) currentHistoryIndex = history.state.index; - scrollTo({ left: 0, top: history.state.scrollY }); + scrollTo({ left: history.state.scrollX, top: history.state.scrollY }); } else if (transitionEnabledOnThisPage()) { - history.replaceState({ index: currentHistoryIndex, scrollY }, ''); + history.replaceState({ index: currentHistoryIndex, scrollX, scrollY }, ''); } const throttle = (cb: (...args: any[]) => any, delay: number) => { let wait = false; @@ -208,17 +209,29 @@ const { fallback = 'animate' } = Astro.props as Props; // Chromium based browsers (Chrome, Edge, Opera, ...) scrollTo({ left: 0, top: 0, behavior: 'instant' }); + let initialScrollX = 0; let initialScrollY = 0; if (!state && loc.hash) { const id = decodeURIComponent(loc.hash.slice(1)); const elem = document.getElementById(id); // prefer scrollIntoView() over scrollTo() because it takes scroll-padding into account - elem && (initialScrollY = elem.offsetTop) && elem.scrollIntoView(); - } else if (state && state.scrollY !== 0) { - scrollTo(0, state.scrollY); // usings default scrollBehavior + if (elem) { + elem.scrollIntoView(); + initialScrollX = Math.max( + 0, + elem.offsetLeft + elem.offsetWidth - document.documentElement.clientWidth + ); + initialScrollY = elem.offsetTop; + } + } else if (state) { + scrollTo(state.scrollX, state.scrollY); // usings default scrollBehavior } !state && - history.pushState({ index: ++currentHistoryIndex, scrollY: initialScrollY }, '', loc.href); + history.pushState( + { index: ++currentHistoryIndex, scrollX: initialScrollX, scrollY: initialScrollY }, + '', + loc.href + ); triggerEvent('astro:after-swap'); }; @@ -280,7 +293,7 @@ const { fallback = 'animate' } = Astro.props as Props; } // Now we are sure that we will push state, and it is time to create a state if it is still missing. - !state && history.replaceState({ index: currentHistoryIndex, scrollY }, ''); + !state && history.replaceState({ index: currentHistoryIndex, scrollX, scrollY }, ''); document.documentElement.dataset.astroTransition = dir; if (supportsViewTransitions) { @@ -357,8 +370,11 @@ const { fallback = 'animate' } = Astro.props as Props; ev.preventDefault(); // push state on the first navigation but not if we were here already if (location.hash) { - history.replaceState({ index: currentHistoryIndex, scrollY: -(scrollY + 1) }, ''); - const newState: State = { index: ++currentHistoryIndex, scrollY: 0 }; + history.replaceState( + { index: currentHistoryIndex, scrollX, scrollY: -(scrollY + 1) }, + '' + ); + const newState: State = { index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 }; history.pushState(newState, '', link.href); } scrollTo({ left: 0, top: 0, behavior: 'instant' }); @@ -404,7 +420,7 @@ const { fallback = 'animate' } = Astro.props as Props; const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back'; currentHistoryIndex = nextIndex; if (state.scrollY < 0) { - scrollTo(0, -(state.scrollY + 1)); + scrollTo(state.scrollX, -(state.scrollY + 1)); } else { navigate(direction, new URL(location.href), state); } @@ -432,7 +448,7 @@ const { fallback = 'animate' } = Astro.props as Props; // There's not a good way to record scroll position before a back button. // So the way we do it is by listening to scrollend if supported, and if not continuously record the scroll position. const updateState = () => { - persistState({ ...history.state, scrollY }); + persistState({ ...history.state, scrollX, scrollY }); }; if ('onscrollend' in window) addEventListener('scrollend', updateState); diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/wide-page.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/wide-page.astro new file mode 100644 index 000000000..4862122c0 --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/wide-page.astro @@ -0,0 +1,12 @@ +--- +import Layout from '../components/Layout.astro'; +--- + + go right +
+
+ go to top | + go to 1 +
+
+
diff --git a/packages/astro/e2e/view-transitions.test.js b/packages/astro/e2e/view-transitions.test.js index 64041ab05..6421b5fc3 100644 --- a/packages/astro/e2e/view-transitions.test.js +++ b/packages/astro/e2e/view-transitions.test.js @@ -585,4 +585,34 @@ test.describe('View Transitions', () => { styles = await page.locator('style').all(); expect(styles.length).toEqual(totalExpectedStyles, 'style count has not changed'); }); + + test('Horizontal scroll position restored on back button', async ({ page, astro }) => { + await page.goto(astro.resolveUrl('/wide-page')); + let article = page.locator('#widepage'); + await expect(article, 'should have script content').toBeVisible('exists'); + + let locator = page.locator('#click-one'); + await expect(locator).not.toBeInViewport(); + + await page.click('#click-right'); + locator = page.locator('#click-one'); + await expect(locator).toBeInViewport(); + locator = page.locator('#click-top'); + await expect(locator).toBeInViewport(); + + await page.click('#click-one'); + let p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + await page.goBack(); + locator = page.locator('#click-one'); + await expect(locator).toBeInViewport(); + + locator = page.locator('#click-top'); + await expect(locator).toBeInViewport(); + + await page.click('#click-top'); + locator = page.locator('#click-one'); + await expect(locator).not.toBeInViewport(); + }); });