Why does a second `cy.mount()` in one Cypress test reset the component's state?
answer
- A second mount is not an update
- The adapter tears the root down first
- State cannot survive an unmount
- cy.mount() yields more than a subject
- Look for rerender on the yielded object
basics
~20 sBecause it is a fresh mount, not an update. As of Cypress 16 the cypress/react adapter unmounts the existing root and builds a new one, deliberately wiping state. To change props on the same instance, use the rerender function cy.mount() yields.
solid answer
~50 s`cy.mount()` is a new mount, not a re-render. As of Cypress 16, `cypress/react`'s `mount` calls its own cleanup first — it unmounts the existing React root and creates a new one — precisely so that no state survives from a previous mount in the same test. It also generates a fresh `key` for the wrapper unless a rerender key is supplied, so React could not reconcile the old tree against the new one even if the root had persisted. That is the right behaviour when you want a clean slate and the wrong one when you are testing how a data grid reacts to a prop *change*. For that, `cy.mount()` yields an object carrying `rerender`: `cy.mount(<DataGrid density="comfortable" />).then(({ rerender }) => rerender(<DataGrid density="compact" />))`. The Vue adapter yields a Vue Test Utils `wrapper` instead, whose `setProps()` plays the same role.
code
javascript · 15 linesimport { DataGrid } from './DataGrid'
import { rows } from './rows'
it('keeps the selected row when density changes', () => {
cy.mount(<DataGrid rows={rows} density="comfortable" />).then(
({ rerender }) => {
cy.get('[data-cy=row-3]').click()
cy.get('[data-cy=row-3]').should('have.attr', 'aria-selected', 'true')
rerender(<DataGrid rows={rows} density="compact" />)
cy.get('[data-cy=row-3]').should('have.attr', 'aria-selected', 'true')
}
)
})go deeper
Remember the rule rather than the mechanism: one mount per test, and if you need different props, write another test.
Be ready to say what cy.mount() yields in your framework's adapter and which member of that object updates the mounted instance.
You should be able to read the symptom straight from a failing spec — vanished selection, a stale spy count — and name a fresh mount as the cause rather than calling it flake.
Set the house rule on how far component tests may model update paths, and where that work belongs instead once a test starts scripting a sequence of prop changes.
Two things a component test wants look almost identical in the spec file and are not the same operation at all: **mounting a component with different props** and **changing the props of a component that is already mounted**. The first proves the initial-render path; the second proves the update path. `cy.mount()` only ever does the first. ## Mount is not re-render Calling `cy.mount()` a second time inside one test does not hand new props to the instance on screen. It tears that instance down and builds another one. Everything the component was holding — selected rows, an open menu, an uncommitted input value, a running timer captured in a closure — is gone. Between tests Cypress unmounts for you anyway, so this behaviour only ever bites inside a single test. ## What the React adapter actually does As of Cypress 16, `mount` from `cypress/react` performs three things in order before it renders anything: 1. **Cleanup.** It unmounts the previously created React root, if there is one. The source comment says why in plain terms: React would otherwise merely replace the last component, and the adapter wants the root gone "to wipe away any state". 2. **A new root.** `ReactDOM.createRoot()` runs against the mount container element. 3. **A new key.** The tree is wrapped in a fragment carrying a generated `key` — the current test title plus a random number — unless an explicit rerender key is supplied. A changed `key` tells React to discard the old subtree rather than reconcile with it. Each of the three, on its own, would be enough to reset state. Together they make the guarantee unambiguous: a mount is always a fresh start. ## Getting a real prop change `cy.mount()` yields a `MountReturn` object, not a DOM element. In the React adapter that object carries `component` (what was rendered) and `rerender` (a function that re-renders new JSX **on the same root, under the same key**): ```js cy.mount(<DataGrid rows={rows} density="comfortable" />).then(({ rerender }) => { cy.get('[data-cy=row-3]').click() cy.get('[data-cy=row-3]').should('have.attr', 'aria-selected', 'true') rerender(<DataGrid rows={rows} density="compact" />) // the selection survives, because the instance did cy.get('[data-cy=row-3]').should('have.attr', 'aria-selected', 'true') }) ``` Because `rerender` skips the adapter's cleanup and reuses the key, React reconciles instead of remounting — which is exactly the production behaviour a density toggle or a locale switch triggers. ## What each adapter yields | Adapter | `cy.mount()` yields | Update the mounted instance with | | --- | --- | --- | | `cypress/react` | `{ component, rerender }` | `rerender(newJsx)` | | `cypress/vue` | `{ wrapper, component }` | Vue Test Utils' `wrapper.setProps()` | | `cypress/svelte` | `{ component }` | no update helper is provided | The lesson generalises: before you assume a re-render is available, look at what the adapter you are using actually hands back. Aliasing the yield with `.as('grid')` early in the test keeps it reachable without nesting everything inside one `.then()`. ## When a second mount is the right tool - **Initial-render assertions.** Mounting a rating widget once with `value={0}` and again with `value={4}` checks that first render honours the prop, which a re-render would not exercise. - **Resetting deliberately.** When the point of the test is that the component starts clean, re-mounting states that intent better than clicking your way back. - **Different configurations of the same tree.** A toast mounted with and then without an action region. Even then, two separate `it` blocks usually read better: with two mounts in one test, a failure message does not say which mount it came from. ## Reading the symptom The tell-tale is a test that "loses" something it just did: - A row was selected, the grid was mounted again with a new prop, and the selection is gone. - A spy that was called before the second mount is still aliased and still holds its calls — spies are not remounted with the component, so a stale `have.been.calledOnce` can pass for the *previous* instance's work. - An input's typed value disappears between two mounts in one test. None of these is flake. They are the adapter doing exactly what it documents, and the fix is `rerender`, `setProps`, or splitting the test in two.
- How would you prove the component really unmounted rather than re-rendered?Assert on the state itself. Select row 3, mount again with identical props, and check that nothing is selected — a re-render would keep the selection, a fresh mount cannot. If the component takes a teardown callback, aliasing it with `.as('onDestroy')` and asserting `have.been.calledOnce` after the second mount makes the same point more directly.
- Do spies and aliases survive a second cy.mount() in the same test?Yes, and that is a trap. Aliases and Sinon's sandbox are reset between tests, not between mounts, so a spy that recorded calls from the first instance still holds them. An assertion like `have.been.calledOnce` can pass on work the previous instance did. Create a fresh spy for the second mount, or assert on `callCount` deltas.
saying these in an interview costs you the question
- Expecting a second cy.mount() to behave like a prop update
- Calling cy.mount() again to force a re-render after changing a variable
- Assuming every framework adapter's mount return exposes rerender
- Blaming flake for state a second mount deliberately wiped