Search code examples
javascriptreactjstimer

Unmounting a Functional react Timer component pushing a call before tab close or page exit


I am writing a react component that can perform a timed session. I am getting a "React useCallback memory leak unmounted component" - I need to mount/unmount the functional component properly - but also on unmounting - as if the user closes the page/tab -- to send a call of the latest timer data before closing the page/browser/tab or navigating away from the page.

enter image description here

I also want to fix this error report. It would be used in a credits system - where people are charged by the timer-- so it needs to reflect the timed session accurately if and when the timer is stopped/closed page.

Also want to join the timer -- with its users -- so if two users joined the session page -- if one starts the timer their end - it starts the timer on the others page -- vice versa if one pauses/stops the timer -- it does the same on the other - so they are both locked in synch - and when the session is stopped - or/and the page is closed - it sends back the latest time without malfunction

research

React Functional component unmount

React useCallback memory leak unmounted component

https://dmitripavlutin.com/react-usecallback/

current sandbox

https://codesandbox.io/s/eloquent-feather-4ijl59

  <Timer
    sessionPauseHandler={function (duration) {
      console.log("duration pause seconds", duration);
    }}
    sessionEndHandler={function (duration) {
      console.log("duration end seconds", duration);

      //$20 for 15 mins
      let rate = 20;
      let sessionQtrSeconds = 15 * 60;

      let cost = rate * (duration / sessionQtrSeconds);
      console.log("cost", cost);
      //https://stackoverflow.com/questions/6162188/javascript-browsers-window-close-send-an-ajax-request-or-run-a-script-on-win
    }}
  />

component

import React, { useEffect, useRef, useState, useCallback } from "react";
import Button from "@mui/material/Button";

//import './Timer.scss';

const useTimer = (props) => {
  const [renderedStreamDuration, setRenderedStreamDuration] = useState(
      "00:00:00"
    ),
    streamDuration = useRef(0),
    previousTime = useRef(0),
    requestAnimationFrameId = useRef(null),
    [isStartTimer, setIsStartTimer] = useState(false),
    [isStopTimer, setIsStopTimer] = useState(false),
    [isPauseTimer, setIsPauseTimer] = useState(false),
    [isResumeTimer, setIsResumeTimer] = useState(false),
    isStartBtnDisabled = isPauseTimer || isResumeTimer || isStartTimer,
    isStopBtnDisabled = !(isPauseTimer || isResumeTimer || isStartTimer),
    isPauseBtnDisabled = !(isStartTimer || (!isStartTimer && isResumeTimer)),
    isResumeBtnDisabled = !isPauseTimer;

  const updateTimer = useCallback(() => {
    let now = performance.now();
    let dt = now - previousTime.current;

    if (dt >= 1000) {
      streamDuration.current = streamDuration.current + Math.round(dt / 1000);
      const formattedStreamDuration = new Date(streamDuration.current * 1000)
        .toISOString()
        .substr(11, 8);
      setRenderedStreamDuration(formattedStreamDuration);
      previousTime.current = now;
    }
    requestAnimationFrameId.current = requestAnimationFrame(updateTimer);
  }, []);

  const startTimer = useCallback(() => {
    previousTime.current = performance.now();
    requestAnimationFrameId.current = requestAnimationFrame(updateTimer);
  }, [updateTimer]);

  //console.log("props", props);

  useEffect(() => {
    // Anything in here is fired on component mount.
    // componentDidMount
    console.log("MOUNT");

    if (isStartTimer && !isStopTimer) {
      startTimer();
      //console.log("START TIMER");
    }
    if (isStopTimer && !isStartTimer) {
      //console.log("STOP TIMER", streamDuration.current);
      props.sessionEndHandler(streamDuration.current);

      streamDuration.current = 0;
      cancelAnimationFrame(requestAnimationFrameId.current);
      setRenderedStreamDuration("00:00:00");
    }

    // componentWillUnmount
    return () => {
      console.log("UNMOUNT");
      // Anything in here is fired on component unmount.
    };
  }, [isStartTimer, isStopTimer, startTimer]);

  const startHandler = () => {
    setIsStartTimer(true);
    setIsStopTimer(false);
  };

  const stopHandler = () => {
    setIsStopTimer(true);
    setIsStartTimer(false);
    setIsPauseTimer(false);
    setIsResumeTimer(false);
  };

  const pauseHandler = () => {
    setIsPauseTimer(true);
    setIsStartTimer(false);
    setIsResumeTimer(false);
    cancelAnimationFrame(requestAnimationFrameId.current);
    props.sessionPauseHandler(streamDuration.current);
  };

  const resumeHandler = () => {
    setIsResumeTimer(true);
    setIsPauseTimer(false);
    startTimer();
  };

  return {
    renderedStreamDuration,
    isStartBtnDisabled,
    isStopBtnDisabled,
    isPauseBtnDisabled,
    isResumeBtnDisabled,
    startHandler,
    stopHandler,
    pauseHandler,
    resumeHandler
  };
};

export default function Timer(props) {
  const {
    renderedStreamDuration,
    isStartBtnDisabled,
    isStopBtnDisabled,
    isPauseBtnDisabled,
    isResumeBtnDisabled,
    startHandler,
    stopHandler,
    pauseHandler,
    resumeHandler
  } = useTimer(props);

  //console.log("props1", props)

  return (
    <div className="timer-controller-wrapper">
      <div className="timer-display">{renderedStreamDuration}</div>
      <div className="buttons-wrapper">
        <Button
          onClick={startHandler}
          disabled={isStartBtnDisabled}
          variant="contained"
          color="primary"
        >
          Start
        </Button>

        <Button
          onClick={stopHandler}
          disabled={isStopBtnDisabled}
          variant="contained"
          color="secondary"
        >
          Stop
        </Button>

        <Button
          onClick={pauseHandler}
          disabled={isPauseBtnDisabled}
          variant="contained"
          color="primary"
        >
          Pause
        </Button>

        <Button
          onClick={resumeHandler}
          disabled={isResumeBtnDisabled}
          variant="contained"
          color="secondary"
        >
          Resume
        </Button>
      </div>
    </div>
  );
}

