Ownership Audit: Five Bugs With One Root Cause
The Trigger
A reactive performance audit flagged two hot $state write paths in gallery-reader: swipeProgress writing at 60Hz during swipe gestures, and saveProgress writing at 60Hz during reader scroll. The plan was textbook Svelte 5 pitfall remediation: bypass $state during high-frequency operations, sync to the reactive graph only when discrete values change.
The saveProgress fix was correct — gate $state writes on pageIndex change, dropping 60 writes/sec to ~1-2 on page boundaries. The swipeProgress fix was wrong, and finding out why it was wrong led to four more bugs hiding in plain sight.
Bug 1: Direct DOM on the Wrong Element
The plan: during swipe gestures, write node.style.transform directly instead of opts.ui.swipeProgress. On gesture end, clear the direct style and hand off to Svelte.
The problem: use:swipeBack is on a <div> inside ReaderView.svelte. But the style="transform:translateX(...)" binding lives on #view-reader — the parent view-layer div in +page.svelte. These are different elements.
+page.svelte
#view-reader ← Svelte binds transform HERE (reads swipeProgress)
ReaderView.svelte
<div use:swipeBack> ← action's `node` is HERE
Writing node.style.transform moved the inner div while the view-layer stayed put. The back view wasn’t revealed (black background during gesture). On touch release, clearing the inner style and writing swipeProgress caused the view-layer to jump.
The fix was to revert to $state writes — identical to the working manga-reader implementation. Swipe gestures last <1 second (~30-60 updates total), which is negligible. The “direct DOM during gestures” pitfall from the Svelte 5 playbook has a critical prerequisite: the action’s node must BE the element Svelte binds the style to. When they’re different elements in different components, the optimization breaks the ownership contract.
The Pattern Behind the Remaining Bugs
Reverting the swipe fix led to an audit against manga-reader, which handles all of this flawlessly. The audit revealed four more bugs, all with the same root cause: two sources of truth for the same data, with no clear owner.
During a session, the DOM owns transient state — scroll positions, loaded data, visual positions. Views stay mounted (visibility: hidden, not unmounted), so scroll positions and loaded galleries persist across navigation. Everything works.
On session restore, the DOM is fresh. That ownership isn’t transferred. The session snapshot and the DOM disagree. Stale data wins.
Bug 2: Session Restores Wrong Reader Position
Two persistent stores held reader position data:
| Store | Written when | Freshness |
|---|---|---|
IDB (via db.setProgress) |
Every 250ms during scroll | Always current |
| Session snapshot (localStorage) | On view changes only | Stale after any scrolling |
The restore flow read from the session snapshot:
const position = snap.activeGalleryPosition ?? { pageIndex: 0, fraction: 0 };
await this.reader.restoreReader(snap.activeGalleryId, position);
If the user scrolled from page 2 to page 15, then closed the app, the session still had page 2 (the position when the reader opened). IDB had page 15.
Ownership fix: IDB is the single owner of persistent position data. The session snapshot stores only the gallery ID — which gallery is open. Position comes from IDB on restore:
const position = this.reader.getProgress(snap.activeGalleryId);
loadProgress() already runs before restoreSession() in init(), so the IDB data is available. The activeGalleryPosition field was removed from the session type entirely — eliminating the second source of truth.
Bug 3: Back View Scroll Position Lost
Normal usage: open reader from list, swipe back — list is still scrolled to the gallery you clicked. The DOM preserved the scroll position because the view stayed mounted.
After restore: reader opens correctly, swipe back — list is at the top. The DOM is fresh, #view-list starts at scrollTop = 0.
The session stored searchQuery and searchPage, which got the right results on the right page. But the scroll position within that page was owned by the DOM and never transferred.
Ownership fix: Derive scroll position from the active gallery ID. After restoring all views, pre-scroll the back view to center the gallery row:
private async prepareBackViews(snap: SessionSnapshot) {
if (!snap.activeGalleryId) return;
const backView = snap.viewStack[snap.viewStack.length - 1];
if (backView === 'favorites') {
await this.favorites.loadGalleries();
const idx = this.favorites.favoriteGalleries.findIndex(
g => g.gallery_id === snap.activeGalleryId
);
if (idx >= 0) {
this.favorites.currentPage = Math.floor(idx / PAGE_SIZE);
}
this.scrollViewToGallery('view-favorites', snap.activeGalleryId);
} else if (backView === 'list') {
this.scrollViewToGallery('view-list', snap.activeGalleryId);
}
}
This also fixed a hidden bug: if the reader was opened from favorites (viewStack = ['list', 'favorites']), the favorites data was never loaded on restore. Swiping back would show an empty view. Now prepareBackViews loads the data and positions the page.
A subtlety: scrollViewToGallery uses container.querySelector('#gallery-{id}') scoped to the view container, not getElementById. The same gallery can appear in both the list and favorites views, creating duplicate IDs. Scoping avoids hitting the wrong element.
Bug 4: Thumbnail Strip Shows Stale Position
The thumbnail strip (horizontal row of page previews in each gallery row) only updated its scroll position in closeReader():
closeReader() {
const rawTarget = this.currentPageIndex * SPRITE_THUMB_WIDTH;
this.ui.stripScrolls[galleryId] = rawTarget;
// DOM scroll happens here, after swipe completes
}
During a swipe-back gesture, the back view is revealed with .swipe-back visibility. The user sees the strip at its old position — wherever it was when they opened the reader. When the swipe completes and closeReader fires, the strip snaps to the current page.
Manga-reader solves this with ownership-based scroll sync (documented in a separate post): the reader writes a scroll target to $state on every chapter boundary, and the hidden chapter list consumes it via $effect.
Gallery-reader needed the same pattern but adapted for its architecture. Manga-reader has one ChapterList component. Gallery-reader has N GalleryRow components (one per search result). Using $state + $effect would wake all N rows on every update — the broadcast pitfall.
Ownership fix: Direct DOM from the state layer. saveProgress calls syncStripScroll on every page boundary crossing. The method writes directly to the hidden back view’s strip element:
syncStripScroll(galleryId: number, pageIndex: number) {
const rawTarget = pageIndex * SPRITE_THUMB_WIDTH;
this.ui.stripScrolls[galleryId] = rawTarget;
const backView = this.ui.peekBack();
const viewId = backView ? `view-${backView}` : null;
if (!viewId) return;
const container = document.getElementById(viewId);
const strip = container
?.querySelector(`#gallery-${galleryId}`)
?.querySelector('.row-strip') as HTMLElement;
if (strip) {
const centered = rawTarget - (strip.clientWidth / 2) + (SPRITE_THUMB_WIDTH / 2);
strip.scrollLeft = Math.max(0, centered);
}
}
Zero reactive cost. The hidden view maintains layout (visibility: hidden), so clientWidth and scrollLeft work. By the time the user swipes back, the strip is already positioned. closeReader simplified to just popView().
Bug 5: saveProgress $state at 60Hz
The one fix from the original plan that was correct. saveProgress wrote two $state fields on every requestAnimationFrame:
saveProgress(galleryId: number, position: PagePosition) {
this.progress[galleryId] = position; // wakes all GalleryRow $derived
this.currentPosition = position; // wakes ReaderView $derived
}
Both produce new object refs every frame. progress is a $state Record — writing any key dirties all subscribers. With 20 GalleryRows each reading progress[myId], that’s 20 wasted reactive evaluations per frame.
Fix: Gate on discrete value change. The user can only be on one page at a time, and the page index changes ~1-2 times per page of scrolling:
saveProgress(galleryId: number, position: PagePosition) {
if (position.pageIndex !== this._lastSyncedPageIndex) {
this._lastSyncedPageIndex = position.pageIndex;
this.progress[galleryId] = position;
this.currentPosition = position;
this.syncStripScroll(galleryId, position.pageIndex);
}
// IDB writes still happen at full frequency (debounced 250ms)
}
$state writes drop from 60/sec to ~1-2/sec. IDB writes (the persistent owner of position data) still debounce at 250ms with full precision.
The Ownership Model
Every bug traced back to the same question: who owns this data?
| Data | Before (broken) | After (fixed) |
|---|---|---|
| Reader position | Session snapshot + IDB (two owners) | IDB only (single owner) |
| List scroll position | DOM only (lost on restore) | Derived from gallery ID on restore |
| Strip scroll position | closeReader only (too late) |
saveProgress continuous sync |
| Swipe transform | node (wrong element) |
$state → Svelte binding (correct element) |
The Rust analogy isn’t perfect — JavaScript has no borrow checker. But the thinking helps: for each piece of state, there should be exactly one place that’s authoritative. Everything else either derives from it or borrows it temporarily. When two stores both claim to own the same data, they will eventually disagree, and the wrong one will win at the worst possible time.
Takeaway
Performance optimizations that bypass the reactive graph (direct DOM writes, plain fields instead of $state) are powerful but shift the ownership boundary. The moment you have a plain field AND a $state field tracking the same value, you have two owners. One will go stale.
The audit started as a performance fix and turned into an architecture fix. The performance improvement (gating $state writes) was the smallest change. The real wins were clarifying ownership: IDB owns position. The DOM of hidden views is writable. Session snapshots should store identity (which gallery), not derived state (what position). Following these rules, every swipe-back, every session restore, and every strip scroll “just works” — because there’s only one source of truth for each piece of data, and it’s always fresh.