You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Render-then-swap the canvas to avoid the blank flash on re-render (and enable smooth zoom)
#2115
Hi 👋 — first off, thanks for react-pdf. This is an idea + a question, not a bug report,
so I'm putting it here rather than the tracker.
The observation
When a <Page> re-renders at a new size — a change to scale, width, height, or rotate, or a page-number change that reuses a mounted Page — the canvas blanks to white
for the duration of page.render() and only reappears once rendering resolves. On a
content-heavy page it's a visible flash.
It surfaces most when trying to build interactive zoom on top of react-pdf: the gesture
itself is cheap to do in user-land (hold scale fixed, apply a CSS transform to a wrapper,
commit the new scale when motion settles), but the flash at commit can't be hidden from
outside the library.
Why it happens (from reading the source)
Two things combine so the old pixels are gone before the new ones exist:
scale/rotate are part of the canvas React key, so a size change unmounts the old <canvas> and mounts a fresh empty one:
Page/Canvas.tsx resizes the canvas (which clears it), sets visibility: 'hidden',
starts the async page.render(), and only un-hides on success.
Net: [old pixels destroyed] → [blank] → (async render) → [new pixels], blank for exactly
one page.render().
The idea: render-then-swap
Rather than clear-then-render on the visible surface, rasterize the next frame into an
off-DOM / second buffer and only swap it in once its render promise resolves — the current
page stays on screen until the replacement is ready.
Roughly: keep the visible <canvas> mounted across size changes (track a renderedScale
internally instead of keying on it), render into a back buffer (OffscreenCanvas or a hidden
second canvas), and on resolve either toggle which canvas is visible or blit the result in.
The appeal is that it's behavior-only (no public API change) and fixes the flash for all
re-renders — scale/width/height/rotate and page nav — not just zoom.
The trade-off (and my actual question)
CPU: basically unchanged — page.render() still runs once per committed size; the swap
is a style toggle or a single GPU blit, negligible next to rasterization.
Memory: transiently ~2× for the page being re-rendered while both buffers coexist.
That memory point is why I'm asking rather than just sending a PR — the aggressive canvas.width = 0 teardown reads like memory footprint is a deliberate design constraint.
So:
Is trading a bit of transient per-page memory for no-flash something you'd want in the
library at all, or do you consider the flash acceptable / out of scope?
If desirable, should it be the default or opt-in (e.g. a prop)?
Any prior art / reason it was done as clear-then-render that I'm missing?
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Hi 👋 — first off, thanks for react-pdf. This is an idea + a question, not a bug report,
so I'm putting it here rather than the tracker.
The observation
When a
<Page>re-renders at a new size — a change toscale,width,height, orrotate, or a page-number change that reuses a mountedPage— the canvas blanks to whitefor the duration of
page.render()and only reappears once rendering resolves. On acontent-heavy page it's a visible flash.
It surfaces most when trying to build interactive zoom on top of react-pdf: the gesture
itself is cheap to do in user-land (hold
scalefixed, apply a CSStransformto a wrapper,commit the new
scalewhen motion settles), but the flash at commit can't be hidden fromoutside the library.
Why it happens (from reading the source)
Two things combine so the old pixels are gone before the new ones exist:
scale/rotateare part of the canvas Reactkey, so a size change unmounts the old<canvas>and mounts a fresh empty one:Page/Canvas.tsxresizes the canvas (which clears it), setsvisibility: 'hidden',starts the async
page.render(), and only un-hides on success.Net:
[old pixels destroyed] → [blank] → (async render) → [new pixels], blank for exactlyone
page.render().The idea: render-then-swap
Rather than clear-then-render on the visible surface, rasterize the next frame into an
off-DOM / second buffer and only swap it in once its render promise resolves — the current
page stays on screen until the replacement is ready.
Roughly: keep the visible
<canvas>mounted across size changes (track arenderedScaleinternally instead of keying on it), render into a back buffer (
OffscreenCanvasor a hiddensecond canvas), and on resolve either toggle which canvas is visible or blit the result in.
The appeal is that it's behavior-only (no public API change) and fixes the flash for all
re-renders — scale/width/height/rotate and page nav — not just zoom.
The trade-off (and my actual question)
page.render()still runs once per committed size; the swapis a style toggle or a single GPU blit, negligible next to rasterization.
That memory point is why I'm asking rather than just sending a PR — the aggressive
canvas.width = 0teardown reads like memory footprint is a deliberate design constraint.So:
library at all, or do you consider the flash acceptable / out of scope?
Happy to prototype a PR if there's interest.
Thanks for considering it!
All reactions