Platform v12 Migration Guide: Vite & React 18
Version 12.0.0 introduces Vite and React 18 to the platform, and introduces some great benefits including faster dev server startup, faster application builds, and better plugin UX. Check out the announcement blog post here!
This guide describes the features that version 12 introduces, then some tips on how to easily upgrade to @dhis2/cli-app-scripts
v12 and what to expect along the way.
Features
Vite is now used to start and build apps, replacing Create React App (CRA). This confers several significant improvements:
- It's fast! Starting up an app is nearly instant, and building an app is about 3-4 times faster compared to CRA
- Plugins are handled better:
- Start-up of both the app and plugin is nearly instant
- The app and plugin are run on the same port
- Support for a plugin without an app is improved
- Code is shared between entrypoints, which makes bundles smaller
- Hot Module Replacement (HMR) for code changes in the plugin will be as fast as in the app
- There's a small suite of tools available in the CLI when running an app in dev mode:
- With the dev server running, press
h + enter
to see the options - Options include exposing the server on LAN, opening the app in the browser, cleanly quitting the server, and restarting the dev server (which can be helpful if you're modifying libraries in
node_modules
)
- With the dev server running, press
- The build output includes a summary to inspect chunks
The React version is now increased to v18, which provides some performance benefits and new APIs. More details about React 18 can be found at the React docs.
Version 12 also includes some new features for TypeScript:
- Bootstrapping an app with a TypeScript template is now supported:
d2-app-scripts init --typescript my-app
. See theinit
docs for more about the command - Vite has native support for TypeScript
- Although not in the App Platform package, with the latest
@dhis2/cli-style
, TypeScript type checking is performed when runningd2-style lint
- With
@dhis2/ui
version 9, the UI library is now typed as well
Getting started
By running these steps, you should be able to run your app right away:
yarn add @dhis2/app-runtime @dhis2/ui -D @dhis2/cli-app-scripts
npx yarn-deduplicate yarn.lock && yarn
- Try out
yarn start --allowJsxInJs
, and your app should be running 🚀
There will be some other changes you will probably want to make, which are described in the following sections. Our goal is to make it easy to adopt the new changes, so we have some tools to facilitate the process.
Vite & JSX
As suggested by the --allowJsxInJs
flag, Vite does not allow JSX syntax in files without a .jsx or .tsx extension by default; attempting to run start
or build
in that case will throw an error like ✘ [ERROR] The JSX syntax extension is not currently enabled
.
JSX in .js files is not supported for performance reasons: it saves needing to parse non-JSX files for JSX syntax. We can add complicated config to enable parsing of JSX in .js files, but that approach has several downsides:
- It's nonstandard — it is most correct to use the right file extensions
- It requires more config to maintain
- It has performance costs on startup and when parsing files
The new default for
@dhis2/cli-app-scripts
, therefore, will be the same as Vite's. To make the transition easier, though, we provide some tools to 1) support JSX in .js files temporarily, and 2) easily migrate JS files to .jsx extensions if they contain JSX syntax.
Allow JSX in JS temporarily
To make it as fast as possible to upgrade and get up and running with Vite and React 18, we added an --allowJsxInJs
flag that you can pass to start
and build
scripts, so that you can test out the changes without any file renaming. In many cases, you should be able to upgrade your platform dependencies and run start
with the flag and see your app running! Other times, you may need to add a few tweaks — see the troubleshooting and breaking changes sections below.
Due to the downsides mentioned above, however, we will remove the --allowJsxInJs
option in the next major version. When you are ready to rename your files, you can use the migration script we've made.
.js to .jsx migration script
We made a codemod that can migrate your codebase to the right file extensions quickly! It checks .js files for JSX syntax, updates the extension if needed, then updates imports within the file tree to target the new filenames.
yarn d2-app-scripts migrate js-to-jsx
Check out the script docs for options, tips, and caveats.
React 18
With this update, we also upgraded from React 16 to React 18. Upgrading to @dhis2/cli-app-scripts
version 12 should generally a smooth process for the consuming apps. Most of the changes shouldn't affect most apps.
Default props
The main change in React 18 that is expected to affect apps at runtime is that using MyComponent.defaultProps = { ... }
is deprecated, and will be removed in the next major version. While non-breaking, this change comes along with warning messages in the console for every rendered component that uses them, which can be chatty and annoying.
The fix is to move prop defaults -- either to default parameters for functional components, or to a static property for class components. This code mod can help with the migration, and was used for the @dhis2/ui
library: transform.js
Default params for functional components are computed on each render, so default values like arrays and objects as defaults may unintentionally retrigger useEffect
and useMemo
functions on each render. Instead, use a stable reference like so:
const arr = []
const MyComponent = ({ options = arr }) => {
/* ... */
}
Tests
React 18 has minimal effects on the operation of an app in a browser, but it has a more significant effect on tests, depending on what tools are used and how the tests are written.
Snapshots
When the tests are up and running, you can expect some small changes from React 18. You may see changed classnames and different whitespace; these shouldn't cause any functional changes, and they're safe to approve.
React Testing Library
Testing-library supports React 18 since v13, so the first step is to upgrade that library and the supporting libraries around it. These major versions bumps come with a few breaking changes that we will document here with some examples to make migration easier.
Examples come from the migration for the Aggregate Data Entry app; you can check out the PR here for more examples in context.
@testing-library/dom
is now a required peer dependency
Since @testing-library/react
v16, @testing-library/dom
is now required to be installed as a dev dependency alongside @testing-library/react
.
userEvent
userEvent
is async now in @testing-library/user-event
. Check the release notes for an idea of the improvements and new API. In practice, this means mostly that you'd need to add an await
for userEvent
interactions like userEvent.click()
.
Actions using the fireEvent
API should also be migrated to userEvent
, e.g. fireEvent.change(selector(), { target: { value: 'input text' } })
should be changed to userEvent.type(selector(), 'input text')
.
Here are some examples that illustrate the changes:
+ import userEvent from '@testing-library/user-event'
- it('should allow re-running validation', () => {
+ // Make the function async:
+ it('should allow re-running validation', async () => {
// ...
- userEvent.click(getByText('Run validation again'))
- // Or this other form:
- fireEvent.click(getByText('Run validation again'))
+ // Now that it's asynchronous, await the event:
+ await userEvent.click(getByText('Run validation again'))
await findByText('2 medium priority alerts')
expect(queryByText('There was a problem running validation')).toBeNull()
})
- const login = async ({ user }) => {
+ // Don't need `user`; will use `userEvent` later:
+ const login = async () => {
- // Remove fireEvent calls:
- fireEvent.change(screen.getByLabelText('Username'), {
- target: { value: 'Fl@klypa.no' },
- })
- fireEvent.change(screen.getByLabelText('Password'), {
- target: { value: 'SolanOgLudvig' },
- })
+ // Replace with userEvent.type():
+ await userEvent.type(screen.getByLabelText('Username'), 'Fl@klypa.no')
+ await userEvent.type(screen.getByLabelText('Password'), 'SolanOgLudvig')
- await user.click(screen.getByRole('button', { name: /log in/i }))
+ // Replace `user` with `userEvent`:
+ await userEvent.click(screen.getByRole('button', { name: /log in/i }))
}
renderHook
renderHook
is not a separate library anymore; it is part of the @testing-library/react
, and it has quite a different API.
Some docs online still point to the old version, which can be confusing.
- Use
import { renderHook } from '@testing-library/react'
instead ofimport { renderHook } from '@testing-library/react-hooks
- The return type for renderHook is different. One major difference is the absence of
waitForNextUpdate
returned fromrenderHook
. Now it can be replaced with thewaitFor
from@testing-library
, and adding an expectation to wait for, for example. - Testing hooks that throw errors is different. Use
expect(renderHook(() => { ... })).toThrow(....)
This example demonstrates some of the changes:
- import { renderHook } from '@testing-library/react-hooks'
+ // Update import, and include waitFor:
+ import { renderHook, waitFor } from '@testing-library/react'
import React from 'react'
import { useRootOrgData } from './use-root-org-data.js'
it('should provide the org unit data', async () => {
- const { result, waitForNextUpdate } = renderHook(
- () => useRootOrgData(['A0000000000']),
- { wrapper }
- )
+ // waitForNextUpdate is no longer part of the result:
+ const { result } = renderHook(() => useRootOrgData(['A0000000000']), {
+ wrapper,
+ })
- await waitForNextUpdate()
+ // Instead, use waitFor:
+ await waitFor(() => {})
// ...
})
In some cases, renderHookResult.waitFor
can just be removed and doesn't need replacing:
it('should update validationToRefresh', async () => {
- const { result, waitFor } = renderHook(useValidationStore)
+ const { result } = renderHook(useValidationStore)
act(() => {
result.current.setValidationToRefresh(true)
})
- await waitFor(() => {
- expect(result.current.getValidationToRefresh()).toBe(true)
- })
+ expect(result.current.getValidationToRefresh()).toBe(true)
})
And testing errors:
it('throws an error when passing both a client- and a serverDate', async () => {
const clientDate = new Date('2022-10-13 10:00:00')
const serverDate = new Date('2022-10-13 08:00:00')
- const { result } = renderHook(() =>
- useClientServerDate({ clientDate, serverDate })
- )
- expect(result.error).toEqual(
- new Error(
- '`useClientServerDate` does not accept both a client and a server date'
- )
+ expect(() => {
+ renderHook(() => useClientServerDate({ clientDate, serverDate }))
+ }).toThrow(
+ '`useClientServerDate` does not accept both a client and a server date'
+ )
})
Fake timers
Faking timers can help with tests that seem to fail with concurrency issues:
describe('<Comment />', () => {
+ // Set up fake timers:
+ beforeEach(() => {
+ jest.useFakeTimers
+ })
afterEach(() => {
// ...
})
it('shows a loading indicator when submitting a comment change', async () => {
// ...
- const { getByRole, queryByRole } = render(<Comment item={item} />)
+ const { getByRole, queryByRole, findByRole } = render(
+ <Comment item={item} />
+ )
- userEvent.click(getByRole('button', { name: 'Edit comment' }))
+ const editButton = await findByRole('button', { name: 'Edit comment' })
+ // Set up user event with fake timers:
+ const user = userEvent.setup({
+ advanceTimers: jest.advanceTimersByTime,
+ })
+ await user.click(editButton)
// ...
expect(queryByRole('progressbar')).not.toBeInTheDocument()
- userEvent.click(getByRole('button', { name: 'Save comment' }))
- expect(getByRole('progressbar')).toBeInTheDocument()
+ // Use the setup from above:
+ const btnSaveComment = await findByRole('button', {
+ name: 'Save comment',
+ })
+ await user.click(btnSaveComment)
+ await findByRole('progressbar')
})
})
Enzyme
Enzyme is considered dead: there is no official adapter for React 18 (or 17), so it's best to migrate to React Testing Library for tests, if possible.
If it's not currently possible to migrate, there is an unofficial React 18 adapter that can help in the mean time. However, this should be considered a temporary solution, and migrating away from Enzyme should be scheduled.
Environment variables
REACT_APP_
prefix no longer needed
With the migration away from Create React App, the formatting for environment variable names has gotten simpler.
As before, environment variables with names that start with DHIS2_
are added to the app, and can be picked up from .env
files or on the command line for example. Before, the platform had to add REACT_APP_
before DHIS2_
so that Create React App would add the variable to the app, so a variable like DHIS2_MY_VAR
in the environment would become accessible on process.env.REACT_APP_DHIS2_MY_VAR
in the app. Now, the REACT_APP_
prefix isn't needed, so the variable can be accessed on just process.env.DHIS2_MY_VAR
.
The REACT_APP_
-prefixed variables are now deprecated and will be removed in the next major version. However they will be kept around alongside the shorter DHIS2_
variables in this version for backwards compatibility.
For example, both process.env.REACT_APP_DHIS2_MY_VAR
and process.env.DHIS2_MY_VAR
will be available.
The REACT_APP_
prefixed variables will be removed in the next version, so make sure to migrate to the shorter DHIS2_
variable names.
import.meta.env
By default, Vite adds its environment variables to the import.meta.env
object. The App Platform will add DHIS2_
env vars to import.meta.env
as well, and the configured envPrefix
is DHIS2_
. Emphasis is given to environment variables on the process.env
object, however, since it is a more widely-used pattern.
Troubleshooting & tips
Here are some edge cases that were encountered when upgrading some apps with the new platform version:
- There is a bug in Vite: importing from a directory, e.g.
'./app/'
where we would assume to get./app/index.[jt]s
, can incorrectly resolve to a file with a similar name to the directory, e.g../App.tsx
. I see this as a bug with Vite, but as a workaround, file and dir names can be changed so they don't conflict. - If you're using
identity-obj-proxy
in your test config, make sure you add it to the project's dependencies. Previously, some apps got away with using the proxy without an explicitid dependency because the package is a dependency ofreact-scripts
- You may see some build warnings coming from the
mathjs
package. Upgrading to a recent version ofmathjs
will handle these warnings.
Full list of breaking changes and deprecations
The most likely and impactful breaking changes have been described in detail above. However this doesn't cover all the breaking changes and deprecations introduced in this upgrade. Below is the exhausted list.
Breaking changes
- Node 18 or Node 20+ is required. Node 19 is not supported.
- JSX in files without .jsx or .tsx extensions is not supported by default
- React 18: Most significant testing changes are described above; the rest can be read about in the React migration guide
- Flow types won't work, although this is a motivator for extensible config
- Import aliases will likely break (also a motivator for extensible config)
global.variableName
is no longer supported; usewindow.variableName
instead- In order to make the dev server available on LAN, the
--host
flag needs to be passed to the start script - Workers imported in the “webpack” way will need to be imported in a new way: docs
- An
App.jsx
entrypoint is no longer quietly added if a plugin entrypoint is defined or for a defined but empty entrypoints object - Plugins and apps are started together in the same process on the same port
- Custom
index.html
files are no longer supported import * as MyClass from 'my-module'
is now more restrictive, and may break in some cases. So far, changing the import toimport MyClass from 'my-module'
has fixed it. DOMPurify is one example case.
Deprecations
- The default
direction
config value isltr
; in the future, it will beauto
--allowJsxInJs
will be removed- Some deprecated PWA config caching option names will be removed in the next version
- Env vars prefixed with
REACT_APP_DHIS2_
will be removed in the next version in favor of just theDHIS2_
prefix