Keep scroll from jumping on edit #69
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
gtfs.zone/coloring-book#69
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Every time a patch is recorded,
patchManager.on('change', ...)insrc/index.ts:192callsbrowseNavigation.refresh(), which callsrender(), which doesthis.container.innerHTML = .... Replacing the entire container HTML destroys the.contentdiv (which carries theoverflow-y-autoscroll state), resetting itsscrollTopto 0. The same happens on undo/redo viarefreshAfterUndoRedoatsrc/index.ts:175.The fix is minimal: in
render(), save.content'sscrollTopbefore rebuilding the HTML, then restore it after — but only when the page state hasn't changed (i.e., this is an in-place refresh, not user navigation to a new entity). Navigation should naturally reset scroll to 0.Relevant Context
src/modules/browse-navigation.ts:300—render()is the only placethis.container.innerHTMLis assigned (excluding the loading/error state helpers). This is the only file that needs to change.src/types/page-state.ts—PageStateis a small union of plain objects.JSON.stringifycomparison is safe for equality checking.src/index.ts:188-199—patchManager.on('change', ...)is the primary trigger for spurious re-renders.src/index.ts:168-186covers undo/redo; the fix handles both since they all flow throughrender()..content(the<div class="content flex-1 overflow-y-auto">rendered insiderender()). There are no other independently-scrolling containers within it that need preservation — they're wiped out with the innerHTML reset anyway.Phase 1: Scroll Preservation in
render()Single file change:
src/modules/browse-navigation.ts.Add
private lastRenderedPageState: PageState | null = nullto theBrowseNavigationclass (near the other private fields around line 87).In
render()(line 300), before fetching page state:const contentDiv = this.container.querySelector<HTMLElement>('.content'); const savedScrollTop = contentDiv?.scrollTop ?? 0;After computing
pageState(before building the HTML), determine if this is a same-page re-render:After
this.container.innerHTML = ..., updatelastRenderedPageStateand restore scroll if same page:Place the restore after
attachEventListeners()so event wiring doesn't interfere.Gotchas:
render()is async. Concurrent calls (rapid edits) both readsavedScrollTopfrom the same live DOM element before either has written new HTML — this is fine; both will restore to the same position and the last write wins.renderLoadingState()andrenderErrorState()also replacecontainer.innerHTML. These are called only in edge cases (loading/error) where scroll position doesn't matter; leave them unchanged.lastRenderedPageStateisnull, soisSamePageisfalseand scroll starts at 0 — correct.isSamePageisfalse, scroll resets to 0 — correct.Phase 2: Scroll Preservation in
refreshCurrentTimetable()Single file change:
src/modules/schedule-controller.ts.The timetable has a completely separate re-render path from the browse panel. Timetable cell edits call
refreshCurrentTimetable()(line 930), which doescontainer.innerHTML = htmlon#schedule-view(line 951). This wipes out the innerdiv.flex-1.overflow-x-autothat holds horizontal scroll state. The browse-navigation fix does not apply here at all.The scroll container to preserve is the
div.flex-1.overflow-x-autorendered inside#schedule-viewbytimetable-renderer.ts:158. There is no meaningful vertical scroll within#schedule-viewitself — the relevant scroll is horizontal (scrollLeft) so columns don't jump back to the left edge after an edit.In
refreshCurrentTimetable()(line 948), before assigningcontainer.innerHTML, capture the horizontal scroll position of the inner scroll div:After
container.innerHTML = html, restore the scroll position:Gotchas:
.overflow-x-automatches the one direct child scroll div reliably;timetable-renderer.tsrenders only one such element inside#schedule-view.#schedule-view; that container is not rebuilt here, so its scroll is naturally preserved without any extra work.refreshCurrentTimetable()is also the method called when switching direction tabs — in that case, resetting horizontal scroll to 0 is correct behavior. But since the same timetable is being re-rendered with the same route/service/direction, a blanket restore is fine: if the user just switched directions,savedScrollLeftwill be 0 already (the new direction tab starts at 0).Phase 3: Preserve Timetable Horizontal Scroll in
browseNavigation.render()Single file change:
src/modules/browse-navigation.ts.The root cause of the timetable horizontal scroll jump is that
browseNavigation.render()(triggered bypatchManager.on('change')) runs a full re-render of the timetable viacontentRenderer.renderPage()afterrefreshCurrentTimetable()has already restored the scroll. The browse-nav re-render replacesthis.container.innerHTMLentirely, recreating#schedule-viewfrom scratch withscrollLeft=0. Phase 2's fix is already undone by the time the user sees the result.The fix is the same pattern as phase 1 — in
render(), before replacingthis.container.innerHTML, also save thescrollLeftof any.overflow-x-autoinside the container, then restore it after (only on same-page re-renders).In
render()(browse-navigation.ts), after capturingsavedScrollTop, also capture horizontal scroll:After restoring
newContent.scrollTop, also restorescrollLeftif same page:Gotchas:
.overflow-x-autoselector matches only the timetable's scroll div (the only such element inside the browse-navigation container during timetable view). No collision risk.savedScrollLeft > 0guard avoids a needless querySelector on every non-timetable re-render.refreshCurrentTimetable()is now redundant for scroll preservation (browse-nav handles it), but leave it in place — it also runs in cases where no patch is recorded (e.g. adding a pending stop) and the direct re-render is still valuable.Phase 4: Debug and Fix Remaining Timetable Scroll Issue
Phases 2 and 3 were committed but the timetable horizontal scroll still resets on edit. All the logic in
render()looks correct on paper —savedScrollLeftshould be captured as positive,isSamePageshould be true, and the new.overflow-x-autoshould be findable after the rebuild. The root cause can only be determined from runtime logs. This phase adds targeted debug logging first, then fixes the root cause based on what the logs reveal.console.logstatements inrender()(browse-navigation.ts) at: capture time (scrollXDiv found?, savedScrollLeft value, isSamePage), and restore time (newScrollXDiv found?, scrollLeft after set).console.logstatements inrefreshCurrentTimetable()(schedule-controller.ts) at: savedScrollLeft capture, and after restore.Root cause discovered: The DOM
scrollLeftvalue was always 0 at the timerender()captured it, because the browser resets scroll position during asyncawaitcalls. The fix was to install a capture-phasescrollevent listener ondocumentinScheduleControllerthat synchronously recordsscrollLeft/scrollTopintotimetableScrollLeft/timetableScrollTopinstance fields. Bothrender()andrefreshCurrentTimetable()read from these fields instead of the DOM. On navigation to a new page,resetTimetableScroll()zeroes the fields so the new timetable opens at the top-left.Debug logs were removed after the fix was confirmed working.
Original Issue
When we make an edit, the browse panel refreshes and if you have scrolled down, the scroll jumps back to the top. Not critical, but very annoying.