Evict scrolled-away pages' images instead of only loading lazily - #155
Merged
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
evictPageImage(), called from the samepageObserverthat already triggers lazy loading: once a page's container leaves therootMargin-extended viewport, itsImageBitmapis explicitly.close()'d and its canvas backing store shrunk to 1x1. Scrolling back re-triggersensurePageImage()exactly as if the page had never loaded (bothimageBitmapandimageLoadPromisereset tonull).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.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 secondIntersectionObserver(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,PageRenderStatenow tracksvisibleInMainView/visibleInThumbnailseparately, 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 ownsrc, 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 viacommitZoom, 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- cleannpx eslint src/main.ts- 0 errorsnpx vitest run- 95 passednpm run build- clean