In Cypress, why is a jQuery element captured in .then() unsafe after an action?
answer
- The callback runs once, never again
- A snapshot of one node
- Detached nodes still answer jQuery reads
- Assertions can pass against a dead row
- Carry values across, never elements
basics
~20 sThe callback runs once and is never retried, so it captured a snapshot of one node. Once an action re-renders that node away, the snapshot still answers reads with stale values, letting an assertion pass against a row that left the page.
solid answer
~40 s`.then(($row) => …)` hands you a jQuery wrapper around one specific node, and Cypress runs the callback once — it is not retried, so nothing refreshes `$row`. As soon as something re-renders the room list, that node is detached. The dangerous part is that a detached node keeps its children, attributes and text, so `$row.text()` and `$row.find('[data-cy=nightly-rate]').text()` still return values — the values from before the action. An assertion built on them can pass while describing a row nobody can see. Feeding it back with `cy.wrap($row).should('have.text', …)` does at least trip Cypress's attached guard, but that guard is skipped for existence and length assertions, so `cy.wrap($row).should('not.exist')` passes on a detached node. Carry values out of the callback, and start the next step with a query.
code
javascript · 14 lines// RISKY: $row is a snapshot; booking replaces the row and the read still succeeds
cy.get('[data-cy=room-row]').contains('Deluxe King').parent().then(($row) => {
cy.get('[data-cy=book-room]').first().click()
expect($row.find('[data-cy=nightly-rate]').text()).to.equal('$210')
})
// SAFE: take the value out, then query the page again after the action
cy.get('[data-cy=room-row]').contains('Deluxe King').parent()
.find('[data-cy=nightly-rate]')
.invoke('text')
.then((rate) => {
cy.get('[data-cy=book-room]').first().click()
cy.get('[data-cy=booking-summary]').should('contain', rate)
})go deeper
Be ready to say that the element handed to a .then() callback is a snapshot taken once, and that you should query the page again after an action rather than reusing it.
Explain why the callback is never retried, and what that means for the captured wrapper once the framework replaces the node it points at.
An interviewer expects the second-order point: a detached node still answers jQuery reads, so the real risk is a test that passes against a row that has left the page rather than one that fails loudly.
Own the convention. Deciding that only values cross an action boundary, and that elements never do, is the kind of rule that keeps a large suite's failures honest without case-by-case review.
## What `.then()` hands you `cy.get('[data-cy=room-row]').first().then(($row) => { … })` calls your callback once with a jQuery wrapper around the element as it was at that moment. Two properties of that wrapper matter: - **It is a snapshot.** It holds one specific node. It is not a live selector and it does not re-resolve. - **The callback is not retried.** Cypress runs it once and moves on, so nothing ever refreshes `$row` for you. The Cypress documentation makes the point directly: wrapping several actions in a `.then()` "does not protect against detached DOM errors any better than direct chaining", and the captured element "becomes stale" once the DOM updates. So the moment anything inside or after that callback causes the room list to re-render — confirming a booking, changing the check-in date, a price refresh landing — `$row` refers to a node that has left the document. ## Why this is worse than the error A detached-subject error is loud. This is quiet. A node removed from the document keeps its children, attributes and text, so ordinary jQuery reads against it still succeed and still return values: - `$row.text()` returns the text the row had **when it was captured**, not what the page shows now. - `$row.hasClass('is-booked')` reports the class list of the dead node. - `$row.find('[data-cy=nightly-rate]').text()` walks the detached subtree happily. Every one of those can make an assertion pass while describing a row nobody can see. A test that books the Deluxe King room and then asserts `expect($row.text()).to.contain('Deluxe King')` proves only that the string you captured before the click still says what it said before the click. Feeding the stale element back through Cypress catches *some* of this. `cy.wrap($row).should('have.text', …)` runs the attached guard and throws the detached error. But the guard is skipped for existence and `have.length` assertions, so `cy.wrap($row).should('not.exist')` passes against a detached node — the assertion is satisfied by the very staleness you were trying to rule out. ## The shape that works The rule is to carry **values** out of a callback and **queries** across an action, never elements. 1. **Read what you need inside the callback and keep the primitive.** Pull the rate out as a string or a number; the string does not go stale. 2. **Start the next step with a query.** `cy.get('[data-cy=room-row]').contains('Deluxe King')` after the action resolves against the page as it is now, whatever the framework replaced. 3. **Alias when the row is hard to name.** `.as('deluxeRow')` and re-reading the alias re-runs the query for you rather than pinning the element. 4. **Do not wrap several actions in one callback to "keep" the element.** Separate statements are both simpler and safer, because each one queries again before it acts. ## What this looks like in review | pattern | verdict | |---|---| | `.then(($row) => { const rate = $row.text() })`, then query again | fine — a string crossed the boundary | | `.then(($row) => { cy.wrap($row).click(); cy.wrap($row).should(…) })` | the wrap does not refresh anything; the second step is holding a dead node | | `$row` stored in a `let` outside the callback and used in a later `it` | the node is from a previous page state and almost certainly detached | | `cy.wrap($row).should('not.exist')` used as a "did it go away" check | passes for a detached node, so it cannot distinguish removed from replaced | ## How to spot it in a suite that is already green The symptom is not a failure, so you cannot wait for CI to find it. What you look for instead: - **An assertion whose expected value came from before the action.** If a test reads a rate, books the room, and then asserts on the rate it read, it is asserting that a string equals itself. - **A check that would pass if the feature were deleted.** Take the behaviour away and re-run the test in your head. If a captured element still answers the assertion, the test proves nothing about the page. - **A `.then()` callback that contains an action.** The callback is where a snapshot and an action can meet, and it is the shape worth reviewing first. - **A jQuery element in a variable outside a callback.** Once it outlives the statement that created it, it is holding a node from an earlier page state and almost certainly a detached one. None of these needs tooling to find; they are grep-and-read reviews. The payoff is disproportionate, because a test in this shape is worse than no test — it occupies the slot where real coverage would be, and it reports green while the booking flow it names goes unchecked.
- Does cy.wrap() refresh a stale element captured in a Cypress .then() callback?No. `cy.wrap()` puts the value you give it into the chain as the subject; it has no selector to re-run, so wrapping a detached node yields a detached subject. It does mean later assertions hit Cypress's attached guard, which turns a silent wrong pass into a visible failure — but it does not recover the element.
- Why can cy.wrap($row).should('not.exist') pass on a stale Cypress subject?The attached guard Cypress runs before an assertion is skipped for existence and length chainers, and the existence check itself is the attached test. A detached node is not attached, so it satisfies `not.exist` — meaning the check cannot tell a row that was removed from one that was merely replaced.
saying these in an interview costs you the question
- Believes Cypress retries the .then() callback body
- Thinks a detached element returns empty text
- Expects cy.wrap to refresh a stale element
- Stores a jQuery element in a variable between tests
- Wraps several actions in one callback to keep the element