Developing with a framework like React can add a level of complexity to debugging. Hooks like useEffect can often be a culprit, because it can be configured to only run during certain renders. If you rely on a hook running and it doesn’t, this can cause issues. Here’s a real world example of debugging a useEffect bug in Replay DevTools.
The bug
We received reports that sometimes the tooltip that displays the line hit count would be stuck in a loading state forever. This was an intermittent bug, making it harder to pin down because it couldn’t always be reproduced. Fortunately with replay, once we were able to record the bug, we had a debuggable reproduction to work with.
With a bug like this, there could be a lot of things going on. It could be a backend error, network request related, or the result of something going wrong in the front end. Replay engineer Josh Morrow describes his debugging process below.
The debugging process
Create a reproducible example
This replay captures the bug behavior.
In the replay, we can see the tooltip working properly initially, with line hit counts displayed when hovering over the line of code.
There is a click on a comment, which opens a new source in the replay. In Replay, comments can be linked to a specific line of code, so clicking the comment automatically opens the editor and focuses on the line of code.
After this, the tooltip always shows Loading...
on hover.
Understand expected behavior
Replay will fetch the number of times a line of code has executed and display that number in a tool tip when you hover on the line. There is a LineNumberToolTip
component that handles this functionality on the front end.
We can see in the replay that the updateBreakpointHitCounts
function executes early in the replay, and we can see the tooltip updating with the line hit count as expected. The two hits of this function line up with the application working properly in the UI.
Once we understand how the application is supposed to act, we can investigate the point where it doesn’t behave in this same way.
Isolate the issue
The updateBreakpointHitCounts
does not execute after the new source is opened. This clues us in that whatever triggers that function is not working properly.
We can see that setBreakpointHitCounts
within the setHoveredLineNumber
function does execute at the proper locations, so we can narrow in to functionality between this function and updateBreakpointHitCounts
, which doesn’t execute as expected.
Logging the parameters of setBreakpointHitCounts
shows that the line number changes, but the source id is the same for the entire replay. This means that even though a different file is open in the editor, the LineNumberTooltip
component thinks we are still in the same file as before.
In this video below, Josh outlines how he isolated the issue to the sourceId
value.
Tracing the root cause
Now that we have isolated the issue to the source id not updating, we can dig in and investigate why.
Within the LineNumberToolTip
component on line 89
, we are getting the source from useSelector
. By logging the source.id
on the next line, we can see that it does update correctly. (Read more about why we log the value on the next line in our documentation here).
However, this updated value is not being passed into setBreakpointHitCounts
, because when that function executes, it always uses the stale sourceId
.
So what’s going on here? The function setBreakpointHitCounts
is being called inside a defined function, setHoveredLineNumber
. When you define a function in JavaScript, you create what’s called a closure. This means that the value of sourceId
when the function is defined, is what the value will always be inside that particular function definition. (You can read more about closures here). When you have a bug where a particular value is not updating as expected or evaluating as a stale value, one of the first questions to consider if whether you’re dealing with the effect of a closure.
The setHoveredLineNumber
function is being called inside a useEffect
hook in the LineNumberTooltip
component on line 142
. This hook calls the setHoveredLineNumber
function whenever we hover over a line, but because of the closure created by useEffect
, the original sourceId
is still being used.
The fix
Here is the pull request with the fix.
To dig into what’s happening here — the setHoveredLineNumber
function declaration is re-executed every time the component renders. However, the function is being assigned to the editor.codemirror.on
hook on line 142
inside the useEffect
hook. This creates another closure around the assignment of setHoveredLineNumber
with the original sourceId
value.
The useEffect
hook is configurable, so it may or may not run on every render. In this case, by passing an empty array as the second parameter, we are telling useEffect
to only run once when the component is mounted. This means that even if the updated sourceId
is passed to the setHoveredLineNumber
function in a new declaration, the original assignment with the original value is still being used.
In the fix, we pass source
as a value to the array in the second parameter. This means that the useEffect
hook will run again whenever the value of source
is updated. This way, we always use the correct sourceId
when assigning setHoveredLineNumber
.
In the video below, Josh digs into the root cause and how it was ultimately resolved.
Learn more
Replay has React DevTools built in so you can debug issues related to components, props, and state. Check out our documentation here for more examples.