Skip to content

Commit 605a880

Browse files
authored
Polyfill onScrollEnd Event in Safari (facebook#32427)
We added support for `onScrollEnd` in facebook#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 facebook#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.
1 parent 2980f27 commit 605a880

12 files changed

+281
-4
lines changed

packages/react-dom-bindings/src/client/ReactDOMComponent.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ import sanitizeURL from '../shared/sanitizeURL';
6565

6666
import {trackHostMutation} from 'react-reconciler/src/ReactFiberMutationTracking';
6767

68-
import {enableTrustedTypesIntegration} from 'shared/ReactFeatureFlags';
68+
import {
69+
enableScrollEndPolyfill,
70+
enableTrustedTypesIntegration,
71+
} from 'shared/ReactFeatureFlags';
6972
import {
7073
mediaEventTypes,
7174
listenToNonDelegatedEvent,
@@ -545,6 +548,10 @@ function setProp(
545548
warnForInvalidEventListener(key, value);
546549
}
547550
listenToNonDelegatedEvent('scrollend', domElement);
551+
if (enableScrollEndPolyfill) {
552+
// For use by the polyfill.
553+
listenToNonDelegatedEvent('scroll', domElement);
554+
}
548555
}
549556
return;
550557
}
@@ -955,6 +962,10 @@ function setPropOnCustomElement(
955962
warnForInvalidEventListener(key, value);
956963
}
957964
listenToNonDelegatedEvent('scrollend', domElement);
965+
if (enableScrollEndPolyfill) {
966+
// For use by the polyfill.
967+
listenToNonDelegatedEvent('scroll', domElement);
968+
}
958969
}
959970
return;
960971
}
@@ -3058,6 +3069,10 @@ export function hydrateProperties(
30583069

30593070
if (props.onScrollEnd != null) {
30603071
listenToNonDelegatedEvent('scrollend', domElement);
3072+
if (enableScrollEndPolyfill) {
3073+
// For use by the polyfill.
3074+
listenToNonDelegatedEvent('scroll', domElement);
3075+
}
30613076
}
30623077

30633078
if (props.onClick != null) {

packages/react-dom-bindings/src/client/ReactDOMComponentTree.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const internalEventHandlerListenersKey = '__reactListeners$' + randomKey;
4545
const internalEventHandlesSetKey = '__reactHandles$' + randomKey;
4646
const internalRootNodeResourcesKey = '__reactResources$' + randomKey;
4747
const internalHoistableMarker = '__reactMarker$' + randomKey;
48+
const internalScrollTimer = '__reactScroll$' + randomKey;
4849

4950
export function detachDeletedInstance(node: Instance): void {
5051
// TODO: This function is only called on host components. I don't think all of
@@ -293,6 +294,18 @@ export function markNodeAsHoistable(node: Node) {
293294
(node: any)[internalHoistableMarker] = true;
294295
}
295296

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+
296309
export function isOwnedInstance(node: Node): boolean {
297310
return !!(
298311
(node: any)[internalHoistableMarker] || (node: any)[internalInstanceKey]

packages/react-dom-bindings/src/events/DOMEventProperties.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
2020
TRANSITION_END,
2121
} from './DOMEventNames';
2222

23-
import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags';
23+
import {
24+
enableCreateEventHandleAPI,
25+
enableScrollEndPolyfill,
26+
} from 'shared/ReactFeatureFlags';
2427

2528
export const topLevelEventsToReactNames: Map<DOMEventName, string | null> =
2629
new Map();
@@ -100,13 +103,16 @@ const simpleEventPluginEvents = [
100103
'touchStart',
101104
'volumeChange',
102105
'scroll',
103-
'scrollEnd',
104106
'toggle',
105107
'touchMove',
106108
'waiting',
107109
'wheel',
108110
];
109111

112+
if (!enableScrollEndPolyfill) {
113+
simpleEventPluginEvents.push('scrollEnd');
114+
}
115+
110116
if (enableCreateEventHandleAPI) {
111117
// Special case: these two events don't have on* React handler
112118
// and are only accessible via the createEventHandle API.

packages/react-dom-bindings/src/events/DOMPluginEventSystem.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
enableScopeAPI,
5555
enableOwnerStacks,
5656
disableCommentsAsDOMContainers,
57+
enableScrollEndPolyfill,
5758
} from 'shared/ReactFeatureFlags';
5859
import {createEventListenerWrapperWithPriority} from './ReactDOMEventListener';
5960
import {
@@ -69,6 +70,7 @@ import * as EnterLeaveEventPlugin from './plugins/EnterLeaveEventPlugin';
6970
import * as SelectEventPlugin from './plugins/SelectEventPlugin';
7071
import * as SimpleEventPlugin from './plugins/SimpleEventPlugin';
7172
import * as FormActionEventPlugin from './plugins/FormActionEventPlugin';
73+
import * as ScrollEndEventPlugin from './plugins/ScrollEndEventPlugin';
7274

7375
import reportGlobalError from 'shared/reportGlobalError';
7476

@@ -93,6 +95,9 @@ EnterLeaveEventPlugin.registerEvents();
9395
ChangeEventPlugin.registerEvents();
9496
SelectEventPlugin.registerEvents();
9597
BeforeInputEventPlugin.registerEvents();
98+
if (enableScrollEndPolyfill) {
99+
ScrollEndEventPlugin.registerEvents();
100+
}
96101

97102
function extractEvents(
98103
dispatchQueue: DispatchQueue,
@@ -184,6 +189,17 @@ function extractEvents(
184189
targetContainer,
185190
);
186191
}
192+
if (enableScrollEndPolyfill) {
193+
ScrollEndEventPlugin.extractEvents(
194+
dispatchQueue,
195+
domEventName,
196+
targetInst,
197+
nativeEvent,
198+
nativeEventTarget,
199+
eventSystemFlags,
200+
targetContainer,
201+
);
202+
}
187203
}
188204

189205
// List of events that need to be individually attached to media elements.
@@ -811,6 +827,7 @@ export function accumulateSinglePhaseListeners(
811827
// - BeforeInputEventPlugin
812828
// - ChangeEventPlugin
813829
// - SelectEventPlugin
830+
// - ScrollEndEventPlugin
814831
// This is because we only process these plugins
815832
// in the bubble phase, so we need to accumulate two
816833
// phase event listeners (via emulation).
@@ -846,9 +863,14 @@ export function accumulateTwoPhaseListeners(
846863
);
847864
}
848865
}
866+
if (instance.tag === HostRoot) {
867+
return listeners;
868+
}
849869
instance = instance.return;
850870
}
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 [];
852874
}
853875

854876
function getParent(inst: Fiber | null): Fiber | null {
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ export const enableViewTransition = __EXPERIMENTAL__;
9494

9595
export const enableSwipeTransition = __EXPERIMENTAL__;
9696

97+
export const enableScrollEndPolyfill = __EXPERIMENTAL__;
98+
9799
/**
98100
* Switches the Fabric API from doing layout in commit work instead of complete work.
99101
*/

packages/shared/forks/ReactFeatureFlags.native-fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export const enableYieldingBeforePassive = false;
8585
export const enableThrottledScheduling = false;
8686
export const enableViewTransition = false;
8787
export const enableSwipeTransition = false;
88+
export const enableScrollEndPolyfill = true;
8889

8990
// Flow magic to verify the exports of this file match the original version.
9091
((((null: any): ExportsType): FeatureFlagsType): ExportsType);

0 commit comments

Comments
 (0)