Skip to content

Commit dead6ae

Browse files
committed
Polyfill 'scrollend'
1 parent d79c9cd commit dead6ae

File tree

3 files changed

+162
-9
lines changed

3 files changed

+162
-9
lines changed

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/DOMPluginEventSystem.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,7 @@ export function accumulateSinglePhaseListeners(
827827
// - BeforeInputEventPlugin
828828
// - ChangeEventPlugin
829829
// - SelectEventPlugin
830+
// - ScrollEndEventPlugin
830831
// This is because we only process these plugins
831832
// in the bubble phase, so we need to accumulate two
832833
// phase event listeners (via emulation).
@@ -862,9 +863,14 @@ export function accumulateTwoPhaseListeners(
862863
);
863864
}
864865
}
866+
if (instance.tag === HostRoot) {
867+
return listeners;
868+
}
865869
instance = instance.return;
866870
}
867-
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 [];
868874
}
869875

870876
function getParent(inst: Fiber | null): Fiber | null {

packages/react-dom-bindings/src/events/plugins/ScrollEndEventPlugin.js

Lines changed: 142 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,106 @@ import type {ReactSyntheticEvent} from '../ReactSyntheticEventType';
1616
import {registerTwoPhaseEvent} from '../EventRegistry';
1717
import {SyntheticUIEvent} from '../SyntheticEvent';
1818

19+
import {canUseDOM} from 'shared/ExecutionEnvironment';
20+
import isEventSupported from '../isEventSupported';
21+
1922
import {IS_CAPTURE_PHASE} from '../EventSystemFlags';
2023

21-
import {accumulateSinglePhaseListeners} from '../DOMPluginEventSystem';
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;
2244

2345
function registerEvents() {
2446
registerTwoPhaseEvent('onScrollEnd', [
2547
'scroll',
2648
'scrollend',
2749
'touchstart',
50+
'touchcancel',
2851
'touchend',
52+
'mousedown',
53+
'mouseup',
2954
]);
3055
}
3156

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+
32119
/**
33120
* This plugin creates an `onScrollEnd` event polyfill when the native one
34121
* is not available.
@@ -42,21 +129,68 @@ function extractEvents(
42129
eventSystemFlags: EventSystemFlags,
43130
targetContainer: null | EventTarget,
44131
) {
45-
if (domEventName !== 'scrollend') {
132+
if (!enableScrollEndPolyfill) {
46133
return;
47134
}
48135

49-
const reactName = 'onScrollEnd';
50-
51136
const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
52137

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+
53187
// In React onScrollEnd doesn't bubble.
54188
const accumulateTargetOnly = !inCapturePhase;
55189

56190
const listeners = accumulateSinglePhaseListeners(
57191
targetInst,
58-
reactName,
59-
nativeEvent.type,
192+
'onScrollEnd',
193+
'scrollend',
60194
inCapturePhase,
61195
accumulateTargetOnly,
62196
nativeEvent,
@@ -65,8 +199,8 @@ function extractEvents(
65199
if (listeners.length > 0) {
66200
// Intentionally create event lazily.
67201
const event: ReactSyntheticEvent = new SyntheticUIEvent(
68-
reactName,
69-
domEventName,
202+
'onScrollEnd',
203+
'scrollend',
70204
null,
71205
nativeEvent,
72206
nativeEventTarget,

0 commit comments

Comments
 (0)