Files
readest/apps/readest-app/src/app/library/hooks/useOpenBook.ts
T
Huang Xin 52be6fa066 fix(reader): open books without a View Transition to avoid timeout (#4949)
useAppRouter wraps every navigation in a View Transition. Opening a book is a
heavy render (the reader mounts and loads the book) that can overrun the
transition's ~4s DOM-update budget and abort with a TimeoutError (Sentry
READEST-9). Navigate to the reader with the plain router instead, matching
every other into-reader path in the app; the transition router stays for
lighter navigation. Applies to the tap-to-open flow (useOpenBook) and the
post-import queued open (library/page).

Fixes READEST-9

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:12:05 +02:00

102 lines
4.1 KiB
TypeScript

import { Dispatch, SetStateAction, useCallback } from 'react';
import { Book } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useRouter } from 'next/navigation';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { navigateToReader, showReaderWindow } from '@/utils/nav';
interface UseOpenBookOptions {
setLoading: Dispatch<SetStateAction<boolean>>;
handleBookDownload: (
book: Book,
options?: { redownload?: boolean; queued?: boolean },
) => Promise<boolean>;
}
/**
* Shared "open this book" flow used both by per-item taps (`BookshelfItem`) and
* the recently-read shelf. Centralizing it keeps the availability handling in
* one place: cloud-synced books (which arrive on other devices as metadata +
* progress without the file blob) are downloaded on demand, and a stale
* in-place record is dropped instead of bouncing the user into a broken reader.
*/
export const useOpenBook = ({ setLoading, handleBookDownload }: UseOpenBookOptions) => {
const _ = useTranslation();
// Open the reader with the plain router, NOT the View Transition router:
// the reader's initial render is heavy enough to blow the transition's ~4s
// DOM-update budget, which aborts with a TimeoutError (Sentry READEST-9).
// This matches how every other into-reader path already navigates.
const router = useRouter();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
const makeBookAvailable = useCallback(
async (book: Book) => {
if (book.uploadedAt && !book.downloadedAt) {
if (await appService?.isBookAvailable(book)) {
if (!book.downloadedAt || !book.coverDownloadedAt) {
book.downloadedAt = Date.now();
book.coverDownloadedAt = Date.now();
await updateBook(envConfig, book);
}
return true;
}
let available = false;
const loadingTimeout = setTimeout(() => setLoading(true), 200);
try {
available = await handleBookDownload(book, { queued: false });
await updateBook(envConfig, book);
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
}
return available;
}
return true;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService, envConfig, handleBookDownload, setLoading],
);
const openBook = useCallback(
async (book: Book) => {
// In-place books point at a file outside Books/<hash>/ that the user (or
// another app) may have moved, renamed, or deleted between sessions. Probe
// the source before navigating: if it's gone, drop the stale record
// instead of opening the reader only to fail and bounce back. Restricted
// to purely-local in-place books — cloud-synced books (`uploadedAt`) still
// go through `makeBookAvailable`'s on-demand download path.
if (book.filePath && !book.uploadedAt && !book.deletedAt) {
const available = await appService?.isBookAvailable(book);
if (!available) {
eventDispatcher.dispatch('toast', {
message: _(
'Book file no longer exists. Confirm deletion to remove it from the library.',
),
type: 'info',
});
eventDispatcher.dispatch('delete-books', { ids: [book.hash] });
return;
}
}
const available = await makeBookAvailable(book);
if (!available) return;
if (appService?.hasWindow && settings.openBookInNewWindow) {
showReaderWindow(appService, [book.hash]);
} else {
setTimeout(() => {
navigateToReader(router, [book.hash]);
}, 0);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService, makeBookAvailable, settings.openBookInNewWindow],
);
return { openBook, makeBookAvailable };
};