Next.js

MUI DatePicker readonly in Cypress CI: fix the variant

A wider Cypress viewport does not guarantee the desktop picker. Diagnose the media query first, then test the UI your users actually receive.

Mahdi Ben RhoumaMahdi Ben Rhouma
8 min read

TL;DR#

If Cypress can type into a MUI date picker locally but encounters a readonly input in CI, inspect which picker is rendered before changing timeouts. The legacy responsive picker can select its mobile variant when (pointer: fine) does not match. Its outer input opens a dialog instead of accepting text. Choose DesktopDatePicker if that is the intended product interaction, or make the test exercise the mobile dialog. Current MUI X fields have different editing behaviour, so check the installed package and the actual DOM before applying an old workaround.

The readonly failure reported on GitHub Actions#

The original Stack Overflow report describes a test that found the picker input, cleared it and typed a date. The same test failed inside a GitHub Actions container:

text
cy.clear() failed because this element is readonly

The useful clue was the changed picker UI, not a missing console warning. An answer identified the mobile picker being rendered where the test expected the desktop picker. That explanation applies to the legacy MUI lab implementation: its PureDateInput source explicitly makes the outer input readonly and routes clicks to opening the picker.

Keep the distinction between a browser interaction failure and a component state failure. Cypress stopping at a readonly field is evidence that the selector reached an element that cannot accept the intended interaction. It does not show that React failed to mount, that the runner needs more memory, or that the input merely needs longer to become ready.

Why the responsive branch changes#

The MUI DatePicker documentation describes a responsive component that chooses between desktop and mobile variants using a pointer media query. This is a capability check rather than a viewport-width breakpoint. A wide browser can therefore receive the mobile variant.

MUI also documents headless-browser testing caveats: the default pointer query may not match in a test environment. That is a possibility to inspect, not a promise that every GitHub-hosted browser behaves identically. The browser, launch options and environment matter; the runner's operating-system label alone is not a diagnosis.

Log the result from the application window that Cypress is testing:

js
cy.window().then((win) => {
  const finePointer = win.matchMedia('(pointer: fine)').matches
  cy.log(`Application pointer: fine = ${finePointer}`)
})

Compare that with the local failing or passing run, then inspect the input's readonly property and the UI opened by clicking it. A screenshot is useful here because a modal dialog and a desktop popover represent different interaction paths. Raising the timeout will not change a media query that consistently selects the other path.

Fix the picker or the test deliberately#

1. Confirm which generation is installed#

Inspect the project's installed dependencies before copying a snippet:

bash
npm ls @mui/lab @mui/x-date-pickers @mui/material

The incident above used the older lab picker API. Modern projects import pickers from MUI X. Do not replace a package name while leaving all its old component props in place. If installation itself fails because a tutorial mixes old Material UI packages with newer React, resolve the Material UI peer-dependency mismatch before debugging the picker test.

For an existing legacy lab project, the following complete component chooses the desktop interaction explicitly. The meaningful change from the responsive version is the picker component; the provider, value and input rendering remain in place:

jsx
import { useState } from 'react'
import TextField from '@mui/material/TextField'
import LocalizationProvider from '@mui/lab/LocalizationProvider'
import AdapterDayjs from '@mui/lab/AdapterDayjs'
import DesktopDatePicker from '@mui/lab/DesktopDatePicker'
 
export default function DeparturePicker() {
  const [value, setValue] = useState(null)
 
  return (
    <LocalizationProvider dateAdapter={AdapterDayjs}>
      <DesktopDatePicker
        label="Departure"
        value={value}
        onChange={setValue}
        renderInput={(params) => <TextField {...params} />}
      />
    </LocalizationProvider>
  )
}

This is a fix only when the product is meant to use the desktop picker. A test should not remove a required mobile experience merely to make a desktop selector pass. If the application intentionally remains responsive, retain the responsive component and test the mobile flow separately: open the picker, select or edit through its supported dialog controls, confirm when required, and assert the application value.

2. Isolate a desktop-branch test before the app loads#

When the product remains responsive but a particular test covers its desktop branch, control only the relevant media query. Cypress provides an onBeforeLoad callback for changes that must happen before application code runs. For an application served at the configured Cypress base URL:

js
cy.visit('/', {
  onBeforeLoad(win) {
    const originalMatchMedia = win.matchMedia.bind(win)
 
    cy.stub(win, 'matchMedia').callsFake((query) => {
      const normalised = query.replace(/^@media\s*/, '').trim()
      if (normalised !== '(pointer: fine)') {
        return originalMatchMedia(query)
      }
 
      return {
        matches: true,
        media: query,
        onchange: null,
        addListener() {},
        removeListener() {},
        addEventListener() {},
        removeEventListener() {},
        dispatchEvent() { return true },
      }
    })
  },
})

The stub deliberately holds that capability constant; it does not simulate changes to pointer hardware. Other media queries still use the browser implementation. Keep a complementary mobile test, and do not present this as testing real device detection. The purpose is to make the branch under test explicit and repeatable.

If the new test file fails linting before Cypress opens a browser, that is a separate configuration issue. The Cypress TSConfig and ESLint fix explains how to include the test file in the TypeScript program used by type-aware rules.

Verify the cause with a negative control#

The component behaviour was reproduced on Windows with Node 26.7.0 and jsdom 26.1.0. The legacy run used React 17.0.2, MUI lab 5.0.0-alpha.54 and Material UI 5.0.4. A controlled media result selected the mobile input; changing only the component to the desktop picker removed readonly. Increasing the viewport alone did not:

text
legacy responsive false: pointerFine=false; width=1000; inputReadOnly=true
legacy wider viewport negative control: pointerFine=false; width=1600; inputReadOnly=true
legacy one change DesktopDatePicker: pointerFine=false; width=1000; inputReadOnly=false
legacy one change pointer fine: pointerFine=true; width=1000; inputReadOnly=false

This is a native DOM reproduction of the component's branch and input properties. It is not a Cypress browser run or a hosted GitHub Actions reproduction. On the actual CI runner, re-run the original user interaction after applying one change and assert the saved form value. A visible date string alone is insufficient if the application never receives or stores the change.

For the legacy text-input path, re-query the element after clearing it before typing. The Cypress clear documentation warns against chaining subsequent subject-dependent actions directly after clearing because the app may replace the element. Avoid removing the readonly attribute or forcing actions as a substitute for selecting the intended UI: that would test a mutated control rather than the one presented to users.

Current MUI X: check editable sections separately#

The legacy explanation must not become the claim that every mobile picker is readonly. A second native reproduction with React 19.2.4, MUI X 9.16.0 and Material UI 9.2.0 still switched between a mobile modal and a desktop popover when the media result changed. However, the mobile field had an input that was not readonly and editable date sections.

The field DOM migration guide explains that the accessible structure became the default in v8, with the old opt-out removed in v9. A selector copied from an older test can now target the visually hidden input instead of the visible editing sections. Inspect the rendered field and adapt the interaction to that structure; swapping to a desktop picker does not automatically restore the old DOM.

Also distinguish changing a date from accepting it. The picker lifecycle documentation describes different desktop and mobile closing defaults. A test that waits for the calendar to disappear immediately after selecting a day can fail on the mobile flow even when selection worked. Complete the appropriate confirmation step, then check the application state or submitted payload.

Keep the date format and locale explicit in assertions so that the same characters do not represent different calendar dates. If a Next.js Pages Router page subsequently fails because a date object was returned from its data loader, continue with Date cannot be serialised as JSON. That is a server-prop problem after the picker interaction, not evidence that the media-query fix failed.

Further reading: React Hook useEffect has a missing dependency: the fix.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.