Skip to content

Commit

Permalink
Merge pull request #32098 from margelo/feat/##23220-web-maintainVisib…
Browse files Browse the repository at this point in the history
…leContentPosition

HIGH: (Comment linking: step 2) [23220] WEB maintain visible content position
  • Loading branch information
roryabraham authored Jan 9, 2024
2 parents c83e9f1 + 54921a9 commit 4e25271
Show file tree
Hide file tree
Showing 11 changed files with 839 additions and 955 deletions.
1 change: 0 additions & 1 deletion .github/workflows/reassurePerformanceTests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,3 @@ jobs:
with:
DURATION_DEVIATION_PERCENTAGE: 20
COUNT_DEVIATION: 0

872 changes: 617 additions & 255 deletions patches/react-native-web+0.19.9+001+initial.patch

Large diffs are not rendered by default.

687 changes: 0 additions & 687 deletions patches/react-native-web+0.19.9+002+fix-mvcp.patch

This file was deleted.

207 changes: 207 additions & 0 deletions src/components/FlatList/MVCPFlatList.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/* eslint-disable es/no-optional-chaining, es/no-nullish-coalescing-operators, react/prop-types */
import PropTypes from 'prop-types';
import React from 'react';
import {FlatList} from 'react-native';

function mergeRefs(...args) {
return function forwardRef(node) {
args.forEach((ref) => {
if (ref == null) {
return;
}
if (typeof ref === 'function') {
ref(node);
return;
}
if (typeof ref === 'object') {
// eslint-disable-next-line no-param-reassign
ref.current = node;
return;
}
console.error(`mergeRefs cannot handle Refs of type boolean, number or string, received ref ${String(ref)}`);
});
};
}

function useMergeRefs(...args) {
return React.useMemo(
() => mergeRefs(...args),
// eslint-disable-next-line
[...args],
);
}

const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizontal, onScroll, ...props}, forwardedRef) => {
const {minIndexForVisible: mvcpMinIndexForVisible, autoscrollToTopThreshold: mvcpAutoscrollToTopThreshold} = maintainVisibleContentPosition ?? {};
const scrollRef = React.useRef(null);
const prevFirstVisibleOffsetRef = React.useRef(null);
const firstVisibleViewRef = React.useRef(null);
const mutationObserverRef = React.useRef(null);
const lastScrollOffsetRef = React.useRef(0);

const getScrollOffset = React.useCallback(() => {
if (scrollRef.current == null) {
return 0;
}
return horizontal ? scrollRef.current.getScrollableNode().scrollLeft : scrollRef.current.getScrollableNode().scrollTop;
}, [horizontal]);

const getContentView = React.useCallback(() => scrollRef.current?.getScrollableNode().childNodes[0], []);

const scrollToOffset = React.useCallback(
(offset, animated) => {
const behavior = animated ? 'smooth' : 'instant';
scrollRef.current?.getScrollableNode().scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior});
},
[horizontal],
);

const prepareForMaintainVisibleContentPosition = React.useCallback(() => {
if (mvcpMinIndexForVisible == null) {
return;
}

const contentView = getContentView();
if (contentView == null) {
return;
}

const scrollOffset = getScrollOffset();

const contentViewLength = contentView.childNodes.length;
for (let i = mvcpMinIndexForVisible; i < contentViewLength; i++) {
const subview = contentView.childNodes[i];
const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop;
if (subviewOffset > scrollOffset || i === contentViewLength - 1) {
prevFirstVisibleOffsetRef.current = subviewOffset;
firstVisibleViewRef.current = subview;
break;
}
}
}, [getContentView, getScrollOffset, mvcpMinIndexForVisible, horizontal]);

const adjustForMaintainVisibleContentPosition = React.useCallback(() => {
if (mvcpMinIndexForVisible == null) {
return;
}

const firstVisibleView = firstVisibleViewRef.current;
const prevFirstVisibleOffset = prevFirstVisibleOffsetRef.current;
if (firstVisibleView == null || prevFirstVisibleOffset == null) {
return;
}

const firstVisibleViewOffset = horizontal ? firstVisibleView.offsetLeft : firstVisibleView.offsetTop;
const delta = firstVisibleViewOffset - prevFirstVisibleOffset;
if (Math.abs(delta) > 0.5) {
const scrollOffset = getScrollOffset();
prevFirstVisibleOffsetRef.current = firstVisibleViewOffset;
scrollToOffset(scrollOffset + delta, false);
if (mvcpAutoscrollToTopThreshold != null && scrollOffset <= mvcpAutoscrollToTopThreshold) {
scrollToOffset(0, true);
}
}
}, [getScrollOffset, scrollToOffset, mvcpMinIndexForVisible, mvcpAutoscrollToTopThreshold, horizontal]);

const setupMutationObserver = React.useCallback(() => {
const contentView = getContentView();
if (contentView == null) {
return;
}

mutationObserverRef.current?.disconnect();

const mutationObserver = new MutationObserver(() => {
// This needs to execute after scroll events are dispatched, but
// in the same tick to avoid flickering. rAF provides the right timing.
requestAnimationFrame(() => {
// Chrome adjusts scroll position when elements are added at the top of the
// view. We want to have the same behavior as react-native / Safari so we
// reset the scroll position to the last value we got from an event.
const lastScrollOffset = lastScrollOffsetRef.current;
const scrollOffset = getScrollOffset();
if (lastScrollOffset !== scrollOffset) {
scrollToOffset(lastScrollOffset, false);
}

adjustForMaintainVisibleContentPosition();
});
});
mutationObserver.observe(contentView, {
attributes: true,
childList: true,
subtree: true,
});

mutationObserverRef.current = mutationObserver;
}, [adjustForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]);

