Skip to content

Back/forward scroll restore: drop the pre-swap popstate listener that corrupted saved offsets#212

Merged
joestump merged 1 commit into
mainfrom
feature-190-scroll-restore
Jul 7, 2026
Merged

Back/forward scroll restore: drop the pre-swap popstate listener that corrupted saved offsets#212
joestump merged 1 commit into
mainfrom
feature-190-scroll-restore

Conversation

@joestump

@joestump joestump commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Part of #190.

Fixes the back/forward reading-position corruption introduced with the #197 scroll-restore mechanism (shell.js).

Root cause

restoreScroll was registered on popstate in addition to htmx:historyRestore. shell.js loads before htmx.min.js (both defer, template order in partials.html), so its popstate listener fires before htmx's own — while the departing page is still mounted in #main-content but location has already flipped to the destination. It therefore scrolled the departing page to the destination's saved offset (clamped to the departing page's max). htmx's popstate handler then fired htmx:beforeHistorySave for the departing page, and saveScroll persisted that corrupted value under the departing page's key.

Net effect, deterministically: scroll /c/N to 400, boosted-nav to /media, then Back → /media's key corrupts 0→24; Forward → /media lands at 24 and /c/N's key clobbers 400→24; Back again → reading position gone.

Fix

Drop the popstate binding; htmx:historyRestore is the only restore trigger. The popstate listener covered nothing: the vendored htmx 2.0.4 fires htmx:historyRestore after the swap on both history-cache paths — cache hit and server-refetch miss ({cacheMiss: true}) — and on popstates htmx ignores (non-htmx state entries) no swap happens, so touching the still-mounted page's scroller is exactly the corruption above. The block comment now documents why popstate must never restore.

TestShellScrollRestoreBindings pins the wiring statically: restore bound to htmx:historyRestore only, exactly one popstate listener (the idempotent tab-state sync), and saveScroll keyed by detail.path.

Verification (headless Chrome CDP, fixture archive, before → after)

5-step repro — scroll /c/2 to 400, boosted nav to /media, then traverse history:

Step main (ccda63f) this branch
Back → /c/2 400 ✅ but /media key 0→24 ❌ 400 ✅, /media key stays 0 ✅
Forward → /media lands 24 ❌, /c/2 key 400→24 ❌ lands 0 ✅, /c/2 key stays 400 ✅
Back → /c/2 lands 24 — position lost ❌ 400 ✅

Cache-miss coverage: with htmx-history-cache forcibly cleared on /media, Back refetched /c/2 from the server and htmx:historyRestore fired with cacheMiss: true — scroller restored to 400, so the dropped listener loses no coverage on the no-snapshot path.

Hygiene: rm -rf .tools && make css leaves the committed app.css byte-identical (drift guard green); full go test ./... passes.

🤖 Generated with Claude Code

🤖 Posted on behalf of @joestump by Claude.

…ved offsets

shell.js registered restoreScroll on popstate as well as htmx:historyRestore.
Because shell.js loads before htmx.min.js (both defer, template order), its
popstate listener fires before htmx's — while the DEPARTING page is still
mounted in #main-content but location already names the destination. That
scrolled the departing page to the destination's saved offset (clamped to the
departing page's max), which htmx:beforeHistorySave then persisted under the
departing page's key: every back/forward traversal corrupted both entries,
and a back → forward → back round trip lost the reading position entirely.

htmx 2.0.4 fires htmx:historyRestore after the swap on BOTH history-cache
paths — hit and server-refetch miss — so the popstate listener covered
nothing. Drop it; keep the single htmx:historyRestore binding.

Verified in headless Chrome (CDP, fixture archive): scroll /c/2 to 400,
boosted nav to /media, back/forward/back — /c/2 restores 400 both times,
/media restores 0, keys stay clean; a forced htmx history-cache miss
(cacheMiss:true) also restores 400. TestShellScrollRestoreBindings pins the
event wiring statically.

Part of #190.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@joestump joestump merged commit acb2183 into main Jul 7, 2026
2 checks passed
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.

1 participant