Skip to content

fix(explorer): the sweep box keeps up with the pointer (#332) - #333

Merged
Maxaubert merged 5 commits into
mainfrom
fix/332-sweep-box-keeps-up
Oct 9, 2026
Merged

Maxaubert merged 5 commits into
mainfrom
fix/332-sweep-box-keeps-up

Conversation

@Maxaubert

Copy link
Copy Markdown
Owner

What the owner said

the drag select box that appears on left click drag to select multiple items, it is a bit slow, fast movements make it fall behind the cursor, while it should stay at the cursor position perfectly the whole time, file explorers highlight does it perfectly. it is more apparent the faster you move your mouse

Cause

The sweep box was React state. Every pointermove set it, and React rendered the list after the frame had been painted, so each frame showed the box where the pointer had been one move before. While the list auto-scrolled, the box was placed from the rendered scrollTop, so every scroll frame was off as well.

Fix

  • useSweep only mounts the band (sweeping, bandRef). The pointer handler writes its place straight onto the element, from geometry measured at the press, at the sweep's start, after a scroll or a resize (ResizeObserver, window resize) and at the release. Nothing reads layout per move.
  • What the box marks is worked out at most once a frame in the tick, and again on the release, so the marks that stand are exactly the box's.
  • The band mounts through flushSync in the move that starts the sweep. A plain state update from a native listener could paint that first frame with no box.
  • Explorer list and project tree both use it. The look of the band is unchanged.

Before and after (sweepLag e2e, 300 rows, CDP moves of 25-40 px)

Before After
Fast sweep, Explorer and tree every move's first frame a move behind, up to 54 px (mean 23 px) 0 px in every frame
Auto-scroll, Explorer every frame off, up to 430 px 0 px
Auto-scroll, tree every frame off, up to 203 px 0 px
Old code overall 59 of 119 frames off 0

Handler cost after: mean 0.07-0.08 ms, max 1.8 ms per pointermove.

Tests

  • New sweepLag e2e: it samples the box against the pointer in every painted frame through a fast walk, a burst of four moves a frame, and an auto-scroll hold, in the tree and in the Explorer, and asserts 0 frames more than 1 px off. A frame past the threshold with no box showing counts as late. Without the flushSync mount that check fails 1 of 120 in both lists. It also checks that a burst released at once leaves the same marks as a slow sweep.
  • marquee, marqueeQuiet, marqueeEdge, drag, dragLabel e2e pass.
  • typecheck, lint (no errors), unit suite 2656 passed.
  • Review fixes in the second commit: measure again at the sweep's start and at the release, watch for resizes mid-sweep, mount the band synchronously, and a comment on the compressed-scroll (180k+ rows) frame where box and rows can disagree.

Version 0.99.1 (patch).

Closes #332

🤖 Generated with Claude Code

https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t

Maxaubert and others added 5 commits October 8, 2026 13:13
Owner, 2026-10-08: "fast movements make it fall behind the cursor, while
it should stay at the cursor position perfectly the whole time, file
explorer's highlight does it perfectly".

The box was React state set on every pointermove, so the list (and the
tree) rendered it after each frame was painted. useSweep now only mounts
the band (sweeping, bandRef); the pointer handler writes its place straight
onto the element from geometry measured at the press and after a scroll,
with no layout read per move. Hit-testing and onChange run at most once a
frame in the tick, from the latest position, and once more on the release,
so the marks that stand are the box's.

Measured by the new sweepLag e2e (300 rows, CDP moves of 25-40 px, one and
four per frame, then an auto-scroll hold), moving corner against the pointer
in the frame about to be painted:
- before: list and tree, every move one event behind (max 53.8 px, mean
  about 23 px); auto-scroll every frame off (max 430 px list, 203 px tree)
- after: 0 px in every frame, list and tree, auto-scroll included

Patch bump to 0.99.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
…frame (#332)

Review of #332:
- measure again when the sweep starts (a wheel between the press and the
  threshold move left the box off by that scroll) and at the release,
  before the last marks;
- a ResizeObserver on the scroller and its contents, and window resize,
  so rows added or removed and pane resizes are seen mid-sweep;
- the band mounts through flushSync in the move that starts the sweep:
  a state update from a native listener painted that frame with no box
  (MEASURED: sweepLag now counts such a frame as late and failed 1 of 120
  in both lists without the fix);
- a comment on the compressed-scroll frame of disagreement in BrowseList.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
The box trailed unevenly: renders of the list mid-sweep, a scroll
round-trip through App that undid newer scrolls, a box inside the
scroller that re-rastered the rows, and fractional edges. As Explorer's
UIMarqueeSelector does it:

- marks during a sweep are the hook's (data-sweep-mark on the rows in
  view, drawn by the stylesheets), committed to React once on release
- the Explorer's list draws from its own scroll and tells App on
  scrollend, at a release and before a press or key
- the box is a clipped overlay beside the scroller, anchored in list
  coordinates, snapped to whole device pixels, one device pixel of edge
- one position source per pointermove, no per-frame layout read

sweepLag now asserts device-pixel edges, a still anchor and no list
render while the box moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
…helpers (#332)

A wheel turn after Escape, with the button still held, came to rest while
the list carried data-sweeping, so scrollend told App nothing and the place
waited for the next press or key. bandBox, Band and sameHits had no callers
left after the box moved out of React state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
…ged nothing visible (#332)

The owner, zoomed in, saw no difference with the DOM marks, the scroll
round-trip fix and the pixel-snapped overlay, so the smaller code stays.
CLAUDE.md records what was tried and that the rest of the trail is Chromium's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
@Maxaubert
Maxaubert merged commit be41e47 into main Oct 9, 2026
3 checks passed
@Maxaubert
Maxaubert deleted the fix/332-sweep-box-keeps-up branch October 9, 2026 16:00
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.

fix(explorer): the sweep box keeps up with the pointer

1 participant