0

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>
  );
}
The Old County
  • 89
  • 13
  • 59
  • 129
  • As this is a Client Side Code, every user will have their own copy of that page, which would not be in the sync. The way you want to link them, you can use Socket.Io for this. You need to develop back-end APIs to manage the sessions and users can connect to a particular session. – Abhijeet Abnave Mar 26 '23 at 21:03
  • yes I understand that -- I think the main issue with this for now is that there is an issue with unmounting -- there is an error that occurs during an unmount - and I want to ensure it makes an api call to send the latest timer data before the page is closed – The Old County Mar 28 '23 at 19:06

1 Answers1

0

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>
  );
}
The Old County
  • 89
  • 13
  • 59
  • 129