React Testing Library not triggering useEffect - javascript

I have a Nextjs app with a steps dynamic page. Each page is a step in the process to reach a report's page. Each page has a Go back button which routes to a previous step. Routing to a previous step is triggered only when the context state changes, basically the useEffect fires again. This works perfectly well on the browser, however, I want to test this behavior with Jest and React Testing Library. I could not get it to satisfy the test when I click on a Go Back button as the useEffect is not triggered again to cause the routing. The test I created basically tries to see if the router was called with the previous step slug Below is a simplified version of the page with functions important to the this problem:
Steps page [slug].tsx
const StepsPage: NextPage = () => {
const { steps, setSteps } = useStepContext();
useEffect(() => {
if (!steps) {
return;
}
const nextStep = getNextStep(steps);
if (!nextStep) {
router.push("/report");
return;
} else if (nextStep) {
// Redirect to next step
router.push(`/step/${step.slug}`);
return;
}
}, [router, steps]);
const selectStep = (value, step) => {
setSteps((_steps) => {
return updateSteps(_steps, value, step);
});
};
const handleGoBack = () => {
const prevStep = getPrevStep(step, steps);
// Unset the value of the previous step useEffect() will react to this and route to the previous step page
return selectStep(null, prevStep);
};
return <>some components</>;
};
Here is my test
// Test
describe("Step Page - In Progress Steps", () => {
beforeAll(() => {
mockAssessmentData = stepsMock;
mockRouter = {
...mockRouter,
query: { slug: stepsMock[3]?.slug },
};
});
describe("Step - Go Back Button", () => {
it("should route to a previous step page", () => {
act(() => {
render(<SkillPage />);
});
const button = screen.getByRole("button", { name: /go back/i });
expect(button).toBeInTheDocument();
fireEvent.click(button);
const step = stepsMock[3];
const prevStep = getPrevStep(step, stepsMock);
expect(mockRouter.push).toHaveBeenCalledWith(`/step/${prevStep.slug}`);
});
});
});
Any leads as to why this is happening?

Related

How can I use async function in hardwareBackPress listener?

I made an application that can make agora voice calls with React native. When clicking leave on the screen, the below agoraEnd function works correctly. I also want this function to work when the phone's back button is pressed. That's why I prepared the useFocusEffet below. But when I press the back button during a voice call, I get the following error. Why is this function not working correctly? How can I fix this problem.
Possible Unhandled Promise Rejection (id: 2): TypeError: engine.leaveChannel is not a function. (In 'engine.leaveChannel()', 'engine.leaveChannel' is undefined)
const agoraEnd = async () => {
await engine.leaveChannel()
await engine.destroy()
navigation.goBack();
};
useFocusEffect(
useCallback(() => {
const backHandler = BackHandler.addEventListener(
'hardwareBackPress',
agoraEnd,
);
return () => backHandler.remove();
}, []),
)
Based on your code, you don't return anything from back handler (though it expects boolean, where true when you handled back press and false when you didn't and want to continue default behaviour (usually exiting the app))
You can just do:
useFocusEffect(
useCallback(() => {
const backHandler = BackHandler.addEventListener(
'hardwareBackPress',
() => agoraEnd(),
);
return () => backHandler.remove();
}, []),
)
if you don't want to wait for agoraEnd to finish.
So in my case I used BackHandler.exitApp(); to do async stuff:
const handleBackButtonAsync = async () => {
// do some await stuff
if (something) {
// the back button event is handled;
} else {
// THE MAGIC:
// instead of returning `false` in `handleBackButton`
// to not prevent default logic, you exit app explicitly
BackHandler.exitApp();
}
}
const handleBackButton = () => {
handleBackButtonAsync();
return true; // always "handle" the back button event to not exit app
}
useEffect(() => {
BackHandler.addEventListener("hardwareBackPress", handleBackButton);
return () =>
BackHandler.removeEventListener("hardwareBackPress", handleBackButton);
}, []);

testing with jest to update a react state inside a rejected promise

