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.
This commit is contained in:
Richard R 2026-02-15 13:31:06 -07:00
parent 3eebfe6c6c
commit d3c7831f70
6 changed files with 59 additions and 37 deletions

View file

@ -729,6 +729,22 @@ async function migrateDatabaseRows(database, userId, docCandidates, audiobookBoo
await database.query('UPDATE documents SET file_path = id WHERE file_path <> id'); 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' 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 = ?', [userId])
: await database.query('SELECT id FROM documents WHERE user_id = $1', [userId]); : await database.query('SELECT id FROM documents WHERE user_id = $1', [userId]);

View file

@ -136,11 +136,16 @@ async function waitForEndpoint(url, timeoutSeconds) {
const deadline = Date.now() + waitMs; const deadline = Date.now() + waitMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
try { try {
const res = await fetch(url, { method: 'GET' }); const res = await fetch(url, { method: 'GET', signal: controller.signal });
if (res) return; if (res) return;
} catch { } catch {
// retry // retry
} finally {
clearTimeout(timeoutId);
} }
await delay(1000); await delay(1000);
} }

View file

@ -119,18 +119,18 @@ export function AuthLoader({ children }: { children: ReactNode }) {
if (!allowAnonymousAuthSessions && session.user.isAnonymous) { if (!allowAnonymousAuthSessions && session.user.isAnonymous) {
if (clearingDisallowedAnonymousRef.current) return; if (clearingDisallowedAnonymousRef.current) return;
clearingDisallowedAnonymousRef.current = true; clearingDisallowedAnonymousRef.current = true;
setIsRedirecting(true);
try { try {
setIsRedirecting(true);
const client = getAuthClient(baseUrl); const client = getAuthClient(baseUrl);
await client.signOut(); await client.signOut();
} catch (err) { } catch (err) {
console.error('[AuthLoader] failed to clear disallowed anonymous session', err); console.error('[AuthLoader] failed to clear disallowed anonymous session', err);
} finally { } finally {
clearingDisallowedAnonymousRef.current = false; clearingDisallowedAnonymousRef.current = false;
}
router.replace('/signin'); router.replace('/signin');
return; return;
} }
}
clearingDisallowedAnonymousRef.current = false; clearingDisallowedAnonymousRef.current = false;
attemptedForNullSessionRef.current = false; attemptedForNullSessionRef.current = false;

View file

@ -1,6 +1,6 @@
'use client'; '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 { Document, Page } from 'react-pdf';
import type { Dest } from 'react-pdf/src/shared/types.js'; import type { Dest } from 'react-pdf/src/shared/types.js';
import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/AnnotationLayer.css';
@ -60,19 +60,16 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
// IMPORTANT: // IMPORTANT:
// - pdf.js may transfer/detach ArrayBuffers when sending them to its worker, so we must clone. // - 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. // - 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 // We use useMemo to create a stable file object that only changes when currDocId or currDocData changes.
// identical bytes doesn't create a new `file` object and trigger the warning. const documentFile = useMemo(() => {
const fileCacheRef = useRef<Map<string, { data: Uint8Array }>>(new Map()); if (!currDocId || !currDocData) return undefined;
if (currDocId && currDocData && !fileCacheRef.current.has(currDocId)) {
try { try {
fileCacheRef.current.set(currDocId, { data: new Uint8Array(currDocData.slice(0)) }); return { data: new Uint8Array(currDocData.slice(0)) };
} catch (e) { } catch (e) {
console.error('Failed to prepare PDF data for viewer:', e); console.error('Failed to prepare PDF data for viewer:', e);
return undefined;
} }
} }, [currDocId, currDocData]);
const documentFile = currDocId ? fileCacheRef.current.get(currDocId) : undefined;
const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`; const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`;

View file

@ -230,12 +230,14 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
try { try {
const meta = await getDocumentMetadata(id); const meta = await getDocumentMetadata(id);
if (!meta) { if (!meta) {
clearCurrDoc();
console.error('Document not found on server'); console.error('Document not found on server');
return; return;
} }
const doc = await ensureCachedDocument(meta); const doc = await ensureCachedDocument(meta);
if (doc.type !== 'epub') { if (doc.type !== 'epub') {
clearCurrDoc();
console.error('Document is not an EPUB'); console.error('Document is not an EPUB');
return; return;
} }

View file

@ -6,21 +6,8 @@ import { getAuthClient } from '@/lib/auth-client';
type SessionHookResult = ReturnType<ReturnType<typeof getAuthClient>['useSession']>; type SessionHookResult = ReturnType<ReturnType<typeof getAuthClient>['useSession']>;
/** /** Stable empty result returned when auth is disabled. */
* Hook for session that uses the correct baseUrl from context const EMPTY_SESSION: SessionHookResult = {
*/
export function useAuthSession() {
const { baseUrl, authEnabled } = useAuthConfig();
const client = useMemo(() => {
if (!authEnabled || !baseUrl) return null;
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, data: null,
isPending: false, isPending: false,
isRefetching: false, isRefetching: false,
@ -29,8 +16,23 @@ export function useAuthSession() {
error: null as any, error: null as any,
refetch: async () => { }, refetch: async () => { },
}; };
return empty;
} /** Stub client whose useSession() always returns the empty shape. */
const STUB_CLIENT = { useSession: () => EMPTY_SESSION } as ReturnType<typeof getAuthClient>;
/**
* 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 STUB_CLIENT;
return getAuthClient(baseUrl);
}, [baseUrl, authEnabled]);
return client.useSession(); return client.useSession();
} }