Virtualized PDF Rendering System¶
Overview¶
The PDF annotation system implements a sophisticated virtualization approach to handle large documents efficiently. Instead of rendering all pages at once, only visible pages (plus a small buffer) are rendered, dramatically improving performance and memory usage.
Architecture¶
Core Concept¶
The virtualization system works by: 1. Calculating heights of all pages at the current zoom level 2. Determining which pages are visible in the viewport 3. Only rendering those pages (plus overscan) 4. Ensuring selected items' pages are always rendered
Component Structure¶
PDF.tsx (Virtualization Engine)
├── Manages visible page range
├── Handles scroll events
├── Calculates page positions
└── Renders PDFPage components conditionally
└── PDFPage.tsx (Individual Page)
├── Renders PDF canvas when visible
├── Displays annotations for the page
└── Manages its own lifecycle
Implementation Details¶
Page Height Calculation¶
When the PDF loads or zoom changes:
// In PDF.tsx
useEffect(() => {
if (!pdfDoc) return;
(async () => {
const h: number[] = [];
for (let i = 1; i <= pdfDoc.numPages; i++) {
const page = await pdfDoc.getPage(i);
h.push(page.getViewport({ scale: zoomLevel }).height + 32);
}
setPageHeights(h); // Cache heights at this zoom level
})();
}, [pdfDoc, zoomLevel]);
Cumulative Heights¶
For efficient position calculations:
const cumulative = useMemo(() => {
const out: number[] = [0];
for (let i = 0; i < pageHeights.length; i++) {
out.push(out[i] + pageHeights[i]);
}
return out; // cumulative[i] = top position of page i
}, [pageHeights]);
Visible Range Detection¶
The system uses binary search for efficiency:
const calcRange = useCallback(() => {
const el = getScrollElement();
const scroll = /* current scroll position */;
const viewH = /* viewport height */;
// Binary search for first visible page
let lo = 0, hi = cumulative.length - 1;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (cumulative[mid + 1] < scroll) lo = mid + 1;
else hi = mid;
}
const first = lo;
// Find last visible page
const limit = scroll + viewH;
// ... binary search for last visible
// Add overscan for smooth scrolling
const overscan = 2;
let start = Math.max(0, first - overscan);
let end = Math.min(pageCount - 1, last + overscan);
setRange([start, end]);
}, [/* dependencies */]);
Smart Range Expansion¶
The system ensures important content is always rendered:
// Force selected annotation's page to be visible
if (selectedPageIdx !== undefined) {
start = Math.min(start, selectedPageIdx);
end = Math.max(end, selectedPageIdx);
}
// Same for search results
if (selectedSearchPageIdx !== undefined) {
start = Math.min(start, selectedSearchPageIdx);
end = Math.max(end, selectedSearchPageIdx);
}
// And chat source highlights
if (selectedChatSourcePageIdx !== undefined) {
start = Math.min(start, selectedChatSourcePageIdx);
end = Math.max(end, selectedChatSourcePageIdx);
}
Rendering Loop¶
Only pages in range are rendered:
return (
<div style={{ position: "relative" }}>
{pageInfos.map((pInfo, idx) => {
const top = cumulative[idx];
const height = pageHeights[idx];
const visible = idx >= range[0] && idx <= range[1];
return (
<div
key={pInfo.page.pageNumber}
style={{
position: "absolute",
top,
height,
width: "100%",
}}
>
{visible && (
<PDFPage
pageInfo={pInfo}
/* other props */
/>
)}
</div>
);
})}
{/* Spacer maintains correct scroll height */}
<div style={{ height: cumulative[cumulative.length - 1] }} />
</div>
);
Scroll-to-Annotation System¶
The system implements a two-phase approach for scrolling to specific items:
Phase 1: Page-Level Scroll (PDF.tsx)¶
When an annotation is selected: 1. Calculate which page contains the annotation 2. Scroll the container so the page is visible 3. Set a pending scroll ID for phase 2
useEffect(() => {
if (selectedAnnotations.length === 0 || pageHeights.length === 0) return;
if (selectedPageIdx === undefined) return;
const targetId = selectedAnnotations[0];
// Scroll to page
const topOffset = Math.max(0, cumulative[selectedPageIdx] - 32);
getScrollElement().scrollTo({ top: topOffset, behavior: "smooth" });
// Tell PDFPage to center the annotation
setPendingScrollId(targetId);
}, [selectedAnnotations, selectedPageIdx, /* ... */]);
Phase 2: Element-Level Scroll (PDFPage.tsx)¶
Once the page is rendered: 1. PDFPage checks for pending scroll requests 2. Finds the specific annotation element 3. Scrolls it into view with centering
useEffect(() => {
if (!hasPdfPageRendered) return;
if (pendingScrollId) {
const pageOwnsAnnotation = /* check if annotation is on this page */;
if (!pageOwnsAnnotation) return;
let cancelled = false;
const tryScroll = () => {
if (cancelled) return;
const el = document.querySelector(`.selection_${pendingScrollId}`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
setPendingScrollId(null); // Clear pending
} else {
requestAnimationFrame(tryScroll); // Retry
}
};
tryScroll();
}
}, [hasPdfPageRendered, pendingScrollId, /* ... */]);
Performance Benefits¶
Memory Usage¶
- Only visible pages hold rendered canvases
- Annotations for non-visible pages aren't mounted
- Dramatic reduction for documents with 100+ pages
Rendering Performance¶
- Initial load only renders visible pages
- Scrolling only renders newly visible pages
- Zoom changes only affect rendered pages
Smooth Scrolling¶
- Overscan ensures pages are ready before visible
- Height caching prevents layout recalculations
- RequestAnimationFrame for optimal timing
Configuration¶
Overscan Amount¶
const overscan = 2; // Pages to render above/below viewport
Scroll Container¶
The system supports both window scrolling and container scrolling:
const getScrollElement = useCallback((): HTMLElement | Window => {
const el = scrollContainerRef?.current;
if (el && el.scrollHeight > el.clientHeight) return el;
return window; // Fallback to window scrolling
}, [scrollContainerRef]);
Best Practices¶
- Keep overscan reasonable - Too much defeats virtualization benefits
- Cache computations - Page heights are expensive to calculate
- Use binary search - Linear search is too slow for large documents
- Handle edge cases - Selected items must always be visible
- Debounce scroll events - Use requestAnimationFrame for smoothness
Unified Content Feed Virtualization¶
Overview¶
The Unified Content Feed (UnifiedContentFeed.tsx) aggregates annotations, relationships, notes, and search results into a single virtualized feed. Unlike the PDF viewer which has fixed page heights, this feed handles variable-height items with dynamic content.
Implementation Approach¶
We use react-window v2 with dynamic height measurement for optimal performance with mixed content types.
Key Component Structure¶
<AutoSizer>
{({ height, width }) => (
<div style={{ position: "relative", width, height }}>
<List
listRef={setListRef}
defaultHeight={height}
rowCount={virtualItems.length}
rowHeight={dynamicRowHeight}
rowComponent={RowComponent}
overscanCount={5}
/>
</div>
)}
</AutoSizer>
Critical Implementation Details¶
1. Positioned Parent Container (The Tricky Part)¶
Problem: react-window v2 uses position: absolute for rows with transform: translateY() for positioning. Without a proper positioning context, rows render but remain invisible.
Solution: Wrap the List in a div with position: relative and explicit dimensions:
// CRITICAL: This wrapper provides positioning context for absolute rows
<div style={{ position: "relative", width: `${width}px`, height: `${height}px` }}>
<List ... />
</div>
Why this matters: We spent significant debugging time discovering rows were rendering (console logs confirmed) but weren't visible. The issue was the absolutely-positioned rows had no positioned ancestor, causing them to position incorrectly outside the visible area.
2. Dynamic Height Measurement¶
For variable-height content, we use useDynamicRowHeight:
const rowHeightManager = useDynamicRowHeight({
defaultRowHeight: ESTIMATED_HEIGHTS.annotation,
});
const rowHeight = useMemo(() => {
return {
...rowHeightManager,
getRowHeight: (index: number) => {
// First check measured height
const measured = rowHeightManager.getRowHeight(index);
if (measured !== undefined) return measured;
// Fallback to smart estimation
const item = virtualItems[index];
switch (item.type) {
case "annotation": {
const textLength = item.data.rawText?.length || 0;
// Account for text wrapping at ~50 chars/line
return ESTIMATED_HEIGHTS.annotation + Math.floor(textLength / 50) * 20;
}
case "relationship": {
// More connections = taller
const connections = item.data.sourceIds.length + item.data.targetIds.length;
return ESTIMATED_HEIGHTS.relationship + connections * 10;
}
// ... other types
}
},
};
}, [virtualItems, rowHeightManager]);
3. Height Estimation Strategy¶
Initial estimates prevent layout shifts during measurement:
const ESTIMATED_HEIGHTS = {
pageHeader: 90,
note: 200,
annotation: 160, // Includes padding (0.875rem) + margin (0.5rem) + content
relationship: 220,
search: 140,
};
Content-based adjustments improve accuracy: - Text length → extra line height - Number of relationships → additional row space - Note content length → proportional height increase
Debugging Lessons Learned¶
Issue 1: Invisible Rows¶
- Symptom: Console logs showed rows rendering, but nothing visible
- Diagnosis: Inspected DOM showed
height: 0px, width: 0pxon row divs - Root Cause: Missing positioned parent for absolute positioning
- Fix: Added
position: relativewrapper with explicit dimensions
Issue 2: Overlapping Content¶
- Symptom: Items rendering on top of each other
- Diagnosis: Fixed heights (100px) didn't match actual content
- Root Cause: Variable content needs dynamic measurement
- Fix: Implemented
useDynamicRowHeightwith smart estimation
Issue 3: Excessive Spacing¶
- Symptom: Large gaps between items after initial fix
- Diagnosis: Over-generous height estimates
- Root Cause: Estimates didn't account for actual rendered size
- Fix: Dynamic measurement with content-aware fallback estimates
Performance Characteristics¶
Advantages over custom virtualization: - No manual scroll event handling - Automatic visible range calculation - Built-in overscan management - Smooth height transitions as content measures
Trade-offs: - Initial render shows estimated heights briefly - Height measurement happens on first display - Slight layout shift as measurements complete (minimal with good estimates)
Best Practices¶
- Always wrap List in positioned container - Critical for visibility
- Provide good height estimates - Reduces measurement-induced layout shifts
- Use content-aware estimation - Text length, connection count, etc.
- Set appropriate overscan - Balance smoothness vs. performance (we use 5)
- Maintain listRef - Enables programmatic scrolling to specific items
Testing Approach¶
When debugging virtualization issues:
- Check console for render calls - Are components being invoked?
- Inspect DOM for positioning - Look for
position: absoluteandtransform - Verify container hierarchy - Positioned parent present?
- Test with simple content - Use colored divs to isolate rendering vs. content issues
- Monitor height estimates - Log estimated vs. measured heights
Future Enhancements¶
- Dynamic overscan - Adjust based on scroll velocity
- Progressive rendering - Low-res preview while scrolling
- Intersection Observer - More efficient visibility detection
- Memory pressure handling - Reduce overscan under memory constraints
- Predictive preloading - Anticipate scroll direction