Skip to content

build: upgrade Material UI to v9 and material-table to v8 - #1747

Draft
shivoomiess wants to merge 31 commits into
upgrade-react-19from
upgrade-mui-9-material-table-8
Draft

build: upgrade Material UI to v9 and material-table to v8#1747
shivoomiess wants to merge 31 commits into
upgrade-react-19from
upgrade-mui-9-material-table-8

Conversation

@shivoomiess

@shivoomiess shivoomiess commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Third of three pull requests in the reordered upgrade stack, on top of #1746. It combines the earlier #1694 and #1710.

Why these are one pull request

The two upgrades depend on each other, so neither can come first.

material-table v8 requires Material UI v9. @material-table/core@8.0.3 lists @mui/material ^9.2.0, @mui/icons-material ^9.2.0 and @mui/x-date-pickers ^9.10.0 as direct dependencies rather than peer dependencies. On a v5 application npm cannot deduplicate these and has to nest a second Material UI tree under the table. The table then reads useTheme() from a different module instance than the application's ThemeProvider and does not pick up the theme.

Material UI v9 requires workarounds for material-table 6.4.4: an alias mapping DeleteOutline to DeleteOutlined in the Vite configuration, and the fixed 6.4.4 version. Both are removed here, in the same change that makes them unnecessary.

Combining them also means the nine files both branches modify are resolved once rather than repeatedly during rebases.

Result

There is now a single Material UI tree, with no nested copies of @mui/material; only deepmerge remains under the table. The vendor bundle goes from 3,879 kB to 3,441 kB.

Two commits addressing existing problems

Neither of these is caused by the reordering.

The first completes three Material UI v9 migrations that the codemod did not reach: an InputLabelProps in FormikUIDayRangePicker, six Grid v1 item xs={12} usages in ChangeExperimentSafetyStatus, and two Typography fontWeight properties in ExperimentSafetyNotification. Two of these three files are identical to the version on #1694; the third differs only by the ): JSX.Element annotation that React 19 removes.

The second applies eslint --fix to 297 prettier errors across 71 files, all of them unformatted codemod output. No other rule was involved.

These were not noticed because #1694 cannot be installed. Its package-lock.json contains two unresolved merge conflict blocks, in the immutable and shell-quote entries, both marked >>>>>>> 135141b97 (WIP). They are present from the first commit of that branch through to its tip, so npm ci fails on invalid JSON before the type check or linter runs.

Two commits from #1694 have no effect here and were left out. The first restored platform-specific binaries to the lock file; all 88 entries are already present, because the lock file was updated by removing individual entries rather than regenerating it. The second updated zustand, which already resolves to 4.5.7 with use-sync-external-store 1.6.0.

Outstanding

There are no end-to-end tests for material-table v8. The 8 test files in this branch cover Material UI v9 date pickers, tabs and autocomplete. Version 8 changes the table markup, and the test suite uses table selectors in many places, so this needs to be looked at next.

Checks

tsc --noEmit, npm run lint, npm run build, npm ci, and the linter for the end-to-end test workspace all pass.

@shivoomiess
shivoomiess requested a review from a team as a code owner August 25, 2026 20:11
@shivoomiess
shivoomiess requested review from SourangshuSTFC and removed request for a team August 25, 2026 20:12
@shivoomiess shivoomiess reopened this Aug 25, 2026
@shivoomiess
shivoomiess force-pushed the upgrade-mui-9-material-table-8 branch from dd84b06 to 8948528 Compare August 25, 2026 20:27
@shivoomiess
shivoomiess marked this pull request as draft August 25, 2026 20:50
@shivoomiess
shivoomiess force-pushed the upgrade-mui-9-material-table-8 branch from 8948528 to 080d664 Compare August 26, 2026 16:06
@shivoomiess shivoomiess reopened this Aug 26, 2026
@shivoomiess
shivoomiess force-pushed the upgrade-mui-9-material-table-8 branch 2 times, most recently from 4c9f8c8 to 7c0b297 Compare August 27, 2026 20:35
yoganandaness and others added 15 commits August 28, 2026 14:25
MenuItem calls useMenuListContext() on every render, and in v9 that throws
"MUI: MenuListContext is missing. MenuItems must be placed within Menu or
MenuList." when no provider is present (MenuList/MenuListContext.js). The throw
sits outside the NODE_ENV guard, so it fires in production too.

Autocomplete renders its listbox as a plain styled('ul') with no such provider,
so both call sites threw:

- ProposalPeopleSelectorModal returned a MenuItem from renderOption, so every
  option that rendered crashed the modal.
- NoOptionsText returned one for an exact email match.

Both now use ListItemButton. renderOption also passes component="li" so the
option stays a valid child of the listbox <ul>; the spread `props` already
carries the role, id and event handlers Autocomplete needs.

