Skip to content
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

Merged
merged 15 commits into from
Aug 9, 2023
Merged

Upgrade React 18 #3793

merged 15 commits into from
Aug 9, 2023

Conversation

adghayes
Copy link
Collaborator

@adghayes adghayes commented Aug 6, 2023

Overview

Closes #1660. Closes #2798.

Demo Video or Screenshot

Testing Plan

Checklist

  • I have added logging where appropriate to any new user actions, system updates such as file reads or storage writes, or errors introduced.
  • I have added a screenshot and/or video to this PR to demo the change
  • I have added the "user_facing_change" label to this PR to automate an announcement in #machine-product-updates

apps/admin/frontend/package.json Show resolved Hide resolved
apps/admin/frontend/package.json Show resolved Hide resolved
package.json Show resolved Hide resolved
libs/ui/src/export_logs_modal.test.tsx Show resolved Hide resolved
libs/ui/src/export_logs_modal.test.tsx Show resolved Hide resolved
@@ -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…');
Copy link
Collaborator Author

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.

Copy link
Collaborator

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

Copy link
Collaborator Author

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 () => {
Copy link
Collaborator Author

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:

  1. 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?
  2. Explicitly calling rerender in the tests that this affects after the sound plays.

Copy link
Collaborator

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.

Copy link
Collaborator Author

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

Copy link
Collaborator

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 () => {
Copy link
Collaborator Author

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:

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:

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?

Copy link
Collaborator

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.

Copy link
Collaborator Author

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

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙌

Comment on lines 12 to 16
const reset = useCallback(() => {
endCardlessVoterSessionMutate(undefined, {
onSuccess: () => resetBallot(),
});
}, [endCardlessVoterSessionMutate, resetBallot]);
Copy link
Collaborator Author

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 useEffects 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?

Copy link
Collaborator

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?

Copy link
Collaborator Author

@adghayes adghayes Aug 8, 2023

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.

Copy link
Collaborator

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.

@adghayes adghayes marked this pull request as ready for review August 7, 2023 16:30
@adghayes adghayes requested review from carolinemodic and removed request for a team August 7, 2023 16:30
Copy link
Collaborator

@jonahkagan jonahkagan left a 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();
Copy link
Collaborator

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?

Copy link
Collaborator Author

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();
Copy link
Collaborator

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?

Copy link
Collaborator Author

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.

Copy link
Collaborator

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

Comment on lines +33 to 39
await suppressingConsoleOutput(async () => {
await printElement(simpleElement, fakeOptions);
expect(printer.print).toHaveBeenCalledTimes(1);
expect(printer.print).toHaveBeenLastCalledWith(
expect.objectContaining(fakeOptions)
);
});
Copy link
Collaborator

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}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this change?

Copy link
Collaborator Author

@adghayes adghayes Aug 8, 2023

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.

Copy link
Collaborator

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);
Copy link
Collaborator

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

Copy link
Collaborator Author

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.

Copy link
Collaborator

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

Copy link
Collaborator Author

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 () => {
Copy link
Collaborator

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);
Copy link
Collaborator

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.

Copy link
Collaborator Author

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 () => {
Copy link
Collaborator

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.

Comment on lines 12 to 16
const reset = useCallback(() => {
endCardlessVoterSessionMutate(undefined, {
onSuccess: () => resetBallot(),
});
}, [endCardlessVoterSessionMutate, resetBallot]);
Copy link
Collaborator

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?

@adghayes adghayes merged commit 2539d94 into main Aug 9, 2023
52 checks passed
@adghayes adghayes deleted the drew/upgrade-react-1000 branch August 9, 2023 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Testing Warnings in Election Manager due to React Query Upgrade to React 18
2 participants