The SonarQube Server interface is written in React and we recently went through the process of upgrading from version 17 to 18. To give you a bit more of the picture, the app is also written in TypeScript and uses Jest with React Testing Library (RTL) for testing.
We wanted to share the biggest three issues we faced and the lessons we learned as we carried out the upgrade. In brief, they were:
- Some TypeScript types changed
- React Testing Library must also be updated
- React 18 brings breaking changes
Let's get into what these meant and how we dealt with them.
Note: this post was co-written by the SonarQube Server front-end team of David Cho-Lerat, Ambroise Christea, and Philippe Perrin.
TypeScript type changes
The React 18 upgrade guide points out that both @types/react
and @types/react-dom
must be updated as you upgrade, and the "most notable change is that the children
prop now needs to be listed explicitly when defining props."
The good news for this update is that Sebastian Silbermann, from the React core team, maintains a collection of codemods that help to automatically update the types when upgrading from React 17.
You can run the codemod using npx like so:
It will present a number of transforms you can apply and will default to the transforms that are required.
For example, the transform to list the children
prop explicitly will take a component that looks like this:
and replace it with:
Watch out though, we found that the codemod can end up nesting the PropsWithChildren
type and your type might end up looking like this:
While this isn't harmful, you will want to correct these types as you come across them.
The new types are also more picky in some areas. For example, previously we were able to override the type of children
in an interface like this:
With the React 18 types, this no longer works and you must now omit the declaration of children
first.
The new types also don't allow implicit any
types for the parameters to a useCallback
function. You will need to explicitly declare the types, for example:
When you upgrade @types/react
to version 18, expect to see a few issues like this.
React Testing Library update
We found that many of our tests that used to pass now failed after updating React and RTL. There were two categories of failure: timing and calls to act()
.
Fake timers
RTL uses a setTimeout
for a defined delay when simulating user events, but this does not play nicely with Jest's fake timers. This caused tests to hang and fail with a timeout.
In version 14.1.0, RTL added an advanceTimers
option to the setup step for user-event so that you can provide your own timer. We were able to fix our tests by passing the jest.advanceTimersByTime
method.
Acting out
The dreaded act(...)
warning had plagued our codebase for a while and in some cases had been patched up by adding an extra call to act
around some RTL events and helpers.
RTL helpers use act
internally, so while adding an extra call to act
was initially a valid workaround to suppress the warning, it now caused the tests to fail. Removing the excess calls to act
got the tests passing again. If you still receive warnings, Kent C. Dodds has a comprehensive post on what causes the act(...) warning and how to fix it in the context of RTL.
React 18 breaking changes
The biggest change in React 18 is right at the root of the application. ReactDOM.render
is no longer supported and should be replaced with createRoot
. While on the surface this seems like a simple change that provides a better way for React to manage the root of the application, it actually changes how React renders your application. Two new features are enabled: automatic batching and the new concurrent renderer.
Concurrent rendering allows React to interrupt the rendering of a component if there is other work that needs to be done at a higher priority. You opt-in to this behaviour by defining a state update as a transition using the useTransition
hook. If you don't opt-in, your components will render sequentially as before, so this should make no difference as you upgrade your application.
However, automatic batching is enabled immediately. Automatic batching is a performance improvement in React 18 to reduce the number of renders by collecting state changes into one update. It can cause some unexpected behaviour though.
We discovered some parts of our code fell foul of this new batching when several tests started failing. The tests were expecting parts of components to be rendered, yet found them to be empty.
We realized that this batching includes any execution sub-context in the same scope! This means that if you have a setState
, then a Promise that also does a setState
when it resolves/rejects, both state changes will be batched at the end of the scope if they happen close to each other (for instance in tests, where mocked queries are almost instantaneous).
In this simplified example of two methods in a class-based component we set a state, then, within the body of an asynchronous function, we relied on that new state in a conditional.
In React 17 when handleFetchProjectsClick
was called it would set the shouldFetch
state to true
, then call on fetchProjects
. Within fetchProjects
the test for shouldFetch
would be true
and the data was fetched. This is because the state update task happens before the fetchProjects
promise is handled.
In React 18 with createRoot
the projects aren't fetched because the state update is deferred until the end of handleFetchProjectsClick
, so when fetchProjects
runs shouldFetch
would still be falsy.
If you need to ensure code runs after state is set, you can either use the callback form of setState or the new ReactDOM.flushSync()
method.
Asynchronous renders in tests
The above fixed the component rendering on the page, but we found that the tests continued to fail. Debugging these failures step-by-step showed that the content was not present on the initial render, but when we re-rendered the component it then appeared. Because we now used the setState
callback method to fetch the data, the initial render didn't include the content.
Our tests were using RTL's synchronous methods to find that content on the page, like this:
Replacing RTL's synchronous getBy
queries with the asynchronous await findBy
query fixes the issue. For example:
Using a findBy
query uses waitFor
under the hood to give the DOM time to update when it doesn't happen immediately.
The upgrade was a success
The SonarQube Server UI is now running successfully on React 18. While some of the issues we came across had to do with the test suite needing an upgrade, others were caught because we have a comprehensive test suite across both unit tests for components and end-to-end tests avoiding production failures when it was time to deploy. Writing testable code is one part of writing adaptable code, one of the properties of Clean Code. Those tests highlighted things that needed to be updated and gave us the confidence that when they passed, the application was ready.
If you are running React 17 and planning an upgrade, hopefully, these experiences can help you with some of the pitfalls.