Reached via PrincipalInvestigator and UserManagementTable, so this covers
co-proposers, data access users, visit registration and the proposal basis
questionary component.
The WIP commit replaced the entire UserRole.USER dashboard with a copy of
Material UI's Accordion demo. components/proposalBooking/BasicCard.tsx does not
exist on develop, still exports a component named ControlledAccordions, and
renders the docs' placeholder copy verbatim ("I am an accordion", "You are
currently not an owner", "Filtering has been entirely disabled for whole web
server").

Users therefore saw four sample accordions instead of:

- ProposalTableUser, their proposal list
- UserUpcomingExperimentsTable, gated on the SCHEDULER feature flag

Restores OverviewPage.tsx to develop's version and deletes BasicCard.tsx.
Nothing else referenced it. develop has not touched OverviewPage.tsx since the
branch point, so this is a clean revert rather than a merge.

Neither restored component needed migrating: both are free of v5-only APIs and
neither was modified anywhere on this branch.

This was scratch work rather than part of the v9 migration, which is why it is
separated out. Note that the merge commit's "Remove unused isSchedulerEnabled in
OverviewPage" was a consequence of this change, not an unrelated tidy-up.
inputProps={{ maxLength: '20' }}
  -> slotProps={{ htmlInput: { maxLength: '20' } }}

docs:
  old https://v5.mui.com/material-ui/api/text-field/#text-field-prop-inputProps
  new https://mui.com/material-ui/api/text-field/#text-field-prop-slotProps
  migration guide https://mui.com/material-ui/migration/migrating-from-deprecated-apis/

Material UI v9 removed `inputProps` from TextField. TextField.js:111-112
destructures only `slots` and `slotProps`; everything else falls into `...other`,
which TextField.js:165-171 forwards to the root slot, TextFieldRoot =
styled(FormControl) (TextField.js:42-45). So the object landed on the wrapper
<div> and never reached the <input>.

Nothing caught this. The type dropped the prop, but Formik's Field renders
`createElement(component, { field, form, ...props })`
(formik.cjs.development.js:1383) and forwards extra props verbatim without
checking them against the target component, so `tsc --noEmit` stayed green.
components/common/FormikUITextField.tsx:34 then spreads them on unchanged.

82 sites across 37 files: 79 written as <Field component={TextField}>, and 3 as
<Field component={TextFieldNoSubmit}>, where TextFieldNoSubmit is
withPreventSubmit(TextField) in all three files and forwards to the same place.

What this restores:
- maxLength on 4 fields, where limits had stopped being enforced:
  CallGeneralInfo.tsx (20 and 100), CallNotificationAndCycleInfo.tsx (100),
  TemplateTopicEditor.tsx (32)
- min on 4 sample-count fields in the sample declaration question editors
- data-cy on 70 inputs the e2e suite selects on
- onChange/onBlur handlers on 3 questionary basis components

Applied with a jscodeshift transform rather than by hand or with
`@mui/codemod deprecations/text-field-props`: that codemod matches JSX elements
named TextField, and this codebase has none carrying the prop - the element is
always Field.

Existing slotProps entries take precedence, so any hand-migrated call site is
left untouched.
InputProps={{ endAdornment: <IconButton …/> }}
  -> slotProps={{ input: { endAdornment: <IconButton …/> } }}

docs:
  old https://v5.mui.com/material-ui/api/text-field/#text-field-prop-InputProps
  new https://mui.com/material-ui/api/text-field/#text-field-prop-slotProps
  migration guide https://mui.com/material-ui/migration/migrating-from-deprecated-apis/

Same mechanism as the previous commit: v9's TextField does not destructure
InputProps, so it fell through to the root FormControl.

Note this maps to `input`, not `htmlInput`. On TextField the `input` slot is the
InputBase and `htmlInput` is the element itself (TextField.d.ts:65 and :75,
TextField.js:184 and :195). Adornments are React nodes rendered by the InputBase,
so they belong on `input`.

16 sites across 9 files, carrying: 8 data-cy, 3 endAdornment, 1 startAdornment,
5 nested inputProps objects, and one minRows/maxRows pair.

The one clearly user-visible loss was in CallGeneralInfo.tsx: the help button
opening the reference-number-format dialog sits inside InputProps.endAdornment,
so the whole IconButton was unrendered and the dialog unreachable from that
screen. The rest are test hooks and adornments in CreateUnit,
TemplateMetadataEditor, CreateUpdateApiAccessToken, ProposalAdmin, the file
upload question editors and QuestionDynamicMultipleChoiceForm.

Deliberately excluded: the 22 InputProps call sites on <FormikUIAutocomplete>.
That is not MUI's prop - the component declares InputProps in its own props
interface (FormikUIAutocomplete.tsx:105), destructures it (:119) and already
forwards it into slotProps.input correctly (:154-158). Renaming those would have
broken working code, since the wrapper does not accept slotProps. This is also
why the eight RefreshListIcon adornments in CallGeneralInfo.tsx are untouched:
they are on FormikUIAutocomplete and were never broken.
InputLabelProps={{ shrink: true }}
  -> slotProps={{ inputLabel: { shrink: true } }}

