@samitouri / QOS-React-1 / commits / 537228f9fd

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