Skip to content

build: upgrade frontend React 18 to 19 - #1708

Draft
shivoomiess wants to merge 5 commits into
build/frontend-typescript-6from
build/frontend-react-19
Draft

build: upgrade frontend React 18 to 19#1708
shivoomiess wants to merge 5 commits into
build/frontend-typescript-6from
build/frontend-react-19

Conversation

@shivoomiess

@shivoomiess shivoomiess commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Description

This PR upgrades the frontend React version from 18 to 19 and updates the associated dependencies accordingly.

Motivation and Context

Upgrading React to version 19 is crucial for improving the performance and stability of the application, as it comes with several bug fixes and new features. This upgrade also ensures compatibility with the latest libraries and tools in the React ecosystem, making future developments easier and more efficient.

Changes

  • Updated the file extensions in apps/frontend/eslint.config.js to include additional types.
  • Upgraded several dependencies in apps/frontend/package-lock.json to their respective latest versions, including "@hello-pangea/dnd", "@tinymce/tinymce-react", "html-react-parser", "react", and "react-dom".
  • Updated the "@types/react" and "@types/react-dom" in apps/frontend/package-lock.json to match the updated React version.
  • Resolved new dependencies in apps/frontend/package-lock.json to their respective registries.

How Has This Been Tested?

Fixes Jira Issue

https://jira.ess.eu//browse/

Depends On

Tests included/Docs Updated?

  • I have added tests to cover my changes.
  • All relevant doc has been updated

@shivoomiess shivoomiess changed the title build/frontend react 19 build: upgrade frontend React 18 to 19 Aug 5, 2026
@shivoomiess shivoomiess closed this Aug 5, 2026
@shivoomiess shivoomiess reopened this Aug 5, 2026
@shivoomiess
shivoomiess force-pushed the build/frontend-react-19 branch from 5c0c9cf to 5693546 Compare August 6, 2026 09:20
@shivoomiess
shivoomiess force-pushed the build/frontend-react-19 branch from 5693546 to 3c9e675 Compare August 6, 2026 11:41
React 19.2.8, with `@types/react` 19.2.18 and `@types/react-dom` 19.2.4.
Prerequisite for @material-table/core v8, which peers `react >=19.0.0`.

Three dependencies capped at React 18 and had to move with it:

- `@hello-pangea/dnd` 16.6.0 -> 18.0.1 (peer was `^16.8.5 || ^17 || ^18`).
  material-table v8 wants ^18.0.1 too, so this covers both.
- `@tinymce/tinymce-react` 5.1.1 -> 6.3.0. v6 peers `tinymce ^8 || ^7 || ^6 ||
  ^5.5.1`, and the repo is on ^7.5.1, so tinymce itself is unaffected.
- `html-react-parser` 1.4.12 -> 6.1.5. All six call sites use the bare
  `parse(html)` form with no `replace` options, so nothing else changed.

Everything else was already permissive. reactflow 11 peers `>=17` and installs,
but it is superseded by @xyflow/react v12 - left alone deliberately, it is the
whole workflow editor and deserves its own change.

Adds an `overrides` block pinning `@types/react` and `@types/react-dom` to 19.
@material-table/core dragged in a nested `@types/react@18.3.31`, and the two
copies disagreed about `ReactNode` and about ref types, which alone accounted for
19 of the type errors - all 17 in materialIcons.tsx among them.

That nested copy was also the only thing still declaring the **global** `JSX`
namespace: @types/react 19 moved it to `React.JSX`, and react-i18next's
`declare global` only augments `IntrinsicAttributes`. So every `JSX.Element`
annotation was compiling by accident and would have broken at once when
material-table v8 lands.

Rather than import `JSX` back into each file, the 54 `): JSX.Element` return
annotations are simply deleted - TypeScript infers component return types, and
the annotation never carried information. The remaining 19 uses become the type
that was actually meant:

- `ReactNode` for anything renderable (`icon`, `extra`, `content`, `info`,
  `actionIcon`, the `answerRenderer` return, `alertText[]`).
- `ReactElement | null` for the two questionary form factories, matching the
  sibling `questionaryComponent` declaration in the same interface.
