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:
parent
3eebfe6c6c
commit
d3c7831f70
6 changed files with 59 additions and 37 deletions
|
|
@ -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(() => { });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue