build: upgrade Material UI to v9 and material-table to v8 - #1747
Draft
shivoomiess wants to merge 31 commits into
Draft
build: upgrade Material UI to v9 and material-table to v8#1747shivoomiess wants to merge 31 commits into
shivoomiess wants to merge 31 commits into
Conversation
shivoomiess
requested review from
SourangshuSTFC
and removed request for
a team
August 25, 2026 20:12
shivoomiess
force-pushed
the
upgrade-mui-9-material-table-8
branch
from
August 25, 2026 20:27
dd84b06 to
8948528
Compare
shivoomiess
marked this pull request as draft
August 25, 2026 20:50
shivoomiess
force-pushed
the
upgrade-mui-9-material-table-8
branch
from
August 26, 2026 16:06
8948528 to
080d664
Compare
shivoomiess
force-pushed
the
upgrade-mui-9-material-table-8
branch
2 times, most recently
from
August 27, 2026 20:35
4c9f8c8 to
7c0b297
Compare
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.
shivoomiess
force-pushed
the
upgrade-mui-9-material-table-8
branch
from
August 28, 2026 12:25
b18aa74 to
c901a94
Compare
…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
force-pushed
the
upgrade-mui-9-material-table-8
branch
from
August 28, 2026 15:24
c901a94 to
128db56
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.
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.3lists@mui/material ^9.2.0,@mui/icons-material ^9.2.0and@mui/x-date-pickers ^9.10.0as 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 readsuseTheme()from a different module instance than the application'sThemeProviderand does not pick up the theme.Material UI v9 requires workarounds for material-table 6.4.4: an alias mapping
DeleteOutlinetoDeleteOutlinedin 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; onlydeepmergeremains 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
InputLabelPropsinFormikUIDayRangePicker, six Grid v1item xs={12}usages inChangeExperimentSafetyStatus, and two TypographyfontWeightproperties inExperimentSafetyNotification. Two of these three files are identical to the version on #1694; the third differs only by the): JSX.Elementannotation that React 19 removes.The second applies
eslint --fixto 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.jsoncontains two unresolved merge conflict blocks, in theimmutableandshell-quoteentries, both marked>>>>>>> 135141b97 (WIP). They are present from the first commit of that branch through to its tip, sonpm cifails 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-store1.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.