From 8195d5124ed58e7ca98d885c222cd3c95f42f696 Mon Sep 17 00:00:00 2001 From: Sturla Thorvaldsson Date: Mon, 6 Apr 2026 11:04:51 +0000 Subject: [PATCH 1/2] Persist sidebar scroll position across navigation Implements sessionStorage-based persistence for the docs sidebar scroll position, so users retain their scroll state when navigating between articles. Refactors sidebar event binding for idempotency and adds Playwright E2E tests to verify scroll restoration works as intended. --- docs/src/components/Sidebar.astro | 66 ++++++++++++++++++++++++++---- docs/tests/sidebar.spec.mjs | 68 +++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 docs/tests/sidebar.spec.mjs diff --git a/docs/src/components/Sidebar.astro b/docs/src/components/Sidebar.astro index a631d562c..c28cdc237 100644 --- a/docs/src/components/Sidebar.astro +++ b/docs/src/components/Sidebar.astro @@ -62,19 +62,71 @@ const base = import.meta.env.BASE_URL; diff --git a/docs/tests/sidebar.spec.mjs b/docs/tests/sidebar.spec.mjs new file mode 100644 index 000000000..443b2ac38 --- /dev/null +++ b/docs/tests/sidebar.spec.mjs @@ -0,0 +1,68 @@ +/** + * Playwright e2e tests for docs sidebar behavior. + */ +import { test, expect } from '@playwright/test'; + +const START = '/squad/docs/get-started/five-minute-start/'; +const TARGET = '/squad/docs/scenarios/ci-cd-integration/'; + +test.describe('Docs sidebar navigation', () => { + test('keeps the sidebar scroll position when opening another article', async ({ page }) => { + await page.goto(START); + + await page.evaluate(() => sessionStorage.removeItem('squad-docs-sidebar-scroll')); + + const sidebar = page.locator('#sidebar'); + await expect(sidebar).toBeVisible(); + + await page.evaluate(() => { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) { + throw new Error('Sidebar not found'); + } + + sidebar.scrollTop = 900; + sidebar.dispatchEvent(new Event('scroll')); + window.scrollTo(0, 1000); + }); + + const initialSidebarScroll = await page.evaluate(() => { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) { + throw new Error('Sidebar not found'); + } + + return sidebar.scrollTop; + }); + + expect(initialSidebarScroll).toBeGreaterThan(0); + + await page.evaluate((targetHref) => { + const link = document.querySelector(`#sidebar a[href="${targetHref}"]`) + ?? document.querySelector('#sidebar a[href*="docs/scenarios/ci-cd-integration/"]'); + + if (!(link instanceof HTMLAnchorElement)) { + throw new Error('Target sidebar link not found'); + } + + link.click(); + }, TARGET); + + await page.waitForURL(new RegExp(TARGET.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + await page.waitForTimeout(100); + + const restoredSidebarScroll = await page.evaluate(() => { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) { + throw new Error('Sidebar not found'); + } + + return sidebar.scrollTop; + }); + + const pageScroll = await page.evaluate(() => window.scrollY); + + expect(pageScroll).toBeLessThan(20); + expect(Math.abs(restoredSidebarScroll - initialSidebarScroll)).toBeLessThan(10); + }); +}); From 54761f4ddb1455b60e59db35b0959fa7e7f55a56 Mon Sep 17 00:00:00 2001 From: Sturla Thorvaldsson Date: Mon, 6 Apr 2026 11:54:30 +0000 Subject: [PATCH 2/2] Optimize sidebar scroll persistence and test reliability Refactored sidebar scroll tracking to use requestAnimationFrame, reducing sessionStorage writes during rapid scrolling. Added flushSidebarScroll to persist the last scroll position on navigation events. Updated event listeners to use the new logic. Improved test stability by switching to expect.poll for scroll restoration checks. --- docs/src/components/Sidebar.astro | 23 +++++++++++++++++++---- docs/tests/sidebar.spec.mjs | 12 +++++++----- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/src/components/Sidebar.astro b/docs/src/components/Sidebar.astro index c28cdc237..788df5660 100644 --- a/docs/src/components/Sidebar.astro +++ b/docs/src/components/Sidebar.astro @@ -77,11 +77,24 @@ const base = import.meta.env.BASE_URL; overlay?.classList.add('hidden'); } - function saveSidebarScroll() { + let lastScrollTop = 0; + let rafPending = false; + + function trackSidebarScroll() { const { sidebar } = getSidebarElements(); if (!sidebar) return; - sessionStorage.setItem(SIDEBAR_SCROLL_KEY, String(sidebar.scrollTop)); + if (!rafPending) { + rafPending = true; + requestAnimationFrame(() => { + lastScrollTop = sidebar.scrollTop; + rafPending = false; + }); + } + } + + function flushSidebarScroll() { + sessionStorage.setItem(SIDEBAR_SCROLL_KEY, String(lastScrollTop)); } function restoreSidebarScroll() { @@ -95,6 +108,7 @@ const base = import.meta.env.BASE_URL; if (Number.isNaN(scrollTop)) return false; sidebar.scrollTop = scrollTop; + lastScrollTop = scrollTop; return true; } @@ -103,7 +117,7 @@ const base = import.meta.env.BASE_URL; if (!sidebar) return; if (!sidebar.dataset.scrollPersistenceBound) { - sidebar.addEventListener('scroll', saveSidebarScroll, { passive: true }); + sidebar.addEventListener('scroll', trackSidebarScroll, { passive: true }); sidebar.dataset.scrollPersistenceBound = 'true'; } @@ -122,9 +136,10 @@ const base = import.meta.env.BASE_URL; } if (!window.__squadDocsSidebarStateSetup) { + document.addEventListener('astro:before-swap', flushSidebarScroll); document.addEventListener('astro:after-swap', initSidebar); document.addEventListener('astro:page-load', initSidebar); - window.addEventListener('pagehide', saveSidebarScroll); + window.addEventListener('pagehide', flushSidebarScroll); window.__squadDocsSidebarStateSetup = true; } diff --git a/docs/tests/sidebar.spec.mjs b/docs/tests/sidebar.spec.mjs index 443b2ac38..77917c9ee 100644 --- a/docs/tests/sidebar.spec.mjs +++ b/docs/tests/sidebar.spec.mjs @@ -49,14 +49,16 @@ test.describe('Docs sidebar navigation', () => { }, TARGET); await page.waitForURL(new RegExp(TARGET.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); - await page.waitForTimeout(100); - const restoredSidebarScroll = await page.evaluate(() => { + await expect.poll(async () => page.evaluate(() => { const sidebar = document.getElementById('sidebar'); - if (!sidebar) { - throw new Error('Sidebar not found'); - } + if (!sidebar) throw new Error('Sidebar not found'); + return sidebar.scrollTop; + }), { timeout: 5000 }).toBeGreaterThan(initialSidebarScroll - 10); + const restoredSidebarScroll = await page.evaluate(() => { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) throw new Error('Sidebar not found'); return sidebar.scrollTop; });