Skip to content

Keep the Gantt tree panel and timeline in lock-step while scrolling - #81

Merged
vincentmakes merged 1 commit into
mainfrom
fix/gantt-scroll-sync
Oct 17, 2026
Merged

vincentmakes merged 1 commit into
mainfrom
fix/gantt-scroll-sync

Conversation

@vincentmakes

Copy link
Copy Markdown
Owner

Why

Two things you could see in the Gantt (and the Staff, Equipment, Cross-site and Archived views, which share the mechanism): the left tree panel trailed the timeline by a frame and stuttered during fast scrolls, and the last rows misaligned by up to 8 px at the bottom.

Both come from useScrollSync, which keeps the two separate vertical scroll containers together by mirroring scrollTop on scroll events.

  • The lag and stutter. The follower's own scroll event arrives a frame later, after the rAF isSyncing guard has reset, so it copied its older position back onto the pane the user was scrolling.
  • The bottom. The left panes carried a fixed padding-bottom: 8px "to match the horizontal scrollbar". That only holds for classic scrollbars; with overlay scrollbars (macOS) and scrollbar-gutter: stable the scrollbar takes 0 px, so the left pane scrolled 8 px further than the right.

Release 1.10.1.

What changed

useScrollSync keeps its (ref1, ref2) signature; none of the five callers change. It now has three behaviours:

  • No echo. Every value the hook writes is remembered; the follower's next scroll event within 1 px of it is consumed, anything else is a real scroll and is mirrored. It only writes when the panes differ by ≥ 1 px.
  • Wheel in the same frame. A non-passive wheel listener on both panes moves both scrollTops synchronously and prevents the native scroll, so the follower never trails. Ctrl+wheel (the zoom gesture), Shift+wheel, any event with a horizontal component and zero vertical delta are left to the browser, so zoom and sideways trackpad panning keep working; at the end of the range the event is not cancelled so the page can scroll. Line and page delta modes are normalised.
  • Equal scroll range. The hook measures each pane's natural scrollHeight - clientHeight (excluding the padding it applied itself) and pads the shorter pane by the difference, on mount and one frame after a ResizeObserver notification on either pane or its content (window resize fallback where ResizeObserver is missing). The fixed 8 px paddings are removed from the five stylesheets.

Docs: the hook row in CLAUDE.md, and a UI_GUIDELINES §3.9 rule that no pane gets a fixed scrollbar-height padding.

Tests

  • useScrollSync.test.ts rewritten (21 cases): mirroring both ways; echo ignored (incl. a fractional one), a real scroll while an echo is pending still propagates, nothing written under 1 px; wheel moves both panes from either side, clamps at both ends, no preventDefault at the ends, Ctrl / Shift / horizontal / diagonal / zero-delta events ignored, line and page modes; padding 600 vs 592 → 8px / 0px and stable on re-measure, flips to the other pane, returns to 0, merged callbacks, panes without a content child, the window resize fallback, panes not laid out; unmount removes listeners, observer, pending frame and paddings.
  • Frontend: 200 files / 2170 tests pass; the hook is at 100 % line coverage and changed-line coverage is 100 %. tsc, eslint (0 warnings on the changed files) and npm run build are clean.
  • trail check flags one keyword match (vi.stubGlobal against the window.location learning); nothing a learning ruled out is reintroduced.

Visible changes to check

  • Wheel and trackpad over the tree panel and over the timeline: both move together, no trailing or stutter during a fast scroll.
  • Ctrl+wheel still zooms; a sideways trackpad gesture still pans the timeline.
  • Drag the vertical scrollbar and use the keyboard: the other pane mirrors.
  • Scroll to the very bottom: the last rows line up, on macOS (overlay scrollbars) and on Windows (classic).
  • Same checks in the Staff and Equipment views.

https://claude.ai/code/session_01FPFQEucReHG1Wo9R6PpEpj


Generated by Claude Code

The two panes are separate vertical scroll containers mirrored by
useScrollSync. The follower lagged a frame and stuttered during fast
scrolls: its own scroll event arrived after the rAF guard had reset and
copied its older position back onto the pane being scrolled. The hook now
remembers each value it writes and ignores the follower's matching scroll
event, and a non-passive wheel listener moves both panes in the same frame
(Ctrl+wheel zoom, Shift+wheel and horizontal trackpad gestures are left to
the browser).

The last rows also misaligned by up to 8 px: the left panes carried a
fixed 8 px bottom padding "to match the horizontal scrollbar", which is 0 px
tall with overlay scrollbars. The hook now measures both panes' natural
scroll range and pads the shorter one, re-measured one frame after a
ResizeObserver notification; the fixed paddings are gone from the five
stylesheets.

trail check flags one keyword match (vi.stubGlobal against the
window.location learning); nothing ruled out is reintroduced.

Release 1.10.1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPFQEucReHG1Wo9R6PpEpj
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying milestone-planner with  Cloudflare Pages  Cloudflare Pages

Latest commit: cf82570
Status: ✅  Deploy successful!
Preview URL: https://92423a89.milestone-planner.pages.dev
Branch Preview URL: https://fix-gantt-scroll-sync.milestone-planner.pages.dev

View logs

@vincentmakes
vincentmakes merged commit d0dd979 into main Oct 17, 2026
21 checks passed
@vincentmakes
vincentmakes deleted the fix/gantt-scroll-sync branch October 17, 2026 19:18
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.

2 participants