Skip to content

Commit a1c97df

Browse files
committed
Fix duplicate onStartReached calls
1 parent c7eab40 commit a1c97df

File tree

5 files changed

+341
-8
lines changed

5 files changed

+341
-8
lines changed
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import React from "react";
2+
import { render } from "@quilted/react-testing";
3+
4+
import { useUnmountAwareCallbacks } from "../recyclerview/hooks/useUnmountAwareCallbacks";
5+
6+
const TestComponent = ({
7+
onRender,
8+
}: {
9+
onRender: (api: ReturnType<typeof useUnmountAwareCallbacks>) => void;
10+
}) => {
11+
const api = useUnmountAwareCallbacks();
12+
onRender(api);
13+
return null;
14+
};
15+
16+
describe("useUnmountAwareCallbacks", () => {
17+
beforeEach(() => {
18+
jest.useFakeTimers();
19+
});
20+
21+
afterEach(() => {
22+
jest.clearAllTimers();
23+
jest.clearAllMocks();
24+
});
25+
26+
it("returns a setTimeout function", () => {
27+
let api: ReturnType<typeof useUnmountAwareCallbacks> | undefined;
28+
render(
29+
<TestComponent
30+
onRender={(hookApi) => {
31+
api = hookApi;
32+
}}
33+
/>
34+
);
35+
36+
expect(api).toBeDefined();
37+
expect(api?.setTimeout).toBeDefined();
38+
expect(typeof api?.setTimeout).toBe("function");
39+
});
40+
41+
it("executes the callback after the specified delay", () => {
42+
const callback = jest.fn();
43+
let api: ReturnType<typeof useUnmountAwareCallbacks> | undefined;
44+
45+
render(
46+
<TestComponent
47+
onRender={(hookApi) => {
48+
api = hookApi;
49+
}}
50+
/>
51+
);
52+
53+
api?.setTimeout(callback, 1000);
54+
55+
expect(callback).not.toHaveBeenCalled();
56+
57+
// Fast-forward time
58+
jest.advanceTimersByTime(1000);
59+
60+
expect(callback).toHaveBeenCalledTimes(1);
61+
});
62+
63+
it("executes multiple callbacks after their respective delays", () => {
64+
const callback1 = jest.fn();
65+
const callback2 = jest.fn();
66+
let api: ReturnType<typeof useUnmountAwareCallbacks> | undefined;
67+
68+
render(
69+
<TestComponent
70+
onRender={(hookApi) => {
71+
api = hookApi;
72+
}}
73+
/>
74+
);
75+
76+
api?.setTimeout(callback1, 1000);
77+
api?.setTimeout(callback2, 2000);
78+
79+
expect(callback1).not.toHaveBeenCalled();
80+
expect(callback2).not.toHaveBeenCalled();
81+
82+
// Fast-forward time by 1000ms
83+
jest.advanceTimersByTime(1000);
84+
85+
expect(callback1).toHaveBeenCalledTimes(1);
86+
expect(callback2).not.toHaveBeenCalled();
87+
88+
// Fast-forward time by another 1000ms
89+
jest.advanceTimersByTime(1000);
90+
91+
expect(callback1).toHaveBeenCalledTimes(1);
92+
expect(callback2).toHaveBeenCalledTimes(1);
93+
});
94+
95+
it("clears all timeouts when the component unmounts", () => {
96+
const callback = jest.fn();
97+
let api: ReturnType<typeof useUnmountAwareCallbacks> | undefined;
98+
99+
const component = render(
100+
<TestComponent
101+
onRender={(hookApi) => {
102+
api = hookApi;
103+
}}
104+
/>
105+
);
106+
107+
api?.setTimeout(callback, 1000);
108+
api?.setTimeout(callback, 2000);
109+
110+
// Spy on clearTimeout to verify it's called during unmount
111+
const clearTimeoutSpy = jest.spyOn(global, "clearTimeout");
112+
113+
// Unmount the component
114+
component.unmount();
115+
116+
// Fast-forward time
117+
jest.advanceTimersByTime(2000);
118+
119+
// Expect callbacks not to be called because timeouts were cleared
120+
expect(callback).not.toHaveBeenCalled();
121+
expect(clearTimeoutSpy).toHaveBeenCalled();
122+
});
123+
124+
it("removes timeout from tracking set once it executes", () => {
125+
const callback = jest.fn();
126+
let api: ReturnType<typeof useUnmountAwareCallbacks> | undefined;
127+
128+
const component = render(
129+
<TestComponent
130+
onRender={(hookApi) => {
131+
api = hookApi;
132+
}}
133+
/>
134+
);
135+
136+
api?.setTimeout(callback, 1000);
137+
138+
// Fast-forward time
139+
jest.advanceTimersByTime(1000);
140+
141+
// Verify callback was called
142+
expect(callback).toHaveBeenCalledTimes(1);
143+
144+
// We can't directly check the timeoutIds Set, so we'll verify indirectly
145+
// by making sure no clearTimeout calls happen on unmount (since the timeout was already cleared)
146+
const clearTimeoutSpy = jest.spyOn(global, "clearTimeout");
147+
clearTimeoutSpy.mockClear(); // Reset the mock calls before unmount
148+
149+
// Unmount the component
150+
component.unmount();
151+
152+
// If the timeout was properly removed from the set, clearTimeout won't be called on unmount
153+
expect(clearTimeoutSpy).not.toHaveBeenCalled();
154+
});
155+
156+
it("handles multiple timeouts correctly", () => {
157+
const callback1 = jest.fn();
158+
const callback2 = jest.fn();
159+
const callback3 = jest.fn();
160+
let api: ReturnType<typeof useUnmountAwareCallbacks> | undefined;
161+
162+
const component = render(
163+
<TestComponent
164+
onRender={(hookApi) => {
165+
api = hookApi;
166+
}}
167+
/>
168+
);
169+
170+
// Set up three timeouts with different delays
171+
api?.setTimeout(callback1, 1000);
172+
api?.setTimeout(callback2, 2000);
173+
api?.setTimeout(callback3, 3000);
174+
175+
// Fast-forward time by 1500ms (should trigger only the first callback)
176+
jest.advanceTimersByTime(1500);
177+
178+
expect(callback1).toHaveBeenCalledTimes(1);
179+
expect(callback2).not.toHaveBeenCalled();
180+
expect(callback3).not.toHaveBeenCalled();
181+
182+
// Unmount the component (should clear remaining timeouts)
183+
component.unmount();
184+
185+
// Fast-forward time to when all callbacks would have been called
186+
jest.advanceTimersByTime(2000);
187+
188+
// Only the first callback should have been called
189+
expect(callback1).toHaveBeenCalledTimes(1);
190+
expect(callback2).not.toHaveBeenCalled();
191+
expect(callback3).not.toHaveBeenCalled();
192+
});
193+
194+
it("handles callbacks that trigger new timeouts", () => {
195+
const finalCallback = jest.fn();
196+
let api: ReturnType<typeof useUnmountAwareCallbacks> | undefined;
197+
198+
render(
199+
<TestComponent
200+
onRender={(hookApi) => {
201+
api = hookApi;
202+
}}
203+
/>
204+
);
205+
206+
const firstCallback = () => {
207+
api?.setTimeout(finalCallback, 1000);
208+
};
209+
210+
api?.setTimeout(firstCallback, 1000);
211+
212+
// Fast-forward time to trigger first callback
213+
jest.advanceTimersByTime(1000);
214+
215+
expect(finalCallback).not.toHaveBeenCalled();
216+
217+
// Fast-forward time to trigger second callback
218+
jest.advanceTimersByTime(1000);
219+
220+
expect(finalCallback).toHaveBeenCalledTimes(1);
221+
});
222+
223+
it("handles zero delay timeouts", () => {
224+
const callback = jest.fn();
225+
let api: ReturnType<typeof useUnmountAwareCallbacks> | undefined;
226+
227+
render(
228+
<TestComponent
229+
onRender={(hookApi) => {
230+
api = hookApi;
231+
}}
232+
/>
233+
);
234+
235+
api?.setTimeout(callback, 0);
236+
237+
expect(callback).not.toHaveBeenCalled();
238+
239+
// Even with zero delay, we need to advance the timer to execute
240+
jest.advanceTimersByTime(0);
241+
242+
expect(callback).toHaveBeenCalledTimes(1);
243+
});
244+
245+
it("handles errors in callbacks without affecting other timeouts", () => {
246+
const errorCallback = jest.fn(() => {
247+
throw new Error("Test error");
248+
});
249+
const successCallback = jest.fn();
250+
let api: ReturnType<typeof useUnmountAwareCallbacks> | undefined;
251+
252+
// Suppress error log during test
253+
const originalConsoleError = console.error;
254+
console.error = jest.fn();
255+
256+
render(
257+
<TestComponent
258+
onRender={(hookApi) => {
259+
api = hookApi;
260+
}}
261+
/>
262+
);
263+
264+
api?.setTimeout(errorCallback, 1000);
265+
api?.setTimeout(successCallback, 2000);
266+
267+
// Fast-forward time to trigger error callback
268+
try {
269+
jest.advanceTimersByTime(1000);
270+
} catch (error) {
271+
// Expected error
272+
}
273+
274+
expect(errorCallback).toHaveBeenCalledTimes(1);
275+
expect(successCallback).not.toHaveBeenCalled();
276+
277+
// Fast-forward time to trigger success callback
278+
jest.advanceTimersByTime(1000);
279+
280+
expect(successCallback).toHaveBeenCalledTimes(1);
281+
282+
// Restore console.error
283+
console.error = originalConsoleError;
284+
});
285+
});

src/recyclerview/RecyclerView.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,9 @@ const RecyclerViewComponent = <T,>(
218218
*/
219219
const onScrollHandler = useCallback(
220220
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
221+
if (recyclerViewManager.ignoreScrollEvents) {
222+
return;
223+
}
221224
let velocity = event.nativeEvent.velocity;
222225
let scrollOffset = horizontal
223226
? event.nativeEvent.contentOffset.x
@@ -237,7 +240,6 @@ const RecyclerViewComponent = <T,>(
237240
};
238241
}
239242
}
240-
241243
// Update scroll position and trigger re-render if needed
242244
if (recyclerViewManager.updateScrollOffset(scrollOffset, velocity)) {
243245
setRenderId((prev) => prev + 1);
@@ -405,7 +407,7 @@ const RecyclerViewComponent = <T,>(
405407
);
406408
}, [horizontal, shouldRenderFromBottom, adjustmentMinHeight]);
407409

408-
// console.log("render");
410+
// console.log("render", recyclerViewManager.getRenderStack());
409411

410412
// Render the main RecyclerView structure
411413
return (

src/recyclerview/RecyclerViewManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export class RecyclerViewManager<T> {
3333

3434
public disableRecycling = false;
3535
public firstItemOffset = 0;
36+
public ignoreScrollEvents = false;
3637

3738
constructor(props: RecyclerViewProps<T>) {
3839
this.props = props;

src/recyclerview/hooks/useRecyclerViewController.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { RVLayout } from "../layout-managers/LayoutManager";
1616
import { ScrollAnchorRef } from "../components/ScrollAnchor";
1717

1818
import { useUnmountFlag } from "./useUnmountFlag";
19+
import { useUnmountAwareCallbacks } from "./useUnmountAwareCallbacks";
1920

2021
/**
2122
* Parameters for scrolling to a specific position in the list.
@@ -97,6 +98,7 @@ export function useRecyclerViewController<T>(
9798
const pauseAdjustRef = useRef(false);
9899
const initialScrollCompletedRef = useRef(false);
99100
const lastDataLengthRef = useRef(data?.length ?? 0);
101+
const { setTimeout } = useUnmountAwareCallbacks();
100102

101103
// Track the first visible item for maintaining scroll position
102104
const firstVisibleItemKey = useRef<string | undefined>(undefined);
@@ -184,7 +186,7 @@ export function useRecyclerViewController<T>(
184186
currentDataLength > 0 &&
185187
props.maintainVisibleContentPosition?.disabled !== true
186188
) {
187-
const shouldScanData = currentDataLength !== lastDataLengthRef.current;
189+
const hasDataChanged = currentDataLength !== lastDataLengthRef.current;
188190
// If we have a tracked first visible item, maintain its position
189191
if (firstVisibleItemKey.current) {
190192
const currentIndexOfFirstVisibleItem =
@@ -195,7 +197,7 @@ export function useRecyclerViewController<T>(
195197
props.keyExtractor?.(props.data![index], index) ===
196198
firstVisibleItemKey.current
197199
) ??
198-
(shouldScanData
200+
(hasDataChanged
199201
? props.data?.findIndex(
200202
(item, index) =>
201203
props.keyExtractor?.(item, index) ===
@@ -214,9 +216,15 @@ export function useRecyclerViewController<T>(
214216
if (diff !== 0 && !pauseAdjustRef.current) {
215217
// console.log("diff", diff, firstVisibleItemKey.current);
216218
scrollAnchorRef.current?.scrollBy(diff);
217-
updateScrollOffsetAsync(
218-
recyclerViewManager.getAbsoluteLastScrollOffset() + diff
219-
);
219+
if (hasDataChanged) {
220+
updateScrollOffsetAsync(
221+
recyclerViewManager.getAbsoluteLastScrollOffset() + diff
222+
);
223+
recyclerViewManager.ignoreScrollEvents = true;
224+
setTimeout(() => {
225+
recyclerViewManager.ignoreScrollEvents = false;
226+
}, 100);
227+
}
220228
}
221229
}
222230
}
@@ -237,7 +245,7 @@ export function useRecyclerViewController<T>(
237245
}
238246
}
239247
lastDataLengthRef.current = props.data?.length ?? 0;
240-
}, [props.data, props.keyExtractor, recyclerViewManager]);
248+
}, [props.data, props.keyExtractor, recyclerViewManager, setTimeout]);
241249

242250
const handlerMethods = useMemo(() => {
243251
return {

0 commit comments

Comments
 (0)