setTimeout not clearing with React useEffect hook on mobile devices
Asked Answered
A

1

7

Problem Summary: setTimeout's are not clearing on mobile devices when using React's useEffect hook. They are, however, clearing on desktop.

Problem Reproduction: https://codepen.io/amliving/pen/QzmPYE.

NB: run on a mobile device to reproduce the problem.

My Question: Why does my solution (explained below) work?

Details: I'm creating a custom hook to detect idleness. Let's call it useDetectIdle. It dynamically adds and removes event listeners to window from a set of events, which when triggered call a provided callback after a period of time, via setTimeout.

Here is the list of events that will be dynamically added to and then removed from window:

const EVENTS = [
  "scroll",
  "keydown",
  "keypress",
  "touchstart",
  "touchmove",
  "mousedown", /* removing 'mousedown' for mobile devices solves the problem */
];

Here's the useDetectIdle hook. The import piece here is that this hook, when its calling component unmounts, should clear any existing timeout (and remove all event listeners):

const useDetectIdle = (inactivityTimeout, onIdle) => {
  const timeoutRef = useRef(null);
  const callbackRef = useRef(onIdle);

  useEffect(() => {
    callbackRef.current = onIdle;
  });

  const reset = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    const id = setTimeout(callbackRef.current, inactivityTimeout);
    timeoutRef.current = id;
  };

  useEffect(() => {
    reset();

    const handleEvent = _.throttle(() => {
      reset();
    }, 1000);

    EVENTS.forEach(event => window.addEventListener(event, handleEvent));

    return () => {
      EVENTS.forEach(event => window.removeEventListener(event, handleEvent));
      timeoutRef.current && clearTimeout(timeoutRef.current);
    };
  }, []);
};

useDetectIdle is called inside components like this:

const Example = () => {
  useDetectIdle(5000, () => alert("first"));
  return <div className="first">FIRST</div>;
};

On non-touchscreen devices, useDetectIdle works perfectly. But on mobile devices (both iOS and Android), any existing timeout is not cleared when its calling component unmounts. I.e. the callback passed to setTimemout still fires.

My Solution: Through some trial and error, I discovered that removing mousedown from the list of events solves the problem. Does anyone know what's happening under the hood?

Angelineangelique answered 6/1, 2019 at 8:46 Comment(1)
maybe it's intentional change in Chrome #41181872 but I'm still surprised not running into that beforePortillo
H
0

Note: this doesn't answer "why your solution works", resp. why it seemed to help, but it points out 2 bugs in your code that I think are the real cause of the behavior. (I.e. your solution does not really work.)

You are handling _.throttle insufficiently - imagine the following scenario:

  1. Your component with hook is mounted.
  2. User triggers one of the events - throttled function is called, i.e. it just internally sets the timeout for 1000ms (throttled callback will be called at the end of 1000ms).
  3. Before the timeout gets hit, you unmount the component. Listeners get removed just fine, but the internal timeout remained and will eventually fire your reset(), even though your component is already unmounted (and from there it will fire the idle callback after another inactivityTimeout ms).

Why the bug was prevalent on mobile was probably tied with what the user had to do to unmount the component on mobile vs desktop, the timing, and what events fired while doing it.

There is also the very tiny possibility that your component's DOM gets unmounted, and because React >= 17.x runs effect cleanup methods asynchronously, a timeout could fire just before your effect cleanup method. I doubt this would be consistently simulated but can be fixed too.

You can fix both issues by switching both effects to useLayoutEffect and introducing local variable unmounted:

const useDetectIdle = (inactivityTimeout, onIdle) => {
  const timeoutRef = useRef(null);
  const callbackRef = useRef();

  useLayoutEffect(() => {
    callbackRef.current = onIdle;
  });

  const reset = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    const id = setTimeout(callbackRef.current, inactivityTimeout);
    timeoutRef.current = id;
  };

  useLayoutEffect(() => {
    reset();

    let unmounted = false;
    const handleEvent = _.throttle(() => {
      if (!unmounted) reset();
    }, 1000);

    EVENTS.forEach(event => window.addEventListener(event, handleEvent));

    return () => {
      unmounted = true;
      EVENTS.forEach(event => window.removeEventListener(event, handleEvent));
      timeoutRef.current && clearTimeout(timeoutRef.current);
    };
  }, []);
};

PS: Idle callback after mount fires after inactivityTimeoutms, whereas subsequent callbacks after inactivityTimeout + 1000ms.

Hade answered 14/8, 2021 at 18:31 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.