docs:
  old https://v5.mui.com/material-ui/api/text-field/#text-field-prop-InputLabelProps
  new https://mui.com/material-ui/api/text-field/#text-field-prop-slotProps
  migration guide https://mui.com/material-ui/migration/migrating-from-deprecated-apis/

`InputLabelProps` does not appear anywhere in v9's TextField.js, so it fell
through to the root FormControl like the other two. Every site passed
`shrink: true`, which forces the label to stay floated; without it the label
drops back over the field's value.

Three sites migrated mechanically: ExperimentSafetyReviewPage.tsx:77 and
QuestionaryComponentProposalBasis.tsx:109,142.

Two more needed a different shape. QuestionaryComponentVisitBasis.tsx:56,83 pass
the prop to components/common/FormikUIDatePicker, which spreads unknown props
straight through to the MUI X picker, where a bare InputLabelProps is not a prop
at all. Both already had the wrapper's `textField` prop, so the label props nest
inside it:

  textField={{ fullWidth: true, required: true }}
  InputLabelProps={{ shrink: true }}
    -> textField={{
         fullWidth: true,
         required: true,
         slotProps: { inputLabel: { shrink: true } },
       }}

That resolves to slotProps.textField.slotProps.inputLabel on the picker, since
the wrapper spreads `textField` into the picker's textField slot
(FormikUIDatePicker.tsx:41-52).

pickers reference https://mui.com/x/migration/migration-pickers-v7/
inputProps={{ 'data-cy': 'includeTime' }}
  -> slotProps={{ input: { 'data-cy': 'includeTime' } }}

docs:
  old https://v5.mui.com/material-ui/api/checkbox/#checkbox-prop-inputProps
  new https://mui.com/material-ui/api/checkbox/#checkbox-prop-slotProps
  migration guide https://mui.com/material-ui/migration/migrating-from-deprecated-apis/

