From 3db193e0123971430334c6218a90fe70bcec0909 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 27 May 2026 16:22:14 -0600 Subject: [PATCH] feat(hooks): introduce useUnmountCleanupRef for effect cleanup abstraction Add useUnmountCleanupRef custom hook to centralize and simplify unmount cleanup logic in document page components. Refactor EPUB, HTML, and PDF page components to utilize this hook, replacing repetitive ref and effect patterns for invoking cleanup functions on unmount. This reduces code duplication and improves maintainability. --- src/app/(app)/epub/[id]/page.tsx | 12 ++---------- src/app/(app)/html/[id]/page.tsx | 12 ++---------- src/app/(app)/pdf/[id]/page.tsx | 12 ++---------- src/hooks/useUnmountCleanupRef.ts | 19 +++++++++++++++++++ 4 files changed, 25 insertions(+), 30 deletions(-) create mode 100644 src/hooks/useUnmountCleanupRef.ts diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index d92e358..57f4c0b 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -19,6 +19,7 @@ import { resolveDocumentId } from '@/lib/client/dexie'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; +import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; import { useEpubDocument } from './useEpubDocument'; export default function EPUBPage() { @@ -46,7 +47,6 @@ export default function EPUBPage() { const inFlightDocIdRef = useRef(null); const loadedDocIdRef = useRef(null); const didInitPadPctRef = useRef(false); - const clearCurrDocRef = useRef(clearCurrDoc); useEffect(() => { setIsLoading(true); @@ -103,15 +103,7 @@ export default function EPUBPage() { loadDocument(); }, [loadDocument, isLoading]); - useEffect(() => { - clearCurrDocRef.current = clearCurrDoc; - }, [clearCurrDoc]); - - useEffect(() => { - return () => { - clearCurrDocRef.current(); - }; - }, []); + useUnmountCleanupRef(clearCurrDoc); // Compute available height = viewport - (header height + tts bar height) useEffect(() => { diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index 4272c3b..21ad5c6 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -17,6 +17,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; +import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import { useHtmlDocument } from './useHtmlDocument'; @@ -46,7 +47,6 @@ export default function HTMLPage() { const [maxPadPx, setMaxPadPx] = useState(0); const inFlightDocIdRef = useRef(null); const loadedDocIdRef = useRef(null); - const clearCurrDocRef = useRef(clearCurrDoc); useEffect(() => { setIsLoading(true); @@ -103,15 +103,7 @@ export default function HTMLPage() { loadDocument(); }, [loadDocument, isLoading]); - useEffect(() => { - clearCurrDocRef.current = clearCurrDoc; - }, [clearCurrDoc]); - - useEffect(() => { - return () => { - clearCurrDocRef.current(); - }; - }, []); + useUnmountCleanupRef(clearCurrDoc); // Compute available height = viewport - (header height + tts bar height) useEffect(() => { diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 7413087..7695b4b 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -26,6 +26,7 @@ import { FORCE_REPARSE_CONFIRM_TITLE, isForceReparseDisabled, } from '@/lib/client/pdf/force-reparse'; +import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; import { usePdfDocument } from './usePdfDocument'; // Dynamic import for client-side rendering only @@ -72,7 +73,6 @@ export default function PDFViewerPage() { const [containerHeight, setContainerHeight] = useState('auto'); const inFlightDocIdRef = useRef(null); const loadedDocIdRef = useRef(null); - const clearCurrDocRef = useRef(clearCurrDoc); const [isNavigatingBack, setIsNavigatingBack] = useState(false); const parseUiState: NonNullable = parseStatus ?? 'pending'; const hasResolvedParseStatus = parseStatus !== null; @@ -153,15 +153,7 @@ export default function PDFViewerPage() { loadDocument(); }, [loadDocument]); - useEffect(() => { - clearCurrDocRef.current = clearCurrDoc; - }, [clearCurrDoc]); - - useEffect(() => { - return () => { - clearCurrDocRef.current(); - }; - }, []); + useUnmountCleanupRef(clearCurrDoc); useEffect(() => { if (isLoading) return; diff --git a/src/hooks/useUnmountCleanupRef.ts b/src/hooks/useUnmountCleanupRef.ts new file mode 100644 index 0000000..ed1a9cb --- /dev/null +++ b/src/hooks/useUnmountCleanupRef.ts @@ -0,0 +1,19 @@ +import { useEffect, useRef } from 'react'; + +/** + * Runs cleanup only on unmount, while always invoking the latest cleanup function. + */ +export function useUnmountCleanupRef(cleanup: () => void) { + const cleanupRef = useRef(cleanup); + + useEffect(() => { + cleanupRef.current = cleanup; + }, [cleanup]); + + useEffect(() => { + return () => { + cleanupRef.current(); + }; + }, []); +} +