This is a continuation of this question. I have made a few changes that simplifies the question(I believe) and changes it drastically.
I have seperated creating the hook and initialization of midi events.
describe("midiConnection", () => {
it("Should fail", () => {
const midiPorts = renderHook(() => { return MidiConnection()})
act(() => {
midiPorts.result.current.enable()
})
console.log(midiPorts.result.current.error)
})
})
export function MidiConnection() {
const {array: midiInputs, push: midiPush, filter: midiFilter} = useArray(["none"])
const [error, setError] = useState<Error | undefined>();
function enable() {
WebMidi.addListener("connected", (e) => { if (isInput(e)) {midiPush(e.port.name)}});
WebMidi.addListener("disconnected", (e) => {
e.port.removeListener()
if (isInput(e)) {midiFilter((str) => {return str != e.port.name})}
});
// setError("this is a test")
WebMidi.
enable().
catch((err) => {
// console.log("test")
// setError(err)
})
}
return ({ports: midiInputs, error, enable})
}
the warning is still;
Warning: An update to TestComponent inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
In addition to seperating out some of the logic I have also experimented with placing setError() on other lines to see if I can trigger the warning (the commented out comments.)
It appears that the warning is only triggered when I try to update the state when the promise from enable() is rejected.
What can I do to stop this error from happening?
EDIT: I have created a working replica of this in CodeSandbox, which you will see if you go to tests and look at the console.
Your hook is async so u need to wait for the next update. Here is the docs that talks more about it.
import { renderHook, act } from "#testing-library/react-hooks/dom";
import CHook from "./../hook/CHook";
test("This is a test", async () => {
const { result, waitForNextUpdate } = renderHook(() => CHook());
act(() => {
result.current.update();
});
await waitForNextUpdate();
console.log(result.current.error);
});
Here is the link to a fixed sandbox.

React native: event source is not closing after logout