- `React.Attributes` for the one `JSX.IntrinsicAttributes` use.
- `React.MouseEvent<HTMLElement>` for four `React.MouseEvent<JSX.Element>`,
  which were always wrong: the parameter is a DOM element, not a React element.
  It only compiled because the parameter is unconstrained.

- `<SomeContext.Provider>` -> `<SomeContext>` in all 17 places. React 19 renders
  a context directly and deprecates the `.Provider` child.
- `forwardRef` removed from the three modal `Transition` components; React 19
  passes `ref` as an ordinary prop. The 18 wrappers in `materialIcons.tsx` have
  to stay: @material-table/core 6.4.4 types every icon as
  `ForwardRefExoticComponent<any> & RefAttributes<SVGSVGElement>`, and an
  ordinary function component does not satisfy `ExoticComponent`'s `$$typeof`.
  They come out with material-table v8.
- `useReducerWithMiddleWares` is parameterised by state and action instead of by
  a reducer type. It previously took `R extends Reducer<any, any>` and pulled the
  parts back out with `ReducerState<R>` and `ReducerAction<R>`; React 19 removed
  `ReducerAction`, which is exactly the indirection it was removed for. The three
  call sites now read `<Workflow, Event>` rather than `<Reducer<Workflow, Event>>`.
- Removes 18 dead `propTypes` blocks across 17 files, and the `prop-types` import
  from each. React 19 ignores `propTypes` entirely, and every one of these
  components is already typed in TypeScript. `prop-types` was never a declared
  dependency - it only resolved transitively.

- `FunctionComponent`'s return type widened to `ReactNode | Promise<ReactNode>`
  for async components. `QuestionRenderer` and `AnswerRenderer` were typed that
  way, but all four call sites invoke them directly and embed the result; none
  renders them as JSX. Retyped as plain functions returning `ReactNode`.
- `ReactElement`'s props default went `any` -> `unknown`, which broke
  `step.props` in `MultistepWizard` and `cloneElement(children, {isInsideModal})`
  in both review modals.
- `useRef<T>(null)` now yields `RefObject<T | null>`, so `WorkflowCanvas` has to
  declare its `reactFlowWrapper` prop that way. Five argless `useRef()` calls
  gained an explicit `undefined`.
- `Dispatch` became contravariant in its parameter, exposing a real latent bug:
  `handleRole` was declared `Dispatch<string | null | undefined>` but the
  SELECTROLE reducer calls `payload.toUpperCase()`. Narrowed to
  `(role: string) => void`, which is what the sole caller already passes.

Checked and clean: no findDOMNode, ReactDOM.render/hydrate, react-dom/test-utils,
unmountComponentAtNode or createFactory. `index.tsx` already uses `createRoot`.
The four `defaultProps` hits are MUI theme component defaults, not React's
removed `Component.defaultProps`.

`tsc --noEmit`, `eslint` and `vite build` all pass. There is no unit test runner
in the frontend, so none of this is exercised at runtime by CI before Cypress.
React 19 did not remove `React.FC`, so this is not forced by the upgrade - but it
is the last of the pre-19 idioms left in the tree, and React's own documentation
has not used it for years. Annotating the parameter says the same thing without
routing the props through a wrapper type.

    -const StatusPicker: React.FC<StatusPickerProps> = ({ statuses }) => {
    +const StatusPicker = ({ statuses }: StatusPickerProps) => {

14 declarations across 14 files. No behaviour change: `React.FC` stopped implying
`children` in the React 18 types, so nothing depended on it here.

Left alone: the three `TableProps<FunctionComponent<unknown>>` type arguments in
the questionary review components. `FunctionComponent` is a legitimate
`ElementType` there and is not deprecated - it is a type argument, not a
component annotation.
… warning

`npm run lint` passes `--quiet`, so warnings never surfaced. There were 41.
Two causes accounted for half of them, both fixable at the source.

**`vite.config.mts` was outside the project config.** The custom block matched
`**/*.{ts,tsx,js,jsx}`, which `.mts` does not. So that one file picked up
`react.configs.flat.recommended` - which is applied globally with no `files` key
- but not the sibling `settings.react.version`, and eslint-plugin-react printed
"React version not specified in eslint-plugin-react settings" on every single
lint run. Widened the glob to cover mts/cts/mjs/cjs.

**Three components exported the same symbol twice**, as both a named and a
default export, which is what `import/no-named-as-default` was reporting at all
20 of its call sites: `SuperMaterialTable` (13), `FormikUICustomTable` (6) and
`DenseMaterialTable` (1). Nothing imports the named form anywhere, so dropping
the `export` keyword from the declaration fixes every call site without touching
a single import.

41 warnings -> 21. The rest are left deliberately:

- 18 `react-hooks/exhaustive-deps`. All pre-existing - none of them sits in a
  dependency array this branch touched - and each is a behaviour change that
  wants e2e verification rather than a bulk edit.
- 2 `import/no-named-as-default-member` for `tseslint.configs` and `i18n.use`.
  Both are the documented way to use those packages.
- 1 `import/no-named-as-default` for `ReactFlow`, which is reactflow's own
  double export and not ours to fix.
…ation

Three pages - Shipment, Visit and Feedback templates - crashed with
"Maximum call stack size exceeded" inside deepmerge as soon as their table was
empty.

@material-table/core deep-merges the `localization` prop against its defaults
(`store/LocalizationStore.js`). deepmerge skips React elements via

    var REACT_ELEMENT_TYPE = Symbol.for('react.element');
    function isReactElement(value) { return value.$$typeof === REACT_ELEMENT_TYPE }

React 19 renamed that symbol to `react.transitional.element`, so the guard never
matches. deepmerge then treats the element as a plain object and recurses through
its internals until the stack overflows.

The three pages pass JSX as `emptyDataSourceMessage`, which `TemplatesTable` put
straight into `localization.body`. material-table's own types declare that field
as `React.ReactNode`, so this was documented usage, not misuse - the library is
merging a field it invites you to fill with elements.

`localization` now only ever receives plain strings. A richer message is rendered
under the table instead, so the wording and formatting are unchanged.

**This is not fixed by upgrading material-table.** v8.0.3 depends on the same
`deepmerge ^4.3.1` and its `LocalizationStore` still calls
`merge(defaults, props.localization ?? {})` with no `isMergeableObject` option.
deepmerge's latest release is 4.3.1 and predates React 19, so there is no version
to upgrade to either.

A patched deepmerge was considered and rejected: formik pulls its own
`deepmerge@2.2.1` with the identical stale guard, so an override would either
miss it or change formik's merge behaviour.

Only `localization` is deep-merged - `icons`, `options` and `components` are not
- so no other prop is affected. `tableLocalization` in utils is strings only.
Logging in looped between / and /external-auth, with the backend
reporting `invalid_grant (grant request is invalid)` from the OpenID
client. An authorization code is single-use, and the code was reaching
the token endpoint twice.

ExternalAuth guarded against this with a `useRef`, which holds for
StrictMode's simulated remount but resets whenever the component
genuinely remounts within a page load. The second mount then replayed a
code the provider had already spent.

The exchange is now memoised at module scope, keyed on the code, so a
remount joins the in-flight request instead of starting a second one.
Joining rather than skipping matters: a later mount that merely skipped
would sit on "Please wait" forever whenever the first exchange failed.

What remounts the component is still unidentified. The provider gates in
SettingsContextProvider and FeatureContextProvider both resolve before
ExternalAuth mounts, IdleContextPicker cannot flip with STFC_IDLE_TIMER
off, and plain StrictMode is what the existing ref already covers. The
fix does not depend on the answer, since exchanging a single-use code
twice is a client bug whatever triggers the second attempt.

The same code is on develop, so the bug is not new here. It is placed on
this branch because React 19 is the change most likely to have altered
the mount behaviour that triggers it.
@shivoomiess
shivoomiess force-pushed the build/frontend-react-19 branch from 3c9e675 to 5e242f7 Compare August 10, 2026 12:22
@shivoomiess shivoomiess added dependencies Pull requests that update a dependency file review: please labels Aug 20, 2026
@yoganandaness
yoganandaness marked this pull request as ready for review August 21, 2026 08:29
@yoganandaness
yoganandaness requested a review from a team as a code owner August 21, 2026 08:29
@yoganandaness
yoganandaness requested review from Bhaswati1148 and removed request for a team August 21, 2026 08:29
@shivoomiess
shivoomiess marked this pull request as draft August 25, 2026 20:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants