scrollend event does not fire for each scroll
Categories
(Core :: Panning and Zooming, defect, P2)
Tracking
()
| Tracking | Status | |
|---|---|---|
| firefox158 | --- | fixed |
People
(Reporter: nige.animal, Assigned: emilio)
References
(Blocks 1 open bug, )
Details
Attachments
(1 file, 5 external files, 1 obsolete file)
|
2.42 KB,
text/html
|
Details |
If we set scrollTop twice, both scroll and scrollend should fire twice.
Or, if "optimized", the scrollend could fire once AFTER both scrolls have been completed (See example running on Safari).
In any case, a scrollend should always fire AFTER all scrolls within the same animation frame have been completed.
Run the attached file.
It performs two scrolls.
On scroll it adds a class to the scrolling element.
On scrollend, it removes that class.
On all other browsers this works, and the element ends up with the "scrolling" class removed.
On Firefox, the scrolling class is stuck. I believe this is a common pattern, to flag an element as scrolling using a CSS class.
Chrome seems to have the most logical behaviour.
This pattern works with the legacy delayed synthesized scrollend event. This issue is preventing us (probably others) from moving on to using the native scrollend event.
Updated•8 months ago
|
| Reporter | ||
Comment 1•8 months ago
|
||
On second thoughts, I think Safari makes more sense. Multiple scroll commands are processed by the element, but finally, only one scrollend event comes out of the end of that process.
Comment 2•8 months ago
|
||
I was able to reproduce this issue in Firefox 147.0.3 on macOS 15 using the test case provided. Marking this as New.
Comment 3•8 months ago
|
||
The severity field is not set for this bug.
:botond, could you have a look please?
For more information, please visit BugBot documentation.
Comment 4•7 months ago
|
||
I agree the observed behaviour here is wrong. I don't have a diagnosis offhand but will circle back to this and investigate further.
Comment 5•7 months ago
|
||
Though I just skimmed the test case so I haven't yet grasped what the test case in comment 0 does exactly, I would say our behavior matches what the spec describes.
From the spec, specifically the
"Whenever an element gets scrolled (whether in response to user interaction or by an API), the user agent must run these steps:" section:
3. If (target, "scrollend") is already in doc’s pending scroll events, abort these steps.
4. Append (target, "scrollend") to doc’s pending scroll events.
So if there's already an scrollend event in the queue, no new scrollend event is queued.
Now I re-read the test case carefully, what the test does roughly is;
- In a setTimeout callback
- Do
scrollTop = 100 - A scroll event and a scrollend event are appended into the pending queue
- A scroll event is dispatched
- Thus a (capture=true) scroll event listener's Promise is resolved
- At this moment, the other callback functions of scroll/scrollend listeners haven't yet been called
6-1) Thus, both of scrollCount and scrollEndCount are 0 - Do
scrollTop = 200in the micro task of the Promise - At this moment, a new scroll event MIGHT be appended in the queue, since the previous scroll event has been already processed, NOTE that the spec is ambiguous though
8-1) But a new scrollend event is never appended since there's the previous scrollend event in the queue
I may be overlooking something though.
Comment 6•7 months ago
|
||
Thanks for the analysis, Hiro!
I think the outcome that the order of dispatched events is "scroll, scrollend, scroll" is clearly inconsistent with the design intent behind scrollend events and impacts their practical usefulness.
If the current spec requires (or even permits) this outcome, I think we need to file a spec issue to address that.
Comment 7•7 months ago
|
||
A workaround I can think of is to wait a requestAnimationFrame callback just before the second scrollTop.
--- scrollend-bug.html.old 2026-03-12 09:44:55.149316533 +0900
+++ scrollend-bug.html 2026-03-12 09:43:19.118660035 +0900
@@ -64,6 +64,8 @@ setTimeout(async () => {
console.log('Scroll count:', scrollCount);
console.log('Scroll end count:', scrollEndCount);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
container.scrollTop = 200;
await scrollPromise(container);
I've confirmed with this change, there are two scrollend events can be observed.
Comment 8•7 months ago
|
||
A tricky part to change our implementation is here
void ScrollContainerFrame::PostScrollEndEvent() {
if (mScrollEndEvent) {
return;
}
// The ScrollEndEvent constructor registers itself.
mScrollEndEvent = new ScrollEndEvent(this);
}
We check in each scroll container whether there's already a scrollend even in the pending queue. An advantage that each scroll container has it is
a) checking there's already one is quite fast
b) if the scroll container gets destroyed while the event in the queue, the event is easily discarded
So we will have to check it in PresShell::PostScrollEvent, even it would be slow.
Comment 9•7 months ago
|
||
Hmm now I wondered on Chrome the scroll position when the first scrollend event is received, it's 200. Is that what web developers want to see? That's super confusing.
Comment 10•7 months ago
|
||
Nige, how useful is the workaround discussed in comment 7 for the purposes of adopting the native scrollend event in your codebase?
Comment 11•7 months ago
|
||
Updated•7 months ago
|
Comment 12•7 months ago
|
||
I came up with a patch that fixes the WPT added in bug 2025588 (and the behaviour on the original page in comment 0) and doesn't regress any other tests. I think we'll still want to file the spec issue discussed in comment 6, but hopefully we can proceed with the fix in parallel.
Updated•6 months ago
|
Comment 13•6 months ago
|
||
Filed a spec issue at https://github.com/w3c/csswg-drafts/issues/13772.
Comment 14•5 months ago
|
||
(In reply to Botond Ballo [:botond] from comment #13)
Filed a spec issue at https://github.com/w3c/csswg-drafts/issues/13772.
The spec issue has been resolved so implementation work in this bug has been unblocked.
(The resolution is different from what the draft patch currently implements, so the patch will need to be revised.)
| Reporter | ||
Comment 15•1 month ago
|
||
Is anybody making any progress on this?
Because I suspect that universally, applications which need to clear a state or take some other action when a scroll is finished use a timer which kicks off on every scroll event, cancelling the last timer from the last scroll event if any.
This is incredibly wasteful.
But the scrollend event MUST actually work, and fire when a scroll has completely finished for this pattern to stop being used. Otherwise some states will be locked in place.
Comment 16•1 month ago
|
||
Sorry for the inconvenience. We have planned to fix this bug in this H2, but unfortunately we haven't yet started on this now.
I might be able pick this bug on this week. Thanks!
Comment 17•1 month ago
|
||
VisualViewportScrollEvent's constructor posted itself to the pres shell, and so
did the constructor of its base class VisualViewportScrollEndEvent, so every
visual viewport scroll event landed in the pending scroll events twice. The
duplicate is harmless today only because firing the first copy revokes the
event. Post from PostScrollEvent()/PostScrollEndEvent() instead, once per
event.
Comment 18•1 month ago
|
||
Per the CSSWG resolution on w3c/csswg-drafts#13772, the pending scroll events
are snapshotted and the list emptied before any of them is dispatched, so a
scroll performed by a scroll event listener queues its own events for the next
tick. PresShell::RunScrollSteps already moved the list aside, but a scroll
container and a visual viewport each keep a reference to the event they posted
as their "one is already queued" marker, and that reference outlived the queue
entry: an instant scroll from a scroll event listener was folded into the
scrollend already being dispatched, and the second scroll never got a scrollend.
Detach() drops those references for the whole batch up front. A detached event
outlives the object that posted it, so the events hold a WeakFrame / WeakPtr
rather than a raw pointer plus Revoke() on destruction.
The scroll container case is covered by the WPT added in bug 2025588, which
this makes pass. For the visual viewport, a scroll of an element that is inside
the layout viewport but outside the visual viewport scrolls visually only,
which queues a visual viewport scroll event without the layout viewport moving.
The WPT for it needs a pinch zoom gesture, which GeckoDriver does not support
yet (bug 2006404), so it is marked as expected FAIL; it passes on Chrome
Canary. The mochitest covers the same behaviour in Firefox by zooming with
setResolutionAndScaleTo.
| Assignee | ||
Updated•1 month ago
|
| Assignee | ||
Comment 19•1 month ago
|
||
We disabled this a while ago, so it can go.
Comment 20•1 month ago
|
||
| Assignee | ||
Comment 21•1 month ago
|
||
This makes the scroll event handling more per-spec, and I think it's a
much simpler approach than bug 2015089.
Note that this subtly changes behavior around the edges, which I'm not
totally happy about: With this patch, if you reframe, you might end up
with two scroll events in the same frame, while pre-patch you end up
just with one. However, pre-patch has a separate set of issues, e.g. you
might end up with zero scroll events rather than one if you scroll and
then go display: none or reframe.
With this setup it is very easy to fix tho, by keeping mWeakFrame in
ScrollEvent and refusing to fire if the frame is dead. So if this causes
trouble it is trivial to go back to the previous behavior. But I think
it's better to incorrectly fire one more event than incorrectly not
firing any event...
| Assignee | ||
Comment 22•1 month ago
|
||
Comment 23•1 month ago
|
||
Comment 24•1 month ago
|
||
| bugherder | ||
Comment 25•1 month ago
|
||
| bugherder | ||
Updated•1 month ago
|
Updated•1 month ago
|
Comment 26•1 month ago
|
||
| Assignee | ||
Comment 27•1 month ago
|
||
Make the code more similar between scroll / scrollend and add a missing
nullcheck.
Comment 28•1 month ago
|
||
| Assignee | ||
Updated•1 month ago
|
Comment 29•1 month ago
|
||
| bugherder | ||
https://hg.mozilla.org/mozilla-central/rev/9db9e1767922
https://hg.mozilla.org/mozilla-central/rev/88a4b2dab83d
Updated•27 days ago
|
| Reporter | ||
Comment 30•24 days ago
|
||
This is not fixed in Firefox 159.0a1 (2026-09-28) (aarch64).
A scrollend must follow ANY scroll event even if a visible scroll is not actually done. If there was a scroll event emitted there must be a scrollend event after that. Otherwise application state can get stuck.
Run the example file attached to this ticket. The scrolling class is stuck on the scrollable element.
A scroll is requested, but then the overflowing element is cut back to height : 50em which no longer overflows.
It logs
Scroll count: 2
Scroll end count: 1
And the `scrolling class remains on the container element.
| Reporter | ||
Comment 31•23 days ago
|
||
(In reply to Nige White from comment #30)
This is not fixed in Firefox 159.0a1 (2026-09-28) (aarch64).
A
scrollendmust follow ANY scroll event even if a visible scroll is not actually done. If there was ascrollevent emitted there must be ascrollendevent after that. Otherwise application state can get stuck.Run the example file attached to this ticket. The
scrollingclass is stuck on the scrollable element.A scroll is requested, but then the overflowing element is cut back to
height : 50emwhich no longer overflows.It logs
Scroll count: 2 Scroll end count: 1And the
scrollingclass remains on the container element.
| Assignee | ||
Comment 32•23 days ago
|
||
The example shows:
Scroll count: 0
Scroll end count: 0
Scroll count: 2
Scroll end count: 2
container
Final Scroll count: 2
Final Scroll end count: 2
for me, what am I missing? In any case I think we should not reopen this, filing a separate ticket is less messy to track whatever is remaining.
| Assignee | ||
Comment 33•23 days ago
|
||
Let's keep the discussion in bug 2076420. I can reproduce the Scroll end count: 1 in 157, but not in 159, FWIW.
Description
•