React.useEffect(() => {
requestAnimationFrame(() => {
prepareForMaintainVisibleContentPosition();
setupMutationObserver();
});
}, [prepareForMaintainVisibleContentPosition, setupMutationObserver]);

const setMergedRef = useMergeRefs(scrollRef, forwardedRef);

const onRef = React.useCallback(
(newRef) => {
// Make sure to only call refs and re-attach listeners if the node changed.
if (newRef == null || newRef === scrollRef.current) {
return;
}

setMergedRef(newRef);
prepareForMaintainVisibleContentPosition();
setupMutationObserver();
},
[prepareForMaintainVisibleContentPosition, setMergedRef, setupMutationObserver],
);

React.useEffect(() => {
const mutationObserver = mutationObserverRef.current;
return () => {
mutationObserver?.disconnect();
};
}, []);

const onScrollInternal = React.useCallback(
(ev) => {
lastScrollOffsetRef.current = getScrollOffset();

prepareForMaintainVisibleContentPosition();

onScroll?.(ev);
},
[getScrollOffset, prepareForMaintainVisibleContentPosition, onScroll],
);

return (
<FlatList
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
maintainVisibleContentPosition={maintainVisibleContentPosition}
horizontal={horizontal}
onScroll={onScrollInternal}
scrollEventThrottle={1}
ref={onRef}
/>
);
});

MVCPFlatList.displayName = 'MVCPFlatList';
MVCPFlatList.propTypes = {
maintainVisibleContentPosition: PropTypes.shape({
minIndexForVisible: PropTypes.number.isRequired,
autoscrollToTopThreshold: PropTypes.number,
}),
horizontal: PropTypes.bool,
};

MVCPFlatList.defaultProps = {
maintainVisibleContentPosition: null,
horizontal: false,
};

export default MVCPFlatList;
3 changes: 3 additions & 0 deletions src/components/FlatList/index.web.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import MVCPFlatList from './MVCPFlatList';

export default MVCPFlatList;
2 changes: 0 additions & 2 deletions src/components/InvertedFlatList/BaseInvertedFlatList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import React, {forwardRef} from 'react';
import type {FlatListProps} from 'react-native';
import FlatList from '@components/FlatList';

const AUTOSCROLL_TO_TOP_THRESHOLD = 128;
const WINDOW_SIZE = 15;

function BaseInvertedFlatList<T>(props: FlatListProps<T>, ref: ForwardedRef<FlatList>) {
Expand All @@ -15,7 +14,6 @@ function BaseInvertedFlatList<T>(props: FlatListProps<T>, ref: ForwardedRef<Flat
windowSize={WINDOW_SIZE}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: AUTOSCROLL_TO_TOP_THRESHOLD,
}}
inverted
/>
Expand Down
2 changes: 2 additions & 0 deletions src/components/InvertedFlatList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {FlatList, FlatListProps, NativeScrollEvent, NativeSyntheticEvent} f
import {DeviceEventEmitter} from 'react-native';
import CONST from '@src/CONST';
import BaseInvertedFlatList from './BaseInvertedFlatList';
import CellRendererComponent from './CellRendererComponent';

// This is adapted from https://codesandbox.io/s/react-native-dsyse
// It's a HACK alert since FlatList has inverted scrolling on web
Expand Down Expand Up @@ -87,6 +88,7 @@ function InvertedFlatList<T>({onScroll: onScrollProp = () => {}, ...props}: Flat
{...props}
ref={ref}
onScroll={handleScroll}
CellRendererComponent={CellRendererComponent}
/>
);
}
Expand Down
7 changes: 0 additions & 7 deletions src/pages/home/ReportScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,13 +293,6 @@ function ReportScreen({
const onSubmitComment = useCallback(
(text) => {
Report.addComment(getReportID(route), text);

// We need to scroll to the bottom of the list after the comment is added
const refID = setTimeout(() => {
flatListRef.current.scrollToOffset({animated: false, offset: 0});
}, 10);

return () => clearTimeout(refID);
},
[route],
);
Expand Down
13 changes: 10 additions & 3 deletions src/pages/home/report/ReportActionsList.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {useRoute} from '@react-navigation/native';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {DeviceEventEmitter} from 'react-native';
import {DeviceEventEmitter, InteractionManager} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import _ from 'underscore';
import InvertedFlatList from '@components/InvertedFlatList';
Expand Down Expand Up @@ -138,7 +138,6 @@ function ReportActionsList({
isComposerFullSize,
}) {
const styles = useThemeStyles();
const reportScrollManager = useReportScrollManager();
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const route = useRoute();
Expand All @@ -151,6 +150,7 @@ function ReportActionsList({
}
return cacheUnreadMarkers.get(report.reportID);
};
const reportScrollManager = useReportScrollManager();
const [currentUnreadMarker, setCurrentUnreadMarker] = useState(markerInit);
const scrollingVerticalOffset = useRef(0);
const readActionSkipped = useRef(false);
Expand Down Expand Up @@ -261,6 +261,13 @@ function ReportActionsList({
};
}, [report.reportID]);

useEffect(() => {
InteractionManager.runAfterInteractions(() => {
reportScrollManager.scrollToBottom();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useEffect(() => {
// Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function?
// Answer: On web, when navigating to another report screen, the previous report screen doesn't get unmounted,
Expand All @@ -282,7 +289,7 @@ function ReportActionsList({
if (!isFromCurrentUser) {
return;
}
reportScrollManager.scrollToBottom();
InteractionManager.runAfterInteractions(() => reportScrollManager.scrollToBottom());
});

const cleanup = () => {
Expand Down

0 comments on commit 4e25271

Please sign in to comment.