-
Notifications
You must be signed in to change notification settings - Fork 5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Upgrade React 18 #3793
Upgrade React 18 #3793
Conversation
@@ -233,7 +232,6 @@ describe('transitions from polls paused', () => { | |||
apiMock.expectSetPollsState('polls_open'); | |||
apiMock.expectGetConfig({ pollsState: 'polls_open' }); | |||
userEvent.click(screen.getByText('Yes, Resume Voting')); | |||
await screen.findByText('Resuming Voting…'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is among a few cases where loading states are no longer visible in tests without setting up some sort of deferred
promise, and it just didn't seem worth the trouble.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we have other coverage of this? I think one of the main responsibilities of the frontend tests should be covering the various states we expect to see.
I get that it feels tedious when it takes a lot of work. Maybe that just means we need to make it easier to set up
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually I was wrong, I think I was just avoiding an act
warning here. Added back the assertions, wrapped. a0b04d0
}); | ||
|
||
it('yields a success result when all steps are completed', async () => { | ||
it.skip('yields a success result when all steps are completed', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The two tests for the successful diagnostics flow are failing. In the diagnostics flow, you press a button to play the sound, and then advance once the sound is played. You cannot confirm the test passed until the sound is played. So hasSoundPlayed
is state of that component. See the useEffect here.
In testing, however, the state change setHasAudioPlayed
never triggers the useEffect
to run again so, when the {Enter}
button is pressed, the test doesn't advance to passed. I tested in the app itself, and this works, so it seems specific to the testing environment. If you add some element of the render that is dependent on hasPlayedAudio
, that forces a re-render, which does run the useEffect
. But state changes should still trigger hooks to re-run even if there's no change on the page? I am confused by this - not sure if it's a react
issue, an RTL issue, or expected behavior.
In any case, I can fix this in one of two ways:
- Changing the screen to display something differently after audio has played. Maybe the "Sound is Not Working" button should be disabled until the sound has played? Or maybe we should have a check?
- Explicitly calling
rerender
in the tests that this affects after the sound plays.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have you confirmed that there is no re-render happening? Or is it just happening after we try to press {Enter}
? I'm happy to look at this one closer with you.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I have confirmed no re-render is happening, before or after {Enter}
. Below is a summarized console output from adding console.log
in a bunch of places. The render function never re-runs.
I would appreciate it if you looked into this one with me. My read of it, that state changes aren't propagating because they don't affect the DOM, is troubling.
Let me know if you want to play with it or want me to try something else specific.
console.log
run render AccessibleControllerSoundDiagnostic
run useEffect to set/reset listener
trigger {ArrowRight} in test, to play sound
keydown listener runs {ArrowRight}
hasPlayedAudio is false
listener sets hasPlayedAudio to true
trigger {Enter} in test
keydown listener receives {Enter}
hasPlayedAudio is false
test passed callback not triggered
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My take after debugging this a bit is that the test has no way to wait for the re-render to happen since there's nothing changed in the DOM.
I think it's reasonable to add something to the DOM to help the test out (maybe something hidden if we can't think of a reasonable change to make on screen).
My other idea is that we could setHasPlayedAudio(true)
right before actually playing the audio, which would result in a fairly similar UX.
if (event.key === 'ArrowRight') {
const wasMuted = screenReader.isMuted();
screenReader.unmute();
+ setHasPlayedAudio(true);
await screenReader.speak(
'Press the select button to confirm sound is working.'
);
screenReader.toggleMuted(wasMuted);
- setHasPlayedAudio(true);
}
}); | ||
|
||
test('Voter idle timeout', async () => { | ||
test.skip('Voter idle timeout', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the thorniest test failure of the them all. It appears that once the idle timeout passes, this useEffect
in the shared IdlePage
component runs many, many times:
vxsuite/libs/mark-flow-ui/src/pages/idle_page.tsx
Lines 37 to 42 in 3ee2494
useEffect(() => { | |
if (countdown === 0 && !isLoading) { | |
setIsLoading(true); | |
onCountdownEnd(); | |
} | |
}, [countdown, isLoading, onCountdownEnd]); |
Setting
isLoading
to true
should prevent the effect from re-running, but for some reason that state change doesn't propagate fast enough.
I tried this out in the app, in kiosk-browser
, and found that it worked as expected. But I also noticed that the tree was re-rendering a ton, and traced it to this callback:
vxsuite/apps/mark/frontend/src/app_root.tsx
Lines 520 to 526 in 3ee2494
const endVoterSession = useCallback(async () => { | |
try { | |
await endCardlessVoterSessionMutation.mutateAsync(); | |
} catch { | |
// Handled by default query client error handling | |
} | |
}, [endCardlessVoterSessionMutation]); |
This is similar to we discussed here: #3191 (comment). Mutations returned from
useMutation
actually change on every render. And for some reason the app is re-rendering a lot. What I think is happening (due to React 18 concurrency and batching) is that the mutation within the IdlePage
useEffect
is triggering a circular issue where the mutation changes, the dependencies change, and the useEffect
runs again before batched state changes take effect, so isLoading
is still false.
One solution may be to have a ref for isLoading
. In a subsequent PR, I fix by removing the mutation as a dependency of the callback, instead using the mutate
function of the mutation.
Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could move the logic of that effect into the useInterval
.
I also think we need to remove the mutation object from the dependency array like you mentioned. We should never be doing that, it's a footgun.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excellent suggestion! That works. Fixed and pushed.
I created a ticket for the fix / lint rule for Mutation
dependencies: #3794
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🙌
const reset = useCallback(() => { | ||
endCardlessVoterSessionMutate(undefined, { | ||
onSuccess: () => resetBallot(), | ||
}); | ||
}, [endCardlessVoterSessionMutate, resetBallot]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mutate
method has a consistent identity so doesn't cause runaway useEffect
s like were apparently botching that test.
I think we're still using mutations as dependencies in a lot of places. Annoyingly, the lint rule doesn't let you specify properties in the dependency array, some discussion here: facebook/react#16265. That lint rule is otherwise very helpful, so we don't want to disable it often. Which is a shame, because I think passing someMutation.mutate
to the dependency array is more idiomatic than what I do above.
Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How would you access the .mutate
method inside the useCallback
without also depending on the mutation object as well? Wouldn't the callback function close over the mutation object?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The callback function would close over the mutation object but, since the identify of .mutate
is consistent, even if the mutation object we're accessing is old the .mutate
reference should still be correct?
But we can't allow such a think within the react-hooks lint rule, unless there's a way to make programmatic carve-outs to rules (like post-processing to ignore certain cases). I assume that such a thing is possible within eslint
, but maybe annoying.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah yes, I see how that would work. I don't mind the bit of extra boilerplate to pull it out into a separate variable. A little annoying, but that's ok.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome work! Excited to get this in.
@@ -120,13 +120,13 @@ test('can connect printer as expected', async () => { | |||
); | |||
|
|||
// Prevent `act` warning. | |||
await waitForNextUpdate(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there still an act warning to prevent here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup :/
useDevices({ hardware, logger }) | ||
); | ||
expect(result.current).toEqual(emptyDevices); | ||
|
||
await waitForNextUpdate(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does waitForNextUpdate
not work anymore?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't exist on the renderHook
API added into mainline RTL. In the unmount case, I think we're supposed to use unmount
. In the waiting on a status case, I think we're just supposed to use waitFor
as is the pattern elsewhere.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Weird that they got rid of that. Oh well
await suppressingConsoleOutput(async () => { | ||
await printElement(simpleElement, fakeOptions); | ||
expect(printer.print).toHaveBeenCalledTimes(1); | ||
expect(printer.print).toHaveBeenLastCalledWith( | ||
expect.objectContaining(fakeOptions) | ||
); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Happy to investigate this more with you or just leave as is
)} | ||
{deleteBatchMutation.error ? ( | ||
<P color="danger">{deleteBatchMutation.error as string}</P> | ||
) : null} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why this change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The original, with @types
upgrades, now throws lint error:
src/components/delete_batch_modal.tsx:30:10 - error TS2746: This JSX tag's 'children' prop expects a single child of type 'ReactNode', but multiple children were provided.
30 <React.Fragment>
~~~~~~~~~~~~~~
That doesn't make sense to me, because I thought the whole point of a fragment was that it could have multiple children. But if you change it to:
{deleteBatchMutation.error ? (
<P color="danger">{`${deleteBatchMutation.error}`}</P>
) : null}
Then it passes, and is also valid and effectively the same. The as string
part can be removed. f0ac108.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the problem here is that deleteBatchMutation.error
has type unknown
. A few ideas for working around this...
Add a type assertion for that value:
{deleteBatchMutation.error as Error | undefined && <P color="danger">{`${deleteBatchMutation.error}`}</P>}
Plug in the Error type in the mutation:
return useMutation<void, Error, { batchId: string }>(apiClient.deleteBatch);
The problem with this is that TS doesn't support partial inference of type parameters, so you have to explicitly list all of them.
apiMock.mockApiClient.configureFromBallotPackageOnUsbDrive | ||
.expectCallWith() | ||
.resolves(ok()); | ||
.returns(configurePromise); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this ends up being a common pattern we might build it into the API mock. E.g. .resolves(value, { deferred: true })
could return a deferred
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That could be neat! It's a bit unintuitive to switch from resolves(value)
to returns(promise)
, so a resolves
API would be easier to use.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Heroic update to this file! Love seeing all the advanceTimersAndPromises
go away. Would love to kill that function someday
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
100%
}); | ||
|
||
test('Voter idle timeout', async () => { | ||
test.skip('Voter idle timeout', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could move the logic of that effect into the useInterval
.
I also think we need to remove the mutation object from the dependency array like you mentioned. We should never be doing that, it's a footgun.
|
||
// Remove charger and reduce battery level slightly | ||
act(() => { | ||
hardware.setBatteryDischarging(true); | ||
hardware.setBatteryLevel(0.6); | ||
}); | ||
await advanceTimersAndPromises(BATTERY_POLLING_INTERVAL / 1000); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does removing these significantly slow down the test overall? I think it's good to use fake timers and advance them when we have any sort of significant polling delay.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope, it has no impact on the speed of the test. Just confirmed. This test file uses fake timers and the waitFor
and findBy...
APIs are also advancing fake timers between each of their checks.
}); | ||
|
||
it('yields a success result when all steps are completed', async () => { | ||
it.skip('yields a success result when all steps are completed', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have you confirmed that there is no re-render happening? Or is it just happening after we try to press {Enter}
? I'm happy to look at this one closer with you.
const reset = useCallback(() => { | ||
endCardlessVoterSessionMutate(undefined, { | ||
onSuccess: () => resetBallot(), | ||
}); | ||
}, [endCardlessVoterSessionMutate, resetBallot]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How would you access the .mutate
method inside the useCallback
without also depending on the mutation object as well? Wouldn't the callback function close over the mutation object?
This reverts commit 2905b3b.
Overview
Closes #1660. Closes #2798.
Demo Video or Screenshot
Testing Plan
Checklist