1 Commits
Author SHA1 Message Date
john-okeefe 6aa958c78f fix(reader): save progress on real position change, not on detected intent
Release / build-and-push (push) Successful in 2m27s
The userMoved gate from ce3ae31 broke progress saving entirely for
EPUBs. The flag was set only in the app's navigation wrappers
(goLeft/goRight, keys, slider, search/TOC/bookmark/back-stack jumps),
but foliate-js's paginator handles the most common reading gestures
itself — touch-swipe paging, scrolled-mode reading, in-content links,
selection auto-advance — dispatching relocate directly without ever
calling those wrappers. Every one of those relocations hit the
"if (!this.userMoved) return" guard, so the position never saved at
all on EPUB; only tap-zone-paged formats (comics/fixed layout) kept
saving, which matched the intermittent reports.

Detecting intent was the wrong tool: the set of foliate-internal
navigation paths is open-ended and lives in a forked dependency.
Compare the position itself instead:

- The relocate handler records the latest CFI (lastCfi), and the
  baseline (lastSyncedCfi/lastSyncedFraction) is captured right after
  view.init() resolves — i.e. the restored position, or the start of
  the book on a fresh open.
- debouncedSaveProgress saves only when the position actually moved:
  CFI comparison for reflowable books, fraction comparison (1e-4
  epsilon) for CFI-less fixed layout and PDF.
- The baseline updates after each successful save, and a
  bfcache-resurrected page re-baselines to its frozen position, so the
  anti-clobber property survives: a displayed/restored position can
  still never overwrite a newer device push.

The twelve userMoved assignments in the wrapper methods are gone —
change detection covers deliberate jumps and internal gestures alike.
Backend untouched; SaveProgress was never the problem.
2026-09-11 21:50:03 -04:00
+38 -26
View File
@@ -482,11 +482,14 @@ document.addEventListener("alpine:init", () => {
tocItems: [] as any[], tocItems: [] as any[],
mediaItemId: "" as string, mediaItemId: "" as string,
saveTimeout: null as ReturnType<typeof setTimeout> | null, saveTimeout: null as ReturnType<typeof setTimeout> | null,
// Set only by deliberate navigation (page turns, jumps, slider). The // Position last known to be stored (the restore at open time, or the
// restore at open time and section-load relocations never set it, so // last successful save). Relocations that don't move from it are never
// progress saves can only ever write a position the user actually // written back, so a restored position can't clobber a newer device
// moved to — never a stale restore clobbering a newer device push. // push — while swipe/scroll paging (handled inside foliate, with no
userMoved: false as boolean, // wrapper method to flag) still saves normally.
lastSyncedCfi: "" as string,
lastSyncedFraction: -1 as number,
lastCfi: "" as string,
contextText: "" as string, contextText: "" as string,
readingTheme: "light" as string, readingTheme: "light" as string,
readingMode: "light" as string, readingMode: "light" as string,
@@ -883,6 +886,7 @@ document.addEventListener("alpine:init", () => {
const { fraction, location, pageItem, cfi, tocItem, section } = const { fraction, location, pageItem, cfi, tocItem, section } =
e.detail; e.detail;
this.hideSelectionPopover(); this.hideSelectionPopover();
this.lastCfi = cfi || "";
this.lastRelocateDetail = { this.lastRelocateDetail = {
fraction, fraction,
location, location,
@@ -944,6 +948,11 @@ document.addEventListener("alpine:init", () => {
} else { } else {
await this.view.init({}) await this.view.init({})
} }
// The position restored above (or the start of the book on a fresh
// open) is the baseline: only relocations that actually move from
// it may write progress.
this.lastSyncedCfi = this.lastCfi;
this.lastSyncedFraction = this.lastRelocateDetail?.fraction ?? -1;
// The renderer only knows it's a PDF once frames exist (they carry // The renderer only knows it's a PDF once frames exist (they carry
// pdf.js onZoom), i.e. after init has rendered the first spread. // pdf.js onZoom), i.e. after init has rendered the first spread.
// Read it now and apply the saved pointer mode — this also makes the // Read it now and apply the saved pointer mode — this also makes the
@@ -953,10 +962,15 @@ document.addEventListener("alpine:init", () => {
this.renderer.setAttribute("interaction-mode", this.interactionMode); this.renderer.setAttribute("interaction-mode", this.interactionMode);
} }
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null; this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
// A bfcache-resurrected page is stale by definition: forbid it from // A bfcache-resurrected page is stale by definition: re-baseline to
// writing its frozen position back until the user navigates again. // its frozen position so it can't write that back until the user
// actually navigates again.
window.addEventListener("pageshow", (e: PageTransitionEvent) => { window.addEventListener("pageshow", (e: PageTransitionEvent) => {
if (e.persisted) this.userMoved = false; if (e.persisted) {
this.lastSyncedCfi = this.lastCfi;
this.lastSyncedFraction =
this.lastRelocateDetail?.fraction ?? -1;
}
}); });
this.fetchReadingSpeed(); this.fetchReadingSpeed();
this.refreshAnnotations(); this.refreshAnnotations();
@@ -1557,9 +1571,9 @@ document.addEventListener("alpine:init", () => {
} }
}, },
// Fresh reading position from the database — the single source of // Fresh reading position from the database — the single source of
// truth at open time. Fails soft to a fresh start: the userMoved gate // truth at open time. Fails soft to a fresh start: change detection
// guarantees merely opening (even at the wrong spot) can never // against the restored baseline guarantees merely opening (even at
// overwrite the stored position. // the wrong spot) can never overwrite the stored position.
async fetchSavedLocation(): Promise<{ async fetchSavedLocation(): Promise<{
cfi?: string; cfi?: string;
page?: number; page?: number;
@@ -1591,9 +1605,17 @@ document.addEventListener("alpine:init", () => {
} }
}, },
debouncedSaveProgress(fraction: number, location: any, cfi: string) { debouncedSaveProgress(fraction: number, location: any, cfi: string) {
// Only deliberate navigation writes progress: displaying a restored // Only an actual change from the last stored position writes
// position must never overwrite a newer device push. // progress: displaying a restored position must never overwrite a
if (!this.userMoved) return; // newer device push. Swipes and scrolls are handled inside foliate
// with no wrapper method to flag, so position — not intent — is the
// signal. Books without CFIs (fixed layout, PDF) compare fraction.
const currentCfi = cfi || "";
const changed =
currentCfi || this.lastSyncedCfi
? currentCfi !== this.lastSyncedCfi
: Math.abs(fraction - this.lastSyncedFraction) > 1e-4;
if (!changed) return;
if (this.saveTimeout) clearTimeout(this.saveTimeout); if (this.saveTimeout) clearTimeout(this.saveTimeout);
this.saveTimeout = setTimeout(() => { this.saveTimeout = setTimeout(() => {
this.saveProgress(fraction, location, cfi); this.saveProgress(fraction, location, cfi);
@@ -1644,6 +1666,8 @@ document.addEventListener("alpine:init", () => {
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
this.lastSyncedCfi = cfi || "";
this.lastSyncedFraction = fraction;
} catch (_e) { } catch (_e) {
// silent fail — progress save is non-critical // silent fail — progress save is non-critical
} }
@@ -1734,23 +1758,18 @@ document.addEventListener("alpine:init", () => {
saveSettings({ double_page_spread: this.doublePageSpread }); saveSettings({ double_page_spread: this.doublePageSpread });
}, },
goLeft() { goLeft() {
this.userMoved = true;
this.view?.goLeft?.(); this.view?.goLeft?.();
}, },
goRight() { goRight() {
this.userMoved = true;
this.view?.goRight?.(); this.view?.goRight?.();
}, },
nextPage() { nextPage() {
this.userMoved = true;
this.view?.next?.(); this.view?.next?.();
}, },
previousPage() { previousPage() {
this.userMoved = true;
this.view?.prev?.(); this.view?.prev?.();
}, },
goToFraction(value: string) { goToFraction(value: string) {
this.userMoved = true;
this.view?.goToFraction?.(parseFloat(value)); this.view?.goToFraction?.(parseFloat(value));
}, },
toggleTOC() { toggleTOC() {
@@ -1929,11 +1948,9 @@ document.addEventListener("alpine:init", () => {
}, },
goToSearchResult(item: { cfi?: string; page?: number | null }) { goToSearchResult(item: { cfi?: string; page?: number | null }) {
if (item.cfi) { if (item.cfi) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view?.goTo?.(item.cfi); this.view?.goTo?.(item.cfi);
} else if (item.page != null) { } else if (item.page != null) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view?.goTo?.(item.page); this.view?.goTo?.(item.page);
} else return; } else return;
@@ -1962,7 +1979,6 @@ document.addEventListener("alpine:init", () => {
goBackToLocation() { goBackToLocation() {
const loc = this.backStack.pop(); const loc = this.backStack.pop();
if (!loc) return; if (!loc) return;
this.userMoved = true;
if (loc.cfi) this.view?.goTo?.(loc.cfi); if (loc.cfi) this.view?.goTo?.(loc.cfi);
else if (typeof loc.page === "number") this.view?.goTo?.(loc.page); else if (typeof loc.page === "number") this.view?.goTo?.(loc.page);
}, },
@@ -1978,7 +1994,6 @@ document.addEventListener("alpine:init", () => {
}, },
goToTOCItem(item: any) { goToTOCItem(item: any) {
if (this.view && item.href) { if (this.view && item.href) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view.goTo(item.href); this.view.goTo(item.href);
this.tocOpen = false; this.tocOpen = false;
@@ -2106,7 +2121,6 @@ document.addEventListener("alpine:init", () => {
}, },
goToPage(index: number) { goToPage(index: number) {
if (!this.view || typeof index !== "number" || index < 0) return; if (!this.view || typeof index !== "number" || index < 0) return;
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view.goTo(index); this.view.goTo(index);
this.tocOpen = false; this.tocOpen = false;
@@ -2114,11 +2128,9 @@ document.addEventListener("alpine:init", () => {
goToBookmark(item: { cfi: string; page: number | null }) { goToBookmark(item: { cfi: string; page: number | null }) {
if (!this.view) return; if (!this.view) return;
if (item.cfi) { if (item.cfi) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view.goTo(item.cfi); this.view.goTo(item.cfi);
} else if (item.page != null && item.page > 0) { } else if (item.page != null && item.page > 0) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
// Fixed-layout/comic: sections are pages; foliate takes an index. // Fixed-layout/comic: sections are pages; foliate takes an index.
this.view.goTo(item.page - 1); this.view.goTo(item.page - 1);