@samitouri / QOS-React / commits / 605a880c8c

Polyfill onScrollEnd Event in Safari (#32427)

We added support for `onScrollEnd` in #26789 but it only works in Chrome and Firefox. Safari still doesn't support `scrollend` and there's no indication that they will anytime soon so this polyfills it. While I don't particularly love our synthetic event system this tries to stay within the realm of how our other polyfills work. This implements all `onScrollEnd` events as a plugin. The basic principle is to first feature detect the `onscrollend` DOM property to see if there's native support and otherwise just use the native event. Then we listen to `scroll` events and set a timeout. If we don't get any more scroll events before the timeout we fire `onScrollEnd`. Basically debouncing it. If we're currently pressing down on touch or a mouse then we wait until it is lifted such as if you're scrolling with a finger or using the scrollbars on desktop but isn't currently moving. If we do get any native events even though we're in polyfilling mode, we use that as an indication to fire the `onScrollEnd` early. Part of the motivation is that this becomes extra useful pair for https://github.com/facebook/react/pull/32422. We also probably need these events to coincide with other gesture related internals so you're better off using our polyfill so they're synced.

Sebastian Markbåge committed Mar 3, 2025 at 14:24 UTC 605a880c8c5191e9f8c52468458709cd17a486c1
12 files changed +281 -4
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+16 -1
@@ -65,7 +65,10 @@ import sanitizeURL from '../shared/sanitizeURL';
65
66 import {trackHostMutation} from 'react-reconciler/src/ReactFiberMutationTracking';
67
68 -import {enableTrustedTypesIntegration} from 'shared/ReactFeatureFlags';
68 +import {
69 + enableScrollEndPolyfill,
70 + enableTrustedTypesIntegration,
71 +} from 'shared/ReactFeatureFlags';
72 import {
73 mediaEventTypes,
74 listenToNonDelegatedEvent,
@@ -545,6 +548,10 @@ function setProp(
548 warnForInvalidEventListener(key, value);
549 }
550 listenToNonDelegatedEvent('scrollend', domElement);
551 + if (enableScrollEndPolyfill) {
552 + // For use by the polyfill.
553 + listenToNonDelegatedEvent('scroll', domElement);
554 + }
555 }
556 return;
557 }
@@ -955,6 +962,10 @@ function setPropOnCustomElement(
962 warnForInvalidEventListener(key, value);
963 }
964 listenToNonDelegatedEvent('scrollend', domElement);
965 + if (enableScrollEndPolyfill) {
966 + // For use by the polyfill.
967 + listenToNonDelegatedEvent('scroll', domElement);
968 + }
969 }
970 return;
971 }
@@ -3058,6 +3069,10 @@ export function hydrateProperties(
3069
3070 if (props.onScrollEnd != null) {
3071 listenToNonDelegatedEvent('scrollend', domElement);
3072 + if (enableScrollEndPolyfill) {
3073 + // For use by the polyfill.
3074 + listenToNonDelegatedEvent('scroll', domElement);
3075 + }
3076 }
3077
3078 if (props.onClick != null) {
packages/react-dom-bindings/src/client/ReactDOMComponentTree.js
+13
@@ -45,6 +45,7 @@ const internalEventHandlerListenersKey = '__reactListeners$' + randomKey;
45 const internalEventHandlesSetKey = '__reactHandles$' + randomKey;
46 const internalRootNodeResourcesKey = '__reactResources$' + randomKey;
47 const internalHoistableMarker = '__reactMarker$' + randomKey;
48 +const internalScrollTimer = '__reactScroll$' + randomKey;
49
50 export function detachDeletedInstance(node: Instance): void {
51 // TODO: This function is only called on host components. I don't think all of
@@ -293,6 +294,18 @@ export function markNodeAsHoistable(node: Node) {
294 (node: any)[internalHoistableMarker] = true;
295 }
296
297 +export function getScrollEndTimer(node: EventTarget): ?TimeoutID {
298 + return (node: any)[internalScrollTimer];
299 +}
300 +
301 +export function setScrollEndTimer(node: EventTarget, timer: TimeoutID): void {
302 + (node: any)[internalScrollTimer] = timer;
303 +}
304 +
305 +export function clearScrollEndTimer(node: EventTarget): void {
306 + (node: any)[internalScrollTimer] = undefined;
307 +}
308 +
309 export function isOwnedInstance(node: Node): boolean {
310 return !!(
311 (node: any)[internalHoistableMarker] || (node: any)[internalInstanceKey]
packages/react-dom-bindings/src/events/DOMEventProperties.js
+8 -2
@@ -20,7 +20,10 @@ import {
20 TRANSITION_END,
21 } from './DOMEventNames';
22
23 -import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags';
23 +import {
24 + enableCreateEventHandleAPI,
25 + enableScrollEndPolyfill,
26 +} from 'shared/ReactFeatureFlags';
27
28 export const topLevelEventsToReactNames: Map<DOMEventName, string | null> =
29 new Map();
@@ -100,13 +103,16 @@ const simpleEventPluginEvents = [
103 'touchStart',
104 'volumeChange',
105 'scroll',
103 - 'scrollEnd',
106 'toggle',
107 'touchMove',
108 'waiting',
109 'wheel',
110 ];
111
112 +if (!enableScrollEndPolyfill) {
113 + simpleEventPluginEvents.push('scrollEnd');
114 +}
115 +
116 if (enableCreateEventHandleAPI) {
117 // Special case: these two events don't have on* React handler
118 // and are only accessible via the createEventHandle API.
packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
+23 -1
@@ -54,6 +54,7 @@ import {
54 enableScopeAPI,
55 enableOwnerStacks,
56 disableCommentsAsDOMContainers,
57 + enableScrollEndPolyfill,
58 } from 'shared/ReactFeatureFlags';
59 import {createEventListenerWrapperWithPriority} from './ReactDOMEventListener';
60 import {
@@ -69,6 +70,7 @@ import * as EnterLeaveEventPlugin from './plugins/EnterLeaveEventPlugin';
70 import * as SelectEventPlugin from './plugins/SelectEventPlugin';
71 import * as SimpleEventPlugin from './plugins/SimpleEventPlugin';
72 import * as FormActionEventPlugin from './plugins/FormActionEventPlugin';
73 +import * as ScrollEndEventPlugin from './plugins/ScrollEndEventPlugin';
74
75 import reportGlobalError from 'shared/reportGlobalError';
76
@@ -93,6 +95,9 @@ EnterLeaveEventPlugin.registerEvents();
95 ChangeEventPlugin.registerEvents();
96 SelectEventPlugin.registerEvents();
97 BeforeInputEventPlugin.registerEvents();
98 +if (enableScrollEndPolyfill) {
99 + ScrollEndEventPlugin.registerEvents();
100 +}
101
102 function extractEvents(
103 dispatchQueue: DispatchQueue,
@@ -184,6 +189,17 @@ function extractEvents(
189 targetContainer,
190 );
191 }
192 + if (enableScrollEndPolyfill) {
193 + ScrollEndEventPlugin.extractEvents(
194 + dispatchQueue,
195 + domEventName,
196 + targetInst,
197 + nativeEvent,
198 + nativeEventTarget,
199 + eventSystemFlags,
200 + targetContainer,
201 + );
202 + }
203 }
204
205 // List of events that need to be individually attached to media elements.
@@ -811,6 +827,7 @@ export function accumulateSinglePhaseListeners(
827 // - BeforeInputEventPlugin
828 // - ChangeEventPlugin
829 // - SelectEventPlugin
830 +// - ScrollEndEventPlugin
831 // This is because we only process these plugins
832 // in the bubble phase, so we need to accumulate two
833 // phase event listeners (via emulation).
@@ -846,9 +863,14 @@ export function accumulateTwoPhaseListeners(
863 );
864 }
865 }
866 + if (instance.tag === HostRoot) {
867 + return listeners;
868 + }
869 instance = instance.return;
870 }
851 - return listeners;
871 + // If we didn't reach the root it means we're unmounted and shouldn't
872 + // dispatch any events on the target.
873 + return [];
874 }
875
876 function getParent(inst: Fiber | null): Fiber | null {
packages/react-dom-bindings/src/events/plugins/ScrollEndEventPlugin.js new
+212
@@ -0,0 +1,212 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +import type {AnyNativeEvent} from '../PluginModuleType';
10 +import type {DOMEventName} from '../DOMEventNames';
11 +import type {DispatchQueue} from '../DOMPluginEventSystem';
12 +import type {EventSystemFlags} from '../EventSystemFlags';
13 +import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
14 +import type {ReactSyntheticEvent} from '../ReactSyntheticEventType';
15 +
16 +import {registerTwoPhaseEvent} from '../EventRegistry';
17 +import {SyntheticUIEvent} from '../SyntheticEvent';
18 +
19 +import {canUseDOM} from 'shared/ExecutionEnvironment';
20 +import isEventSupported from '../isEventSupported';
21 +
22 +import {IS_CAPTURE_PHASE} from '../EventSystemFlags';
23 +
24 +import {batchedUpdates} from '../ReactDOMUpdateBatching';
25 +import {
26 + processDispatchQueue,
27 + accumulateSinglePhaseListeners,
28 + accumulateTwoPhaseListeners,
29 +} from '../DOMPluginEventSystem';
30 +
31 +import {
32 + getScrollEndTimer,
33 + setScrollEndTimer,
34 + clearScrollEndTimer,
35 +} from '../../client/ReactDOMComponentTree';
36 +
37 +import {enableScrollEndPolyfill} from 'shared/ReactFeatureFlags';
38 +
39 +const isScrollEndEventSupported =
40 + enableScrollEndPolyfill && canUseDOM && isEventSupported('scrollend');
41 +
42 +let isTouchStarted = false;
43 +let isMouseDown = false;
44 +
45 +function registerEvents() {
46 + registerTwoPhaseEvent('onScrollEnd', [
47 + 'scroll',
48 + 'scrollend',
49 + 'touchstart',
50 + 'touchcancel',
51 + 'touchend',
52 + 'mousedown',
53 + 'mouseup',
54 + ]);
55 +}
56 +
57 +function manualDispatchScrollEndEvent(
58 + inst: Fiber,
59 + nativeEvent: AnyNativeEvent,
60 + target: EventTarget,
61 +) {
62 + const dispatchQueue: DispatchQueue = [];
63 + const listeners = accumulateTwoPhaseListeners(inst, 'onScrollEnd');
64 + if (listeners.length > 0) {
65 + const event: ReactSyntheticEvent = new SyntheticUIEvent(
66 + 'onScrollEnd',
67 + 'scrollend',
68 + null,
69 + nativeEvent, // This will be the "scroll" event.
70 + target,
71 + );
72 + dispatchQueue.push({event, listeners});
73 + }
74 + batchedUpdates(runEventInBatch, dispatchQueue);
75 +}
76 +
77 +function runEventInBatch(dispatchQueue: DispatchQueue) {
78 + processDispatchQueue(dispatchQueue, 0);
79 +}
80 +
81 +function fireScrollEnd(
82 + targetInst: Fiber,
83 + nativeEvent: AnyNativeEvent,
84 + nativeEventTarget: EventTarget,
85 +): void {
86 + clearScrollEndTimer(nativeEventTarget);
87 + if (isMouseDown || isTouchStarted) {
88 + // If mouse or touch is down, try again later in case this is due to having an
89 + // active scroll but it's not currently moving.
90 + debounceScrollEnd(targetInst, nativeEvent, nativeEventTarget);
91 + return;
92 + }
93 + manualDispatchScrollEndEvent(targetInst, nativeEvent, nativeEventTarget);
94 +}
95 +
96 +// When scrolling slows down the frequency of new scroll events can be quite low.
97 +// This timeout seems high enough to cover those cases but short enough to not
98 +// fire the event way too late.
99 +const DEBOUNCE_TIMEOUT = 200;
100 +
101 +function debounceScrollEnd(
102 + targetInst: null | Fiber,
103 + nativeEvent: AnyNativeEvent,
104 + nativeEventTarget: EventTarget,
105 +) {
106 + const existingTimer = getScrollEndTimer(nativeEventTarget);
107 + if (existingTimer != null) {
108 + clearTimeout(existingTimer);
109 + }
110 + if (targetInst !== null) {
111 + const newTimer = setTimeout(
112 + fireScrollEnd.bind(null, targetInst, nativeEvent, nativeEventTarget),
113 + DEBOUNCE_TIMEOUT,
114 + );
115 + setScrollEndTimer(nativeEventTarget, newTimer);
116 + }
117 +}
118 +
119 +/**
120 + * This plugin creates an `onScrollEnd` event polyfill when the native one
121 + * is not available.
122 + */
123 +function extractEvents(
124 + dispatchQueue: DispatchQueue,
125 + domEventName: DOMEventName,
126 + targetInst: null | Fiber,
127 + nativeEvent: AnyNativeEvent,
128 + nativeEventTarget: null | EventTarget,
129 + eventSystemFlags: EventSystemFlags,
130 + targetContainer: null | EventTarget,
131 +) {
132 + if (!enableScrollEndPolyfill) {
133 + return;
134 + }
135 +
136 + const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
137 +
138 + if (domEventName !== 'scrollend') {
139 + if (!isScrollEndEventSupported && inCapturePhase) {
140 + switch (domEventName) {
141 + case 'scroll': {
142 + if (nativeEventTarget !== null) {
143 + debounceScrollEnd(targetInst, nativeEvent, nativeEventTarget);
144 + }
145 + break;
146 + }
147 + case 'touchstart': {
148 + isTouchStarted = true;
149 + break;
150 + }
151 + case 'touchcancel':
152 + case 'touchend': {
153 + // Note we cannot use pointer events for this because they get
154 + // cancelled when native scrolling takes control.
155 + isTouchStarted = false;
156 + break;
157 + }
158 + case 'mousedown': {
159 + isMouseDown = true;
160 + break;
161 + }
162 + case 'mouseup': {
163 + isMouseDown = false;
164 + break;
165 + }
166 + }
167 + }
168 + return;
169 + }
170 +
171 + if (!isScrollEndEventSupported && nativeEventTarget !== null) {
172 + const existingTimer = getScrollEndTimer(nativeEventTarget);
173 + if (existingTimer != null) {
174 + // If we do get a native scrollend event fired, we cancel the polyfill.
175 + // This could happen if our feature detection is broken or if there's another
176 + // polyfill calling dispatchEvent to fire it before we fire ours.
177 + clearTimeout(existingTimer);
178 + clearScrollEndTimer(nativeEventTarget);
179 + } else {
180 + // If we didn't receive a 'scroll' event first, we ignore this event to avoid
181 + // double firing. Such as if we fired our onScrollEnd polyfill and then
182 + // we also observed a native one afterwards.
183 + return;
184 + }
185 + }
186 +
187 + // In React onScrollEnd doesn't bubble.
188 + const accumulateTargetOnly = !inCapturePhase;
189 +
190 + const listeners = accumulateSinglePhaseListeners(
191 + targetInst,
192 + 'onScrollEnd',
193 + 'scrollend',
194 + inCapturePhase,
195 + accumulateTargetOnly,
196 + nativeEvent,
197 + );
198 +
199 + if (listeners.length > 0) {
200 + // Intentionally create event lazily.
201 + const event: ReactSyntheticEvent = new SyntheticUIEvent(
202 + 'onScrollEnd',
203 + 'scrollend',
204 + null,
205 + nativeEvent,
206 + nativeEventTarget,
207 + );
208 + dispatchQueue.push({event, listeners});
209 + }
210 +}
211 +
212 +export {registerEvents, extractEvents};
packages/shared/ReactFeatureFlags.js
+2
@@ -94,6 +94,8 @@ export const enableViewTransition = __EXPERIMENTAL__;
94
95 export const enableSwipeTransition = __EXPERIMENTAL__;
96
97 +export const enableScrollEndPolyfill = __EXPERIMENTAL__;
98 +
99 /**
100 * Switches the Fabric API from doing layout in commit work instead of complete work.
101 */
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -85,6 +85,7 @@ export const enableYieldingBeforePassive = false;
85 export const enableThrottledScheduling = false;
86 export const enableViewTransition = false;
87 export const enableSwipeTransition = false;
88 +export const enableScrollEndPolyfill = true;
89
90 // Flow magic to verify the exports of this file match the original version.
91 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -75,6 +75,7 @@ export const enableViewTransition = false;
75 export const enableSwipeTransition = false;
76 export const enableFastAddPropertiesInDiffing = false;
77 export const enableLazyPublicInstanceInFabric = false;
78 +export const enableScrollEndPolyfill = true;
79
80 // Profiling Only
81 export const enableProfilerTimer = __PROFILE__;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -74,6 +74,7 @@ export const enableViewTransition = false;
74 export const enableSwipeTransition = false;
75 export const enableFastAddPropertiesInDiffing = true;
76 export const enableLazyPublicInstanceInFabric = false;
77 +export const enableScrollEndPolyfill = true;
78
79 // TODO: This must be in sync with the main ReactFeatureFlags file because
80 // the Test Renderer's value must be the same as the one used by the
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+1
@@ -71,6 +71,7 @@ export const enableViewTransition = false;
71 export const enableSwipeTransition = false;
72 export const enableFastAddPropertiesInDiffing = false;
73 export const enableLazyPublicInstanceInFabric = false;
74 +export const enableScrollEndPolyfill = true;
75
76 // Flow magic to verify the exports of this file match the original version.
77 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -86,6 +86,7 @@ export const enableViewTransition = false;
86 export const enableSwipeTransition = false;
87 export const enableFastAddPropertiesInDiffing = false;
88 export const enableLazyPublicInstanceInFabric = false;
89 +export const enableScrollEndPolyfill = true;
90
91 // Flow magic to verify the exports of this file match the original version.
92 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.www.js
+2
@@ -116,5 +116,7 @@ export const enableLazyPublicInstanceInFabric = false;
116
117 export const enableSwipeTransition = false;
118
119 +export const enableScrollEndPolyfill = false;
120 +
121 // Flow magic to verify the exports of this file match the original version.
122 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);