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) {
|
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(() => { });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue