From b9dfa12808b174b61993d359f847770378a9bf04 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 10:03:28 +0000 Subject: [PATCH 1/3] perf(viewer): isolate the PDF subtree and decouple page navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three render-cost reductions on the document viewer, all frontend/offline: - Page/chunk navigation no longer triggers a full document reload. The main load effect now distinguishes a full (re)load — new documentId or explicit retry — from navigation on the already-loaded document via a loadedKeyRef. Navigation skips the loading reset, keeps the mounted PDF/content on screen (no flash), preserves current content on a transient detail failure, and does not re-issue signed URLs. - The app-shell scroll container (#main-content) is resolved synchronously and the MutationObserver only watches until it appears, then disconnects — rather than reacting to every app-wide DOM mutation for the viewer's lifetime. - The heaviest subtree (PdfCanvasViewer, holding the pdf.js document and the canvas raster) plus NativePdfEmbed and NonPdfSourcePreview are wrapped in memo, and the two PDF callbacks (onUrlExpired/onLoadSuccess) are stabilised with useCallback. Unrelated parent re-renders — source-search keystrokes, composer focus, connectivity changes — no longer re-render or re-rasterise the viewer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DqMTk5fDNgi5GwEK5peRmj --- src/components/DocumentViewer.tsx | 67 +++++++++++++------ .../non-pdf-source-preview.tsx | 6 +- .../document-viewer/pdf-canvas-viewer.tsx | 22 ++++-- 3 files changed, 68 insertions(+), 27 deletions(-) diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 09d276f2b..70237fd87 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1600,18 +1600,25 @@ export function DocumentViewer({ const [shellScrollContainer, setShellScrollContainer] = useState(null); useEffect(() => { let cancelled = false; + let observer: MutationObserver | null = null; + // #main-content is the app-shell scroll container: it mounts once (usually + // before this effect runs) and persists for the viewer's lifetime. Resolve it + // synchronously, and only fall back to observing the DOM until it appears — + // then disconnect, rather than reacting to every app-wide mutation forever. const sync = () => { if (cancelled) return; const main = window.document.getElementById("main-content"); + if (!main) return; setShellScrollContainer((current) => (current === main ? current : main)); + observer?.disconnect(); + observer = null; }; - const frame = window.requestAnimationFrame(sync); - const observer = new MutationObserver(sync); + observer = new MutationObserver(sync); observer.observe(window.document.body, { childList: true, subtree: true }); + sync(); return () => { cancelled = true; - window.cancelAnimationFrame(frame); - observer.disconnect(); + observer?.disconnect(); }; }, []); const scrollHidden = useHideOnScroll(shellScrollContainer ? { scrollContainer: shellScrollContainer } : {}); @@ -1823,6 +1830,12 @@ export function DocumentViewer({ useEffect(() => () => refreshControllerRef.current?.abort(), []); + // Distinguishes a full document (re)load — a new documentId or an explicit + // retry (previewAttempt) — from page/chunk navigation on the already-loaded + // document. Navigation only re-windows the detail; a full load also resets the + // preview and re-issues signed URLs. + const loadedKeyRef = useRef(null); + useEffect(() => { if (!canViewSourceDocuments && authStatus === "loading") { return () => undefined; @@ -1833,8 +1846,12 @@ export function DocumentViewer({ const controller = new AbortController(); const authRequest = registerAuthRequest(controller); + const loadKey = `${documentId}::${previewAttempt}`; + const isFullReload = loadedKeyRef.current !== loadKey; const reset = window.setTimeout(() => { - if (!controller.signal.aborted) { + // Skip the reset on navigation so the mounted PDF and current content stay + // visible (no loading flash) while the new page window loads in the background. + if (!controller.signal.aborted && isFullReload) { setLoadingDocument(true); setViewerError(null); setPreviewError(null); @@ -1871,17 +1888,21 @@ export function DocumentViewer({ if (!response.ok) throw new Error(payload.error || "Document details could not be loaded."); return payload; }); - const signedUrlPair = fetchSignedUrlPair(signedUrlEndpoint, downloadSignedUrlEndpoint, { - signal: controller.signal, - headers: clientDemoMode ? undefined : authorizationHeader, - onUnauthorized: markSessionExpired, - useCache: true, - }); + // Navigation keeps the current signed URLs; only a full load re-issues them. + const signedUrlPair = isFullReload + ? fetchSignedUrlPair(signedUrlEndpoint, downloadSignedUrlEndpoint, { + signal: controller.signal, + headers: clientDemoMode ? undefined : authorizationHeader, + onUnauthorized: markSessionExpired, + useCache: true, + }) + : Promise.resolve(null); return Promise.all([Promise.allSettled([detailRequest]), signedUrlPair]); }) - .then(([[detailResult], [signedUrlResult, signedDownloadUrlResult]]) => { + .then(([[detailResult], signedUrlPair]) => { if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return; + loadedKeyRef.current = loadKey; if (detailResult.status === "fulfilled") { const detail = detailResult.value; @@ -1891,7 +1912,9 @@ export function DocumentViewer({ setTableFacts(detail.tableFacts ?? []); setChunks(detail.chunks ?? []); setIndexHealth(detail.indexHealth ?? null); - } else { + } else if (isFullReload) { + // Only clear the viewer on a full load; a transient detail failure + // during navigation keeps the current content on screen. setDocument(null); setPages([]); setImages([]); @@ -1911,11 +1934,14 @@ export function DocumentViewer({ } } - applySignedUrlResults(signedUrlResult, signedDownloadUrlResult, signedUrlEndpoint, downloadSignedUrlEndpoint); + if (signedUrlPair) { + const [signedUrlResult, signedDownloadUrlResult] = signedUrlPair; + applySignedUrlResults(signedUrlResult, signedDownloadUrlResult, signedUrlEndpoint, downloadSignedUrlEndpoint); + } }) .catch((error) => { if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return; - setViewerError(error instanceof Error ? error.message : "Document could not be loaded."); + if (isFullReload) setViewerError(error instanceof Error ? error.message : "Document could not be loaded."); }) .finally(() => { if (!controller.signal.aborted) setLoadingDocument(false); @@ -2191,20 +2217,23 @@ export function DocumentViewer({ // The PDF signed URL has a 10-min TTL and pdf.js holds a dead reference once it // expires. When the canvas reports an expiry, drop the cached URLs and re-run // the fetch pipeline to mint fresh ones (bounded so a broken URL can't loop). - const handleSignedUrlExpired = () => { + // Stable identity (useCallback) so the memoised PdfCanvasViewer isn't re-rendered + // — and its page re-rastered — every time an unrelated parent state (source-search + // keystroke, composer focus, online/offline) changes. + const handleSignedUrlExpired = useCallback(() => { if (signedUrlRefreshCountRef.current >= 2) return; signedUrlRefreshCountRef.current += 1; const signedUrlEndpoint = `/api/documents/${documentId}/signed-url`; clearCachedSignedUrl(signedUrlEndpoint); clearCachedSignedUrl(`${signedUrlEndpoint}?download=true`); refreshSignedUrls(); - }; + }, [documentId, refreshSignedUrls]); // A successful reload means the refreshed URL was accepted, so the recovery // worked — restore the budget for the next (unrelated) TTL expiry. A broken // URL never loads, so it never resets, and the cap still stops its loop. - const handlePdfLoadSuccess = () => { + const handlePdfLoadSuccess = useCallback(() => { signedUrlRefreshCountRef.current = 0; - }; + }, []); const handleDocumentRenamed = (updatedDocument: ClinicalDocument) => { setDocument((current) => (current?.id === updatedDocument.id ? { ...current, ...updatedDocument } : current)); }; diff --git a/src/components/document-viewer/non-pdf-source-preview.tsx b/src/components/document-viewer/non-pdf-source-preview.tsx index 140cc5b6e..b69bd54f2 100644 --- a/src/components/document-viewer/non-pdf-source-preview.tsx +++ b/src/components/document-viewer/non-pdf-source-preview.tsx @@ -2,7 +2,7 @@ /* eslint-disable @next/next/no-img-element */ -import { useState } from "react"; +import { memo, useState } from "react"; import { CircleAlert, Download, ExternalLink, FileText } from "lucide-react"; import { cn, floatingControl } from "@/components/ui-primitives"; @@ -21,7 +21,7 @@ const placeholderSurface = * - other (DOCX/XLSX/…) → an honest "download to view" affordance, * - no signed URL yet → the original placeholder. */ -export function NonPdfSourcePreview({ +export const NonPdfSourcePreview = memo(function NonPdfSourcePreview({ fileType, title, signedUrl, @@ -87,7 +87,7 @@ export function NonPdfSourcePreview({ ); -} +}); /** * Inline image with a failure fallback. The source is a direct signed URL owned diff --git a/src/components/document-viewer/pdf-canvas-viewer.tsx b/src/components/document-viewer/pdf-canvas-viewer.tsx index 6f213fe9a..709c4a581 100644 --- a/src/components/document-viewer/pdf-canvas-viewer.tsx +++ b/src/components/document-viewer/pdf-canvas-viewer.tsx @@ -1,6 +1,6 @@ "use client"; -import { type KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; +import { type KeyboardEvent as ReactKeyboardEvent, memo, useCallback, useEffect, useRef, useState } from "react"; import { ChevronLeft, ChevronRight, @@ -44,7 +44,11 @@ function isLikelyExpiredUrl(error: unknown): boolean { ); } -export function PdfCanvasViewer({ +// Memoised: this is the heaviest subtree in the document view (it holds the +// pdf.js document and re-rasters the canvas). With stable props from the parent +// it skips re-render when unrelated parent state (search, composer, connectivity) +// changes, so a keystroke elsewhere never re-rasterises the page. +export const PdfCanvasViewer = memo(function PdfCanvasViewer({ url, title, initialPage, @@ -553,14 +557,22 @@ export function PdfCanvasViewer({ ); -} +}); function nativePdfEmbedUrl(url: string, initialPage: number) { const page = Math.max(1, Math.trunc(initialPage || 1)); return `${url.split("#")[0]}#page=${page}`; } -export function NativePdfEmbed({ url, title, initialPage }: { url: string; title: string; initialPage: number }) { +export const NativePdfEmbed = memo(function NativePdfEmbed({ + url, + title, + initialPage, +}: { + url: string; + title: string; + initialPage: number; +}) { return (