Fix stale overlay scroll locks

This commit is contained in:
leowang 2026-05-24 17:29:04 +08:00
parent f8eb7cea4c
commit 12d88d3991
8 changed files with 137 additions and 67 deletions

File diff suppressed because one or more lines are too long

View File

@ -25,13 +25,13 @@ import {
import { getDoubanCategories } from '@/lib/douban.client';
import { DoubanItem } from '@/lib/types';
import { AnnouncementBanner } from '@/components/AnnouncementBanner';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import ContinueWatching from '@/components/ContinueWatching';
import PageLayout from '@/components/PageLayout';
import ScrollableRow from '@/components/ScrollableRow';
import { useSite } from '@/components/SiteProvider';
import VideoCard from '@/components/VideoCard';
import { AppDialog } from '@/components/ui/HeroPrimitives';
function HomeClient() {
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
@ -190,6 +190,13 @@ function HomeClient() {
</div>
<div className='mx-auto max-w-[1380px] space-y-10'>
{announcement && showAnnouncement && (
<AnnouncementBanner
announcement={announcement}
onDismiss={() => handleCloseAnnouncement(announcement)}
/>
)}
{activeTab === 'favorites' ? (
// 收藏夹视图
<Card>
@ -427,25 +434,6 @@ function HomeClient() {
)}
</div>
</div>
{announcement && (
<AppDialog
isOpen={showAnnouncement}
onOpenChange={(isOpen) => {
if (!isOpen) handleCloseAnnouncement(announcement);
}}
title='提示'
footer={
<Button
fullWidth
onPress={() => handleCloseAnnouncement(announcement)}
>
</Button>
}
>
<p className='text-sm leading-6 text-muted'>{announcement}</p>
</AppDialog>
)}
</PageLayout>
);
}

View File

@ -0,0 +1,30 @@
'use client';
import { Button, Card } from '@heroui/react';
interface AnnouncementBannerProps {
announcement: string;
onDismiss: () => void;
}
export function AnnouncementBanner({
announcement,
onDismiss,
}: AnnouncementBannerProps) {
return (
<Card>
<Card.Header className='flex-row items-start justify-between gap-4'>
<div>
<Card.Description>Announcement</Card.Description>
<Card.Title></Card.Title>
</div>
<Button size='sm' variant='secondary' onPress={onDismiss}>
</Button>
</Card.Header>
<Card.Content>
<p className='text-sm leading-6 text-muted'>{announcement}</p>
</Card.Content>
</Card>
);
}

View File

@ -72,29 +72,6 @@ export const UserMenu: React.FC = () => {
const imageRef = useRef<HTMLImageElement>(null);
const [showCropper, setShowCropper] = useState(false);
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
useEffect(() => {
if (isSettingsOpen || isChangePasswordOpen || isChangeAvatarOpen) {
const body = document.body;
const html = document.documentElement;
// 保存原始样式
const originalBodyOverflow = body.style.overflow;
const originalHtmlOverflow = html.style.overflow;
// 只设置 overflow 来阻止滚动
body.style.overflow = 'hidden';
html.style.overflow = 'hidden';
return () => {
// 恢复所有原始样式
body.style.overflow = originalBodyOverflow;
html.style.overflow = originalHtmlOverflow;
};
}
}, [isSettingsOpen, isChangePasswordOpen]);
// 设置相关状态
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');

View File

@ -49,28 +49,6 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
return () => setMounted(false);
}, []);
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
useEffect(() => {
if (isOpen) {
const body = document.body;
const html = document.documentElement;
// 保存原始样式
const originalBodyOverflow = body.style.overflow;
const originalHtmlOverflow = html.style.overflow;
// 只设置 overflow 来阻止滚动
body.style.overflow = 'hidden';
html.style.overflow = 'hidden';
return () => {
// 恢复所有原始样式
body.style.overflow = originalBodyOverflow;
html.style.overflow = originalHtmlOverflow;
};
}
}, [isOpen]);
// 获取远程变更日志
useEffect(() => {
if (isOpen) {

View File

@ -0,0 +1,31 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { AnnouncementBanner } from '../AnnouncementBanner';
describe('AnnouncementBanner', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
});
it('renders a non-blocking announcement without a dialog or scroll lock', () => {
const onDismiss = jest.fn();
render(
<AnnouncementBanner
announcement='请注意站点公告'
onDismiss={onDismiss}
/>
);
expect(screen.getByText('请注意站点公告')).toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(document.documentElement.style.overflow).toBe('');
expect(document.body.style.overflow).toBe('');
fireEvent.click(screen.getByRole('button', { name: '我知道了' }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});

View File

@ -20,11 +20,41 @@ import type {
SpinnerProps,
TabsProps,
} from '@heroui/react';
import { forwardRef } from 'react';
import { forwardRef, useEffect } from 'react';
import type { Key, ReactNode } from 'react';
type AppButtonProps = ButtonProps;
let activeRootOverlayCount = 0;
function releaseRootScrollLockIfIdle() {
if (activeRootOverlayCount > 0 || typeof document === 'undefined') return;
const html = document.documentElement;
const body = document.body;
if (html.style.overflow === 'hidden') {
html.style.overflow = '';
}
if (body.style.overflow === 'hidden') {
body.style.overflow = '';
}
}
function useRootOverlayCleanup(isOpen: boolean) {
useEffect(() => {
if (!isOpen) return undefined;
activeRootOverlayCount += 1;
return () => {
activeRootOverlayCount = Math.max(0, activeRootOverlayCount - 1);
releaseRootScrollLockIfIdle();
};
}, [isOpen]);
}
export const AppButton = forwardRef<HTMLButtonElement, AppButtonProps>(
function AppButton(props, ref) {
return <Button ref={ref} {...props} />;
@ -151,6 +181,7 @@ export function AppDialog({
isDismissable = true,
}: AppDialogProps) {
const state = useOverlayState({ isOpen, onOpenChange });
useRootOverlayCleanup(isOpen);
return (
<Modal state={state}>
@ -202,6 +233,7 @@ export function AppDrawer({
isDismissable = true,
}: AppDrawerProps) {
const state = useOverlayState({ isOpen, onOpenChange });
useRootOverlayCleanup(isOpen);
return (
<Drawer state={state}>

View File

@ -72,4 +72,38 @@ describe('HeroPrimitives', () => {
fireEvent.click(screen.getByRole('button', { name: '播放' }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('clears stale root scroll locks when dialogs unmount', () => {
const onOpenChange = jest.fn();
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
const { unmount } = render(
<AppDialog isOpen title='提示' onOpenChange={onOpenChange}>
<p></p>
</AppDialog>
);
unmount();
expect(document.documentElement.style.overflow).toBe('');
expect(document.body.style.overflow).toBe('');
});
it('clears stale root scroll locks when drawers unmount', () => {
const onOpenChange = jest.fn();
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
const { unmount } = render(
<AppDrawer isOpen title='更多操作' onOpenChange={onOpenChange}>
<p></p>
</AppDrawer>
);
unmount();
expect(document.documentElement.style.overflow).toBe('');
expect(document.body.style.overflow).toBe('');
});
});