fix(ios): reduce GPU memory pressure to prevent WebKit GPU process crash (#3842)

On iOS, navigating to a book group in the library caused the WebKit GPU
process to exceed its 300 MB jetsam limit (peaking at ~328 MB), resulting
in a blank screen flash and broken scroll state.

Three changes reduce peak GPU memory usage:

- Add overscan={200} to VirtuosoGrid/Virtuoso so only items within 200px
  of the viewport are rendered, limiting simultaneous image decoding
- Add loading="lazy" to both Image components in BookCover so the browser
  defers decoding offscreen cover images
- Conditionally mount the <video> and <audio> elements in AtmosphereOverlay
  only when atmosphere mode is active, eliminating idle H.264 decoder
  memory overhead

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-04-12 15:06:16 +08:00
committed by GitHub
parent cc780712b9
commit 7b60b1bb0c
5 changed files with 104 additions and 19 deletions
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
// Mock zustand stores before importing the component
vi.mock('@/store/atmosphereStore', () => ({
useAtmosphereStore: vi.fn(),
}));
vi.mock('@/store/themeStore', () => ({
useThemeStore: vi.fn(),
}));
import AtmosphereOverlay from '@/components/AtmosphereOverlay';
import { useAtmosphereStore } from '@/store/atmosphereStore';
import { useThemeStore } from '@/store/themeStore';
afterEach(cleanup);
const setupStores = (active: boolean) => {
vi.mocked(useAtmosphereStore).mockImplementation((selector: unknown) =>
(selector as (s: { active: boolean }) => unknown)({ active }),
);
vi.mocked(useThemeStore).mockImplementation((selector: unknown) =>
(selector as (s: { isDarkMode: boolean }) => unknown)({ isDarkMode: false }),
);
};
describe('AtmosphereOverlay', () => {
it('does not render <video> element when inactive', () => {
setupStores(false);
const { container } = render(<AtmosphereOverlay />);
const video = container.querySelector('video');
expect(video).toBeNull();
});
it('renders <video> element when active', () => {
setupStores(true);
const { container } = render(<AtmosphereOverlay />);
const video = container.querySelector('video');
expect(video).toBeTruthy();
});
});
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import BookCover from '@/components/BookCover';
import { Book } from '@/types/book';
vi.mock('next/image', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
// biome-ignore lint/a11y/useAltText: test mock; alt comes from spread props
return <img {...props} />;
},
}));
afterEach(cleanup);
const makeBook = (overrides?: Partial<Book>): Book =>
({
hash: 'abc123',
title: 'Test Book',
author: 'Test Author',
format: 'epub',
coverImageUrl: 'https://example.com/cover.jpg',
...overrides,
}) as Book;
describe('BookCover', () => {
it('passes loading="lazy" to crop-mode Image', () => {
const { container } = render(<BookCover book={makeBook()} coverFit='crop' />);
const img = container.querySelector('img.cover-image');
expect(img).toBeTruthy();
expect(img?.getAttribute('loading')).toBe('lazy');
});
it('passes loading="lazy" to fit-mode Image', () => {
const { container } = render(<BookCover book={makeBook()} coverFit='fit' />);
const img = container.querySelector('img.cover-image');
expect(img).toBeTruthy();
expect(img?.getAttribute('loading')).toBe('lazy');
});
});
@@ -591,6 +591,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
{scrollParentEl && hasItems && isGridMode && (
<VirtuosoGrid<unknown, BookshelfListContext>
customScrollParent={scrollParentEl}
overscan={200}
totalCount={gridTotalCount}
components={GRID_VIRTUOSO_COMPONENTS}
context={listContext}
@@ -601,6 +602,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
{scrollParentEl && hasItems && !isGridMode && (
<Virtuoso
customScrollParent={scrollParentEl}
overscan={200}
totalCount={sortedBookshelfItems.length}
components={LIST_VIRTUOSO_COMPONENTS}
computeItemKey={computeItemKey}
@@ -16,20 +16,15 @@ const AtmosphereOverlay = () => {
useEffect(() => {
const video = videoRef.current;
const audio = audioRef.current;
if (!video || !audio) return;
if (active) {
document.body.classList.add('atmosphere');
video.play().catch(() => {});
video?.play()?.catch(() => {});
if (!isInitialMount.current) {
audio.play().catch(() => {});
audio?.play()?.catch(() => {});
}
} else {
document.body.classList.remove('atmosphere');
video.pause();
video.currentTime = 0;
audio.pause();
audio.currentTime = 0;
}
isInitialMount.current = false;
}, [active]);
@@ -38,23 +33,27 @@ const AtmosphereOverlay = () => {
const audio = audioRef.current;
if (!audio || !active) return;
audio.src = audioSrc;
audio.play().catch(() => {});
audio.play()?.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioSrc]);
return (
<>
<video
ref={videoRef}
id='atmosphere-overlay'
src='/assets/komorebi.mp4'
loop
muted
playsInline
preload='none'
/>
{/* biome-ignore lint/a11y/useMediaCaption: ambient background audio, no spoken content */}
<audio ref={audioRef} id='forest-audio' src={audioSrc} loop preload='none' />
{active && (
<video
ref={videoRef}
id='atmosphere-overlay'
src='/assets/komorebi.mp4'
loop
muted
playsInline
preload='none'
/>
)}
{active && (
// biome-ignore lint/a11y/useMediaCaption: ambient background audio, no spoken content
<audio ref={audioRef} id='forest-audio' src={audioSrc} loop preload='none' />
)}
</>
);
};
@@ -74,6 +74,7 @@ const BookCover: React.FC<BookCoverProps> = memo<BookCoverProps>(
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
alt={book.title}
fill={true}
loading='lazy'
className={clsx('cover-image crop-cover-img object-cover', imageClassName)}
onLoad={handleImageLoad}
onError={handleImageError}
@@ -96,6 +97,7 @@ const BookCover: React.FC<BookCoverProps> = memo<BookCoverProps>(
width={0}
height={0}
sizes='100vw'
loading='lazy'
className={clsx(
'cover-image fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
imageClassName,