Stop the autoscroll throwing the reader to the end when a page loads above

Prepending older actions changes `actions`, and the bottom-pinning effect
watches `actions`. If nothing had un-pinned the view first, loading earlier
turns scrolled straight past them to the newest one -- the reader asks to go
back and the story throws them forward.

Scrolling up is what normally un-pins, so the case that breaks is the one
where the reader never scrolled: a window short enough to fit the viewport,
where the "N earlier moments" button is the only way back and the button is
what fails. Reading backwards is the opposite of following along, so a prepend
now clears the pin explicitly rather than relying on a scroll having happened.

Found by re-reading this path rather than by running it -- the scroll
behaviour is the one part of the paging work nothing exercises, since the
frontend has no test runner.

Also holds off three seconds after a failed page fetch. Parked near the top,
momentum scrolling calls this dozens of times a second, and against an
endpoint that is failing that is a retry storm rather than a retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
This commit is contained in:
parththakkar106
2026-08-17 15:22:46 +05:30
co-authored by Claude Opus 5
parent 0eef367f30
commit 39296d1b44
+15 -1
View File
@@ -1324,6 +1324,9 @@ export default function Play() {
// the reader back where they were. See the layout effect below. // the reader back where they were. See the layout effect below.
const restoreScrollRef = useRef(null) const restoreScrollRef = useRef(null)
const loadingOlderRef = useRef(false) const loadingOlderRef = useRef(false)
// Earliest time another attempt is allowed after a failure. See the catch
// in loadOlder.
const retryAfterRef = useRef(0)
// The drop cap belongs to the story's first narrated beat. `start` is the // The drop cap belongs to the story's first narrated beat. `start` is the
// scenario's opening prompt, so it's usually that; an adventure begun blank // scenario's opening prompt, so it's usually that; an adventure begun blank
@@ -1367,6 +1370,7 @@ export default function Play() {
// and two in-flight requests would fetch the same page twice. // and two in-flight requests would fetch the same page twice.
const loadOlder = useCallback(async () => { const loadOlder = useCallback(async () => {
if (loadingOlderRef.current || !hasMore) return if (loadingOlderRef.current || !hasMore) return
if (Date.now() < retryAfterRef.current) return
const oldest = actions[0] const oldest = actions[0]
if (!oldest) return if (!oldest) return
loadingOlderRef.current = true loadingOlderRef.current = true
@@ -1374,6 +1378,12 @@ export default function Play() {
try { try {
const page = await api.getActions(id, { beforeId: oldest.id }) const page = await api.getActions(id, { beforeId: oldest.id })
if (page.actions.length) { if (page.actions.length) {
// Reading backwards is the opposite of following along, so stop
// autoscrolling. Without this the bottom-pinning effect below fires on
// the same `actions` change and throws the reader to the end of the
// story — worst exactly where the button matters, on a window short
// enough that it never scrolled and so never un-pinned itself.
pinnedRef.current = false
// Record the height before the prepend; the layout effect below uses // Record the height before the prepend; the layout effect below uses
// it to keep the reader looking at the same paragraph. // it to keep the reader looking at the same paragraph.
restoreScrollRef.current = { restoreScrollRef.current = {
@@ -1391,7 +1401,11 @@ export default function Play() {
setHasMore(page.has_more) setHasMore(page.has_more)
} catch { } catch {
// Leave hasMore alone: a failed fetch should let the reader try again // Leave hasMore alone: a failed fetch should let the reader try again
// by scrolling, not permanently hide the rest of their story. // by scrolling, not permanently hide the rest of their story. But hold
// off briefly first — parked near the top, momentum scrolling fires this
// dozens of times a second, and against an endpoint that is failing that
// is a retry storm rather than a retry.
retryAfterRef.current = Date.now() + 3000
} finally { } finally {
loadingOlderRef.current = false loadingOlderRef.current = false
setLoadingOlder(false) setLoadingOlder(false)