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

@ -544,7 +544,7 @@ async function collectAudiobookCandidates(audiobooksDir, docstoreDir) {
if (Number.isInteger(idx) && idx >= 0 && chapterTitle) { if (Number.isInteger(idx) && idx >= 0 && chapterTitle) {
chapterMetaByIndex.set(idx, chapterTitle); chapterMetaByIndex.set(idx, chapterTitle);
} }
} catch {} } catch { }
} }
const unresolvedSha = []; 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'); 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]);
@ -868,7 +884,7 @@ async function main() {
if (deleteLocal && !dryRun) { if (deleteLocal && !dryRun) {
for (const dirPath of book.sourceDirs) { for (const dirPath of book.sourceDirs) {
await fsp.rm(dirPath, { recursive: true, force: true }).catch(() => {}); await fsp.rm(dirPath, { recursive: true, force: true }).catch(() => { });
} }
} }
} }

View file

@ -115,7 +115,7 @@ function parseCommandFromArgs(argv) {
} }
function forwardChildStream(stream, target) { function forwardChildStream(stream, target) {
if (!stream) return () => {}; if (!stream) return () => { };
const onData = (chunk) => { const onData = (chunk) => {
target.write(chunk); target.write(chunk);
}; };
@ -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);
} }
@ -293,8 +298,8 @@ async function main() {
let weedExitPromise = Promise.resolve(); let weedExitPromise = Promise.resolve();
let appProc = null; let appProc = null;
let shutdownPromise = null; let shutdownPromise = null;
let stopWeedStdoutForward = () => {}; let stopWeedStdoutForward = () => { };
let stopWeedStderrForward = () => {}; let stopWeedStderrForward = () => { };
let didExit = false; let didExit = false;
const exitOnce = (code) => { const exitOnce = (code) => {

View file

@ -119,17 +119,17 @@ 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');
return;
} }
router.replace('/signin');
return;
} }
clearingDisallowedAnonymousRef.current = false; clearingDisallowedAnonymousRef.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,31 +6,33 @@ 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. */
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<typeof getAuthClient>;
/** /**
* 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() { export function useAuthSession() {
const { baseUrl, authEnabled } = useAuthConfig(); const { baseUrl, authEnabled } = useAuthConfig();
const client = useMemo(() => { const client = useMemo(() => {
if (!authEnabled || !baseUrl) return null; if (!authEnabled || !baseUrl) return STUB_CLIENT;
return getAuthClient(baseUrl); return getAuthClient(baseUrl);
}, [baseUrl, authEnabled]); }, [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(); return client.useSession();
} }