Vitest issue with vi.spyOn() when used with clearInterval - reactjs

I've created a Countdown timer component and am writing out tests. I want to test that the setInterval gets cleared when the distance is less than 0.
I've set up a test as follows using vi.spyOn(global, 'clearInterval'):
test('should clear the interval when the distance is less than 0', () => {
const date = setDate(SECOND);
vi.spyOn(global, 'clearInterval');
render(<CountdownClock endDate={date} etc.../>)
act(() => {
vi.runOnlyPendingTimers();
vi.runOnlyPendingTimers();
vi.clearAllMocks();
});
// expect assertions ...
});
The code itself handles the timer inside of a useEffect:
useEffect(() => {
// Start straight away (ie before first second elapses in setInterval)
if (distance() > 0) {
runTimer();
}
const interval = setInterval(() => {
if (distance() < 0) {
// Stop Timer
endTimer();
clearInterval(interval);
} else {
runTimer();
}
}, 1000);
return () => {
clearInterval(interval);
};
}, []);
The above test sets the date to 1 second in the future and then I run setInterval twice more using vi.runOnlyPendingTimers(). This pushes the distance into negative which stops the timer and clears the interval.
I've logged this out and I can see that the spy works in this part of the code - I can see callCount of 1 after clearInterval has run.
The issue I get is that it then runs the useEffect return statement to clear up. At this point I get the error:
ReferenceError: clearInterval is not defined
❯ src/components/CountdownClock/CountdownClockContainer.tsx:94:12
92|
93| return () => {
94| clearInterval(interval);
| ^
95| };
I can't work out why clearInterval is undefined at this stage. My understanding is that spyOn doesn't create a mock and is rather 'spying' on the method so I can't figure out why clearInterval becomes undefined.
If anyone has any suggestions as to what might be the issue that would be great.

Related

Issue clearing a recursive timeout with onClick in React

I'm rebuilding a special type of metronome I built in vanilla js, with React. Everything is working, except when a user clicks the 'STOP' button, the metronome doesn't stop. It seems I'm losing the timeout ID on re-renders, so clearTimeout is not working. This is a recursive timeout, so it calls itself after each timeout acting more like setInterval, except for it's adjusting the interval each time, thus I had to use setTimeout.
I've tried to save the timeoutID useing setState, but if I do that from within the useEffect hook, there's an infinite loop. How can I save the timerID and clear it onClick?
The code below is a simplifed version. The same thing is on codepen here. The codepen does not have any UI or audio assets, so it doesn't run anything. It's just a gist of the larger project to convey the issue.
You can also view the vanilla js version that works.
import { useState, useEffect } from 'React';
function startStopMetronome(props) {
const playDrum =
new Audio("./sounds/drum.wav").play();
};
let tempo = 100; // beats per minute
let msTempo = 60000 / tempo;
let drift;
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
let timeout;
let expected;
const round = () => {
playDrum();
// Increment expected time by time interval for every round after running the callback function.
// The drift will be the current moment in time for this round minus the expected time.
let drift = Date.now() - expected;
expected += msTempo;
// Run timeout again and set the timeInterval of the next iteration to the original time interval minus the drift.
timeout = () => setTimeout(round, msTempo - drift);
timeout();
};
// Add method to start metronome
if (isRunning) {
// Set the expected time. The moment in time we start the timer plus whatever the time interval is.
expected = Date.now() + msTempo;
timeout = () => setTimeout(round, msTempo);
timeout();
};
// Add method to stop timer
if (!isRunning) {
clearTimeout(timeout);
};
});
const handleClick = (e) => {
setIsRunning(!isRunning);
};
return (
<div
onClick={handleClick}
className="start-stop"
children={isRunning ? 'STOP' : 'START'}>
</div>
)
}
Solved!
First, my timeouts didn't need the arrow functions. They should just be:
timeout = setTimeout(round, msTempo);
Second, a return in the useEffect block executes at the next re-render. The app will re-render (i thought is would be immediate). So, I added...
return () => clearTimeout(timeout);
to the bottom of the useEffect block.
Lastly, added the dependencies for my useEffect block to ensure it didn't fire on the wrong render.
[isRunning, subdivisions, msTempo, beatCount]);

React state not updating inside setInterval

