skip to content

In Cypress, why can a .should() in the middle of a chain leave the next query with a stale element?

level: seniorimportance: should knowfreq 46%

answer

  1. Where does a chain restart from?
  2. An assertion can end the re-query
  3. A subject can outlive its DOM node
  4. Splitting statements changes what re-runs
  5. One callback, several expectations

basics

~20 s

A mid-chain assertion is a retry boundary. Once it passes, Cypress locks in the subject at that point, so later queries retry from that element instead of re-querying from the top - and a re-render leaves it detached.

solid answer

~40 s

Queries retry as a linked unit, but a `.should()` in the middle of a chain ends that unit. As soon as the assertion passes, Cypress locks in the element it passed on and advances; the queries after it retry from that **locked-in subject**, not from the `cy.get()` at the start. If the application re-renders after the assertion succeeded, that element is replaced in the DOM, and the next query fails with a detached-subject error because Cypress will not re-query past an assertion that has already passed. The failure is intermittent by nature — it depends on whether the re-render lands before or after the first assertion. The fix is either to split the chain into separate statements, so each one starts fresh at `cy.get()`, or to fold all the expectations into a single `.should(callback)`.

code

javascript · 18 lines
javascript
// The loans list re-renders when a background poll returns.

// FRAGILE: the assertion locks in this <li>; a re-render detaches it,
// and .find() below can only retry against the detached node.
cy.get('[data-testid="loan-list"]')
  .find('li')
  .eq(0)
  .should('contain', 'Dune')
  .find('.due-date')
  .should('contain', '12 Mar')

// ROBUST: each statement re-queries from the document.
cy.get('[data-testid="loan-list"]').find('li').eq(0).should('contain', 'Dune')
cy.get('[data-testid="loan-list"]')
  .find('li')
  .eq(0)
  .find('.due-date')
  .should('contain', '12 Mar')

go deeper

for a junior

Know the safe habit before the theory: start a new statement with cy.get() after an assertion instead of chaining more queries onto it.

for a middle

Be ready to explain what a mid-chain assertion locks in and why the queries after it cannot go back to the start of the chain.

for a senior

Expect to be handed an intermittent failure and to trace it from the command that reported it back to the assertion that created the boundary.

for a principal

Own the convention for the suite: how long a chain may get, where assertions may sit in it, and how that rule is enforced in review rather than remembered.

## What a mid-chain assertion does to a chain Cypress links queries together and re-runs them as a unit when a **trailing** assertion fails: it goes back to the top of the linked chain, re-queries, and asserts again. A `.should()` in the **middle** of a chain, with more queries after it, behaves differently. It is a **retry boundary**. Once the assertion passes, Cypress fixes — locks in — the subject at that point and moves on through the command queue. Queries after the boundary retry from that locked-in element only; Cypress will not go back past an assertion that has already succeeded. ```javascript cy.get('[data-testid="loan-list"]') .find('li') .eq(0) .should('contain', 'Dune') // passes -> this <li> is now locked in .find('.due-date') // retries from that <li>, not from cy.get() .should('contain', '12 Mar') ``` ## Why the subject goes stale Nothing about the locked-in element is refreshed. If the catalogue re-renders the loan list — a poll returns, a due-date recalculates, a framework reconciles — the `<li>` the first assertion approved is replaced by a **new node** in the DOM. The old one still exists as a JavaScript object, which is why the chain does not crash immediately; it is simply no longer attached to the document. The next query runs against that orphan and fails with a detached-subject error saying the page updated and Cypress cannot re-query after this point. The failure has three properties that make it expensive to diagnose: - It is **timing-dependent**. If the re-render happens before the first assertion, the test passes. - It is reported by the **wrong command** — the query after the boundary — while the cause is the assertion before it. - It gets **worse under retrying**, not better: retrying the query re-runs it against the same detached subject every time, so the test spends its whole timeout failing identically. ## Which chain shapes re-query from where | Chain | What a failing final assertion re-runs | |---|---| | `cy.get().find().eq().should()` | the whole query chain, from `cy.get()` | | `cy.get().find().eq().should().find().should()` | only from the `.find()` after the first assertion | | `cy.get().find().eq().should(callback)` | the whole query chain plus every expectation in the callback | ## Two ways to write it so it retries 1. **Split the chain at the assertion.** Each statement begins with its own `cy.get()`, so every re-query starts from the document rather than from a remembered node. ```javascript cy.get('[data-testid="loan-list"]').find('li').eq(0).should('contain', 'Dune') cy.get('[data-testid="loan-list"]').find('li').eq(0) .find('.due-date').should('contain', '12 Mar') ``` 2. **Fold the expectations into one callback assertion.** Everything inside a `.should(callback)` retries together against a freshly queried element, so there is no boundary in the middle. ```javascript cy.get('[data-testid="loan-list"]').find('li').eq(0).should(($li) => { expect($li).to.contain('Dune') // Chai's expect expect($li.find('.due-date')).to.contain('12 Mar') // jQuery's find }) ``` ## `.and()` creates the same boundary `.and()` is an alias of `.should()`, so a mid-chain `.and()` behaves identically. A chain reading `cy.get('[data-testid="loan-list"]').find('li').should('have.length', 3).and('be.visible').find('a')` locks in its subject at those assertions and queries onward from it, exactly as the earlier example does. The rule to carry is about the **position** of an assertion in a chain, not about which of the two spellings you happened to type. It is also worth separating this from a superficially similar failure. A subject can go stale for other reasons too. What is specific to the assertion boundary is that Cypress had a perfectly good recovery available — re-query the chain from the top, exactly as it does for a trailing assertion — and is prevented from using it because an assertion in the middle of the chain has already succeeded. Nothing is broken; the chain simply asked Cypress to stop looking. ## Reading a chain for this defect in review - Any `.should()` or `.and()` with **more queries chained after it** is a boundary; ask what the application does to that part of the DOM in the milliseconds afterwards. - Lists, tables and anything driven by polling or a live search are the usual victims, because they re-render for reasons unrelated to the test. - Long fluent chains hide boundaries. Three statements that each start at `cy.get()` are more verbose and strictly more robust. - A `.should(callback)` may not call Cypress commands, so if the second half of the chain needs a real command, splitting the chain is the option that works.

  • Why is this failure intermittent rather than constant?
    It depends on when the re-render lands. If the DOM is replaced before the first assertion runs, the assertion simply retries and picks up the new node. If it is replaced after the assertion passed, the locked-in subject is already detached and the next query cannot recover, so the same test alternates between green and red.
  • Does a longer timeout on the second query help here?
    No. The query retries against the same detached element for however long you allow, so a longer timeout only delays an identical failure. What has to change is where the re-query starts: split the chain so it begins at `cy.get()` again, or express the whole thing as one `.should(callback)`.

It is like noting the shelf position of a book, then having the librarian reshelve the whole aisle: your note is still perfectly readable, it just no longer points at anything on the shelf.

saying these in an interview costs you the question

  • Blames the query that reported the error rather than the assertion before it
  • Adds a longer timeout to a detached-subject failure
  • Believes every failing assertion re-queries from the top of the chain
  • Writes long fluent chains with assertions in the middle by habit
  • Calls a Cypress command inside a .should() callback to re-query