I want to close event source after user logout. My codes are below. When user logging out push notifications are still coming. My questions is how to close event source?
const eventSource = new EventSource(`${environment.notificationUrl}/subscribe?
token=${token}`);
if (!token) {
eventSource.removeAllEventListeners()
eventSource.close()
return;
}
eventSource.addEventListener('message', (event: any) => {
---- some codes here ----
}
Technically, event listeners should be subscribed inside useEffect and keep tracking when user logged out.
const NotificationSubscriptionManager = () => {
const eventSource =
new EventSource(`${environment.notificationUrl}/subscribe?
token=${token}`);
const subScribeToNotifications = () => {
eventSource.addEventListener("message", (event: any) => {
/// ---- some codes here ----
});
};
const unsubScribeToNotifications = () => {
eventSource.removeAllEventListeners();
eventSource.close();
};
useEffect(() => {
if (!token) {
return unsubScribeToNotifications();
}
// Subscribe to notification events
subScribeToNotifications();
// Cancel all subscription when component unmount to avoid
// memory leak
return () => {
unsubScribeToNotifications();
};
}, [token]);
};

React state not giving correct value on useEffect()

I'm trying to build a factorization algorithm using react. I would like to add results to LocalStorage based on results from factorization. However, LocalStorage sets previous results not current ones.
I think this is happening because useEffect runs on every new [number] (=user input) and not based on [results]. However, I need useEffect to run on new user input submition because that's when factorization has to be triggered.
How could I make localStorage set correct results after that factorization has completed (on the finally block if possible) ?
const [results, setResults] = useState({
default: [],
detailed: [],
isPrime: '',
});
const { number } = props
useEffect(() => {
handleWorker(number);
//accessing results here will give previous results
}, [number]);
const handleWorker = number => {
try {
const worker = new Worker(new URL('facto.js', import.meta.url));
worker.postMessage({ number, algorithm });
worker.onmessage = ({ data: { facto } }) => {
setResults({ ...facto });
//the worker 'streams live' results and updates them on setResults
};
} catch(error) {
console.log(error.message)
} finally {
localStorage.setItem(number, results)
//here, results is not current state but previous one
}
};
Please note that everything else works fine
Thanks
You are getting the previous value because localStorage.setItem is executed before setResults updates the state. Yo can do some refactor to make it work:
const [results, setResults] = useState({
default: [],
detailed: [],
isPrime: '',
});
const { number } = props;
const workerRef = useRef(new Worker(new URL('facto.js', import.meta.url)));
const worker = workerRef.current;
useEffect(()=> {
//-> create the listener just once
worker.onmessage = ({ data: { facto } }) => {
setResults({ ...facto });
};
}, []);
useEffect(() => {
//-> send message if number changed
worker.postMessage({ number, algorithm });
}, [number]);
useEffect(() => {
//-> update localStorage if results changed
localStorage.setItem(number, results)
}, [results]);
Here is what you need (probably):
const [results, setResults] = useState({ /* YOUR STATE */ });
const { number } = props
const handleWorker = useCallback((number) => {
// DO WHATEVER YOU NEED TO DO HERE
},[]); // IF YOU DEPEND ON ANY MUTABLE VARIABLES, ADD THEM TO THE DEPENDENCY ARRAY
useEffect(() => {
// DO WHATEVER YOU NEED TO DO HERE
// INSTEAD OF localStorage.setItem(number, results)
// DO localStorage.setItem(number, { ...facto })
}, [number,handleWorker]);
Let me know if it works.
I think you can pass in the result state as dependent for the useEffect, so whenever the value of the result change , useEffect runs
useEffect(() => { //code goes here}, [results]);
Let say results also depends on a function handleWorker
useEffect(() => { //code goes here},
[results,handleWorker]);

Where to put setInterval in react

I need every 5 seconds (actually every 2 minutes, but having 5 seconds while testing) to make a GET request to an API.
This should be done only when the user is logged in.
The data should be displayed by one component that is called "Header".
My first thought was to have an interval inside that component.
So with "useEffect" I had this in the Header.js:
const [data, setData] = useState([]);
useEffect(async () => {
const interval = setInterval(async () => {
if(localStorage.getItem("loggedInUser")){
console.log("Logged in user:");
console.log(new Date());
try{
const data = await getData();
if(data){
setData(data);
}
console.log("data",data);
}
catch(err){
console.log("err",err);
}
}
}, 5000);
return () => clearInterval(interval);
}, []);
Im getting the data. But looking at the console, it does not look so good.
First of all, sometimes I get this:
Warning: Can't perform a React state update on an unmounted component. This is a no-op,
but it indicates a memory leak in your application. To fix, cancel all subscriptions
and asynchronous tasks in a useEffect cleanup function.
It seems to happen when I go to other pages that have been called with a "href" (so without using for example history to change page).
Also, probably because of the thing above, after a while that I browse the pages, I see that the interval is not every 5 seconds anymore. It happens 2-3 times every 5 seconds. Maybe every seconds.
To me this does not look good at all.
Im wondering first of all if this component is the right place to put the interval in.
Maybe the interval should be in App.js? But then I would need a mechanism to pass data to children component (the Header.js, maybe could do this with Context).
As Sergio stated in his answer on async actions you should aways check if the component is currently mounted before doing side effects.
However using a variable that is initialised within the component won't work as it is being destroyed on unmount. You should rather use useRef hook as it will persist between component mounts.
const [data, setData] = useState([]);
const mountedRef = useRef(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(async () => {
const interval = setInterval(async () => {
if (localStorage.getItem("loggedInUser")){
console.log("Logged in user:", new Date());
try {
const data = await getData();
if (data && mountedRef.current) {
setData(data);
console.log("data", data);
}
} catch(err) {
if (mountedRef.current) console.log("err", err);
}
}
}, 5000);
return () => clearInterval(interval);
}, []);
Or alternatively declare a boolean variable in the module scope so that its not freed by the garbage collection process
You can create a isMounted variable to control if the async task happens after the component is unmounted.
As the error said, you are trying to update component state after it's unmounted, so you should "protect" the setData call.
const [data, setData] = useState([]);
useEffect(async () => {
let isMounted = true;
const interval = setInterval(async () => {
if(localStorage.getItem("loggedInUser")){
console.log("Logged in user:");
console.log(new Date());
try{
const data = await getData();
if(data && isMounted){
setData(data);
}
console.log("data",data);
}
catch(err){
console.log("err",err);
}
}
}, 5000);
return () => {
clearInterval(interval);
isMounted = false;
}
}, []);

Categories