I'm trying to learn React with some simple projects and can't seem to get my head around the following code, so would appreciate an explanation.
This snippet from a simple countdown function works fine; however, when I console.log, the setTime appears to correctly update the value of 'seconds', but when I console.log(time) immediately after it gives me the original value of 3. Why is this?
Bonus question - when the function startCountdown is called there is a delay in the correct time values appearing in my JSX, which I assume is down to the variable 'seconds' being populated and the start of the setInterval function, so I don't get a smooth and accurate start to the countdown. Is there a way around this?
const [ time, setTime ] = useState(3);
const [ clockActive, setClockActive ] = useState(false);
function startCountdown() {
let seconds = time * 60;
setClockActive(true);
let interval = setInterval(() => {
setTime(seconds--);
console.log(seconds); // Returns 179
console.log(time); // Returns 3
if(seconds < 0 ) {
clearInterval(interval);
}
}, 1000)
};
Update:
The reason you are not seeing the correct value in your function is the way that setState happens(setTime). When you call setState, it batches the calls and performs them when it wants to in the background. So you cannot call setState then immediately expect to be able to use its value inside of the function.
You can Take the console.log out of the function and put it in the render method and you will see the correct value.
Or you can try useEffect like this.
//This means that anytime you use setTime and the component is updated, print the current value of time. Only do this when time changes.
useEffect(()=>{
console.log(time);
},[time]);
Every time you setState you are rerendering the component which causes a havoc on state. So every second inside of your setInterval, you are re-rendering the component and starting it all over again ontop of what you already having running. To fix this, you need to use useEffect and pass in the state variables that you are using. I did an example for you here:
https://codesandbox.io/s/jolly-keller-qfwmx?file=/src/clock.js
import React, { useState, useEffect } from "react";
const Clock = (props) => {
const [time, setTime] = useState(3);
const [clockActive, setClockActive] = useState(false);
useEffect(() => {
let seconds = 60;
setClockActive(true);
const interval = setInterval(() => {
setTime((time) => time - 1);
}, 1000);
if (time <= 0) {
setClockActive(false);
clearInterval(interval);
}
return () => {
setClockActive(false);
clearInterval(interval);
};
}, [time, clockActive]);
return (
<>
{`Clock is currently ${clockActive === true ? "Active" : "Not Active"}`}
<br />
{`Time is ${time}`}
</>
);
};
export default Clock;

useEffect hook using outdated variables in child functions

