Skip to content

[v3] Programmatic-scroll fallback timer survives LegendList unmount #508

Description

@JacquesLeupin

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:

Expected: 0
Received: 5

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions