Is your feature request related to a problem? Please describe.
When we open a PDF and programmatically scroll to a specific page shortly after onLoaded fires (e.g. deep-linking to a chat citation or a highlighted reference), goToPage(target) intermittently lands at or near the last page of the document instead of the target.
Tracing through the source, two things compound:
scrollToPage (PaginationContext-15f88187.js) calls setFocusedPage(u) synchronously before the early-return guard if (!m.current || !s) return, and also before the actual element.scrollTo(...). So focusedPage reports success even when the scroll was skipped or got browser-clamped.
- The scroll-ratio preservation effect in
RPPages.js (const nt = X / we * Y; i.scrollTo({ top: Math.min(nt, Y) ... })) then amplifies an initial clamped scroll. If the first scrollTo was clamped to scrollHeight - clientHeight because the virtualizer hasn't measured rows yet, X / we ≈ 1, and every subsequent totalInnerDimensions growth pulls the scrollTop back to ~1.0 × newTotalHeight — i.e. the bottom of the document.
The initialPage > 1 code path already handles this correctly (it skips Pe and uses a debounced ne.height > 0 gate). But initialPage is only read on mount, which doesn't help applications where the target page is determined asynchronously (e.g. after text resolution or a user interaction).
Describe the solution you'd like
Any one of these would be enough:
- A public "virtualizer-ready" signal — e.g. an
onLayoutReady?: () => void prop on RPProvider, or exporting useVirtualScrollContext().totalInnerDimensions — so consumers can defer imperative calls until layout is stable.
- Make
goToPage internally queue the scroll if virtualScrollableElementRef / totalInnerDimensions.height aren't ready, and flush it once they are (mirroring the initialPage > 1 effect).
- Guard
Pe against growth caused by initial measurement, e.g. skip the ratio preservation while lastMeasuredRowIndex < 0 — same early return already used for the initialPage > 1 branch.
- Move the
setFocusedPage(u) call in scrollToPage below the if (!m.current || !s) return guard so focusedPage reflects the actual scroll state and can be used as a signal by consumers.
Describe alternatives you've considered
initialPage prop on RPProvider: works for true deep-link-on-mount, but doesn't cover cases where the target page is known only after async work (e.g. async reference resolution, user clicking a citation on an already-open viewer).
- Retry loop around
goToPage: doesn't help because Pe keeps re-applying the bad ratio as dimensions grow; retries just fight it.
- Polling
document.querySelector('[data-rp="pages"] > div').scrollHeight until it's stable for ~100ms, then calling goToPage: current workaround. Works but relies on an internal DOM selector and duplicates logic the viewer already has internally.
Additional context
- Version:
@react-pdf-kit/viewer 2.3.0.
- Environment: React 19 / Next.js 16. Timing-sensitive, so reliably reproducible on cold opens of long PDFs (100+ pages).
- Minimal repro:
function ScrollToPage({ page }: { page: number }) {
const { goToPage } = usePaginationContext()
useEffect(() => { goToPage(page) }, [page])
return null
}
<RPProvider src={url} onLoaded={() => {}}>
<RPLayout toolbar={false}><RPPages /></RPLayout>
<ScrollToPage page={50} />
</RPProvider>
Opening a 100+ page PDF with page={50} intermittently lands near the last page rather than page 50.
Is your feature request related to a problem? Please describe.
When we open a PDF and programmatically scroll to a specific page shortly after
onLoadedfires (e.g. deep-linking to a chat citation or a highlighted reference),goToPage(target)intermittently lands at or near the last page of the document instead of the target.Tracing through the source, two things compound:
scrollToPage(PaginationContext-15f88187.js) callssetFocusedPage(u)synchronously before the early-return guardif (!m.current || !s) return, and also before the actualelement.scrollTo(...). SofocusedPagereports success even when the scroll was skipped or got browser-clamped.RPPages.js(const nt = X / we * Y; i.scrollTo({ top: Math.min(nt, Y) ... })) then amplifies an initial clamped scroll. If the firstscrollTowas clamped toscrollHeight - clientHeightbecause the virtualizer hasn't measured rows yet,X / we ≈ 1, and every subsequenttotalInnerDimensionsgrowth pulls the scrollTop back to ~1.0 × newTotalHeight— i.e. the bottom of the document.The
initialPage > 1code path already handles this correctly (it skipsPeand uses a debouncedne.height > 0gate). ButinitialPageis only read on mount, which doesn't help applications where the target page is determined asynchronously (e.g. after text resolution or a user interaction).Describe the solution you'd like
Any one of these would be enough:
onLayoutReady?: () => voidprop onRPProvider, or exportinguseVirtualScrollContext().totalInnerDimensions— so consumers can defer imperative calls until layout is stable.goToPageinternally queue the scroll ifvirtualScrollableElementRef/totalInnerDimensions.heightaren't ready, and flush it once they are (mirroring theinitialPage > 1effect).Peagainst growth caused by initial measurement, e.g. skip the ratio preservation whilelastMeasuredRowIndex < 0— same earlyreturnalready used for theinitialPage > 1branch.setFocusedPage(u)call inscrollToPagebelow theif (!m.current || !s) returnguard sofocusedPagereflects the actual scroll state and can be used as a signal by consumers.Describe alternatives you've considered
initialPageprop onRPProvider: works for true deep-link-on-mount, but doesn't cover cases where the target page is known only after async work (e.g. async reference resolution, user clicking a citation on an already-open viewer).goToPage: doesn't help becausePekeeps re-applying the bad ratio as dimensions grow; retries just fight it.document.querySelector('[data-rp="pages"] > div').scrollHeightuntil it's stable for ~100ms, then callinggoToPage: current workaround. Works but relies on an internal DOM selector and duplicates logic the viewer already has internally.Additional context
@react-pdf-kit/viewer2.3.0.page={50}intermittently lands near the last page rather than page 50.