I am trying to use the useEffect hook as a way to create an async timer in react. The logic is inside of timeFunc(), and the useEffect is working such that it calls the function every 1000ms. The weird part is, for some reason when timeFunc() gets called (every one sec) it's only accesses the old variable values, (specifically "paused"). For example, if the interval starts with a value of "paused" being false, even if I change 'paused' to be true (paused is a state variable passed in by the parent component), timeFunc() will still think paused is false. Can't figure it out. Any help appreciated!
Code:
//TIMER MANAGER
let timeFunc = () => {
if(paused == false){
let delta = Math.trunc((new Date() - resumedTime)/1000);
setProgress(delta);
console.log('test + ' + paused);
} else {
clearInterval(interval);
}
}
useEffect(() => {
let interval = null;
interval = setInterval(() => {
timeFunc();
}, 1000);
return () => clearInterval(interval);
}, [initialized]);
The timeFunc depends on having an up-to-date value of paused, but it doesn't exist in the useEffect's dependency array.
Either add it to the dependency array and also store the time until the next interval in state, or use a ref for paused instead (with a stable reference) (or in addition to state), eg:
const pausedRef = useRef(false);
// ...
const timeFunc = () => {
if (!pausedRef.current) {
// ...
// to change it:
pausedRef.current = !pausedRef.current;
Also note that
let interval = null;
interval = setInterval(() => {
timeFunc();
}, 1000);
simplifies to
const interval = setInterval(timeFunc, 1000);

Jest: setTimeout is being called too many times

I am testing a react component that uses setTimeout. The problem is that Jest is saying that setTimeout is called even though it clearly isn't. There is a setTimeout to remove something from the ui and another one to pause the timer when the mouse is hovering over the component.
I tried adding a console.log() where the setTimeout is and the console log is never called, which means the setTimeout in the app isn't being called.
//app
const App = (props) => {
const [show, setShow] = useState(true);
const date = useRef(Date.now());
const remaining = useRef(props.duration);
let timeout;
useEffect(() => {
console.log('Should not run');
if (props.duration) {
timeout = setTimeout(() => {
setShow(false)
}, props.duration);
}
}, [props.duration]);
const pause = () => {
remaining.current -= Date.now() - date.current;
clearTimeout(timeout);
}
const play = () => {
date.current = Date.now();
clearTimeout(timeout);
console.log('should not run');
timeout = setTimeout(() => {
setIn(false);
}, remaining.current);
}
return (
<div onMouseOver={pause} onMouseLeave={play}>
{ show &&
props.content
}
</div>
)
}
//test
it('Should not setTimeout when duration is false', () => {
render(<Toast content="" duration={false} />);
//setTimeout is called once but does not come from App
expect(setTimeout).toHaveBeenCalledTimes(0);
});
it('Should pause the timer when pauseOnHover is true', () => {
const { container } = render(<Toast content="" pauseOnHover={true} />);
fireEvent.mouseOver(container.firstChild);
expect(clearTimeout).toHaveBeenCalledTimes(1);
fireEvent.mouseLeave(container.firstChild);
//setTimeout is called 3 times but does not come from App
expect(setTimeout).toHaveBeenCalledTimes(1);
});
So in the first test, setTimeout shouldn't be called but I receive that its called once. In the second test, setTimeout should be called once but is called 3 times. The app works fine I just don't understand what is going on with jest suggesting that setTimeout is being called more than it is.
I'm experiencing the exact same issue with the first of my Jest test always calling setTimeout once (without my component triggering it). By logging the arguments of this "unknown" setTimeout call, I found out it is invoked with a _flushCallback function and a delay of 0.
Looking into the repository of react-test-renderer shows a _flushCallback function is defined here. The Scheduler where _flushCallback is part of clearly states that it uses setTimeout when it runs in a non-DOM environment (which is the case when doing Jest tests).
I don't know how to properly proceed on researching this, for now, it seems like tests for the amount of times setTimeout is called are unreliable.
Thanks to #thabemmz for researching the cause of this, I have a hacked-together solution:
function countSetTimeoutCalls() {
return setTimeout.mock.calls.filter(([fn, t]) => (
t !== 0 ||
!String(fn).includes('_flushCallback')
));
}
Usage:
// expect(setTimeout).toHaveBeenCalledTimes(2);
// becomes:
expect(countSetTimeoutCalls()).toHaveLength(2);
It should be pretty clear what the code is doing; it filters out all calls which look like they are from that react-test-renderer line (i.e. the function contains _flushCallback and the timeout is 0.
It's brittle to changes in react-test-renderer's behaviour (or even function naming), but does the trick for now at least.

react hooks and setInterval

Is there any alternative to just keeping a "clock" in the background to implement auto-next (after a few seconds) in carousel using react hooks?
The custom react hook below implements a state for a carousel that supports manual (next, prev, reset) and automatic (start, stop) methods for changing the carousel's current (active) index.
const useCarousel = (items = []) => {
const [current, setCurrent] = useState(
items && items.length > 0 ? 0 : undefined
);
const [auto, setAuto] = useState(false);
const next = () => setCurrent((current + 1) % items.length);
const prev = () => setCurrent(current ? current - 1 : items.length - 1);
const reset = () => setCurrent(0);
const start = _ => setAuto(true);
const stop = _ => setAuto(false);
useEffect(() => {
const interval = setInterval(_ => {
if (auto) {
next();
} else {
// do nothing
}
}, 3000);
return _ => clearInterval(interval);
});
return {
current,
next,
prev,
reset,
start,
stop
};
};
There are differences between setInterval and setTimeout that you may not want to lose by always restarting your timer when the component re-renders. This fiddle shows the difference in drift between the two when other code is also running. (On older browsers/machines—like from when I originally answered this question—you don't even need to simulate a large calculation to see a significant drift begin to occur after only a few seconds.)
Referring now to your answer, Marco, the use of setInterval is totally lost because effects without conditions dispose and re-run every time the component re-renders. So in your first example, the use of the current dependency causes that effect to dispose and re-run every time the current changes (every time the interval runs). The second one does the same thing, but actually every time any state changes (causing a re-render), which could lead to some unexpected behavior. The only reason that one works is because next() causes a state change.
Considering the fact that you are probably not concerned with exact timing, is is cleanest to use setTimeout in a simple fashion, using the current and auto vars as dependencies. So to re-state part of your answer, do this:
useEffect(
() => {
if (!auto) return;
const interval = setTimeout(_ => {
next();
}, autoInterval);
return _ => clearTimeout(interval);
},
[auto, current]
);
Generically, for those just reading this answer and want a way to do a simple timer, here is a version that doesn't take into account the OP's original code, nor their need for a way to start and stop the timer independently:
const [counter, setCounter] = useState(0);
useEffect(
() => {
const id= setTimeout(() => {
setCounter(counter + 1);
// You could also do `setCounter((count) => count + 1)` instead.
// If you did that, then you wouldn't need the dependency
// array argument to this `useEffect` call.
}, 1000);
return () => {
clearTimeout(id);
};
},
[counter],
);
However, you may be wondering how to use a more exact interval, given the fact that setTimeout can drift more than setInterval. Here is one method, again, generic without using the OP's code:
// Using refs:
const [counter, setCounter] = useState(30);
const r = useRef(null);
r.current = { counter, setCounter };
useEffect(
() => {
const id = setInterval(() => {
r.current.setCounter(r.current.counter + 1);
}, 1000);
return () => {
clearInterval(id);
};
},
[] // empty dependency array
);
// Using the function version of `setCounter` is cleaner:
const [counter, setCounter] = useState(30);
useEffect(
() => {
const id = setInterval(() => {
setCounter((count) => count + 1);
}, 1000);
return () => {
clearInterval(id);
};
},
[] // empty dependency array
);
Here is what is going on above:
(first example, using refs): To get setInterval's callback to always refer to the currently acceptable version of setCounter we need some mutable state. React gives us this with useRef. The useRef function will return an object that has a current property. We can then set that property (which will happen every time the component re-renders) to the current versions of counter and setCounter.
(second example, using functional setCounter): Same idea as the first, except that when we use the function version of setCounter, we will have access to the current version of the count as the first argument to the function. No need to use a ref to keep things up to date.
(both examples, continued): Then, to keep the interval from being disposed of on each render, we add an empty dependency array as the second argument to useEffect. The interval will still be cleared when the component is unmounted.
Note: I used to like using ["once"] as my dependency array to indicate that I am forcing this effect to be set up only once. It was nice for readability at the time, but I no longer use it for two reasons. First, hooks are more widely understood these days and we have seen the empty array all over the place. Second, it clashes with the very popular "rule of hooks" linter which is quite strict about what goes in the dependency array.
So applying what we know to the OP's original question, you could use setInterval for a less-likely-to-drift slideshow like this:
// ... OP's implementation code including `autoInterval`,
// `auto`, and `next` goes above here ...
const r = useRef(null);
r.current = { next };
useEffect(
() => {
if (!auto) return;
const id = setInterval(() => {
r.current.next();
}, autoInterval);
return () => {
clearInterval(id);
};
},
[auto]
);
Because the current value is going to change on every "interval" as long as it should be running, then your code will start and stop a new timer on every render. You can see this in action here:
https://codesandbox.io/s/03xkkyj19w
You can change setInterval to be setTimeout and you will get the exact same behaviour. setTimeout is not a persistent clock, but it doesn't matter since they both get cleaned up anyways.
If you do not want to start any timer at all, then put the condition before setInterval not inside of it.
useEffect(
() => {
let id;
if (run) {
id = setInterval(() => {
setValue(value + 1)
}, 1000);
}
return () => {
if (id) {
alert(id) // notice this runs on every render and is different every time
clearInterval(id);
}
};
}
);
So far, it seems that both solutions below work as desired:
Conditionally creating timer — it requires that useEffect is dependent both on auto and current to work
useEffect(
() => {
if (!auto) return;
const interval = setInterval(_ => {
next();
}, autoInterval);
return _ => clearInterval(interval);
},
[auto, current]
);
Conditionally executing update to state — it does not require useEffect dependencies
useEffect(() => {
const interval = setInterval(_ => {
if (auto) {
next();
} else {
// do nothing
}
}, autoInterval);
return _ => clearInterval(interval);
});
Both solutions work if we replace setInterval by setTimeout
You could use useTimeout hook that returns true after specified number of milliseconds.
https://github.com/streamich/react-use/blob/master/docs/useTimeout.md

Resources