cy.within Command Does Not Retry

📺 Watch this recipe explained in the video Cy Within Command Does Not Retryopen in new window.

Inner elements are replaced

<div id="main">
  <div id="shipping">
    <div>Loading...</div>
  </div>
</div>
<script>
  setTimeout(() => {
    document.getElementById('shipping').innerHTML = `
      <div>
        <div><strong>Cost</strong></div>
        <div id="cost">$2.99</div>
      </div>
    `
  }, 1000)
</script>
cy.get('#shipping')
  .should('be.visible')
  .within(() => {
    cy.contains('#cost', '$2.99')
  })

Outer element is replaced

<div id="main">
  <div id="shipping">
    <div>Loading...</div>
  </div>
</div>
<script>
  setTimeout(() => {
    document.getElementById('main').innerHTML = `
      <div id="shipping">
        <div><strong>Cost</strong></div>
        <div id="cost">$2.99</div>
      </div>
    `
  }, 1000)
</script>

The same code fails.

cy.get('#shipping')
  .should('be.visible')
  .within(() => {
    cy.contains('#cost', '$2.99')
  })

The cy.get('#shipping').within() locks the commands inside to search a stale element that was replaced by the app. The entire #shipping HTML node has been detached from the document and replaced, while Cypress keeps trying to find the new content in the old one.

Outer element is replaced - solution

To solve the problem with the parent element and cy.within, first confirm the application has finished rendering. Then use cy.within to work with the contents inside a stable element.

<div id="main">
  <div id="shipping">
    <div>Loading...</div>
  </div>
</div>
<script>
  setTimeout(() => {
    document.getElementById('main').innerHTML = `
      <div id="shipping">
        <div><strong>Cost</strong></div>
        <div id="cost">$2.99</div>
      </div>
    `
  }, 1000)
</script>

First, confirm the element has finished loading. For example, you can look for the "cost" element inside.

cy.get('#shipping').find('#cost')

Now we know the element has been re-rendered and it is safe to use cy.within

cy.get('#shipping')
  .should('be.visible')
  .within(() => {
    cy.contains('#cost', '$2.99')
  })

Outer element is replaced after a click

Here is another example that has flake due to the outer element being replaced. The test clicks a button inside the element, and the entire element is replaced.

📺 watch this example explained in the video Split The cy.within Blockopen in new window.

<div id="main">
  <div id="shipping">
    <button id="load">Load shipping info</button>
  </div>
</div>
<script>
  document
    .getElementById('load')
    .addEventListener('click', () => {
      setTimeout(() => {
        document.getElementById('main').innerHTML = `
          <div id="shipping">
            <div class="shipped">Shipped 1 day ago</div>
          </div>
        `
      }, 1000)
    })
</script>

First, let's try putting everything into a single cy.within block. The code below will time out and fail, even though you can see the "Shipped 1 day ago" text appearing.

// 🚨 DOES NOT WORK
// since it never "sees" the new "#shipping" element
cy.get('#shipping')
  .should('be.visible')
  .within(() => {
    cy.contains('button', 'Load shipping').click()
    cy.contains('.shipped', 'Shipped')
  })

The problem is the .should('be.visible') assertion that "fixes" the element to the initial reference due to the bug #25134open in new window. It does not allow cy.get to retry finding the new #shipping element that the application put into the document.

Solution 1: remove the be.visible assertion. Then cy.get + cy.within can retry (I know, I know, the docs say cy.within does not retry, but seems its parent queries do retry in practice).

// solution 1: remove the assertion
// to let cy.get + cy.within to retry
cy.get('#shipping').within(() => {
  cy.contains('button', 'Load shipping').click()
  cy.contains('.shipped', 'Shipped')
})

Solution 2: split the cy.within to the commands before the action and after.

// solution 2: split the cy.within block
cy.get('#shipping')
  .should('be.visible')
  .within(() => {
    cy.contains('button', 'Load shipping').click()
  })
// assert the shipped message in a separate query
cy.get('#shipping').contains('.shipped', 'Shipped')