Skip to content

Commit c7b3a44

Browse files
authored
Merge pull request Expensify#72302 from callstack-internal/bugfix/report-preview-jumping
Bugfix/report preview jumping
2 parents 8ad2605 + ce667bf commit c7b3a44

3 files changed

Lines changed: 68 additions & 2 deletions

File tree

src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ class RenderTaskQueue {
1313

1414
private timeout: NodeJS.Timeout | null = null;
1515

16+
private onIsRenderingChange?: (isRendering: boolean) => void;
17+
18+
constructor(onIsRenderingChange?: (isRendering: boolean) => void) {
19+
this.onIsRenderingChange = onIsRenderingChange;
20+
}
21+
1622
add(info: RenderInfo) {
1723
this.renderInfos.push(info);
1824

@@ -30,15 +36,18 @@ class RenderTaskQueue {
3036
return;
3137
}
3238
clearTimeout(this.timeout);
39+
this.onIsRenderingChange?.(false);
3340
}
3441

3542
private render() {
3643
const info = this.renderInfos.shift();
3744
if (!info) {
3845
this.isRendering = false;
46+
this.onIsRenderingChange?.(false);
3947
return;
4048
}
4149
this.isRendering = true;
50+
this.onIsRenderingChange?.(true);
4251

4352
this.handler(info);
4453

src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ function BaseInvertedFlatList<T>({ref, ...props}: BaseInvertedFlatListProps<T>)
4343
return null;
4444
});
4545
const [isInitialData, setIsInitialData] = useState(true);
46+
const [isQueueRendering, setIsQueueRendering] = useState(false);
47+
4648
const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]);
4749
const displayedData = useMemo(() => {
4850
if (currentDataIndex <= 0) {
@@ -56,7 +58,7 @@ function BaseInvertedFlatList<T>({ref, ...props}: BaseInvertedFlatListProps<T>)
5658
const dataIndexDifference = data.length - displayedData.length;
5759

5860
// Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list.
59-
const renderQueue = useMemo(() => new RenderTaskQueue(), []);
61+
const renderQueue = useMemo(() => new RenderTaskQueue(setIsQueueRendering), []);
6062
useEffect(() => {
6163
return () => {
6264
renderQueue.cancel();
@@ -88,6 +90,10 @@ function BaseInvertedFlatList<T>({ref, ...props}: BaseInvertedFlatListProps<T>)
8890
);
8991

9092
const maintainVisibleContentPosition = useMemo(() => {
93+
if (!initialScrollKey && (!isInitialData || !isQueueRendering)) {
94+
return undefined;
95+
}
96+
9197
const config: ScrollViewProps['maintainVisibleContentPosition'] = {
9298
// This needs to be 1 to avoid using loading views as anchors.
9399
minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0,
@@ -98,7 +104,7 @@ function BaseInvertedFlatList<T>({ref, ...props}: BaseInvertedFlatListProps<T>)
98104
}
99105

100106
return config;
101-
}, [data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]);
107+
}, [initialScrollKey, isInitialData, isQueueRendering, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]);
102108

103109
const listRef = useRef<RNFlatList | null>(null);
104110
useImperativeHandle(ref, () => {

tests/unit/RenderTaskQueueTest.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import RenderTaskQueue from '../../src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue';
2+
3+
jest.unmock('../../src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue');
4+
5+
describe('RenderTaskQueue', () => {
6+
beforeEach(() => {
7+
jest.useFakeTimers();
8+
});
9+
10+
afterEach(() => {
11+
jest.clearAllTimers();
12+
jest.useRealTimers();
13+
});
14+
15+
describe('notifyRenderingStateChange callback', () => {
16+
it('should notify rendering state changes when a task completes naturally to track the rendering lifecycle', () => {
17+
// Given a RenderTaskQueue with an isRendering change callback
18+
const mockOnIsRenderingChange = jest.fn();
19+
const queue = new RenderTaskQueue(mockOnIsRenderingChange);
20+
21+
// When a task is added and allowed to complete
22+
queue.add({distanceFromStart: 100});
23+
24+
// Then the callback is invoked with true when rendering starts
25+
expect(mockOnIsRenderingChange).toHaveBeenCalledWith(true);
26+
jest.advanceTimersByTime(500);
27+
28+
// Then the callback is invoked with false when rendering completes
29+
expect(mockOnIsRenderingChange).toHaveBeenCalledTimes(2);
30+
expect(mockOnIsRenderingChange).toHaveBeenCalledWith(false);
31+
});
32+
33+
it('should notify rendering state changes when a task is canceled to ensure proper cleanup', () => {
34+
// Given a RenderTaskQueue with an isRendering change callback
35+
const mockOnIsRenderingChange = jest.fn();
36+
const queue = new RenderTaskQueue(mockOnIsRenderingChange);
37+
38+
// When a task is added but canceled before completion
39+
queue.add({distanceFromStart: 100});
40+
queue.cancel();
41+
42+
// Then the callback is invoked with true when rendering starts
43+
expect(mockOnIsRenderingChange).toHaveBeenCalledWith(true);
44+
jest.advanceTimersByTime(500);
45+
46+
// Then the callback is invoked with false even after canceling to ensure proper cleanup
47+
expect(mockOnIsRenderingChange).toHaveBeenCalledTimes(2);
48+
expect(mockOnIsRenderingChange).toHaveBeenCalledWith(false);
49+
});
50+
});
51+
});

0 commit comments

Comments
 (0)