Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(waitForElement): a wrapper around MutationObserver #10

Merged

Conversation

sompylasar
Copy link
Collaborator

@sompylasar sompylasar commented Apr 8, 2018

Add `mutationobserver-shim` to `devDependencies` to provide
`MutationObserver` in jest tests where `jsdom` has no built-in support
for it: jsdom/jsdom#639
Copy link
Member

@kentcdodds kentcdodds left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is super!

So I guess we're not providing them with the mutation observer polyfill ourselves? They have to install that? I'd rather polyfill it for people I think.

Thanks for this!

README.md Outdated
// ...
// wait until the callback does not throw an error and returns a truthy value. In this case, that means
// it'll wait until we can get a form control with a label that matches "username"
// the difference from `wait` is that it reacts to DOM changes in the container
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this make it more clear?

// the difference from `wait` is that rather than running your callback on
// an interval, it's run as soon as there are DOM changes in the container
// and returns the value returned by the callback

README.md Outdated
// it'll wait until we can get a form control with a label that matches "username"
// the difference from `wait` is that it reacts to DOM changes in the container
// and returns the value returned by the callback
const element = await waitForElements(() => getByLabelText(container, 'username'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make this more clear by changing element to usernameElement

README.md Outdated
Defined as:

```typescript
function waitForElements<T>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally I think people will be looking for a single element. Should this be called waitForElement?

In addition, we should show an example (in the FAQ?) of getting an array of elements:

const elements = waitForElement(() => [
  getByText('username'),
  getByText('password')
])

README.md Outdated
// ...
```

Using `MutationObserver` is more efficient than polling the DOM at regular intervals with `wait`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a link to the MDN docs for this so folks know that it's actually a browser feature. Most people have probably never heard of it. I only heard of it a few weeks ago when building the cypress command waiter.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that I added the link in the above paragraph. But I'll add here as well.

README.md Outdated
Using `MutationObserver` is more efficient than polling the DOM at regular intervals with `wait`.

The default `callback` is a no-op function (used like `await waitForElements()`). This can
be helpful if you only need to wait for the next DOM change (see `mutationObserverOptions` to learn which changes are detected).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a link to mutationObserverOptions :)

README.md Outdated

The default `mutationObserverOptions` is `{subtree: true, childList: true}` which will detect
additions and removals of child elements (including text nodes) in the `container` and any of its descendants.
It won't detect attribute changes unless you add `attributes: true` to the options.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect 👍

package.json Outdated
@@ -39,7 +39,8 @@
},
"devDependencies": {
"jest-in-case": "^1.0.2",
"kcd-scripts": "^0.36.1"
"kcd-scripts": "^0.36.1",
"mutationobserver-shim": "^0.3.2"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a regular dependency.

) {
return new Promise((resolve, reject) => {
// Disabling eslint prefer-const below: either prefer-const or no-use-before-define triggers.
let lastError, observer, timer // eslint-disable-line prefer-const
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really odd that both of these rules trigger 🤔 I don't see how this code is in violation. An ESLint bug?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I have it as const, I have to initialize it at definition, i.e. at this line:

timer = setTimeout(onTimeout, timeout)

but then:

    function onDone(error, result) {
      clearTimeout(timer)

is a usage before definition of timer

timer = setTimeout(onTimeout, timeout)
observer = new MutationObserver(onMutation)
observer.observe(container, mutationObserverOptions)
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we run onMutation above this line? That way if there's no reason to wait for a mutation to proceed we don't have to wait for one.

@sompylasar
Copy link
Collaborator Author

So I guess we're not providing them with the mutation observer polyfill ourselves? They have to install that? I'd rather polyfill it for people I think.

We could bundle it I guess, yes.

- rename `waitForElements` to `waitForElement`
- move `mutationobserver-shim` to dependencies, import from
`waitForElement` to provide the polyfill to the users of
`dom-testing-library`
- fix `kcd-scripts` version to match `master` branch
- add synchronous `callback` call to detect an element if it's already
present before any DOM mutation happens
- add/change tests about synchronous `callback` call
- tweak variable names in docs examples
- add docs about the default `container` option value
- add docs example about querying multiple elements
- add docs about the `mutationobserver-shim` polyfill
- add docs link and anchor to `mutationObserverOptions`
- add docs link to MDN from the second mention of `MutationObserver`
@sompylasar
Copy link
Collaborator Author

@kentcdodds All requested changes addressed here: 1f46848

@sompylasar sompylasar changed the title feat(waitForElements): a small wrapper around MutationObserver feat(waitForElement): a wrapper around MutationObserver Apr 8, 2018
Copy link
Member

@kentcdodds kentcdodds left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking awesome! Actually, I'm thinking we should recommend this over wait. Do you want to update other examples in the tests and README examples where it makes sense? Thanks a bunch!

The default `callback` is a no-op function (used like `await waitForElement()`). This can
be helpful if you only need to wait for the next DOM change (see [`mutationObserverOptions`](#mutationobserveroptions) to learn which changes are detected).

The default `container` is the global `document`. Make sure the elements you wait for will be attached to it, or set a different `container`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a simple example of this. It's probably the most common use case

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe just make the example above use it like that.

Should wait for the next DOM change, as advertised in the docs.

The default value is `undefined` so that the `options` object can be
used while still keeping the default callback:
```
waitForElement(undefined, {attributes: true})
```
- use `container` in the examples as this is a more popular use case
than the default value of global `document`
- use full sentences with capital first letter and period in the
example comments
@sompylasar
Copy link
Collaborator Author

Turned out the no-argument waitForElement() stopped working as advertised after we've added the synchronous onMutation() call. Fixed it here: 88bab87

Added container option to the examples here: b7158cf

@sompylasar
Copy link
Collaborator Author

Actually, I'm thinking we should recommend this over wait. Do you want to update other examples in the tests and README examples where it makes sense?

I'm not sure how you want me to do this, so I'd rather leave this change for you to make later if you don't mind.

I can reorder the wait and waitForElement sections in the README, but then it needs rewriting a bunch of texts that compare waitForElement to wait. From this point this starts to look like a bikeshedding, I wanted to incorporate it into cypress-testing-library this weekend, but this PR is already taking longer than expected.

Copy link
Member

@kentcdodds kentcdodds left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking great! A few items of clarification.

Also, it just occurred to me that this might be a really useful library on its own. 🤔

src/index.js Outdated
@@ -6,4 +6,5 @@ export {queries}

export * from './queries'
export * from './wait'
export * from './waitForElement'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit of a nit-pick and I really should have an eslint rule for this, but due to issues with file casing + different operating systems + git, I prefer filenames to always use kebab-case rather than camelCase. Could you update the files to use kebab-case please?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, but tell this to React team. It's for the sake of searchability that the file is literally named the same as the thing it contains and exports. Better have a lint rule that ensures the names strictly match case with the filesystem (and there is such, I think in the eslint-plugin-import).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have told them and they rejected it. But I've had too many people bitten by file casing issues across operating systems to do anything else.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay we have different experiences (mine is about large codebases where searchability is key, akin to Facebook). I'll update when I get back to it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If searchability is key then you say: "Oh, here's a thing called waitForElement in the code, so I'll turn that to kebab-case and search for wait-for-element." Believe me, I've felt these pains before as well. We just have different solutions.

In addition, it's why I'm starting to avoid default exports now because the greppability of a named export is better.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just select the thing, cmd+shift+f, no need to convert. Especially when doing large refactorings. Default exports I agree can become messy if coders don't follow to name them the same at import site, it's a matter of discipline which is not enforced by the program structure.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👉 1252d08

expect(container).toMatchSnapshot()
expect(testEl.parentNode).toBe(container)

await promise
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than await promise, I'd prefer return promise. Maybe I'm being irrational here, but it feels better to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm changing this, but if this line was the only one with await expression, then eslint would say async function must have an await. We are lucky that there are more awaits in all the tests.

P.S. I don't agree with this rule because async functions have their use for the error handling; async function is just the one that is wrapped in a return new Promise(...).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👉 414a3dc

}

async function skipSomeTimeForMutationObserver(delayMs = 50) {
// Using `setTimeout` >30ms instead of `wait` here because `mutationobserver-shim` uses `setTimeout` ~30ms.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this comment 👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're welcome 😉

expect(errorHandler).toHaveBeenCalledTimes(0)

container.setAttribute('data-test-attribute', 'something changed once')
await skipSomeTimeForMutationObserver(200)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this time need to be so long? I'd prefer it to be as short as possible so these tests run quickly :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it doesn't, will reduce to 50.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👉 edfb241

expect(errorHandler).toHaveBeenCalledTimes(0)

container.setAttribute('data-test-attribute', 'something changed twice')
await skipSomeTimeForMutationObserver(150)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

expect(errorHandler).toHaveBeenCalledTimes(0)

container.setAttribute('data-test-attribute', 'something changed once')
await skipSomeTimeForMutationObserver(200)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here

expect(errorHandler).toHaveBeenCalledTimes(0)

container.setAttribute('data-test-attribute', 'something changed twice')
await skipSomeTimeForMutationObserver(150)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here :)

expect(errorHandler).toHaveBeenCalledTimes(0)

container.setAttribute('data-test-attribute', 'something changed once')
await skipSomeTimeForMutationObserver(200)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Annnnd here

@kentcdodds:
> Rather than `await promise`, I'd prefer `return promise`. Maybe I'm
being irrational here, but it feels better to me.

@sompylasar:
> I'm changing this, but if this line was the only one with `await`
expression, then `eslint` would say `async` function must have an
`await`. We are lucky that there are more `await`s in all the tests.
>
> P.S. I don't agree with this rule because `async` functions have
their use for the error handling; `async` function is just the one that
is wrapped in a `return new Promise(...)`.
Copy link
Member

@kentcdodds kentcdodds left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great! Thank you so much for this!

Now we need to upgrade react-testing-library to support this. I'm thinking it should expose a utility returned from the render function where the container is preset 👍

@kentcdodds kentcdodds merged commit 2289371 into testing-library:master Apr 9, 2018
@sompylasar
Copy link
Collaborator Author

@kentcdodds Thanks for merging! I'm currently more interested in having cypress-testing-library updated to use waitForElement and published. I'm already using the updated Cypress code with waitForElement in my project (and it works!), but it would be nice to install this piece of code via cypress-testing-library.

@gnapse
Copy link
Member

gnapse commented Apr 9, 2018

Wow waitForElement looks awesome! I did not know something like MutationObserver existed.

@kentcdodds
Copy link
Member

I'm currently more interested in having cypress-testing-library updated to use waitForElement and published.

Sounds great! I'll be happy to set that project up with auto-releases and such as soon as it's thoroughly tested 👍 I've got the tooling all set up so that should be pretty seamless 👍

@sompylasar
Copy link
Collaborator Author

Can't promise that, now I only have time to make the product app I'm working on thoroughly tested with Cypress.

@kentcdodds
Copy link
Member

kentcdodds commented Apr 9, 2018

Well, if you can't get cypress-testing-library well tested, then feel free to just get it in a working state and I'd happily manually publish a 0.0.0 so you can at least install and use it until we can get a tested 1.0.0 version 👍

It might comfort you to know that I'll want to use cypress-testing-library for my testing workshop, so I'll probably make some time to get it in a good state before next week.

@sompylasar
Copy link
Collaborator Author

@kentcdodds Deal!

alexkrolick pushed a commit to alexkrolick/dom-testing-library that referenced this pull request Sep 13, 2018
* feat: Add getByTestId utility

* Updates to getByTestId documentation

* minor tweaks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants