Add support for onScrollEnd event (#26789)
## Summary This adds support for the new [scrollend](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollend_event) event. It was recently added to the spec, and is currently supported in Firefox 109 and Chrome Canary (shipping in Chrome 114). You can read more about this event [here](https://developer.chrome.com/blog/scrollend-a-new-javascript-event/). This PR adds support for the `onScrollEnd` prop, following the implementation for `onScroll`. ## How did you test this change? Added unit tests.
Devon Govett committed
Oct 10, 2023 at 21:00 UTC
537228f9fd703d18bea1f6d20fa0e5006b795c42
8 files changed
+186
-37
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+22
@@ -588,6 +588,15 @@ function setProp(
588
}
589
break;
590
}
591
+ case 'onScrollEnd': {
592
+ if (value != null) {
593
+ if (__DEV__ && typeof value !== 'function') {
594
+ warnForInvalidEventListener(key, value);
595
+ }
596
+ listenToNonDelegatedEvent('scrollend', domElement);
597
+ }
598
+ break;
599
+ }
600
case 'dangerouslySetInnerHTML': {
601
if (value != null) {
602
if (typeof value !== 'object' || !('__html' in value)) {
@@ -956,6 +965,15 @@ function setPropOnCustomElement(
965
}
966
break;
967
}
968
+ case 'onScrollEnd': {
969
+ if (value != null) {
970
+ if (__DEV__ && typeof value !== 'function') {
971
+ warnForInvalidEventListener(key, value);
972
+ }
973
+ listenToNonDelegatedEvent('scrollend', domElement);
974
+ }
975
+ break;
976
+ }
977
case 'onClick': {
978
// TODO: This cast may not be sound for SVG, MathML or custom elements.
979
if (value != null) {
@@ -2815,6 +2833,10 @@ export function diffHydratedProperties(
2833
listenToNonDelegatedEvent('scroll', domElement);
2834
}
2835
2836
+ if (props.onScrollEnd != null) {
2837
+ listenToNonDelegatedEvent('scrollend', domElement);
2838
+ }
2839
+
2840
if (props.onClick != null) {
2841
// TODO: This cast may not be sound for SVG, MathML or custom elements.
2842
trapClickOnNonInteractiveElement(((domElement: any): HTMLElement));
packages/react-dom-bindings/src/events/DOMEventNames.js
+1
@@ -88,6 +88,7 @@ export type DOMEventName =
88
| 'reset'
89
| 'resize'
90
| 'scroll'
91
+ | 'scrollend'
92
| 'seeked'
93
| 'seeking'
94
| 'select'
packages/react-dom-bindings/src/events/DOMEventProperties.js
+1
@@ -96,6 +96,7 @@ const simpleEventPluginEvents = [
96
'touchStart',
97
'volumeChange',
98
'scroll',
99
+ 'scrollEnd',
100
'toggle',
101
'touchMove',
102
'waiting',
packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
+1
@@ -226,6 +226,7 @@ export const nonDelegatedEvents: Set<DOMEventName> = new Set([
226
'invalid',
227
'load',
228
'scroll',
229
+ 'scrollend',
230
'toggle',
231
// In order to reduce bytes, we insert the above array of media events
232
// into this Set. Note: the "error" event isn't an exclusive media event,
packages/react-dom-bindings/src/events/TopLevelEventTypes.js
+1
@@ -73,6 +73,7 @@ export type TopLevelType =
73
| 'reset'
74
| 'resize'
75
| 'scroll'
76
+ | 'scrollend'
77
| 'seeked'
78
| 'seeking'
79
| 'selectionchange'
packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js
+2
-1
@@ -140,6 +140,7 @@ function extractEvents(
140
SyntheticEventCtor = SyntheticTransitionEvent;
141
break;
142
case 'scroll':
143
+ case 'scrollend':
144
SyntheticEventCtor = SyntheticUIEvent;
145
break;
146
case 'wheel':
@@ -199,7 +200,7 @@ function extractEvents(
200
// nonDelegatedEvents list in DOMPluginEventSystem.
201
// Then we can remove this special list.
202
// This is a breaking change that can wait until React 18.
202
- domEventName === 'scroll';
203
+ (domEventName === 'scroll' || domEventName === 'scrollend');
204
205
const listeners = accumulateSinglePhaseListeners(
206
targetInst,
packages/react-dom/src/__tests__/ReactDOMEventListener-test.js
+142
-36
@@ -717,10 +717,16 @@ describe('ReactDOMEventListener', () => {
717
const ref = React.createRef();
718
const log = [];
719
const onScroll = jest.fn(e =>
720
- log.push(['bubble', e.currentTarget.className]),
720
+ log.push(['onScroll', 'bubble', e.currentTarget.className]),
721
);
722
const onScrollCapture = jest.fn(e =>
723
- log.push(['capture', e.currentTarget.className]),
723
+ log.push(['onScroll', 'capture', e.currentTarget.className]),
724
+ );
725
+ const onScrollEnd = jest.fn(e =>
726
+ log.push(['onScrollEnd', 'bubble', e.currentTarget.className]),
727
+ );
728
+ const onScrollEndCapture = jest.fn(e =>
729
+ log.push(['onScrollEnd', 'capture', e.currentTarget.className]),
730
);
731
document.body.appendChild(container);
732
try {
@@ -728,15 +734,21 @@ describe('ReactDOMEventListener', () => {
734
<div
735
className="grand"
736
onScroll={onScroll}
731
- onScrollCapture={onScrollCapture}>
737
+ onScrollCapture={onScrollCapture}
738
+ onScrollEnd={onScrollEnd}
739
+ onScrollEndCapture={onScrollEndCapture}>
740
<div
741
className="parent"
742
onScroll={onScroll}
735
- onScrollCapture={onScrollCapture}>
743
+ onScrollCapture={onScrollCapture}
744
+ onScrollEnd={onScrollEnd}
745
+ onScrollEndCapture={onScrollEndCapture}>
746
<div
747
className="child"
748
onScroll={onScroll}
749
onScrollCapture={onScrollCapture}
750
+ onScrollEnd={onScrollEnd}
751
+ onScrollEndCapture={onScrollEndCapture}
752
ref={ref}
753
/>
754
</div>
@@ -748,11 +760,20 @@ describe('ReactDOMEventListener', () => {
760
bubbles: false,
761
}),
762
);
763
+ ref.current.dispatchEvent(
764
+ new Event('scrollend', {
765
+ bubbles: false,
766
+ }),
767
+ );
768
expect(log).toEqual([
752
- ['capture', 'grand'],
753
- ['capture', 'parent'],
754
- ['capture', 'child'],
755
- ['bubble', 'child'],
769
+ ['onScroll', 'capture', 'grand'],
770
+ ['onScroll', 'capture', 'parent'],
771
+ ['onScroll', 'capture', 'child'],
772
+ ['onScroll', 'bubble', 'child'],
773
+ ['onScrollEnd', 'capture', 'grand'],
774
+ ['onScrollEnd', 'capture', 'parent'],
775
+ ['onScrollEnd', 'capture', 'child'],
776
+ ['onScrollEnd', 'bubble', 'child'],
777
]);
778
} finally {
779
document.body.removeChild(container);
@@ -767,10 +788,16 @@ describe('ReactDOMEventListener', () => {
788
const ref = React.createRef();
789
const log = [];
790
const onScroll = jest.fn(e =>
770
- log.push(['bubble', e.currentTarget.className]),
791
+ log.push(['onScroll', 'bubble', e.currentTarget.className]),
792
);
793
const onScrollCapture = jest.fn(e =>
773
- log.push(['capture', e.currentTarget.className]),
794
+ log.push(['onScroll', 'capture', e.currentTarget.className]),
795
+ );
796
+ const onScrollEnd = jest.fn(e =>
797
+ log.push(['onScrollEnd', 'bubble', e.currentTarget.className]),
798
+ );
799
+ const onScrollEndCapture = jest.fn(e =>
800
+ log.push(['onScrollEnd', 'capture', e.currentTarget.className]),
801
);
802
document.body.appendChild(container);
803
try {
@@ -778,11 +805,15 @@ describe('ReactDOMEventListener', () => {
805
<div
806
className="grand"
807
onScroll={onScroll}
781
- onScrollCapture={onScrollCapture}>
808
+ onScrollCapture={onScrollCapture}
809
+ onScrollEnd={onScrollEnd}
810
+ onScrollEndCapture={onScrollEndCapture}>
811
<div
812
className="parent"
813
onScroll={onScroll}
785
- onScrollCapture={onScrollCapture}>
814
+ onScrollCapture={onScrollCapture}
815
+ onScrollEnd={onScrollEnd}
816
+ onScrollEndCapture={onScrollEndCapture}>
817
{/* Intentionally no handler on the child: */}
818
<div className="child" ref={ref} />
819
</div>
@@ -794,9 +825,16 @@ describe('ReactDOMEventListener', () => {
825
bubbles: false,
826
}),
827
);
828
+ ref.current.dispatchEvent(
829
+ new Event('scrollend', {
830
+ bubbles: false,
831
+ }),
832
+ );
833
expect(log).toEqual([
798
- ['capture', 'grand'],
799
- ['capture', 'parent'],
834
+ ['onScroll', 'capture', 'grand'],
835
+ ['onScroll', 'capture', 'parent'],
836
+ ['onScrollEnd', 'capture', 'grand'],
837
+ ['onScrollEnd', 'capture', 'parent'],
838
]);
839
} finally {
840
document.body.removeChild(container);
@@ -808,10 +846,16 @@ describe('ReactDOMEventListener', () => {
846
const ref = React.createRef();
847
const log = [];
848
const onScroll = jest.fn(e =>
811
- log.push(['bubble', e.currentTarget.className]),
849
+ log.push(['onScroll', 'bubble', e.currentTarget.className]),
850
);
851
const onScrollCapture = jest.fn(e =>
814
- log.push(['capture', e.currentTarget.className]),
852
+ log.push(['onScroll', 'capture', e.currentTarget.className]),
853
+ );
854
+ const onScrollEnd = jest.fn(e =>
855
+ log.push(['onScrollEnd', 'bubble', e.currentTarget.className]),
856
+ );
857
+ const onScrollEndCapture = jest.fn(e =>
858
+ log.push(['onScrollEnd', 'capture', e.currentTarget.className]),
859
);
860
document.body.appendChild(container);
861
try {
@@ -829,15 +873,21 @@ describe('ReactDOMEventListener', () => {
873
<div
874
className="grand"
875
onScroll={e => onScroll(e)}
832
- onScrollCapture={e => onScrollCapture(e)}>
876
+ onScrollCapture={e => onScrollCapture(e)}
877
+ onScrollEnd={e => onScrollEnd(e)}
878
+ onScrollEndCapture={e => onScrollEndCapture(e)}>
879
<div
880
className="parent"
881
onScroll={e => onScroll(e)}
836
- onScrollCapture={e => onScrollCapture(e)}>
882
+ onScrollCapture={e => onScrollCapture(e)}
883
+ onScrollEnd={e => onScrollEnd(e)}
884
+ onScrollEndCapture={e => onScrollEndCapture(e)}>
885
<div
886
className="child"
887
onScroll={e => onScroll(e)}
888
onScrollCapture={e => onScrollCapture(e)}
889
+ onScrollEnd={e => onScrollEnd(e)}
890
+ onScrollEndCapture={e => onScrollEndCapture(e)}
891
ref={ref}
892
/>
893
</div>
@@ -849,11 +899,20 @@ describe('ReactDOMEventListener', () => {
899
bubbles: false,
900
}),
901
);
902
+ ref.current.dispatchEvent(
903
+ new Event('scrollend', {
904
+ bubbles: false,
905
+ }),
906
+ );
907
expect(log).toEqual([
853
- ['capture', 'grand'],
854
- ['capture', 'parent'],
855
- ['capture', 'child'],
856
- ['bubble', 'child'],
908
+ ['onScroll', 'capture', 'grand'],
909
+ ['onScroll', 'capture', 'parent'],
910
+ ['onScroll', 'capture', 'child'],
911
+ ['onScroll', 'bubble', 'child'],
912
+ ['onScrollEnd', 'capture', 'grand'],
913
+ ['onScrollEnd', 'capture', 'parent'],
914
+ ['onScrollEnd', 'capture', 'child'],
915
+ ['onScrollEnd', 'bubble', 'child'],
916
]);
917
918
// Update to verify deduplication.
@@ -864,15 +923,21 @@ describe('ReactDOMEventListener', () => {
923
// Note: these are intentionally inline functions so that
924
// we hit the reattachment codepath instead of bailing out.
925
onScroll={e => onScroll(e)}
867
- onScrollCapture={e => onScrollCapture(e)}>
926
+ onScrollCapture={e => onScrollCapture(e)}
927
+ onScrollEnd={e => onScrollEnd(e)}
928
+ onScrollEndCapture={e => onScrollEndCapture(e)}>
929
<div
930
className="parent"
931
onScroll={e => onScroll(e)}
871
- onScrollCapture={e => onScrollCapture(e)}>
932
+ onScrollCapture={e => onScrollCapture(e)}
933
+ onScrollEnd={e => onScrollEnd(e)}
934
+ onScrollEndCapture={e => onScrollEndCapture(e)}>
935
<div
936
className="child"
937
onScroll={e => onScroll(e)}
938
onScrollCapture={e => onScrollCapture(e)}
939
+ onScrollEnd={e => onScrollEnd(e)}
940
+ onScrollEndCapture={e => onScrollEndCapture(e)}
941
ref={ref}
942
/>
943
</div>
@@ -884,11 +949,20 @@ describe('ReactDOMEventListener', () => {
949
bubbles: false,
950
}),
951
);
952
+ ref.current.dispatchEvent(
953
+ new Event('scrollend', {
954
+ bubbles: false,
955
+ }),
956
+ );
957
expect(log).toEqual([
888
- ['capture', 'grand'],
889
- ['capture', 'parent'],
890
- ['capture', 'child'],
891
- ['bubble', 'child'],
958
+ ['onScroll', 'capture', 'grand'],
959
+ ['onScroll', 'capture', 'parent'],
960
+ ['onScroll', 'capture', 'child'],
961
+ ['onScroll', 'bubble', 'child'],
962
+ ['onScrollEnd', 'capture', 'grand'],
963
+ ['onScrollEnd', 'capture', 'parent'],
964
+ ['onScrollEnd', 'capture', 'child'],
965
+ ['onScrollEnd', 'bubble', 'child'],
966
]);
967
968
// Update to detach.
@@ -906,6 +980,11 @@ describe('ReactDOMEventListener', () => {
980
bubbles: false,
981
}),
982
);
983
+ ref.current.dispatchEvent(
984
+ new Event('scrollend', {
985
+ bubbles: false,
986
+ }),
987
+ );
988
expect(log).toEqual([]);
989
} finally {
990
document.body.removeChild(container);
@@ -918,24 +997,37 @@ describe('ReactDOMEventListener', () => {
997
const ref = React.createRef();
998
const log = [];
999
const onScroll = jest.fn(e =>
921
- log.push(['bubble', e.currentTarget.className]),
1000
+ log.push(['onScroll', 'bubble', e.currentTarget.className]),
1001
);
1002
const onScrollCapture = jest.fn(e =>
924
- log.push(['capture', e.currentTarget.className]),
1003
+ log.push(['onScroll', 'capture', e.currentTarget.className]),
1004
+ );
1005
+ const onScrollEnd = jest.fn(e =>
1006
+ log.push(['onScrollEnd', 'bubble', e.currentTarget.className]),
1007
+ );
1008
+ const onScrollEndCapture = jest.fn(e =>
1009
+ log.push(['onScrollEnd', 'capture', e.currentTarget.className]),
1010
);
1011
+
1012
const tree = (
1013
<div
1014
className="grand"
1015
onScroll={onScroll}
930
- onScrollCapture={onScrollCapture}>
1016
+ onScrollCapture={onScrollCapture}
1017
+ onScrollEnd={onScrollEnd}
1018
+ onScrollEndCapture={onScrollEndCapture}>
1019
<div
1020
className="parent"
1021
onScroll={onScroll}
934
- onScrollCapture={onScrollCapture}>
1022
+ onScrollCapture={onScrollCapture}
1023
+ onScrollEnd={onScrollEnd}
1024
+ onScrollEndCapture={onScrollEndCapture}>
1025
<div
1026
className="child"
1027
onScroll={onScroll}
1028
onScrollCapture={onScrollCapture}
1029
+ onScrollEnd={onScrollEnd}
1030
+ onScrollEndCapture={onScrollEndCapture}
1031
ref={ref}
1032
/>
1033
</div>
@@ -950,11 +1042,20 @@ describe('ReactDOMEventListener', () => {
1042
bubbles: false,
1043
}),
1044
);
1045
+ ref.current.dispatchEvent(
1046
+ new Event('scrollend', {
1047
+ bubbles: false,
1048
+ }),
1049
+ );
1050
expect(log).toEqual([
954
- ['capture', 'grand'],
955
- ['capture', 'parent'],
956
- ['capture', 'child'],
957
- ['bubble', 'child'],
1051
+ ['onScroll', 'capture', 'grand'],
1052
+ ['onScroll', 'capture', 'parent'],
1053
+ ['onScroll', 'capture', 'child'],
1054
+ ['onScroll', 'bubble', 'child'],
1055
+ ['onScrollEnd', 'capture', 'grand'],
1056
+ ['onScrollEnd', 'capture', 'parent'],
1057
+ ['onScrollEnd', 'capture', 'child'],
1058
+ ['onScrollEnd', 'bubble', 'child'],
1059
]);
1060
1061
log.length = 0;
@@ -971,6 +1072,11 @@ describe('ReactDOMEventListener', () => {
1072
bubbles: false,
1073
}),
1074
);
1075
+ ref.current.dispatchEvent(
1076
+ new Event('scrollend', {
1077
+ bubbles: false,
1078
+ }),
1079
+ );
1080
expect(log).toEqual([]);
1081
} finally {
1082
document.body.removeChild(container);
packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js
+16
@@ -1256,6 +1256,22 @@ describe('ReactDOMEventListener', () => {
1256
},
1257
});
1258
});
1259
+
1260
+ it('onScrollEnd', () => {
1261
+ testNonBubblingEvent({
1262
+ type: 'div',
1263
+ reactEvent: 'onScrollEnd',
1264
+ reactEventType: 'scrollend',
1265
+ nativeEvent: 'scrollend',
1266
+ dispatch(node) {
1267
+ const e = new Event('scrollend', {
1268
+ bubbles: false,
1269
+ cancelable: true,
1270
+ });
1271
+ node.dispatchEvent(e);
1272
+ },
1273
+ });
1274
+ });
1275
});
1276
1277
// The tests for these events are currently very limited