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};