Skip to content

Conversation

@simonklee
Copy link
Contributor

Add immediate hit grid sync for scroll and translate changes

Before this change, when a scrollbox scrolled, the hit grid stayed stale until the next render completed. Hover states wouldn't update even though elements visually moved. Users would hover over items that had already scrolled away.

The fix adds on-demand hit grid sync. When translateX/Y changes, the renderer
marks the grid dirty and rechecks hover state. The renderer now:

  1. Marks the hit grid dirty
  2. Rechecks hover state using the latest pointer position
  3. Rebuilds the hit grid immediately (before the next render) if a hit test occurs while dirty

The hit grid rebuild mirrors the render traversal but uses screen coordinates for scissor rects. Buffered renderables need this because they render at (0,0) in their local buffer, but hit testing happens in screen space.

Captured renderable (during drag) is excluded from the hit grid so drop targets receive events correctly.

I added the demo from #467 as an example as well as another debug example i used when implementing it. Both should probably be deleted before merging. You can copy these examples to 'main' to see the difference between how things react in main vs the branch.

The PR is a slightly different approach than what you discussed in #470 (comment) comment, and I don't mind if you prefer your approach, but maybe you'll find some ideas here.

@simonklee
Copy link
Contributor Author

Nice that you upgraded to 15, guess I have to refactor for ci to pass :)

@kommander
Copy link
Collaborator

Oh yeah, that just happened earlier today. Finally.

Add immediate hit grid sync for scroll and translate changes

Before this change, when a scrollbox scrolled, the hit grid stayed
stale until the next render completed. Hover states wouldn't update
even though elements visually moved. Users would hover over items
that had already scrolled away.

The fix adds on-demand hit grid synchronization. When translateX or
translateY changes, the renderer now:

1. Marks the hit grid dirty
2. Rechecks hover state using the latest pointer position
3. Rebuilds the hit grid immediately (before the next render) if a
   hit test occurs while dirty

The hit grid rebuild mirrors the render traversal but uses screen
coordinates for scissor rects. Buffered renderables need this because
they render at (0,0) in their local buffer, but hit testing happens
in screen space.

Captured renderable (during drag) is excluded from the hit grid
so drop targets receive events.
@kommander
Copy link
Collaborator

Cool, this is very close to what I had in mind. The updateFromLayoutRecursive and hasDirtyLayoutRecursive methods can accumulate to be very expensive though, because calling yoga methods is ridiculously expensive. I wonder if the eager recursive updates and dirty lookups are really necessary when the hitgrid has its own scissor stack.

@simonklee
Copy link
Contributor Author

simonklee commented Jan 8, 2026

Cool, this is very close to what I had in mind. The updateFromLayoutRecursive and hasDirtyLayoutRecursive methods can accumulate to be very expensive though, because calling yoga methods is ridiculously expensive. I wonder if the eager recursive updates and dirty lookups are really necessary when the hitgrid has its own scissor stack.

Good call. I removed the recursive layout dirty check + update from syncHitGridIfNeeded. Hitgrid rebuilds now just mirror the render traversal with screen‑space scissors and use the last computed layout + current translate. Layout sync stays in the normal render pass, so hit tests remain consistent with what's on screen while avoiding Yoga calls on hover/scroll.

Left is branch, right is main:

recording.mp4

@kommander
Copy link
Collaborator

@simonklee I simplified the implementation to eliminate the need for an additional tree walk to gather the hitgrid, dirty tracking and force recheck for hover state. As far as I can see the behaviour for the hover state is still correct this way. WDYT?

@simonklee
Copy link
Contributor Author

@simonklee I simplified the implementation to eliminate the need for an additional tree walk to gather the hitgrid, dirty tracking and force recheck for hover state. As far as I can see the behaviour for the hover state is still correct this way. WDYT?

Thanks for simplifying, but it also changes semantics. With the new code, hit‑grid updates only after a render and hover only updates on pointer move (translateX/Y no longer rechecks). So if the pointer stays still after a scroll, hover remains stale until the user moves the mouse. I'll record a quick video.

@simonklee
Copy link
Contributor Author

semantics.mp4

See how the hovered doesnt update in the first example while scrolling and not moving mouse.

@kommander
Copy link
Collaborator

I see. Let me try reproducing that in a test. It should be possible to conditionally recheck after a frame was rendered to solve that.

@simonklee
Copy link
Contributor Author

A fun test: if you try this in "Chrome" you'll notice that if you scroll the "hover" doesn't detect things under the cursor until after you stop scrolling and ~500ms have passed. I just randomly thought I'd try. Chrome scrolls on the compositor thread and throttles pointer/hover work, so :hover doesn't update until scrolling settles.

I think:

  • It's reasonable to coalesce hover updates during scroll and only recheck once scrolling stops (debounce or "scroll‑idle" timer).
  • But we should still make sure hover eventually updates.

If we do something similar to Chrome then probably best to implement that as an improvement in a new branch later. With your latest changes the examples/scrollbox-overlay-hit-test.ts still works so that's good. I think you know the library 10x better than me and you're better to judge what to do.

@kommander
Copy link
Collaborator

kommander commented Jan 8, 2026

Yeah, let me merge this and do the hover state update in another PR. It should be possible to reproduce this in a test.

I would not want the renderer to know anything about scroll state, as that could be nested and whatnot, making it complex again. It should be enough to debounce the hover event when a change was detected after a frame.

Do you want to take that on?

EDIT: simple debounce for hover event would also work for anything that is animated and moving etc. so not scroll specific.

@simonklee
Copy link
Contributor Author

Yeah, let me merge this and do the hover state update in another PR. It should be possible to reproduce this in a test.

Agree.

I would not want the renderer to know anything about scroll state, as that could be nested and whatnot, making it complex again. It should be enough to debounce the hover event when a change was detected after a frame.
Sounds fair.
Do you want to take that on?

Yeh, that sounds fun. I'll have a look tonight (CET).

@kommander kommander merged commit 4b241ad into anomalyco:main Jan 8, 2026
4 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.

2 participants