Note the slot differs from TextField. Checkbox has no InputBase, so its `input`
slot is the html element itself (Checkbox.d.ts:19-21, "the component that renders
the input slot, default SwitchBase's input"). On TextField the same payload would
belong on `htmlInput`, because there `input` is the InputBase. Sending these to
`input` on a TextField, or `htmlInput` on a Checkbox, reintroduces the same bug
in a new spelling.

internal/SwitchBase.js, which Checkbox wraps, contains zero occurrences of
inputProps - it destructures only slots and slotProps - so the attribute fell
through to the root and never reached the element.

11 sites across 7 files, all reached through components/common/
FormikUICheckboxWithLabel. That wrapper needs no change: its props extend
Omit<MuiCheckboxProps, ...>, so slotProps is already in its surface, and it
spreads through to MuiCheckbox at line 44.

Both spellings appeared - `inputProps` at 7 sites and `InputProps` at 4 - and
both mean the html input here, so both map to `input`. No element carried both,
so nothing was merged.

Restores 10 data-cy attributes the e2e suite selects on, and one aria-label at
FapGeneralInfo.tsx:135.
ListboxProps={{ 'data-cy': props['data-cy'] + '-options' }}
  -> slotProps={{ listbox: { 'data-cy': props['data-cy'] + '-options' } }}

docs:
  old https://v5.mui.com/material-ui/api/autocomplete/#autocomplete-prop-ListboxProps
  new https://mui.com/material-ui/api/autocomplete/#autocomplete-prop-slotProps
  migration guide https://mui.com/material-ui/migration/migrating-from-deprecated-apis/

One site, FormikUIAutocomplete.tsx:171, but it is the hook every Autocomplete
dropdown assertion in the e2e suite selects on, since the wrapper derives it from
each call site's own data-cy.

v9 removed the prop. The two remaining `ListboxProps` matches in v9's
Autocomplete.js (:491, :591) are `getListboxProps`, an internal hook from
useAutocomplete, not the prop - it is neither destructured from props nor present
in Autocomplete.d.ts as one. The listbox itself is a plain styled('ul')
(Autocomplete.js:334).

The recast printer also reflowed the unrelated AutocompleteProps interface
declaration above; that has been reverted by hand so the diff is the one prop.
inputProps={{ placeholder: dateFormat }}
  -> removed, no replacement

docs:
  old https://v5.mui.com/x/api/date-pickers/date-picker/
  new https://mui.com/x/migration/migration-pickers-v7/
  field structure https://mui.com/x/react-date-pickers/custom-field/

Unlike the other categories this is a deletion, not a migration, for two
independent reasons.

The props were never arriving. DatePicker.js contains zero occurrences of
`inputProps`, so the picker does not destructure it. The app passed it through
components/common/FormikUIDatePicker, which spreads unknown props straight on to
the picker, where it was discarded.

And there is nothing left for them to do. Since the accessible field structure
became the default, the visible field is a PickersSectionList of styled elements
rather than an <input> (PickersInputBase.js:401), with a separate aria-hidden
input for form semantics at :435. `placeholder` is an HTML attribute that only
has an effect on an <input>. The picker already renders the format itself from
the `format` prop these call sites pass, and PickersInputBase.js:102-136 governs
when those empty-state sections are visible: hidden when a label sits unshrunk in
the field, shown otherwise. So deleting the prop changes nothing at runtime.

16 sites across 6 files: CallGeneralInfo, CallNotificationAndCycleInfo,
CallReviewsInfo, QuestionDateForm, QuestionaryComponentDatePicker and
QuestionaryComponentVisitBasis.

Includes the sites written as <Field component={component}> where
`component = includeTime ? DateTimePicker : DatePicker` (QuestionDateForm.tsx:79),
which is why hosts are resolved from the AST rather than by matching the tag name.

The transform only removes the attribute when `placeholder` is its only key, so
any picker carrying something else alongside it is left alone. All 16 qualified;
the diff is deletions only.

Related upstream, for cases where the empty-state sections stay hidden when users
expect them: mui/mui-x#18996
@mui/x-date-pickers ^9.9.0 -> ^9.10.1

This is the only MUI-related package with an upgrade available. @mui/material,
@mui/icons-material and @mui/system are all already at 9.2.0, which is latest,
and @emotion/react 11.14.0 / @emotion/styled 11.14.1 are likewise current.

Lockfile drift is limited to @mui/x-date-pickers and its @mui/x-internals
dependency; no other package changed. npm pkg set also moved the two @emotion
entries into alphabetical order, where they had been inserted out of sequence.

Also brings the range up to what @material-table/core@8 requires (^9.10.0),
should that upgrade happen later.

This does NOT resolve the outstanding peer dependency conflict:

  @mui/system@9.2.0 deduped invalid: "^5.8.0"
    from node_modules/@material-table/core/node_modules/@mui/x-date-pickers

No version of the v9 packages can satisfy it. @material-table/core@6.4.4 depends
on @mui/x-date-pickers@^6.19.0, whose peer range is @mui/system ^5.8.0, so npm
installs a second MUI v5 tree underneath it. The only two ways out are pinning
@mui/material and @mui/system through npm `overrides`, or upgrading
@material-table/core to v8 - which requires React 19 and so is a much larger
piece of work.

Everything else npm reports is UNMET OPTIONAL DEPENDENCY for picker date adapters
this app does not use (dayjs, moment, date-fns-jalali and similar). Those are
expected and not a problem.

Verified with tsc --noEmit and a production build.
The accessible picker field is the only DOM structure from
@mui/x-date-pickers v8 onward, so the input these helpers targeted is now
aria-hidden and cannot be typed into. setDatePickerValue types into the
section spans instead, and clearDatePickerValue joins it for the cases
that clear a field to trigger validation.

Also updates three selectors that MUI renamed or restructured: v9 renders
step connectors inside each Step rather than as siblings, so the studio
selector in samples.cy.ts had shifted onto the wrong step; the
autocomplete tag attribute is now data-item-index; and the notistack
success class is notistack-MuiContent-success.
Material UI v9 renamed the Tabs slot to MuiTabs-list, so the parent in
these selectors no longer exists. The horizontal-tab-N ids come from
SimpleTabs rather than from MUI, so dropping the class is enough and
leaves nothing coupled to MUI internals.

These tests are feature flag gated and skipped in CI, which is why the
upgrade did not surface this.
The min, max and default date fields on the Date question config were
still typed into directly, addressed by data-cy rather than by name,
which is why the first sweep missed them.

calls.cy.ts asserted on the TYPE_ERR_INVALID_DATE typeError after
clearing a date. The accessible picker field cannot hold a malformed
date and reports an incomplete field as having no value, so an emptied
field now fails the required rule instead. The test still covers what it
is named for, that validation blocks the next step.
Removes `fullWidth` and `inputProps={{ 'data-cy': 'dependencies' }}` from the
<Field> at QuestionDependencyList.tsx:145.

Not a Material UI v9 issue. FormikUICustomDependencySelector destructures a
closed list of five props - field, template, form, dependency, currentQuestionId
(FormikUICustomDependencySelector.tsx:25-32) - with no rest element. Formik
forwards everything except validate/name/render/children/as/component/className
verbatim (formik.cjs.development.js:1314-1320, :1383-1389), so both props arrived
at the component and were discarded at the destructure, one frame in. They never
reached Material UI at all.

They have never worked. `git log -S` puts the line in f5e2d5f (Sept 2023), a
folder-renaming refactor, long predating the v9 work.

Nothing depends on the missing hook. No spec selects [data-cy="dependencies"].
The component renders its own hooks instead - dependencyField, dependencyOperator
and dependencyValue - and those are the ones the suite uses, in 2 and 3 spec
files respectively.

Kept separate from the slotProps migration commits deliberately: this looks like
the same breakage, but migrating it to slotProps would have changed nothing,
since the prop is dropped before Material UI sees it.

One call site only. tsc --noEmit clean.
inputProps={{ type: 'number', min, max, step, inputMode }}
  -> slotProps={{ htmlInput: { … } }}   on the TextField branch only

docs:
  old https://v5.mui.com/material-ui/api/text-field/#text-field-prop-inputProps
  new https://mui.com/material-ui/api/text-field/#text-field-prop-slotProps
  select (unchanged) https://mui.com/material-ui/api/select/#select-prop-inputProps

The grade field switches component on whether the grade is picked from a list:
a classification, or a whole-number grade, renders a Select; anything else
renders a TextField. The same condition drove a matching `inputProps` ternary.

Only the TextField branch was broken. Material UI v9 removed inputProps from
TextField in 9.0.0-alpha.4, so the object fell through to the root FormControl
and the numeric field lost:

  type="number"        rendered as a plain text box, no stepper
  inputMode="decimal"  mobile keyboards showed the alphabetic layout
  min="1" / max="10"   the grade bound was unenforced
  step                 decimal precision from config.decimalPoints unconstrained
  id="grade-proposal"  labelId="grade-proposal-label" no longer resolved to it

Select was not converted to slots and still takes inputProps - confirmed against
Select.js:65,113,138 (zero occurrences of slotProps in the .js or the .d.ts), the
shipped CHANGELOG, where the only [select] removals are CSS classes and props
passed via MenuProps, and mui.com, which documents inputProps with no deprecation
notice and lists no slots section. So the two branches need different prop names,
not just different slot keys, which is why a rename could not fix this and the
jscodeshift pass skipped it: the host was a ternary it could not resolve.

Hoists the duplicated condition into `isGradePickedFromList`. It was written out
twice, once for `component` and once for `inputProps`, which is what let the two
drift apart in the first place.

Not verified in a browser. tsc --noEmit clean.
Pure refactor, no behaviour change.

The grade field makes one decision - is the grade picked from a list, or typed -
and that decision was spelled out three separate ways in the same element:

  component={gradeType === 'Classification' || decimalPoints === 0 ? … }
  inputProps={gradeType === 'Classification' || decimalPoints === 0 ? … }
  options={gradeType === 'Classification' ? … : decimalPoints === 0 ? … : undefined}

The first two were collapsed into `isGradePickedFromList` in the previous commit.
This does the third: `gradeOptions` is now derived alongside it, so all three read
from the same predicate. That divergence is what let the component and its input
attributes drift out of step and produce the bug the previous commit fixed.

Equivalence, since the branches are reordered: options were non-undefined exactly
when Classification, or decimalPoints === 0 - which is the definition of
isGradePickedFromList. So `!isGradePickedFromList -> undefined` first, then
Classification, then the whole-number list, covers the same three cases.

Also lifts the 1-to-10 list to a module constant. It never depended on props or
state but was rebuilt on every render, via `[...Array(10)].map((e, i) => …)` with
an unused first parameter. Now Array.from({ length: 10 }, (_, i) => …) at module
scope.

The `label` ternary is left alone; it keys off gradeType only, not the compound
condition, and flattening the layout is a separate change.

tsc --noEmit clean.
Behaviour-neutral. Renders the Select and the TextField as separate elements
under `isGradePickedFromList` instead of one <Field> that switched component and
carried both sets of props at once.

Before, roughly half the props on that element were inert whichever branch ran.
`MenuProps`, `labelId` and `options` mean nothing to TextField; the numeric input
attributes mean nothing to Select. Nothing kept the component and its props in
step, which is exactly how they drifted apart and produced the bug fixed in
e14e36b. After the split each element carries only props its own component
accepts, so that class of drift is structurally impossible rather than merely
absent.

Shared values are hoisted rather than duplicated: `gradeLabel` and
`handleGradeChange`. `gradeInputProps` is gone - the conditional prop object only
existed to paper over the single element, and each branch now states its own
directly.

One prop is deliberately not carried across. `formControl={{ fullWidth: true,
required: true, margin: 'normal' }}` is FormikUISelect's own prop, declared at
FormikUISelect.tsx:19, destructured at :69 and applied to its FormControl at :98.
FormikUITextField has no such prop and neither does MUI's TextField - zero
occurrences in TextField.d.ts - so on the numeric branch it was already being
dropped onto the root and discarded. Copying it into the new TextField element
would carry dead code into new code, so it stays on the Select branch only.

That means the numeric grade field has no fullWidth, no required and no
margin="normal" - as was already the case. Restoring them is a visible change
and is left as a separate decision rather than folded into a refactor.

tsc --noEmit clean. Not verified in a browser.
setDatePickerValue ended its chain on the section span it had just typed into,
so the three `should('have.value', ...)` assertions chained onto it in
calls.cy.ts were reading an element that carries no value at all. The value the
form submits lives on the aria-hidden input, so yield that instead.

templateDeleteAndArchive was the one visit-registration site the v9 picker pass
missed: it still cleared and typed straight into the picker input, which the
accessible field DOM structure makes impossible because that input sits beneath
the section list. Drive it through the helper, the same way visits.cy.ts does.
The previous commit made setDatePickerValue yield the picker input so a chained
should('have.value', ...) would read a real value. That broke visits.cy.ts:
"Visitor should be able to register for a visit" went from green to failing on
every branch in the stack, in the default config, deterministically through
retries. The mechanism is not understood, so the helper goes back to exactly the
code that was green rather than being adjusted further.

The three assertions in calls.cy.ts that needed a value now query the input
themselves. That is where the value being checked is actually read, and it keeps
the helper doing one thing.
The three date questions in experimentSafetyReview.cy.ts still typed straight
into the picker input. Under x-date-pickers v9 that input is aria-hidden and
sits beneath the section list, so Cypress reports it as covered and the type
fails. These were the sites the earlier v9 picker pass missed.

Four of the seven failures on the ae shard came from this. The other three were
Cypress failing to write the failure screenshots, because this spec's nested
describe names exceed the filesystem name limit; that is pre-existing and left
alone here.
The exact `6.4.4` pin existed because the package was built against MUI v5 and
could not be moved without moving MUI. v8 targets `@mui/material ^9.2.0`,
`@mui/x-date-pickers ^9.10.0` and `@hello-pangea/dnd ^18.0.1`, all of which the
earlier layers of this stack already satisfy, so the pin becomes a normal range.

Removes three coupled workarounds:

- **The nested MUI v5 tree.** 6.4.4 dragged in its own `@mui/material@5.18.0` and
  `@mui/x-date-pickers@6.20.2`, which produced the standing
  `@mui/system@9.2.0 deduped invalid: "^5.8.0"` peer conflict. `npm ls @mui/system`
  is now a single 9.2.0.
- **The Vite `DeleteOutline` alias.** 6.4.4 imported an icon name removed in
  @mui/icons-material v9; v8 imports `DeleteOutlined` directly.
- **The `@types/react` / `@types/react-dom` overrides.** Those were added in the
  React 19 commit purely because material-table pinned `@types/react@18.3.31`
  alongside ours. v8 has no `@types/react` dependency, so there is a single copy
  without forcing one.

All 19 type errors had one cause: MUI v9 removed `inputProps` from Checkbox in
favour of `slotProps.input`, and v8 types `selectionProps` / `headerSelectionProps`
as MUI v9 `CheckboxProps`. The v9 prop sweep on `chore/mui-upgrade-pt1` could not
reach these, because 6.4.4 typed them against MUI v5 and they silently type-checked.
22 sites migrated. Other `inputProps` uses - `min`/`max` on number inputs, and
`slotProps.input.inputProps` - are untouched and still correct.

Side effect worth recording: the vendor chunk drops from 4,084 kB to 3,385 kB
(gzip 1,274 kB to 1,093 kB), which is the duplicated MUI v5 tree going away.

Two expectations this upgrade did *not* meet, recorded so they are not assumed again:

- **The `forwardRef` wrappers in `materialIcons.tsx` still have to stay.** v8's
  `Icons` type still declares every icon as
  `ForwardRefExoticComponent<any> & RefAttributes<SVGSVGElement>`, which an
  ordinary function component does not satisfy.
- **The 5 deferred `react-hooks/exhaustive-deps` warnings are still there.** v8
  did not rewrite those components. `useExpandCollapseAll` keeps working because
  `MaterialTable` is still `class extends React.Component` with a `dataManager`
  field, and rows still carry `tableData` - but it is still reaching into
  internals through an `any` ref, which no type will protect.

`zustand` resolves to a nested 5.0.14 for material-table while reactflow keeps
4.5.2 at the top level; the two stores are independent.

`tsc --noEmit`, `eslint` and `vite build` pass. Not exercised at runtime.
Three files still used v5 APIs that v9 removed. They are the same migrations
already applied across the rest of the branch - these sites were simply not
reached, and they are the only remaining type errors under v9.

- FormikUIDayRangePicker: `InputLabelProps` and `InputProps` move into
  `slotProps.inputLabel` and `slotProps.input`, matching the TextField changes
  made everywhere else on this branch.
- ChangeExperimentSafetyStatus: six Grid v1 call sites move from
  `item xs={12} md={8}` to the v2 `size={{ xs: 12, md: 8 }}`. Every other Grid
  in the codebase is already on the v2 API.
- ExperimentSafetyNotification: `fontWeight="bold"` moves onto `sx`, since v9
  drops the Typography system props.

These are pre-existing on the Material UI branch rather than a consequence of
reordering the stack: two of the three files are byte-identical to that
branch's tip, and the third differs only by the `): JSX.Element` annotation
the React 19 change removes. The Grid call sites are identical there too.

They went unnoticed because that branch cannot be installed - its committed
package-lock.json carries two unresolved conflict blocks, so `npm ci` fails on
invalid JSON before any typecheck runs.
297 prettier/prettier errors across 71 files, every one of them raw codemod
output - double-quoted strings, missing trailing commas, props left on one
line past the print width. `eslint --fix` resolves all of them; no rule other
than prettier/prettier fired, and the typecheck is unchanged.

Kept separate from the migrations themselves so that the API changes stay
reviewable without formatting noise on top.

Like the type errors in the preceding commit, this is pre-existing rather than
a consequence of reordering the stack. The Material UI branch cannot be
installed - its committed package-lock.json carries unresolved conflict blocks
- so `npm ci` fails and neither lint nor typecheck has ever run against it.
Two assertions in calls.cy.ts selected `button[aria-label="Save"]` and expected
it to be disabled. Under material-table v8 that button exists and is disabled,
but it does not carry the label.

A disabled button does not emit the events a tooltip needs, so material-table
wraps disabled actions in a span, and Material UI sets `aria-label` on that
span as the tooltip's direct child. The label is therefore on the button while
the action is enabled and on the wrapper once it is disabled. The enabled
Cancel action in the same row still carries its own label. These two assertions
only run in the disabled state.

The selector now accepts either shape. Every other use of `[aria-label="Save"]`
in the suite clicks the action while it is enabled and is unaffected.

The row is also located differently. The assertions previously reached it with
`cy.contains(shortCode).parent()`. That no longer works, because v8 renders
every cell of a row being edited as an input holding the text in its `value`
attribute, leaving no text node for `contains` to match. The availability time
field is in the same row and is what the test exercises, so the row is found
through that field instead.
…rade

The v7 -> v9 upgrade was a faithful mechanical port and left two theming
gaps behind.

Since pickers v8 the fields render their own PickersTextField rather than
a Material UI TextField, so they stopped inheriting the MuiTextField
`standard` default and silently fell back to `outlined`. Every date field
therefore looked different to every other input on the same form. Adding
MuiPickersTextField defaults restores the match.

`desktopModeMediaQuery` was repeated at 18 call sites, all passing the
identical `theme.breakpoints.up('sm')`. Hoisting it into the
MuiDatePicker/MuiDateTimePicker/MuiTimePicker defaults removes the
duplication and resolves the long-standing NOTE in theme.tsx.

Setting picker defaults requires a two-step createTheme, because
`desktopModeMediaQuery` reads the breakpoints from the base theme. The
picker component keys are not type-checked: registering them properly
needs `@mui/x-date-pickers/themeAugmentation`, which makes tsc run out of
heap on this codebase. All four keys were instead verified by hand to be
read through `useThemeProps` in the installed x-date-pickers 9.12.0.

This change was originally made on feat/frontend-mobile-responsive
(#1727), where it does not belong: it repairs the v9 upgrade rather than
adding mobile support, so it is moved onto the branch that performs that
upgrade. It is unchanged apart from the number of call sites. The two in
QuestionaryComponentVisitBasis are absent here because develop has since
replaced that component's two DatePickers with DayRangePicker, which
wraps react-day-picker rather than x-date-pickers and so is affected by
neither half of this change.
FapGradeGuide passes `sm={25}`, which is out of range for a 12 column
grid. The legacy Grid ignored it and emitted a class name that does not
exist, so the value was harmless. Grid v2 computes the width
arithmetically instead:

  width: calc(100% * 25 / var(--Grid-columns)
              - (var(--Grid-columns) - 25) * (...))

With the default 12 columns that is 208%, and because 12 - 25 is
negative the spacing term adds to it rather than subtracting. The editor
therefore overflows its row from `sm` upwards. Verified against
generateGridSizeStyles in the installed @mui/system, rather than from the
migration guide, since the codemod left the value untouched.

The editor is meant to span the full row at every breakpoint, so the two
breakpoint entries collapse to a plain `size={12}`.

This is the only out-of-range Grid size in the frontend. It was found and
fixed on feat/frontend-mobile-responsive (#1727) as part of a commit that
also makes the app shell breakpoint-aware; that half is mobile work and
stays there, while this half repairs the Material UI v9 upgrade and
belongs on this branch.
…pper

Two assertions in templatesBasic.cy.ts selected `[aria-label=Up]` and
`[aria-label=Down]` on the multiple choice answer list and expected the
result to be disabled. Under material-table v8 they matched a `<span>`,
so the assertion failed with `expected '<span>' to be 'disabled'`.

The Up and Down actions in FormikUICustomTable set `disabled` from the
row's position, and a disabled button does not emit the events a tooltip
needs. material-table therefore wraps disabled actions in a span, and
Material UI puts `aria-label` on that span as the tooltip's direct child.
The label sits on the button while the action is enabled and on the
wrapper once it is disabled, which is exactly the state these two
assertions exercise.

The selector now accepts either shape, matching the fix already applied
to the Save action in calls.cy.ts. The surrounding `.click()` calls on the
same labels are left alone: they run against enabled actions, where the
label is still on the button.
The copy affordance is a bare ContentCopyIcon inside a Box. It carried no
attribute of its own, so proposals.cy.ts reached it through
`[data-testid="ContentCopyIcon"]`, which Material UI used to set on every
icon and no longer sets in production builds.

A `data-cy` is the hook the rest of this codebase uses, and it does not
depend on which icon the control happens to render.
…test id

Two selectors in templatesBasic.cy.ts failed against a production build:
`[data-testid="CloseIcon"` for the proposals modal and
`[data-testid="EditIcon"]` for the template edit action.

Material UI v5 set `data-testid="<Name>Icon"` on every @mui/icons-material
icon unconditionally. Since v6 it is gated on
`process.env.NODE_ENV !== 'production'`, and `vite build` always compiles
as production, so the attribute is absent in CI as well as locally. It was
removed on purpose in mui/material-ui#45333 and there is no opt-out in the
9.3.1 we install.

Neither selector has anything to do with @material-table/core v8, which is
where the search for these failures started. material-table sets its own
snake_case ids on its default icons and 6.4.4 and 8.0.3 are identical
there.

The close button already carried `data-cy=close-modal-btn` from
StyledDialog. The edit action moves to `[aria-label="Edit"]`, which is how
the rest of the suite drives material-table actions. The first selector was
also missing its closing bracket.

templatesBasic passes 42 of 42 locally under the standard configuration.
The same Material UI change that broke templatesBasic breaks sixteen more
selectors, in FAPs, invites, proposals and questions. They fail for the
identical reason: `data-testid="<Name>Icon"` is no longer emitted in
production builds. These specs were not the ones being investigated, so
the breakage was latent rather than observed.

Each moves to a hook that does not depend on Material UI internals:

- material-table row actions to `[aria-label="<tooltip>"]`, already how
  most of the suite drives them. The tooltips are literals except
  FapsTable's `t('Edit')`, and `Edit` has no entry in
  public/locales/override/translation.json, so i18next returns the key.
  QuestionsPage's tooltip is `Edit Question`, not `Edit`.
- detail panel chevrons to `[aria-label="Detail panel visibility toggle"]`,
  which material-table sets itself in MTableBodyRow, and which FAPs.cy.ts
  already used in one place.
- the invite chip's delete button to `.MuiChip-deleteIcon`.
- the copy to clipboard control to the `data-cy` added in the previous
  commit.

Verified locally under the standard configuration: invites 21 of 21,
questions 9 of 9, proposals 27 passing with none failing. FAPs cannot be
verified through CI, because an `it.only` on develop reduces it to one
test; run locally with that lifted, the tests covering these selectors
pass. Its two failures are unrelated to this change, one asserting on
`data-cy` proposal counts and the other on a selector the spec already
used.
…s branch

`fix(frontend): correct 13 effect dependency arrays` fixed 13 of the 18
`react-hooks/exhaustive-deps` warnings and deliberately left five, on the
grounds that they sit on @material-table/core internals and would only
create conflicts with the v8 migration. This is that branch, so they are
fixed here.

useExpandCollapseAll took a caller-supplied `DependencyList` and spread it
straight into its effect. That can never satisfy the rule, because the
list is not statically checkable, and it also left `tableSelector` out
even though the effect reads it. All three callers passed loading flags,
so the parameter becomes an explicit `isLoading` boolean and the effect
depends on `[tableSelector, isLoading]`. FapReviewersAndAssignmentsTable
passed two flags and now combines them; the effect only probes the DOM
for a header cell, so it is idempotent and the change in how often it
runs does not matter.

ProposalTableInstrumentScientist depended on `JSON.stringify(selection)`,
which the rule reports twice: as a complex expression and as a missing
`selection`. `searchParams.getAll` returns a new array on every render,
which is why it was serialised. The serialisation now happens outside the
array and feeds a `useMemo` that gives the value one identity, so the
effect can depend on `selection` itself.

FapInstrumentProposalsTable was missing `api`, `fapInstrument.id` and
`sortByRankOrder`. The comparator is rebuilt every render and closes over
`fapInstrument.id`, so it is wrapped in `useCallback` keyed on that id
rather than hoisted out of the component. `api` comes from `useDataApi`
and is already a `useCallback`, so none of the three can loop.

`tsc --noEmit` and `eslint . --quiet` both pass, and no
`react-hooks/*` diagnostic remains anywhere in the frontend.
@shivoomiess
shivoomiess force-pushed the upgrade-mui-9-material-table-8 branch from c901a94 to 128db56 Compare August 28, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants