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) {
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(() => { });
}
}
}

View file

@ -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) => {

View file

@ -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;

View file

@ -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<Map<string, { data: Uint8Array }>>(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<number>(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'

View file

@ -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;
}

View file

@ -6,31 +6,33 @@ import { getAuthClient } from '@/lib/auth-client';
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() {
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();
}