Files
openchamber/packages/ui/src/hooks/useRootScrollLock.ts
T
Bohdan Triapitsyn 59fa91309a fix(ui): keep the app root pinned when the caret scrolls it
Chromium scrolls overflow:hidden ancestors when a textarea caret moves out
of view (PageUp/PageDown in the prompt box, long prompts), shifting the whole
app up and hiding the title bar with no way to scroll back. Snap html/body/#root
back to zero on any root scroll event in the web, desktop, VS Code and mini-chat
apps.

Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98
2026-08-28 20:08:58 +03:00

52 lines
1.8 KiB
TypeScript

import React from 'react';
/**
* The document root (`html`, `body`, `#root`) is `overflow: hidden` and must
* never scroll — every scrollable area lives in a dedicated container. Chromium
* still scrolls hidden-overflow ancestors programmatically, most visibly when
* a textarea caret moves out of view (PageUp/PageDown in the prompt box, or a
* long prompt being typed) and the browser scrolls it into view. Once that
* happens the whole app shifts up, hides the title bar, and nothing the user
* does with the wheel or keyboard can scroll it back.
*
* Snap every root scroll straight back to zero.
*/
const rootScrollTargets = (): HTMLElement[] => {
const targets = [document.documentElement, document.body];
const appRoot = document.getElementById('root');
if (appRoot) targets.push(appRoot);
return targets;
};
export const resetRootScroll = (): boolean => {
let reset = false;
for (const target of rootScrollTargets()) {
if (target.scrollTop !== 0) {
target.scrollTop = 0;
reset = true;
}
if (target.scrollLeft !== 0) {
target.scrollLeft = 0;
reset = true;
}
}
return reset;
};
export const isRootScrollTarget = (target: EventTarget | null): boolean =>
target === document || rootScrollTargets().some((element) => element === target);
export const useRootScrollLock = (): void => {
React.useEffect(() => {
const handleScroll = (event: Event) => {
if (isRootScrollTarget(event.target)) resetRootScroll();
};
// Capture: the root's own scroll events don't bubble to inner listeners,
// and scroll events from inner containers are filtered out above.
document.addEventListener('scroll', handleScroll, { capture: true, passive: true });
resetRootScroll();
return () => document.removeEventListener('scroll', handleScroll, { capture: true });
}, []);
};