From 499deb3546558bc8aa72e81f938b92887b5f3e05 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 9 Jun 2026 07:01:56 -0700 Subject: [PATCH 01/12] fix(search): Stop mobile Kapa modal scroll trap Opening the Kapa AI modal from the in-search suggestion left the mobile search overlay (and its body scroll lock) mounted underneath, so our lock and Kapa's own lock fought and Kapa's content couldn't scroll on mobile. Close the overlay before opening Kapa so Kapa owns the body scroll lock exclusively, and consolidate the ad-hoc body-overflow locks into a shared reference-counted useBodyScrollLock hook so overlays no longer clobber each other's (or Kapa's) lock. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/header.tsx | 101 ++++++++++++++++---------------- src/components/search/index.tsx | 13 +++- src/hooks/useBodyScrollLock.tsx | 37 ++++++++++++ 3 files changed, 97 insertions(+), 54 deletions(-) create mode 100644 src/hooks/useBodyScrollLock.tsx diff --git a/src/components/header.tsx b/src/components/header.tsx index c00e48b684d7d..23ccd5110ea99 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -6,6 +6,7 @@ import dynamic from 'next/dynamic'; import Image from 'next/image'; import Link from 'next/link'; import {useCallback, useEffect, useState} from 'react'; +import {useBodyScrollLock} from 'sentry-docs/hooks/useBodyScrollLock'; import SentryLogoSVG from 'sentry-docs/logos/sentry-logo-dark.svg'; import {Platform} from 'sentry-docs/types'; @@ -65,41 +66,41 @@ export function Header({ setMobileSearchOpen(false); }, [pathname]); - // Lock body scroll when mobile search overlay is open, close on resize to desktop or escape key + useBodyScrollLock(mobileSearchOpen); + + // Close mobile search on resize to desktop or escape key useEffect(() => { - if (mobileSearchOpen) { - document.body.style.overflow = 'hidden'; + if (!mobileSearchOpen) { + return undefined; + } - // Close mobile search if viewport is resized to desktop width - const handleResize = () => { - if (window.innerWidth >= 768) { - setMobileSearchOpen(false); - } - }; + // Close mobile search if viewport is resized to desktop width + const handleResize = () => { + if (window.innerWidth >= 768) { + setMobileSearchOpen(false); + } + }; - // Close mobile search on escape key (only if search input is empty) - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - // Check if search input has a value - if so, let the Search component - // handle the escape key to clear the query first - const searchInput = document.querySelector( - '.mobile-search-overlay input[type="text"]' - ) as HTMLInputElement | null; - if (!searchInput?.value) { - setMobileSearchOpen(false); - } + // Close mobile search on escape key (only if search input is empty) + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + // Check if search input has a value - if so, let the Search component + // handle the escape key to clear the query first + const searchInput = document.querySelector( + '.mobile-search-overlay input[type="text"]' + ) as HTMLInputElement | null; + if (!searchInput?.value) { + setMobileSearchOpen(false); } - }; + } + }; - window.addEventListener('resize', handleResize); - window.addEventListener('keydown', handleKeyDown); - return () => { - document.body.style.overflow = ''; - window.removeEventListener('resize', handleResize); - window.removeEventListener('keydown', handleKeyDown); - }; - } - return undefined; + window.addEventListener('resize', handleResize); + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('keydown', handleKeyDown); + }; }, [mobileSearchOpen]); // Track sidebar checkbox state for non-home pages @@ -126,31 +127,28 @@ export function Header({ return () => checkbox.removeEventListener('change', handleChange); }, [isHomePage]); - // Lock body scroll when sidebar is open on mobile (fixes iOS Safari touch scrolling) + useBodyScrollLock(sidebarOpen); + + // Close sidebar if viewport is resized to desktop width useEffect(() => { - if (sidebarOpen) { - document.body.style.overflow = 'hidden'; + if (!sidebarOpen) { + return undefined; + } - // Close sidebar if viewport is resized to desktop width - const handleResize = () => { - if (window.innerWidth >= 768) { - const checkbox = document.getElementById( - sidebarToggleId - ) as HTMLInputElement | null; - if (checkbox) { - checkbox.checked = false; - setSidebarOpen(false); - } + const handleResize = () => { + if (window.innerWidth >= 768) { + const checkbox = document.getElementById( + sidebarToggleId + ) as HTMLInputElement | null; + if (checkbox) { + checkbox.checked = false; + setSidebarOpen(false); } - }; + } + }; - window.addEventListener('resize', handleResize); - return () => { - document.body.style.overflow = ''; - window.removeEventListener('resize', handleResize); - }; - } - return undefined; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); }, [sidebarOpen]); // Show header search if: not on home page, OR on home page but home search is scrolled out of view @@ -404,6 +402,7 @@ export function Header({ searchPlatforms={searchPlatforms} autoFocus useStoredSearchPlatforms={useStoredSearchPlatforms} + onAskAi={() => setMobileSearchOpen(false)} /> diff --git a/src/components/search/index.tsx b/src/components/search/index.tsx index 2fed908d2d15c..26228f3296d6b 100644 --- a/src/components/search/index.tsx +++ b/src/components/search/index.tsx @@ -60,6 +60,8 @@ const search = new SentryGlobalSearch(config); type Props = { autoFocus?: boolean; + /** Called before the Kapa modal opens, so a parent overlay can close itself first. */ + onAskAi?: () => void; path?: string; searchPlatforms?: string[]; showChatBot?: boolean; @@ -83,6 +85,7 @@ const SDK_AGNOSTIC_PATH_PREFIXES = [ export function Search({ path, autoFocus, + onAskAi, searchPlatforms = [], useStoredSearchPlatforms = true, }: Props) { @@ -373,10 +376,14 @@ export function Search({ className={styles['sgs-ai-button']} onClick={() => { if (window.Kapa?.open) { - // close search results setInputFocus(false); - // open kapa modal - window.Kapa.open({query, submit: true}); + onAskAi?.(); + // Defer a frame so the overlay unmounts and releases its scroll + // lock before Kapa applies its own; otherwise the competing + // locks trap scroll on the document on mobile. + requestAnimationFrame(() => { + window.Kapa?.open({query, submit: true}); + }); } }} > diff --git a/src/hooks/useBodyScrollLock.tsx b/src/hooks/useBodyScrollLock.tsx new file mode 100644 index 0000000000000..61846eddcf273 --- /dev/null +++ b/src/hooks/useBodyScrollLock.tsx @@ -0,0 +1,37 @@ +'use client'; + +import {useEffect} from 'react'; + +// Restore the prior value (not ''), so we don't clobber a lock a third party +// (e.g. the Kapa modal) set after us. +let lockCount = 0; +let previousOverflow = ''; + +function lockBodyScroll() { + if (lockCount === 0) { + previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + } + lockCount += 1; +} + +function unlockBodyScroll() { + if (lockCount === 0) { + return; + } + lockCount -= 1; + if (lockCount === 0) { + document.body.style.overflow = previousOverflow; + } +} + +/** Reference-counted body scroll lock; safe to use from several overlays at once. */ +export function useBodyScrollLock(active: boolean) { + useEffect(() => { + if (!active) { + return undefined; + } + lockBodyScroll(); + return unlockBodyScroll; + }, [active]); +} From 0e19dcf1a8c30e343e1b8b0f5cc5d5934b39ab30 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 9 Jun 2026 09:58:52 -0700 Subject: [PATCH 02/12] fix(search): Clear body overflow on unlock instead of restoring captured value A captured previous value could be a third party's transient overflow:hidden (e.g. Kapa). Re-applying it on release would leave the page unscrollable. Clearing the inline style fails gracefully and is correct for this app, where body overflow is only ever a transient scroll lock. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/hooks/useBodyScrollLock.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/hooks/useBodyScrollLock.tsx b/src/hooks/useBodyScrollLock.tsx index 61846eddcf273..fdd706e97d951 100644 --- a/src/hooks/useBodyScrollLock.tsx +++ b/src/hooks/useBodyScrollLock.tsx @@ -2,14 +2,10 @@ import {useEffect} from 'react'; -// Restore the prior value (not ''), so we don't clobber a lock a third party -// (e.g. the Kapa modal) set after us. let lockCount = 0; -let previousOverflow = ''; function lockBodyScroll() { if (lockCount === 0) { - previousOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; } lockCount += 1; @@ -21,7 +17,10 @@ function unlockBodyScroll() { } lockCount -= 1; if (lockCount === 0) { - document.body.style.overflow = previousOverflow; + // Clear our inline lock rather than restoring a captured value: a captured + // value could be a third party's transient 'hidden' (e.g. Kapa), which we'd + // wrongly re-apply on release and leave the page unscrollable. + document.body.style.overflow = ''; } } From f32177c84e9efe6136caad2172b3e2bc79d571ab Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 9 Jun 2026 10:13:41 -0700 Subject: [PATCH 03/12] fix(search): Close sidebar too when opening Kapa from search The onAskAi handler only closed the search overlay, so if the sidebar was also open its body scroll lock survived into the Kapa modal and recreated the iOS scroll trap. Release every overlay's lock before Kapa opens via a shared closeSidebar helper (also dedupes the existing search-button logic). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/header.tsx | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/components/header.tsx b/src/components/header.tsx index 23ccd5110ea99..c02d302c88685 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -66,6 +66,14 @@ export function Header({ setMobileSearchOpen(false); }, [pathname]); + const closeSidebar = useCallback(() => { + const checkbox = document.getElementById(sidebarToggleId) as HTMLInputElement | null; + if (checkbox) { + checkbox.checked = false; + } + setSidebarOpen(false); + }, []); + useBodyScrollLock(mobileSearchOpen); // Close mobile search on resize to desktop or escape key @@ -281,14 +289,7 @@ export function Header({ onClick={() => { setMobileSearchOpen(true); setHomeMobileNavOpen(false); - // Close sidebar to prevent competing scroll locks - const checkbox = document.getElementById( - sidebarToggleId - ) as HTMLInputElement | null; - if (checkbox) { - checkbox.checked = false; - setSidebarOpen(false); - } + closeSidebar(); }} aria-label="Search" > @@ -402,7 +403,12 @@ export function Header({ searchPlatforms={searchPlatforms} autoFocus useStoredSearchPlatforms={useStoredSearchPlatforms} - onAskAi={() => setMobileSearchOpen(false)} + // Release every overlay's scroll lock before Kapa opens, so it + // owns the body lock exclusively (the sidebar can be open too). + onAskAi={() => { + setMobileSearchOpen(false); + closeSidebar(); + }} /> From 244724603d413136cc056f3e2945491b80e2607b Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 9 Jun 2026 10:33:51 -0700 Subject: [PATCH 04/12] fix(search): Make scroll-lock release deterministic before Kapa opens The lock was released in a passive effect, whose ordering vs the requestAnimationFrame that opens Kapa is not guaranteed. If Kapa opened first, our later cleanup cleared body overflow and wiped Kapa's lock, re-trapping scroll on mobile. Release in a layout effect so it runs synchronously on commit, before the rAF. Also trims verbose comments. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/header.tsx | 3 +-- src/components/search/index.tsx | 5 ++--- src/hooks/useBodyScrollLock.tsx | 18 ++++++++++++------ 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/components/header.tsx b/src/components/header.tsx index c02d302c88685..ae6abe4a0175f 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -403,8 +403,7 @@ export function Header({ searchPlatforms={searchPlatforms} autoFocus useStoredSearchPlatforms={useStoredSearchPlatforms} - // Release every overlay's scroll lock before Kapa opens, so it - // owns the body lock exclusively (the sidebar can be open too). + // Release every overlay's lock before Kapa opens so it owns scrolling alone. onAskAi={() => { setMobileSearchOpen(false); closeSidebar(); diff --git a/src/components/search/index.tsx b/src/components/search/index.tsx index 26228f3296d6b..52cfecf08fbd8 100644 --- a/src/components/search/index.tsx +++ b/src/components/search/index.tsx @@ -378,9 +378,8 @@ export function Search({ if (window.Kapa?.open) { setInputFocus(false); onAskAi?.(); - // Defer a frame so the overlay unmounts and releases its scroll - // lock before Kapa applies its own; otherwise the competing - // locks trap scroll on the document on mobile. + // Open next frame, after the overlay's scroll lock is released + // on commit, so Kapa's lock and ours never overlap. requestAnimationFrame(() => { window.Kapa?.open({query, submit: true}); }); diff --git a/src/hooks/useBodyScrollLock.tsx b/src/hooks/useBodyScrollLock.tsx index fdd706e97d951..88380ac1b8d47 100644 --- a/src/hooks/useBodyScrollLock.tsx +++ b/src/hooks/useBodyScrollLock.tsx @@ -1,6 +1,9 @@ 'use client'; -import {useEffect} from 'react'; +import {useEffect, useLayoutEffect} from 'react'; + +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; let lockCount = 0; @@ -17,16 +20,19 @@ function unlockBodyScroll() { } lockCount -= 1; if (lockCount === 0) { - // Clear our inline lock rather than restoring a captured value: a captured - // value could be a third party's transient 'hidden' (e.g. Kapa), which we'd - // wrongly re-apply on release and leave the page unscrollable. + // Clear, don't restore a captured value — it may be a third party's + // transient lock (e.g. Kapa); re-applying it would strand the page. document.body.style.overflow = ''; } } -/** Reference-counted body scroll lock; safe to use from several overlays at once. */ +/** + * Reference-counted body scroll lock; safe to use from several overlays at once. + * Runs as a layout effect so the release happens synchronously on commit, before + * any rAF that hands the lock off to a third-party modal. + */ export function useBodyScrollLock(active: boolean) { - useEffect(() => { + useIsomorphicLayoutEffect(() => { if (!active) { return undefined; } From 2b788f3964e1c41cfe914322f12db5d5da53a0b0 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 9 Jun 2026 12:27:22 -0700 Subject: [PATCH 05/12] fix(header): Lock body scroll when home mobile nav is open The home-page hamburger overlay rendered fixed inset-0 but never locked body scroll, so the page scrolled behind it on mobile. Apply useBodyScrollLock for homeMobileNavOpen, mirroring the search/sidebar overlays, and close it on navigation and on resize to desktop so the lock can't be stranded. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/header.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/components/header.tsx b/src/components/header.tsx index ae6abe4a0175f..963d22960648a 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -61,9 +61,10 @@ export function Header({ }, []) ); - // Close mobile search overlay on navigation + // Close mobile overlays on navigation useEffect(() => { setMobileSearchOpen(false); + setHomeMobileNavOpen(false); }, [pathname]); const closeSidebar = useCallback(() => { @@ -75,6 +76,21 @@ export function Header({ }, []); useBodyScrollLock(mobileSearchOpen); + useBodyScrollLock(homeMobileNavOpen); + + // Close the home mobile nav if the viewport is resized to desktop width + useEffect(() => { + if (!homeMobileNavOpen) { + return undefined; + } + const handleResize = () => { + if (window.innerWidth >= 768) { + setHomeMobileNavOpen(false); + } + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [homeMobileNavOpen]); // Close mobile search on resize to desktop or escape key useEffect(() => { From 773789e4ae592ce2fc58ecc13f5d91f5fa2858be Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 9 Jun 2026 13:07:08 -0700 Subject: [PATCH 06/12] feat(sidebar): Collapse mobile section nav into a Menu dropdown On subpages the mobile sidebar stacked all 11 top-level sections above the current section's page nav, pushing the scrollable nav far below the fold. Wrap the section list in a single collapsed 'Menu' disclosure so the page nav sits at the top; the active section is conveyed by the page nav heading, and tapping Menu reveals all sections. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/sidebar/MobileSidebarNav.tsx | 83 +++++++++++++-------- 1 file changed, 52 insertions(+), 31 deletions(-) diff --git a/src/components/sidebar/MobileSidebarNav.tsx b/src/components/sidebar/MobileSidebarNav.tsx index 86c9d4bc021cc..2d2f61a748e55 100644 --- a/src/components/sidebar/MobileSidebarNav.tsx +++ b/src/components/sidebar/MobileSidebarNav.tsx @@ -1,5 +1,6 @@ 'use client'; +import {ChevronDownIcon} from '@radix-ui/react-icons'; import Link from 'next/link'; import {usePathname} from 'next/navigation'; import {useEffect, useState} from 'react'; @@ -12,6 +13,11 @@ export function MobileSidebarNav({platforms = []}: {platforms?: Platform[]}) { const isActive = (href: string) => pathname?.startsWith(href); + // Collapse all top-level sections behind a single "Menu" disclosure so the + // current section's page nav stays near the top on mobile. Collapsed by + // default; the active section is conveyed by the page nav heading below. + const [menuOpen, setMenuOpen] = useState(false); + // Compute the SDK link href - use stored platform URL if available const [sdkLinkHref, setSdkLinkHref] = useState('/platforms/'); @@ -61,37 +67,52 @@ export function MobileSidebarNav({platforms = []}: {platforms?: Platform[]}) { return (
- {/* Main navigation sections - simple links that navigate to index pages */} - + + {menuOpen && ( + // Main navigation sections - simple links that navigate to index pages + + )}
); } From bd00b08d99b6eeecfae8166a20112dc2be5f4f63 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 9 Jun 2026 13:56:48 -0700 Subject: [PATCH 07/12] feat(sidebar): Label mobile menu toggle Open/Close Main Menu Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/sidebar/MobileSidebarNav.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/sidebar/MobileSidebarNav.tsx b/src/components/sidebar/MobileSidebarNav.tsx index 2d2f61a748e55..2f3812c1cbe93 100644 --- a/src/components/sidebar/MobileSidebarNav.tsx +++ b/src/components/sidebar/MobileSidebarNav.tsx @@ -73,7 +73,7 @@ export function MobileSidebarNav({platforms = []}: {platforms?: Platform[]}) { aria-expanded={menuOpen} className="flex items-center justify-between w-full py-2 px-2 rounded text-sm font-medium text-[var(--gray-12)] hover:bg-[var(--gray-a3)] transition-colors" > - Menu + {menuOpen ? 'Close Main Menu' : 'Open Main Menu'} Date: Tue, 9 Jun 2026 13:56:48 -0700 Subject: [PATCH 08/12] fix(header): Make home mobile nav scrollable and lock document overscroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home hamburger overlay had no internal scrolling, so on short screens its lower items were unreachable once body scroll was locked. Add overflow-y-auto + overscroll-contain to the overlay. Also lock overflow on the documentElement (the actual scroll container), not just body — body-only overflow doesn't stop overscroll/rubber-band chaining to the page at the scroll bounds. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/header.tsx | 2 +- src/hooks/useBodyScrollLock.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/header.tsx b/src/components/header.tsx index 963d22960648a..3fcb0c08b60f8 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -365,7 +365,7 @@ export function Header({ {/* Home page mobile navigation overlay */} {isHomePage && homeMobileNavOpen && (