fix: virtualisation issues

This commit is contained in:
David Nguyen
2026-02-25 17:30:20 +11:00
parent 9a2f3747db
commit 653d340668
19 changed files with 259 additions and 59 deletions
@@ -98,10 +98,8 @@ export const DocumentReadOnlyFields = ({
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
const highestPageNumber = Math.max(...fields.map((field) => field.page));
return (
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
@@ -163,7 +161,7 @@ export const DocumentReadOnlyFields = ({
</span>
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
<p className="mt-1 text-center text-xs text-muted-foreground">
{getRecipientDisplayText(field.recipient)}
</p>
+23 -1
View File
@@ -5,6 +5,7 @@ import { createPortal } from 'react-dom';
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import { useIsPageInDom } from '@documenso/lib/client-only/hooks/use-is-page-in-dom';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
@@ -81,6 +82,8 @@ export function FieldRootContainer({
readonly,
}: FieldRootContainerProps) {
const [isValidating, setIsValidating] = useState(false);
const isPageInDom = useIsPageInDom(field.page);
const ref = React.useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -88,6 +91,21 @@ export function FieldRootContainer({
return;
}
// Check the validation signal on the PDF viewer container. When a field
// mounts after the virtual list scrolls to its page, the per-element
// `data-validate` attribute will not have been set yet. The signal on the
// `[data-pdf-content]` container bridges this gap so newly-rendered fields
// pick up the validation state immediately.
const pdfContent = document.querySelector('[data-pdf-content]');
if (
pdfContent?.getAttribute('data-validate-fields') === 'true' &&
isFieldUnsignedAndRequired(field)
) {
ref.current.setAttribute('data-validate', 'true');
setIsValidating(true);
}
const observer = new MutationObserver((_mutations) => {
if (ref.current) {
setIsValidating(ref.current.getAttribute('data-validate') === 'true');
@@ -101,7 +119,11 @@ export function FieldRootContainer({
return () => {
observer.disconnect();
};
}, []);
}, [isPageInDom]);
if (!isPageInDom) {
return null;
}
return (
<FieldContainerPortal field={field}>
@@ -16,6 +16,7 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
import { useVirtualList } from '../virtual-list/use-virtual-list';
import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states';
import { useScrollToPage } from './use-scroll-to-page';
export type EnvelopePdfViewerProps = {
className?: string;
@@ -156,7 +157,7 @@ const VirtualizedPageList = ({
}: VirtualizedPageListProps) => {
const contentRef = useRef<HTMLDivElement>(null);
const { virtualItems, totalSize, constraintWidth } = useVirtualList({
const { virtualItems, totalSize, constraintWidth, scrollToItem } = useVirtualList({
scrollRef: scrollParentRef,
constraintRef,
contentRef,
@@ -174,9 +175,12 @@ const VirtualizedPageList = ({
overscan: 10,
});
useScrollToPage(contentRef, scrollToItem);
return (
<div
ref={contentRef}
data-pdf-content=""
style={{
height: `${totalSize}px`,
width: '100%',
@@ -18,6 +18,7 @@ import { useToast } from '../../primitives/use-toast';
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
import { useVirtualList } from '../virtual-list/use-virtual-list';
import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states';
import { useScrollToPage } from './use-scroll-to-page';
export type OverrideImage = {
image: string;
@@ -217,7 +218,7 @@ const VirtualizedPageList = ({
}: VirtualizedPageListProps) => {
const contentRef = useRef<HTMLDivElement>(null);
const { virtualItems, totalSize, constraintWidth } = useVirtualList({
const { virtualItems, totalSize, constraintWidth, scrollToItem } = useVirtualList({
scrollRef: scrollParentRef,
constraintRef,
contentRef,
@@ -235,9 +236,12 @@ const VirtualizedPageList = ({
overscan: 5,
});
useScrollToPage(contentRef, scrollToItem);
return (
<div
ref={contentRef}
data-pdf-content=""
style={{
height: `${totalSize}px`,
width: '100%',
@@ -0,0 +1,46 @@
import { type RefObject, useEffect } from 'react';
/**
* Watch for `data-scroll-to-page` attribute changes on a container element.
*
* When set (by `validateFieldsInserted`, `handleOnNextFieldClick`, or similar),
* scroll the virtual list to the requested page and clear the attribute.
*
* This is the communication bridge between field validation logic (which knows
* which page to scroll to) and the virtual list (which knows how to scroll).
*/
export const useScrollToPage = (
contentRef: RefObject<HTMLElement | null>,
scrollToItem: (index: number) => void,
) => {
useEffect(() => {
const el = contentRef.current;
if (!el) {
return;
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-scroll-to-page') {
const raw = el.getAttribute('data-scroll-to-page');
if (raw) {
const pageNumber = parseInt(raw, 10);
if (!isNaN(pageNumber) && pageNumber >= 1) {
// Pages are 1-indexed, virtual list items are 0-indexed.
scrollToItem(pageNumber - 1);
}
el.removeAttribute('data-scroll-to-page');
}
}
}
});
observer.observe(el, { attributes: true, attributeFilter: ['data-scroll-to-page'] });
return () => observer.disconnect();
}, [contentRef, scrollToItem]);
};
@@ -34,6 +34,14 @@ export type VirtualListResult = {
virtualItems: VirtualItem[];
totalSize: number;
constraintWidth: number;
/**
* Scroll the scroll container so that the item at the given index is visible.
*
* The scroll position is calculated from the precomputed item offsets and
* adjusted for any content offset (e.g. headers above the virtual list).
*/
scrollToItem: (index: number) => void;
};
/**
@@ -292,9 +300,56 @@ export const useVirtualList = (options: VirtualListOptions): VirtualListResult =
findStartIndex,
]);
/**
* Imperatively scroll the scroll container so that the item at the given
* index is at the top of the viewport.
*/
const scrollToItem = useCallback(
(index: number) => {
if (index < 0 || index >= itemCount) {
return;
}
const itemOffset = offsets[index] ?? 0;
if (scrollRef === 'window') {
const contentEl = contentRef?.current;
const contentTop = contentEl ? contentEl.getBoundingClientRect().top + window.scrollY : 0;
window.scrollTo({
top: contentTop + itemOffset,
behavior: 'smooth',
});
} else {
const scrollEl = scrollRef.current;
if (!scrollEl) {
return;
}
// Recalculate content offset to get the most up-to-date value.
const contentEl = contentRef?.current;
let contentOffset = 0;
if (contentEl) {
const scrollRect = scrollEl.getBoundingClientRect();
const contentRect = contentEl.getBoundingClientRect();
contentOffset = contentRect.top - scrollRect.top + scrollEl.scrollTop;
}
scrollEl.scrollTo({
top: contentOffset + itemOffset,
behavior: 'smooth',
});
}
},
[scrollRef, contentRef, offsets, itemCount],
);
return {
virtualItems,
totalSize,
constraintWidth,
scrollToItem,
};
};