Skip to content

Instantly share code, notes, and snippets.

@lencioni
Last active October 20, 2023 14:43
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lencioni/7ba04e0f92558f49454c19c44cf3bc5c to your computer and use it in GitHub Desktop.
Save lencioni/7ba04e0f92558f49454c19c44cf3bc5c to your computer and use it in GitHub Desktop.
cy.waitUntilSettled()
/**
* We often run into a problem with functions that select DOM nodes like
* `cy.get`, where in between the `cy.get` call and the next item in the chain,
* the DOM element that `cy.get` found ends up being removed from the DOM. This
* can affect code as simple as:
*
* cy.get('button').click();
*
* When it fails sporadically, it uses the following error message:
*
* cy...() failed because the element you are chaining off of has become
* detached or removed from the dom
*
* More info:
*
* - https://on.cypress.io/element-has-detached-from-dom
* - https://github.com/cypress-io/cypress/issues/695
*
* This most frequently happens for us on pages that use deferred hydration
* like in PageSlot.
*
* This command will wait until the DOM has not changed.
*
* To use this command, import this module into your project's Cypress support
* file:
*
* // frontend/[project]/cypress/support/index.ts
* import ':cypress/commands/waitUntilSettled';
*
* Then you will be able to use the command:
*
* cy.waitUntilSettled();
*
* On some pages, waitUntilSettled() may consistently throw the 'DOM did not settle'
* error unless we pass in a higher number of maxTries. The default maxTries value exists
* to prevent an infinite loop when we recursively check if the DOM has changed.
* Allowing consumers to set their own maxTries helps us make this utility work
* on pages where the number of sections that need to be wrapped by PageSlot components
* on page load is greater than the default maxTries.
*/
// Typing custom Cypress commands per the official docs
// https://docs.cypress.io/api/cypress-api/custom-commands.html#5-Write-TypeScript-definitions
// Currently requires 'global' workaround
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Waits until the DOM has not been changed. Useful for handling pages
* that use deferred hydration as in PageSlot.
*/
waitUntilSettled(maxTries?: number): Cypress.Chainable;
}
}
}
Cypress.Commands.add('waitUntilSettled', (maxTries = 20) => {
let didDOMChange = false;
let consecutiveIdleCallbacksWithUnchangedDOM = 0;
const handleMutation = () => {
didDOMChange = true;
};
// First install a MutationObserver on the document that will fire the
// callback on any DOM node changes, and set the `didDOMChange` boolean to
// true. Later we will wait a little bit and see if it has been fired.
const observer = new MutationObserver(handleMutation);
cy.document().then((doc) => {
observer.observe(doc.body, { childList: true, subtree: true });
});
function waitAndSee(iteration: number) {
didDOMChange = false;
// Here we wait until the next idle callback, and then check our
// `didDOMChange` boolean. If it changed, we repeat this process, and if it
// has not changed, we allow the test to continue.
//
// Since requestIdleCallback will wait until the CPU is idle before it
// resolves, and there might be a lot of work happening on the page, the
// amount of time that takes could be greater than the Cypress timeout
// (default of 4000ms). To make sure that we don't blow past that timeout
// and fail the test, we want to control the timeouts ourselves here.
//
// Here we are going to use two different timeouts: one for the Cypress
// .then() and another, smaller timeout for our requestIdleCallback. I
// decided to make sure that the requestIdleCallback timeout is quite a bit
// lower than the .then() timeout because if there is a long blocking task,
// it will need to finish before the idle callback will have a chance to
// land. Given that we are calling this in a loop and checking to see if the
// DOM has changed, I think this will be okay because if there was DOM work
// being done and there is still more work to be done, we'll just recurse on
// to the next iteration and check again in a few seconds.
const thenTimeout = 8000;
// eslint-disable-next-line rulesdir/no-two-argument-thenable
cy.window({ log: false })
.then(
{ timeout: thenTimeout },
(win) =>
new Cypress.Promise((resolve) =>
win.requestIdleCallback(resolve, { timeout: thenTimeout / 2 }),
),
)
.then(() => {
if (didDOMChange) {
if (iteration >= maxTries) {
throw new Error('DOM did not settle');
}
consecutiveIdleCallbacksWithUnchangedDOM = 0;
waitAndSee(iteration + 1);
} else if (consecutiveIdleCallbacksWithUnchangedDOM <= 1) {
consecutiveIdleCallbacksWithUnchangedDOM += 1;
waitAndSee(iteration);
}
});
}
waitAndSee(0);
});
@urbanspr1nter
Copy link

Bravo! 👏🥳🙇‍♂️

@mattmutt
Copy link

The MutationObserver watches over the whole DOM tree in this function. What about adding a way to limit the observation to a subtree of interest. There certainly can be unrelated changes happening on a complex web app elsewhere in the DOM not being tested. So being able to dictate which general area to observe and not just the default doc.body could be useful.

@jkoop
Copy link

jkoop commented Oct 20, 2023

Can you please add a line that declares a license? Like:

// Copyright 2022 Joe Lencioni, MIT license

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment