build: upgrade React 18 to 19 - #1746
Draft
shivoomiess wants to merge 9 commits into
Draft
Conversation
shivoomiess
requested review from
Jonathan-Swales
and removed request for
a team
August 25, 2026 20:11
shivoomiess
force-pushed
the
upgrade-react-19
branch
from
August 25, 2026 20:27
e8536b3 to
273ed83
Compare
shivoomiess
marked this pull request as draft
August 25, 2026 20:50
shivoomiess
force-pushed
the
upgrade-react-19
branch
from
August 27, 2026 18:30
91fd6ce to
abe2420
Compare
Both changes are prerequisites for the React 19 upgrade that follows, and both previously sat in the Material UI v9 branch. That branch now lands above React 19, so they have to come down here or the install breaks. notistack v2 declares `react "^16.8.0 || ^17.0.0 || ^18.0.0"`. No .npmrc in this repo sets legacy-peer-deps, so React 19 against notistack v2 is a hard ERESOLVE rather than a warning. v3 drops the `@mui/material` peer entirely - its peers are just react and react-dom - so it sits equally well on Material UI v5 here and on v9 later. Two source changes travel with the bump, neither Material UI dependent: - useDataApi: `WithSnackbarProps` was removed in v3 in favour of `ProviderContext`. A rename, same shape. - index.css: v3 renders its own wrapper instead of a MuiSnackbar and names the content element notistack-MuiContent-<variant>, so both the ancestor and the target of these rules had stopped matching and toasts fell back to notistack's own colours. Also corrects variantInformation, which is spelled info by notistack and looks to have never matched. @material-table/core moves from `^6.4.4` to an exact `6.4.4`. The range is not safe to float: 6.5.2 is published and satisfies it, and it declares `@mui/material ">=7.3.4"` and `@mui/x-date-pickers "8.15.0"` as direct dependencies, not peers. On this branch npm cannot dedupe those against the app's v5, so it would nest a second Material UI major under the table and the table's internals would resolve useTheme() from a different module instance than the app's ThemeProvider - the table would silently lose the app theme. 6.5.2 also peers `react ">=19.2.0"`, which is why develop has not tripped over this yet: on React 18 the float fails outright. It only becomes installable - and therefore dangerous - once React 19 lands in the next commit. The pin is removed again when the table moves to v8.
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`.
Four dependencies were 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.
- `@emotion/react` 11.11.4 -> 11.14.0 and `@emotion/styled` 11.11.5 -> 11.14.1.
Both are promoted from transitive dependencies of Material UI to direct ones,
because their peer range is only `react ">=16.8.0"` - permissive enough that
npm had no reason to move them off the versions the lockfile pinned, and they
had to be asked for explicitly.
Emotion 11.11 types reference the **global** `JSX` namespace, which
`@types/react` 19 removed in favour of `React.JSX`. That silently degraded
`styled()` and `sx` to `any`, which surfaced as 8 errors in 4 files -
ActionButtonContainer, PresetDateSelector, StatusNode and StyledComponents -
all of them an implicitly-any `theme` or a tuple argument narrowed to `any[]`.
11.14 declares `React.JSX`, and all 8 clear.
Verified this is the emotion types and not the Material UI minor: pinning
@mui/material back to 5.15.18 under React 19 reproduces all 8 errors, and
raising emotion alone with @mui/material at 5.18.0 clears them.
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.
`react-hooks/exhaustive-deps` reported 18 warnings. 13 of them are independent of the table stack and are fixed here; the other 5 are left for the material-table migration to absorb (see below). Every missing dependency in the seven data hooks is a query parameter that the effect reads but never re-runs for, so the hook returns data for whatever argument it happened to receive first: - `useEventsData`, `useStatusesData`, `useWorkflowsData` - `entityType` - `useFapProposalsData` - `legacy` - `useVisitRegistration` - `userId` - `useUserExperiments` - `notDraft`, `onlyUpcoming`, `instrumentId` - `useStatusActionsData` - `isPregeneratedProposalPdfsEnabled` None of these can loop. `api` comes from `useDataApi`, which returns a `useCallback`, and every added dependency is a primitive - a string enum, a number, or a boolean. The `useStatusActionsData` one is a real bug rather than a latent risk. The flag comes from `FeatureContext` and decides whether `PROPOSALDOWNLOAD` is filtered out of the result. Because it was not a dependency, the filter used whatever the flag was when the effect first ran; if the feature context resolved after that first fetch, the action list was wrong until something else forced a refetch. The rest are structural, with no behaviour change: - `ChangeProposalStatus` passed `selectedProposalsWorkflowIds[0]` directly in the array, which the rule cannot statically verify. Extracted to `primaryWorkflowId` and used in both places. - `ShipmentContainer` referenced `props.onDirtyStateChange`, so the rule wanted the whole `props` object - which changes every render. Destructured the callback instead; the dependency value is identical, just checkable. - `ReviewSummary` and `TechnicalReviewSummary` depended on `api` while their effects had stopped calling it, and were missing `<review>.proposal?.callId`, which the rule cannot track through an optional chain. Extracted to `callId` and dropped the dead `api` dependency. - `QuestionaryComponentInstrumentPicker` needed `getValueWithInstrumentName`, which was rebuilt on every render. Wrapped in `useCallback` keyed on `value` and `config`. Both are read off `answer`, which the effect already depends on, so the effect cannot run more often than it does today - if `answer` were unstable this component would already be looping, because it already calls `setRequestTimeForInstrument` on every run. Deferred - all 5 sit on @material-table/core internals and will be rewritten or deleted by the v8 migration, so fixing them now would only create conflicts: `useExpandCollapseAll` (reaches into `dataManager.sortedData`, `props.detailPanel` and scrapes `thead tr` from the DOM), `ProposalTableInstrumentScientist` (drives `tableData.checked`) and `FapInstrumentProposalsTable`. `tsc --noEmit`, `eslint` and `vite build` pass. Not exercised at runtime.
Three unrelated failures, all first appearing on this branch.
The role selection dialog no longer opened when the site was visited with
`?selectRoles=true`. Roles are hydrated from localStorage by a dispatch, so the
first render always sees an empty list. The effect read the query parameter on
that first render, decided `hasMultipleRoles` against the empty list, and then
deleted the parameter, so by the time the roles arrived there was nothing left
to act on. The ordering was always fragile; React 19 changed effect timing
enough for it to lose every time. The effect now waits until roles are present
before consuming the parameter. A signed-in user always has at least one role,
so the parameter is still cleared in every case that could reach this code.
`invites.cy.ts` waited for `.SnackbarItem-variantSuccess`. That is a notistack
v2 class name. notistack v3 renders its own wrapper and names the content
element `notistack-MuiContent-<variant>`, which is what `index.css` was updated
to earlier in this branch. The selector was missed at the time. It is the only
occurrence in the suite.
`techniqueProposals.cy.ts` asserted `should('be.selected')` on five Material UI
menu items. jQuery's `:selected` reads the `selected` DOM property, which only
exists on `<option>`. Material UI forwards its `selected` prop to the `<li>`,
and React 18 set it as a DOM property, so the assertion happened to hold. React
19 reworked property handling and only treats `selected` specially on
`<option>`, so it no longer does. Material UI is unchanged here: the class list
is identical in 5.15.18 and 5.18.0, and the failing elements still carry
`Mui-selected`. The assertions now check that class, which is how Material UI
marks a selected menu item in both v5 and v9.
proposalAdministration.cy.ts failed on this branch with a crashed Chrome renderer, 3 tests run and 24 skipped. The same job on the TypeScript 6 branch runs the same specs twice, in the same order, and does not crash, so this arrived with React 19. It is memory rather than a defect in the app. Both jobs issue exactly 16 getWorkflow requests, so no effect is looping, and the spec passes locally in Chrome with all 28 tests green. The difference is that CI runs each shard twice in one job, once for each dependency config, and React 19 holds more in the renderer than React 18 did. The second pass is where it runs out. Two settings, both named in Cypress's own crash output: - experimentalMemoryManagement in the shared config, which is what Cypress recommends for renderer crashes and has no effect on local use. - numTestsKeptInMemory=0 on the CI script only. Cypress keeps DOM snapshots for the last 50 tests so they can be stepped through in the interactive runner. Nothing reads them in a headless run, and scoping this to the CI script keeps snapshots available for cy:open. This lowers the memory ceiling rather than identifying what holds the extra memory. If it crashes again the retention itself needs looking at.
shivoomiess
force-pushed
the
upgrade-react-19
branch
from
August 28, 2026 12:25
a810f55 to
a510f45
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second of three pull requests in the reordered upgrade stack, on top of #1745. It combines the earlier #1708 and #1709.
Dependencies moved down from the Material UI branch
Three dependency changes used to sit in the Material UI branch. That branch is now above React 19, so these changes have to happen here instead, otherwise the install fails.
notistack 2 to 3. notistack 2 declares
react "^16.8.0 || ^17.0.0 || ^18.0.0". There is no.npmrcin this repository settinglegacy-peer-deps, so React 19 with notistack 2 fails the install rather than producing a warning. Version 3 removed the@mui/materialpeer dependency, so it works with both v5 here and v9 later.A fixed version for
@material-table/core. develop declares^6.4.4, which now resolves to 6.5.2. That version lists@mui/material ">=7.3.4"and@mui/x-date-pickers "8.15.0"as direct dependencies rather than peer dependencies, and requiresreact ">=19.2.0". On React 18 that resolution fails, which is why develop has not run into this. It becomes installable, and therefore a problem, once React 19 is in place. The version is pinned to 6.4.4 here and released again in #1747.emotion 11.11 to 11.14, added as direct dependencies. Their peer range is only
react ">=16.8.0", so npm had no reason to move them from the versions recorded in the lock file, and they have to be requested explicitly. The types in emotion 11.11 refer to the globalJSXnamespace, which@types/react19 moved toReact.JSX. As a resultstyled()andsxfell back toany, producing 8 errors across 4 files. To confirm the cause was emotion rather than the Material UI minor version,@mui/materialwas pinned back to 5.15.18 under React 19, which reproduced all 8 errors; raising emotion alone with@mui/materialat 5.18.0 cleared them.Other changes
Material UI v5 moves from 5.15.18 to 5.18.0. The version ranges already allowed React 19, but the lock file held versions from before React 19 support, so
@mui/icons-material@5.15.18still required^17 || ^18and failed to install.The 13 effect dependency corrections from #1709 are included as a separate commit.
Checks
tsc --noEmit,npm run lint,npm run buildandnpm ciall pass.