Skip to content

Evict scrolled-away pages' images instead of only loading lazily - #155

Merged
philips merged 2 commits into
mainfrom
evict-scrolled-away-pages-issue-154
Jul 31, 2026
Merged

Evict scrolled-away pages' images instead of only loading lazily#155
philips merged 2 commits into
mainfrom
evict-scrolled-away-pages-issue-154

Conversation

@philips-clanker

@philips-clanker philips-clanker commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #154 - a 100-page note still crashed on an iPhone 13 mini around page 14-20, even after #151's lazy loading. That fix only bounds when a page's image loads (on scroll-near, not upfront) - it never releases anything, so memory still grows monotonically with how many pages you've scrolled past, not how many are actually near the viewport. On a long enough document, that's still unbounded.

Update: also fixes the same crash reproduced by scrolling the thumbnail sidebar alone (reported separately) - see "Thumbnail sidebar" below.

Changes

  • New evictPageImage(), called from the same pageObserver that already triggers lazy loading: once a page's container leaves the rootMargin-extended viewport, its ImageBitmap is explicitly .close()'d and its canvas backing store shrunk to 1x1. Scrolling back re-triggers ensurePageImage() exactly as if the page had never loaded (both imageBitmap and imageLoadPromise reset to null).
  • Fixed a related gap this exposed in drawPageImage(): it resized every page's canvas backing store unconditionally. commitZoom() calls it for every page on every zoom change - so touching zoom on a long document would reallocate a full-size backing store for every page regardless of load state, undoing the lazy-loading memory benefit immediately. Split so CSS (layout) size still always updates for every page (scroll position / page-anchor links depend on correct page heights even for unloaded pages), but the backing store itself only resizes when there's an actual image to draw.
  • Text layers (search/selection) are deliberately left alone - much lighter than a decoded bitmap/canvas, and losing search highlights on scroll-past would be a worse trade for a small memory saving.

Thumbnail sidebar

The sidebar scrolls independently of the main view, so it could reproduce the same crash on its own (toggleThumbnails() used to eagerly load every page's image the moment it opened at all, regardless of how many thumbnails were actually visible). Added a second IntersectionObserver (thumbObserver), scoped to the sidebar's own scroll container, that lazily loads/evicts thumbnails the same way. Since a page can be near the viewport in one of the two views while far from it in the other, PageRenderState now tracks visibleInMainView/visibleInThumbnail separately, and eviction only fires once both are false - so scrolling just one view doesn't evict a page the other still has visible. evictPageImage() also now clears the thumbnail <img>'s own src, since it decodes/caches its own copy independent of the main canvas.

What I could not verify

Same caveat as #151: no Obsidian/Electron/X server available in my environment, so this is verified by tsc/eslint/the existing test suite and a careful manual trace (zoom via commitZoom, initial layout sizing, goToPage, thumbnails), not by actually scrolling a 100-page note on a real device. Please verify on the actual reported scenario (a 100-page note, iPhone 13 mini or similar - both scrolling the main view and scrolling the thumbnail sidebar) before considering this closed - that's the one thing I can't substitute for here.

Test plan

  • npx tsc --noEmit - clean
  • npx eslint src/main.ts - 0 errors
  • npx vitest run - 95 passed
  • npm run build - clean
  • Manual: scroll through a 100+ page note on a memory-constrained device past page 20 without a crash
  • Manual: open the thumbnail sidebar on the same long note and scroll through it without a crash
  • Manual: zoom in/out on a long document after scrolling partway through - no crash, and pages already scrolled past still redraw correctly if scrolled back to
  • Manual: page-anchor link / thumbnail click-to-jump / page-jump input still land on the correct page even deep into a long, partially-scrolled document

Fixes #154: a 100-page note still crashed on an iPhone 13 mini around
page 14-20, even with #151's lazy loading - because that only bounds
*when* a page's image loads, not whether it ever gets released.
Nothing freed an earlier page's decoded ImageBitmap or canvas backing
store once loaded, so memory still grew monotonically with how many
pages you'd scrolled past, not how many are actually near the
viewport right now.

Adds evictPageImage(), called from the same pageObserver that already
triggers lazy loading (see onOpen()): once a page's container leaves
the 100%-margin window, its ImageBitmap is explicitly closed and its
canvas backing store shrunk to 1x1, reclaiming that memory. Scrolling
back re-triggers ensurePageImage() exactly as if the page had never
loaded, since both its imageBitmap and imageLoadPromise are reset to
null.

Also fixes a related gap this exposed in drawPageImage(): it resized
every page's canvas backing store unconditionally, including pages
with no image loaded at all. commitZoom() calls drawPageImage() for
every page in the document on every zoom change - so touching zoom on
a long document would have reallocated a full-size backing store for
every page regardless of whether it was ever loaded, undoing lazy
loading's benefit immediately. Split so CSS (layout) size still always
updates for every page (scroll position/page-anchor links depend on
that even for unloaded pages), but the backing store itself is now
only resized when there's an actual image to draw.

Text layers (search/selection) are deliberately left alone - much
lighter than a decoded bitmap, and losing highlights/search state on
scroll-past would be a worse trade for a small saving.

Known related gap, not fixed here: toggleThumbnails() still loads
every page's image at once when the sidebar is first opened, which
would reintroduce the same unbounded-memory problem for a long
document if a user opens it. Out of scope for this fix since #154's
report is specifically about scrolling, not thumbnails.
Same crash as #154, reproducible a different way: opening the
thumbnail sidebar on a long document loaded every page's image at
once (toggleThumbnails() eagerly called ensurePageImage() for the
whole document), regardless of how many thumbnails were actually
scrolled into view in the sidebar - the exact "load everything
unconditionally" problem #155 had just fixed for the main view, still
present here since the sidebar scrolls independently of it.

Adds a second IntersectionObserver (thumbObserver), scoped to the
thumbnail sidebar's own scroll container rather than contentEl, that
lazily loads/evicts thumbnails the same way pageObserver already does
for the main view. Re-created per file load in buildThumbSidebar(),
since thumbSidebarEl is a fresh element every load and an
IntersectionObserver's root can't change after construction.

Since the main view and thumbnail sidebar scroll independently, a page
can be near the viewport in one while far from it in the other -
either alone can reproduce the crash. PageRenderState now tracks
visibleInMainView/visibleInThumbnail separately, and a new shared
updatePageLoadState() only evicts once *both* are false, so scrolling
just one view doesn't evict a page the other still has visible.
evictPageImage() also now clears the thumbnail <img>'s own `src` -
it decodes and caches its own copy independent of the main canvas/
ImageBitmap, so freeing only the canvas side left the thumbnail's
copy resident.

Also has goToPage() mark its target page's visibleInMainView eagerly,
closing a narrow race where a stray non-intersecting callback firing
while its scroll-into-view is still settling could otherwise evict the
very page just explicitly jumped to.
@philips
philips merged commit 8ec517d into main Jul 31, 2026
7 checks passed
pull Bot pushed a commit to ben-vargas/supernote-obsidian-plugin that referenced this pull request Jul 31, 2026
Confirmed by testing: main-view scrolling no longer crashes (philips#155),
but opening the thumbnail sidebar at all still did, even with the
lazy-load/evict cycle philips#155 added for it.

Root cause: rootMargin percentages are relative to the *root's own*
size, not the viewport - thumbObserver's root is the sidebar, sized
around each ~150-250px thumbnail item (image + label), while
pageObserver's root (contentEl) is sized around each often-near-
full-viewport main-view page. The same '100% 0px' margin that only
ever brings 1-2 main-view pages into range at once fits several times
that many thumbnails into an equivalent pixel margin - each still
triggering a full, real-page-resolution rasterization
(ensurePageImage() has no separate cheap/low-res path for thumbnail-
only use), just to fill in a 140px-wide image. Opening the sidebar on
a long document meant a burst of many simultaneous full rasterizations
at once, not the 1-2 at a time the main view actually needed.

Removed rootMargin entirely for thumbObserver (defaults to 0px) - a
thumbnail popping in slightly after it's actually scrolled to is an
acceptable trade-off for a navigation aid, unlike the main reading
view, which keeps its own prefetch margin.
philips-clanker added a commit that referenced this pull request Aug 1, 2026
Requested for testing on desktop, where DevTools' Memory tab is
available to cross-check against: "scrolling all 100 pages still
causes a crash" even after #155/#156/#158/#159's fixes, and it isn't
obvious from the outside whether eviction is actually keeping up or
something's still accumulating.

Logs every page load/evict (main view and thumbnails alike, since both
go through the same ensurePageImage()/evictPageImage()) with a rough
memory estimate for that page (decoded ImageBitmap + canvas backing
store - the latter often the larger of the two, up to 9x the bitmap's
pixel count at capped 3x devicePixelRatio) and a running total across
every currently-loaded page. Watching whether "currently loaded" keeps
climbing over a long scroll (something isn't actually being freed) or
stays roughly bounded (eviction is working, and the cause is something
else - e.g. pure CPU/GC pressure from rasterization itself, see
supernote-typescript#40's thumbnail-resolution angle on that) should
narrow down what's actually still going wrong.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

100 page document on iOS causing obsidian to "reboot"

2 participants