forked from akai/readest
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user