Skip to main content

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)
  • 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 the init 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 running d2-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:

  1. yarn add @dhis2/app-runtime @dhis2/ui -D @dhis2/cli-app-scripts
  2. npx yarn-deduplicate yarn.lock && yarn
  3. 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:

  1. It's nonstandard — it is most correct to use the right file extensions
  2. It requires more config to maintain
  3. 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

Note

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.

Note

Some docs online still point to the old version, which can be confusing.

  • Use import { renderHook } from '@testing-library/react' instead of import { renderHook } from '@testing-library/react-hooks
  • The return type for renderHook is different. One major difference is the absence of waitForNextUpdate returned from renderHook. Now it can be replaced with the waitFor 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.

tip

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.

tip

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 of react-scripts
  • You may see some build warnings coming from the mathjs package. Upgrading to a recent version of mathjs 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

  1. Node 18 or Node 20+ is required. Node 19 is not supported.
  2. JSX in files without .jsx or .tsx extensions is not supported by default
  3. React 18: Most significant testing changes are described above; the rest can be read about in the React migration guide
  4. Flow types won't work, although this is a motivator for extensible config
  5. Import aliases will likely break (also a motivator for extensible config)
  6. global.variableName is no longer supported; use window.variableName instead
  7. In order to make the dev server available on LAN, the --host flag needs to be passed to the start script
  8. Workers imported in the “webpack” way will need to be imported in a new way: docs
  9. An App.jsx entrypoint is no longer quietly added if a plugin entrypoint is defined or for a defined but empty entrypoints object
  10. Plugins and apps are started together in the same process on the same port
  11. Custom index.html files are no longer supported
  12. import * as MyClass from 'my-module' is now more restrictive, and may break in some cases. So far, changing the import to import MyClass from 'my-module' has fixed it. DOMPurify is one example case.

Deprecations

  • The default direction config value is ltr; in the future, it will be auto
  • --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 the DHIS2_ prefix