From d3c7831f700171de8499713fa24e771c2d99efdc Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 15 Feb 2026 13:31:06 -0700 Subject: [PATCH] fix: address hook violations and improve resource reliability - Refactor useAuthSession to call useSession unconditionally, adhering to React Rules of Hooks by using a stub client when auth is disabled. - Update migration script to insert a system user to satisfy foreign key constraints during database transitions. - Implement AbortController and timeouts in entrypoint endpoint polling to prevent hanging fetch requests. - Use useMemo in PDFViewer to provide a stable file object, preventing unnecessary re-renders and warnings. - Enhance state cleanup in EPUBContext and AuthLoader to handle missing metadata or disallowed sessions gracefully. --- scripts/migrate-fs-v2.mjs | 20 +++++++++++++++-- scripts/openreader-entrypoint.mjs | 13 +++++++---- src/components/auth/AuthLoader.tsx | 6 ++--- src/components/views/PDFViewer.tsx | 19 +++++++--------- src/contexts/EPUBContext.tsx | 2 ++ src/hooks/useAuthSession.ts | 36 ++++++++++++++++-------------- 6 files changed, 59 insertions(+), 37 deletions(-) diff --git a/scripts/migrate-fs-v2.mjs b/scripts/migrate-fs-v2.mjs index 97cb3d9..5196252 100644 --- a/scripts/migrate-fs-v2.mjs +++ b/scripts/migrate-fs-v2.mjs @@ -544,7 +544,7 @@ async function collectAudiobookCandidates(audiobooksDir, docstoreDir) { if (Number.isInteger(idx) && idx >= 0 && chapterTitle) { chapterMetaByIndex.set(idx, chapterTitle); } - } catch {} + } catch { } } const unresolvedSha = []; @@ -729,6 +729,22 @@ async function migrateDatabaseRows(database, userId, docCandidates, audiobookBoo await database.query('UPDATE documents SET file_path = id WHERE file_path <> id'); } + // Ensure the user exists to satisfy foreign key constraints (cascade delete) + if (!dryRun) { + const now = Date.now(); + if (database.mode === 'sqlite') { + await database.query( + 'INSERT OR IGNORE INTO user (id, name, email, email_verified, created_at, updated_at, is_anonymous) VALUES (?, ?, ?, ?, ?, ?, ?)', + [userId, 'System User', `${userId}@local`, 0, now, now, 0] + ); + } else { + await database.query( + "INSERT INTO user (id, name, email, email_verified, created_at, updated_at, is_anonymous) VALUES ($1, $2, $3, $4, to_timestamp($5 / 1000.0), to_timestamp($6 / 1000.0), $7) ON CONFLICT (id) DO NOTHING", + [userId, 'System User', `${userId}@local`, false, now, now, false] + ); + } + } + const existingDocs = database.mode === 'sqlite' ? await database.query('SELECT id FROM documents WHERE user_id = ?', [userId]) : await database.query('SELECT id FROM documents WHERE user_id = $1', [userId]); @@ -868,7 +884,7 @@ async function main() { if (deleteLocal && !dryRun) { for (const dirPath of book.sourceDirs) { - await fsp.rm(dirPath, { recursive: true, force: true }).catch(() => {}); + await fsp.rm(dirPath, { recursive: true, force: true }).catch(() => { }); } } } diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index 0c0cae5..23f49c2 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -115,7 +115,7 @@ function parseCommandFromArgs(argv) { } function forwardChildStream(stream, target) { - if (!stream) return () => {}; + if (!stream) return () => { }; const onData = (chunk) => { target.write(chunk); }; @@ -136,11 +136,16 @@ async function waitForEndpoint(url, timeoutSeconds) { const deadline = Date.now() + waitMs; while (Date.now() < deadline) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 2000); + try { - const res = await fetch(url, { method: 'GET' }); + const res = await fetch(url, { method: 'GET', signal: controller.signal }); if (res) return; } catch { // retry + } finally { + clearTimeout(timeoutId); } await delay(1000); } @@ -293,8 +298,8 @@ async function main() { let weedExitPromise = Promise.resolve(); let appProc = null; let shutdownPromise = null; - let stopWeedStdoutForward = () => {}; - let stopWeedStderrForward = () => {}; + let stopWeedStdoutForward = () => { }; + let stopWeedStderrForward = () => { }; let didExit = false; const exitOnce = (code) => { diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index 3949808..ced62af 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -119,17 +119,17 @@ export function AuthLoader({ children }: { children: ReactNode }) { if (!allowAnonymousAuthSessions && session.user.isAnonymous) { if (clearingDisallowedAnonymousRef.current) return; clearingDisallowedAnonymousRef.current = true; - setIsRedirecting(true); try { + setIsRedirecting(true); const client = getAuthClient(baseUrl); await client.signOut(); } catch (err) { console.error('[AuthLoader] failed to clear disallowed anonymous session', err); } finally { clearingDisallowedAnonymousRef.current = false; - router.replace('/signin'); - return; } + router.replace('/signin'); + return; } clearingDisallowedAnonymousRef.current = false; diff --git a/src/components/views/PDFViewer.tsx b/src/components/views/PDFViewer.tsx index 2f4e127..93672a6 100644 --- a/src/components/views/PDFViewer.tsx +++ b/src/components/views/PDFViewer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { RefObject, useCallback, useState, useEffect, useRef } from 'react'; +import { RefObject, useCallback, useState, useEffect, useRef, useMemo } from 'react'; import { Document, Page } from 'react-pdf'; import type { Dest } from 'react-pdf/src/shared/types.js'; import 'react-pdf/dist/Page/AnnotationLayer.css'; @@ -60,19 +60,16 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { // IMPORTANT: // - pdf.js may transfer/detach ArrayBuffers when sending them to its worker, so we must clone. // - react-pdf warns if `file` changes by reference but is deep-equal to the previous value. - // Cache a stable `{ data: Uint8Array }` by `currDocId` so reloading the same PDF with - // identical bytes doesn't create a new `file` object and trigger the warning. - const fileCacheRef = useRef>(new Map()); - - if (currDocId && currDocData && !fileCacheRef.current.has(currDocId)) { + // We use useMemo to create a stable file object that only changes when currDocId or currDocData changes. + const documentFile = useMemo(() => { + if (!currDocId || !currDocData) return undefined; try { - fileCacheRef.current.set(currDocId, { data: new Uint8Array(currDocData.slice(0)) }); + return { data: new Uint8Array(currDocData.slice(0)) }; } catch (e) { console.error('Failed to prepare PDF data for viewer:', e); + return undefined; } - } - - const documentFile = currDocId ? fileCacheRef.current.get(currDocId) : undefined; + }, [currDocId, currDocData]); const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`; @@ -251,7 +248,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { const [pageHeight, setPageHeight] = useState(842); // default A4 height // Calculate which pages to show based on viewType - const leftPage = viewType === 'dual' + const leftPage = viewType === 'dual' ? (currDocPage % 2 === 0 ? currDocPage - 1 : currDocPage) : currDocPage; const rightPage = viewType === 'dual' diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 6eb5dde..3bf2877 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -230,12 +230,14 @@ export function EPUBProvider({ children }: { children: ReactNode }) { try { const meta = await getDocumentMetadata(id); if (!meta) { + clearCurrDoc(); console.error('Document not found on server'); return; } const doc = await ensureCachedDocument(meta); if (doc.type !== 'epub') { + clearCurrDoc(); console.error('Document is not an EPUB'); return; } diff --git a/src/hooks/useAuthSession.ts b/src/hooks/useAuthSession.ts index a817221..c9a554b 100644 --- a/src/hooks/useAuthSession.ts +++ b/src/hooks/useAuthSession.ts @@ -6,31 +6,33 @@ import { getAuthClient } from '@/lib/auth-client'; type SessionHookResult = ReturnType['useSession']>; +/** Stable empty result returned when auth is disabled. */ +const EMPTY_SESSION: SessionHookResult = { + data: null, + isPending: false, + isRefetching: false, + // better-auth types use BetterFetchError | null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: null as any, + refetch: async () => { }, +}; + +/** Stub client whose useSession() always returns the empty shape. */ +const STUB_CLIENT = { useSession: () => EMPTY_SESSION } as ReturnType; + /** - * Hook for session that uses the correct baseUrl from context + * Hook for session that uses the correct baseUrl from context. + * A stub client is used when auth is disabled so that useSession() + * is always called unconditionally (Rules of Hooks). */ export function useAuthSession() { const { baseUrl, authEnabled } = useAuthConfig(); const client = useMemo(() => { - if (!authEnabled || !baseUrl) return null; + if (!authEnabled || !baseUrl) return STUB_CLIENT; return getAuthClient(baseUrl); }, [baseUrl, authEnabled]); - if (!client) { - // Keep a stable shape so consumers can always destructure the same fields. - // This avoids union-type issues when auth is disabled. - const empty: SessionHookResult = { - data: null, - isPending: false, - isRefetching: false, - // better-auth types use BetterFetchError | null - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error: null as any, - refetch: async () => {}, - }; - return empty; - } - return client.useSession(); } +