Solution

  • The solution to this - is to wrap setXxxxx parts in the callback - with an if mount check --- so only if mounted.current is true -- only the allow the code to setXXX --

    import React, { useEffect, useRef, useState, useCallback } from 'react';
    import Button from '@mui/material/Button';
    
    import './Timer.scss';
    
    const useTimer = (props) => {
      ///
      const mounted = useRef(false);
    
      useEffect(() => {
            mounted.current = true; // Will set it to true on mount ...
            return () => { mounted.current = false; }; // ... and to false on unmount
      }, []);
    
      //
    
    
      const [renderedStreamDuration, setRenderedStreamDuration] = useState('00:00:00'),
        streamDuration = useRef(0),
        previousTime = useRef(0),
        requestAnimationFrameId = useRef(null),
        [isStartTimer, setIsStartTimer] = useState(false),
        [isStopTimer, setIsStopTimer] = useState(false),
        [isPauseTimer, setIsPauseTimer] = useState(false),
        [isResumeTimer, setIsResumeTimer] = useState(false),
        isStartBtnDisabled = isPauseTimer || isResumeTimer || isStartTimer,
        isStopBtnDisabled = !(isPauseTimer || isResumeTimer || isStartTimer),
        isPauseBtnDisabled = !(isStartTimer || (!isStartTimer && isResumeTimer)),
        isResumeBtnDisabled = !isPauseTimer;
    
      const updateTimer = useCallback(() => {
        let now = performance.now();
        let dt = now - previousTime.current;
    
        if (dt >= 1000) {
          streamDuration.current = streamDuration.current + Math.round(dt / 1000);
          const formattedStreamDuration = new Date(streamDuration.current * 1000)
            .toISOString()
            .substr(11, 8);
    
          // Therefore, you have to check if the component is still mounted before updating states
          if (mounted.current) { 
            setRenderedStreamDuration(formattedStreamDuration);
          }
    
          previousTime.current = now;
        }
        requestAnimationFrameId.current = requestAnimationFrame(updateTimer);
      }, []);
    
      const startTimer = useCallback(() => {
        previousTime.current = performance.now();
        requestAnimationFrameId.current = requestAnimationFrame(updateTimer);
      }, [updateTimer]);
    
      //console.log("props", props);
    
      useEffect(() => {
        // Anything in here is fired on component mount.
        // componentDidMount
        console.log("MOUNT");
    
        if (isStartTimer && !isStopTimer) {
          startTimer();
          //console.log("START TIMER");
        }
        if (isStopTimer && !isStartTimer) {
          //console.log("STOP TIMER", streamDuration.current);
          props.sessionEndHandler(streamDuration.current);
    
          streamDuration.current = 0;
          cancelAnimationFrame(requestAnimationFrameId.current);
    
          // Therefore, you have to check if the component is still mounted before updating states
          if (mounted.current) { 
            setRenderedStreamDuration('00:00:00');
          }
        }
    
        // componentWillUnmount
        return () => {
          console.log("UNMOUNT");
          props.sessionEndHandler(streamDuration.current);
          // Anything in here is fired on component unmount.
        };
      }, [isStartTimer, isStopTimer, startTimer]);
    
      const startHandler = () => {
        setIsStartTimer(true);
        setIsStopTimer(false);
      };
    
      const stopHandler = () => {
        setIsStopTimer(true);
        setIsStartTimer(false);
        setIsPauseTimer(false);
        setIsResumeTimer(false);
      };
    
      const pauseHandler = () => {
        setIsPauseTimer(true);
        setIsStartTimer(false);
        setIsResumeTimer(false);
        cancelAnimationFrame(requestAnimationFrameId.current);    
        props.sessionPauseHandler(streamDuration.current);
      };
    
      const resumeHandler = () => {
        setIsResumeTimer(true);
        setIsPauseTimer(false);
        startTimer();
      };
    
      return {
        renderedStreamDuration,
        isStartBtnDisabled,
        isStopBtnDisabled,
        isPauseBtnDisabled,
        isResumeBtnDisabled,
        startHandler,
        stopHandler,
        pauseHandler,
        resumeHandler,
      };
    };
    
    
    export default function Timer(props) {
      const {
        renderedStreamDuration,
        isStartBtnDisabled,
        isStopBtnDisabled,
        isPauseBtnDisabled,
        isResumeBtnDisabled,
        startHandler,
        stopHandler,
        pauseHandler,
        resumeHandler,
      } = useTimer(props);
    
      //console.log("props1", props)
    
      return (
        <div className="timer-controller-wrapper">
          <div className="timer-display">{renderedStreamDuration}</div>
          <div className="buttons-wrapper">
            <Button
              onClick={startHandler}
              disabled={isStartBtnDisabled}
              variant="contained"
              color="primary"
            >
              Start
            </Button>
    
            <Button
              onClick={stopHandler}
              disabled={isStopBtnDisabled}
              variant="contained"
              color="secondary"
            >
              Stop
            </Button>
    
            <Button
              onClick={pauseHandler}
              disabled={isPauseBtnDisabled}
              variant="contained"
              color="primary"
            >
              Pause
            </Button>
    
            <Button
              onClick={resumeHandler}
              disabled={isResumeBtnDisabled}
              variant="contained"
              color="secondary"
            >
              Resume
            </Button>
          </div>
        </div>
      );
    }