Description
Calling scrollToOffset({ animated: false }) starts LegendList's scroll-completion fallback watchdog. If the list unmounts before that watchdog settles, its timers continue running and call back into the unmounted list.
This is especially visible in React Native Jest tests: the originating test can pass, then a callback runs after Jest has torn down that test environment and reports:
ReferenceError: You are trying to import a file after the Jest environment has been torn down.
With parallel workers, the failure can be attributed to whichever unrelated suite is running next.
Versions
@legendapp/list: 3.3.2
- React Native: 0.81.5
- React: 19.1.0
- Jest: 30.0.2
@testing-library/react-native: 13.2.0
- Node: 24.7.0
- pnpm: 11.9.0
The same cleanup omission is present in the source for v3.3.3 and current main.
Minimal reproduction
This test was verified against 3.3.2 in a React Native Jest environment where the list receives a measured layout:
import { LegendList, type LegendListRef } from '@legendapp/list/react-native';
import { render } from '@testing-library/react-native';
import { useEffect, useRef } from 'react';
import { Text } from 'react-native';
function Harness({ resetKey }: { resetKey: number }) {
const listRef = useRef<LegendListRef>(null);
const previousKey = useRef(resetKey);
useEffect(() => {
if (previousKey.current !== resetKey) {
previousKey.current = resetKey;
void listRef.current?.scrollToOffset({
offset: 0,
animated: false,
});
}
}, [resetKey]);
return (
<LegendList
ref={listRef}
data={['item']}
estimatedItemSize={40}
recycleItems={false}
renderItem={({ item }) => <Text>{item}</Text>}
/>
);
}
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
it('clears programmatic-scroll work on unmount', () => {
jest.useFakeTimers();
const view = render(<Harness resetKey={0} />);
view.rerender(<Harness resetKey={1} />);
expect(jest.getTimerCount()).toBeGreaterThan(0);
view.unmount();
expect(jest.getTimerCount()).toBe(0);
});
The final assertion currently fails with:
With real timers and normal Testing Library auto-cleanup, the same pending work can instead execute after the Jest environment is torn down.
Source analysis
checkFinishedScrollFallback stores its initial and recursive timers in state.timeoutCheckFinishedScrollFallback:
|
// In case checkFinishedScroll does not work correctly, set a maximum timeout |
|
// to make sure it does eventually get cleared, just waiting for scroll to end |
|
export function checkFinishedScrollFallback(ctx: StateContext) { |
|
const state = ctx.state; |
|
const scrollingTo = state.scrollingTo; |
|
const shouldFinishInitialZeroTarget = shouldFinishInitialZeroTargetScroll(ctx); |
|
const silentInitialDispatch = isSilentInitialDispatch(state, scrollingTo); |
|
const canFinishInitialWithoutNativeProgress = |
|
scrollingTo !== undefined ? shouldFinishInitialScrollWithoutNativeProgress(state, scrollingTo) : false; |
|
const slowTimeout = |
|
(scrollingTo?.isInitialScroll && !shouldFinishInitialZeroTarget && !canFinishInitialWithoutNativeProgress) || |
|
!state.didContainersLayout; |
|
const initialDelay = |
|
shouldFinishInitialZeroTarget || canFinishInitialWithoutNativeProgress |
|
? 0 |
|
: silentInitialDispatch |
|
? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS |
|
: slowTimeout |
|
? 500 |
|
: 100; |
|
|
|
state.timeoutCheckFinishedScrollFallback = setTimeout(() => { |
|
let numChecks = 0; |
|
const scheduleFallbackCheck = (delay: number) => { |
|
state.timeoutCheckFinishedScrollFallback = setTimeout(checkHasScrolled, delay); |
|
}; |
|
const checkHasScrolled = () => { |
|
state.timeoutCheckFinishedScrollFallback = undefined; |
|
|
|
const isStillScrollingTo = state.scrollingTo; |
|
if (isStillScrollingTo) { |
|
numChecks++; |
|
const isNativeInitialPending = isNativeInitialNonZeroTarget(state) && !state.hasScrolled; |
|
const maxChecks = silentInitialDispatch |
|
? 5 |
|
: isNativeInitialPending |
|
? INITIAL_SCROLL_MAX_FALLBACK_CHECKS |
|
: 5; |
|
const shouldFinishZeroTarget = shouldFinishInitialZeroTargetScroll(ctx); |
|
const canFinishInitialScrollWithoutNativeProgress = shouldFinishInitialScrollWithoutNativeProgress( |
|
state, |
|
isStillScrollingTo, |
|
); |
|
const completionState = getResolvedScrollCompletionState(ctx, isStillScrollingTo); |
|
const canFinishAfterSilentNativeDispatch = |
|
Platform.OS === "android" && |
|
silentInitialDispatch && |
|
completionState.isAtResolvedTarget && |
|
numChecks >= 1; |
|
const shouldRetrySilentInitialNativeScroll = |
|
Platform.OS === "android" && |
|
canFinishAfterSilentNativeDispatch && |
|
!initialScrollCompletion.didRetrySilentInitialScroll(state); |
|
const shouldFinishAfterObservedScroll = |
|
state.hasScrolled && (!isStillScrollingTo.isInitialScroll || completionState.isAtResolvedTarget); |
|
const shouldRetryUnalignedInitialScroll = |
|
isStillScrollingTo.isInitialScroll && !completionState.isAtResolvedTarget && numChecks <= maxChecks; |
|
const shouldRetryUnalignedEndScroll = |
|
Platform.OS === "ios" && |
|
!isStillScrollingTo.isInitialScroll && |
|
isEndAlignedLastItemTarget(ctx, isStillScrollingTo) && |
|
!completionState.isAtResolvedTarget && |
|
numChecks <= maxChecks; |
|
if (shouldRetrySilentInitialNativeScroll) { |
|
const targetOffset = |
|
getInitialScrollWatchdogTargetOffset(state) ?? isStillScrollingTo.targetOffset ?? 0; |
|
const jiggleOffset = |
|
targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON |
|
? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON |
|
: targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; |
|
initialScrollCompletion.markSilentInitialScrollRetry(state); |
|
scrollToFallbackOffset(ctx, jiggleOffset); |
|
requestAnimationFrame(() => { |
|
scrollToFallbackOffset(ctx, targetOffset); |
|
}); |
|
scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); |
|
} else if (shouldRetryUnalignedEndScroll) { |
|
scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); |
|
scheduleFallbackCheck(100); |
|
} else if ( |
|
shouldFinishZeroTarget || |
|
shouldFinishAfterObservedScroll || |
|
canFinishInitialScrollWithoutNativeProgress || |
|
canFinishAfterSilentNativeDispatch || |
|
numChecks > maxChecks |
|
) { |
|
finishScrollTo(ctx); |
|
} else if ((isNativeInitialPending || shouldRetryUnalignedInitialScroll) && numChecks <= maxChecks) { |
|
const targetOffset = |
|
getInitialScrollWatchdogTargetOffset(state) ?? |
|
isStillScrollingTo.targetOffset ?? |
|
state.scrollPending; |
|
scrollToFallbackOffset(ctx, targetOffset); |
|
scheduleFallbackCheck(silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : 100); |
|
} else { |
|
scheduleFallbackCheck(silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : 100); |
|
} |
|
} |
|
}; |
|
checkHasScrolled(); |
|
}, initialDelay); |
|
} |
The LegendList unmount cleanup cancels queuedFullDrawDistancePrewarm and entries in state.timeouts, but the fallback watchdog is stored separately and is not cleared:
|
useEffect(() => { |
|
return () => { |
|
if (state.queuedFullDrawDistancePrewarm !== undefined) { |
|
cancelAnimationFrame(state.queuedFullDrawDistancePrewarm); |
|
state.queuedFullDrawDistancePrewarm = undefined; |
|
} |
|
for (const timeout of state.timeouts) { |
|
clearTimeout(timeout); |
|
} |
|
state.timeouts.clear(); |
|
}; |
|
}, [state]); |
|
timeouts: Set<number>; |
|
timeoutSetPaddingTop?: any; |
|
timeoutCheckFinishedScrollFallback?: any; |
Starting another scroll does clear the watchdog, showing that it is intended to be cancellable; unmount currently does not:
|
// Clear out previous timeouts which would finishScrollTo |
|
if (state.animFrameCheckFinishedScroll) { |
|
cancelAnimationFrame(ctx.state.animFrameCheckFinishedScroll); |
|
} |
|
if (state.timeoutCheckFinishedScrollFallback) { |
|
clearTimeout(ctx.state.timeoutCheckFinishedScrollFallback); |
|
} |
PR #485 is related watchdog work, but addresses timers multiplying when a live scroll session is re-armed rather than cleanup on unmount:
Expected behavior
Unmounting LegendList cancels all timers and animation frames owned by the list, and any pending imperative-scroll promise is settled.
Actual behavior
The fallback watchdog survives unmount and may subsequently call finishScrollTo, recalculateSettledScroll, and calculateItemsInView against the unmounted list.
Suggested fix
During unmount:
- cancel and unset
timeoutCheckFinishedScrollFallback;
- cancel associated stored and retry animation-frame work;
- settle and unset
pendingScrollResolve, since imperative scroll methods return promises;
- or route all list-owned asynchronous handles through a single lifecycle-managed registry.
A regression test could start a non-animated programmatic scroll, unmount before the fallback runs, advance fake timers, and assert that no fallback callback or pending timer remains.
Description
Calling
scrollToOffset({ animated: false })starts LegendList's scroll-completion fallback watchdog. If the list unmounts before that watchdog settles, its timers continue running and call back into the unmounted list.This is especially visible in React Native Jest tests: the originating test can pass, then a callback runs after Jest has torn down that test environment and reports:
With parallel workers, the failure can be attributed to whichever unrelated suite is running next.
Versions
@legendapp/list: 3.3.2@testing-library/react-native: 13.2.0The same cleanup omission is present in the source for v3.3.3 and current
main.Minimal reproduction
This test was verified against 3.3.2 in a React Native Jest environment where the list receives a measured layout:
The final assertion currently fails with:
With real timers and normal Testing Library auto-cleanup, the same pending work can instead execute after the Jest environment is torn down.
Source analysis
checkFinishedScrollFallbackstores its initial and recursive timers instate.timeoutCheckFinishedScrollFallback:legend-list/src/core/checkFinishedScroll.ts
Lines 161 to 262 in 4c112e6
The LegendList unmount cleanup cancels
queuedFullDrawDistancePrewarmand entries instate.timeouts, but the fallback watchdog is stored separately and is not cleared:legend-list/src/components/LegendList.tsx
Lines 783 to 794 in 4c112e6
legend-list/src/types.internal.ts
Lines 262 to 264 in 4c112e6
Starting another scroll does clear the watchdog, showing that it is intended to be cancellable; unmount currently does not:
legend-list/src/core/scrollTo.ts
Lines 172 to 178 in 4c112e6
PR #485 is related watchdog work, but addresses timers multiplying when a live scroll session is re-armed rather than cleanup on unmount:
Expected behavior
Unmounting LegendList cancels all timers and animation frames owned by the list, and any pending imperative-scroll promise is settled.
Actual behavior
The fallback watchdog survives unmount and may subsequently call
finishScrollTo,recalculateSettledScroll, andcalculateItemsInViewagainst the unmounted list.Suggested fix
During unmount:
timeoutCheckFinishedScrollFallback;pendingScrollResolve, since imperative scroll methods return promises;A regression test could start a non-animated programmatic scroll, unmount before the fallback runs, advance fake timers, and assert that no fallback callback or pending timer remains.