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

getByRole("progressbar") is pretty slow #552

Closed
ValentinH opened this issue May 7, 2020 · 58 comments · Fixed by #590
Closed

getByRole("progressbar") is pretty slow #552

ValentinH opened this issue May 7, 2020 · 58 comments · Fixed by #590
Labels

Comments

@ValentinH
Copy link

  • React Testing Library version: 10.0.4 (I'm using /react not /dom but I'm posting here as getByRole is implemented here)
  • node version: 🤷 (codesandbox)
  • npm (or yarn) version: 🤷 (codesandbox)

I've noticed that some of my tests were pretty slow to run (more than 2 seconds). After investigating, I've noticed that the following pattern was to source of the slowness:

await waitForElementToBeRemoved(() => screen.getByRole('progressbar'))

If I add a data-testid to my loader and switch to:

await waitForElementToBeRemoved(() => screen.getByTestId('loader'))

it's much faster.

Relevant code or config:

I'm sometimes displayed a CircularProgress from material-ui while fetching some data and I use this pattern to wait for it to be removed.

await waitForElementToBeRemoved(() => screen.getByRole('progressbar'))

example of implementation

if(isLoading) {
  return <CircularProgress className={classes.loader} data-testid="loader" />
}

What happened:

Here are 2 tests outputs where I only change the pattern mentioned above.

getByRole
image

getByTestId:
image

Reproduction:

I've create the following Sandbox: https://codesandbox.io/s/musing-gauss-e1ynf?file=/src/Demo.js

The numbers are much smaller but when running the tests several times we can see that the getByRole is slower when used on the CircularProgress.

image

Problem description:

I think that

await waitForElementToBeRemoved(() => screen.getByRole('progressbar'))

is the best pattern to test that a loader is displayed on screen. However, the slowness prevents us from using in all our tests as it quickly gets slow.

@ValentinH ValentinH changed the title getByRole pretty slow getByRole is pretty slow May 7, 2020
@ValentinH ValentinH changed the title getByRole is pretty slow getByRole("progressbar") is pretty slow May 7, 2020
@eps1lon
Copy link
Member

eps1lon commented May 7, 2020

getByRole can get quite expensive in large trees since it currently checks every element while ByTestId is a simple querySelector. It also includes a lot more a11y related checks. For example it won't return inaccessible elements.

Since you actually remove the element from the DOM you can ignore the additional a11y tree checks and go with getByRole('progressbar', { hidden: false }).

On slow machines these additional checks can starve your CPU and you should increase the polling interval with waitForElementToBeRemoved(() => {}, { interval: 200 }) (try out which interval works best).

And last but not least: What version of jest or, more specifically, jsdom are you using? In later jsdom versions (or actual browsers) getByRole should be a lot faster.

For us: I think we should make the perf impact of hidden in ByRole clearer in the docs. Also mention too low polling rate on slow machines.

@ValentinH
Copy link
Author

Thanks for the quick reply!

I've just tested the { hidden: false } and it doesn't have an impact in my example unfortunately. 😞
The thing is that in the sandbox example, the tree is not so big. What I don't get, it the difference when using CircularProgress and directly using its output div + svg.

I'm using version 25.2.7 of Jest.

@eps1lon
Copy link
Member

eps1lon commented May 7, 2020

I've just tested the { hidden: false } and it doesn't have an impact in my example unfortunately.

Have you tried increasing the interval? Would be helpful if you could provide a full repro. Otherwise it's unclear what makes this test so slow.

What I don't get, it the difference when using CircularProgress and directly using its output div + svg.

It's unclear where this difference is coming from. Could just be the CircularProgress doing more things and slowing the test down because of it.

@ValentinH
Copy link
Author

Increasing the interval didn't help either.

I'm preparing a demo with my real use case :)

@ValentinH
Copy link
Author

ValentinH commented May 7, 2020

I've updated the demo with a more complex form and here's the result:
image

Sandbox: https://codesandbox.io/s/musing-gauss-e1ynf?file=/src/DemoForm.js

I've removed the CircularProgress so that we don't focus on it.

@eps1lon
Copy link
Member

eps1lon commented May 7, 2020

Great. I think this is somewhat expected with the current implementation. ByTestId takes 130ms while ByRole takes 200ms. This is 50% slower.

Might be nice to experiment with different implementations that don't loop over all nodes but convert the role directly to a selector and use that directly.

@ValentinH
Copy link
Author

ValentinH commented May 7, 2020

I've dug in the (transpiled) code and try to see where it was spending the most time. Surprisingly, this is not in queryAllByRole, but at this line:

throw getConfig().getElementError(

Especially, the call to getMissingError(). Before this call every operations takes a few milliseconds. getMissingError takes almost 1 second on my example!

@ValentinH
Copy link
Author

Digging even more, I'm landing on this line: https://github.com/testing-library/dom-testing-library/blob/master/src/queries/role.js#L116

However, I'm using 7.3.0 and seeing that you changed the implementation in 7.4.0. Thus, I need to check if it has changed the behaviour. 🙂

@ValentinH
Copy link
Author

Still the same behavior on 7.5.1.

I'm now checking this line:

return hidden === false ? isInaccessible(element) === false : true

isInaccessible gets called 30 times when getByRole rejects and each call last around 30 ms.

@ValentinH
Copy link
Author

Funnily, if I do getByRole('progressbar', { hidden: true }) it solves my issue as it does not call isInaccessible. 😄

@kentcdodds
Copy link
Member

Hi @ValentinH! Thanks for all this digging.

I believe that isInaccessible has historically been some bottleneck code. I'm not sure of the best way to speed it up, but if anyone has ideas that would be pretty sweet :)

@ValentinH
Copy link
Author

Yes, I'm trying to see what part of it is the slowest part :)

@ValentinH
Copy link
Author

Not a big surprise, but most of the time is spent on

window.getComputedStyle(element).display

@ValentinH
Copy link
Author

Using the same caching mechanism than in

const subtreeIsInaccessibleCache = new WeakMap()
function cachedIsSubtreeInaccessible(element) {
if (!subtreeIsInaccessibleCache.has(element)) {
subtreeIsInaccessibleCache.set(element, isSubtreeInaccessible(element))
}
return subtreeIsInaccessibleCache.get(element)
}
makes my tests almost 2 times faster.

They are still 5-6 times slower than getByTestId though.

One idea I have is: do we really need to build the whole "role" error message when using waitElementToBeRemoved() ? Indeed, we are only checking the error name in it:

if (error.name === 'TestingLibraryElementError') {

@ValentinH
Copy link
Author

ValentinH commented May 7, 2020

I could make it even faster by using the same cache idea but for the window.getComputedStyle:

const getComputedStyleCache = new WeakMap()
function cachedGetComputedStyle(element) {
  if (!getComputedStyleCache.has(element)) {
    getComputedStyleCache.set(element, window.getComputedStyle(element))
  }

  return getComputedStyleCache.get(element)
}

As this is used both in isInaccessible and isSubtreeInaccessible, we cache even more.

@eps1lon
Copy link
Member

eps1lon commented May 7, 2020

Funnily, if I do getByRole('progressbar', { hidden: true }) it solves my issue as it does not call isInaccessible.

Oh right. I had it backwards.

But I think it would be a good idea to skip the error helpers when running in a waitFor*. That would speed things up.

@ValentinH
Copy link
Author

I don't see a way to skip the error in this case without explicitly passing a (new) option to getByRole(). Do you have an idea?

@drewschrauf
Copy link

I ran into this today while trying to debug why a mocked network request was so slow. I'll try to put together a minimal reproduction but the Cliffs Notes is:

My app code was making a network request and on success would navigate to another route. I was waiting for the new route to load with screen.findByRole('heading', {name: 'xxx'}). Using console.time around the fetch call was showing ~1900ms elapsed. A similar request that would 400 was taking around ~22ms to display the error message. Swapping to screen.findByText('xxx') brought the elapsed time for the former down to ~25ms. I took a look at the profiler and it seems that *ByRole is starving my CPU. I tried both {hidden: true} and {interval: 500} with no improvement. This is in a toy app with a pretty tiny DOM.

The only real difference between the tests seems to be the route change. I'll try to put together a minimal reproduction this weekend to narrow it down.

@kentcdodds
Copy link
Member

If I understand correctly were determining the accessibility of everything before matching. Is that right? If so... What if we find the match first and then determine if it's accessible?

@eps1lon
Copy link
Member

eps1lon commented May 8, 2020

Is that right? If so... What if we find the match first and then determine if it's accessible?

Pretty sure this is what we do already.

@eps1lon
Copy link
Member

eps1lon commented May 8, 2020

I don't see a way to skip the error in this case without explicitly passing a (new) option to getByRole(). Do you have an idea?

We could flip a global variable and then do something like runWithDiagnosticsDisabled(callback) inside waitFor* methods.

@ValentinH
Copy link
Author

Is that right? If so... What if we find the match first and then determine if it's accessible?

Pretty sure this is what we do already.

What I understand is that we are searching for all the roles in the container tree in order to explain why the role that was requested was not found.

@ValentinH
Copy link
Author

@eps1lon is there already a similar pattern in the existing codebase, if yes I can work on a PR to implement the light error message when run within a waitFor*. 🙂

Actually, I think it should only be for waitForElementToBeRemoved(), for the other ones, the full error message makes sense.

@ValentinH
Copy link
Author

I guess I can use the globalObj in https://github.com/testing-library/dom-testing-library/blob/master/src/helpers.js#L1
and create 2 functions to read /write the flag: toggleDiagnosticsInErrors() and shouldPrintDiagnosticsInErrors(). What do you think?

@kentcdodds
Copy link
Member

I think we really need to be careful here. I want to give it a little bit of extra thought...

@ValentinH
Copy link
Author

Yes this would be quite hacky and just a workaround for a deeper issue: isInaccessible being slow.

@ValentinH
Copy link
Author

If you decide that we can go this way, I'm ready to make a PR for the 2 following things:

  • replaced the existing cachedIsSubtreeInaccessible() by the cachedGetComputedStyle() I mentioned above. As we cache more, it's even faster.
  • implement the "global" shouldPrintDiagnosticsInErrors() check in the getMissingError() of Role queries when run within waitForElementToBeRemoved().

@kentcdodds
Copy link
Member

Ok, so I think the simplest solution is to default hidden to true and make the default configurable. I know the arguments against this that @eps1lon will have (we've been through this before) but I'm convinced that opting into this kind of a performance drain in exchange for the confidence is better than having to opt out of it. I worry that people will switch from ByRole to a suboptimal query due to performance issues and I don't think that's wise.

Because this is a breaking change, we should do this in two phases.

  1. Make it configurable and release that as a minor version
  2. Change the default and release that as a major version (maybe batched with other major changes in we can think of any).

Anyone want to work on phase 1?

@tommarien
Copy link

tommarien commented May 13, 2020

@eps1lon I've setup the repro repo, i've kept as much as possible in because i did not want to remove the problem in some way ( you have a pending collaborator access invite). I've left where to look in the readme ;)

@tommarien
Copy link

@eps1lon Any update on this issue ?

@kentcdodds
Copy link
Member

🎉 This issue has been resolved in version 7.6.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@domarmstrong
Copy link

This doesn't actually improve the performance of getByRole though does it? Just when using with waitFor. I was not using waitFor and getByRole was still unusably slow in a complex form component.

@eps1lon
Copy link
Member

eps1lon commented May 29, 2020

I was not using waitFor and getByRole was still unusably slow in a complex form component.

Please open a new issue (since this issue was concerned about ByRole in waitFor) and fill out the template. Having a repro like the one @tommarien provided helps a lot.

@tommarien
Copy link

tommarien commented May 29, 2020

I've just update testing-library/dom in the reproduction repo and see times go from 280ms on average to 38ms ;), so thanks a lot 👍 . If next release of testing-libs dom dependency to > 7.6.0 than everyone can benefit from this :). @eps1lon Thanks and i'll remove the repro repo today

@ctoppel
Copy link

ctoppel commented Jun 7, 2020

I am also seeing very slow times in a large complex DOM (>800ms/getByRole). @domarmstrong Did you open a new issue already?

@kentcdodds
Copy link
Member

Hi @ctoppel,

I'll just echo what @eps1lon said earlier:

Please open a new issue (since this issue was concerned about ByRole in waitFor) and fill out the template. Having a repro like the one @tommarien provided helps a lot.

Thanks!

@dbertella
Copy link

Hey guys I don't know if anybody else opened a new issue but today I had this exact issue, one of my test suite started to have systematic timeouts after updating to new version of testing library and updating my queries to getByRole
I run a bit of profiling and those are my findings

Let's say I have an Input component similar to this

<Input
  {...el.input}
  placeholder="My El"
  data-testid="MyEl"
/>

In my tests I'm running this:

console.log('pre getByTestId', performance.now())
screen.getByTestId('MyEl')
console.log('after getByTestId', performance.now())
console.log('pre getByRole', performance.now())
screen.getByRole('textbox', { name: /My El/ })
console.log('after getByRole', performance.now())
console.log('pre getByText', performance.now())
screen.getByText(/My El/)
console.log('after getByText', performance.now())

Those are my results before without configure({ defaultHidden: true })

pre getByTestId 10829.295586
after getByTestId 10835.550812
pre getByRole 10836.638695
after getByRole 12147.583377
pre getByText 12148.72231
after getByText 12153.527319

Those are the results after configure({ defaultHidden: true })

pre getByTestId 6450.390944
after getByTestId 6454.832352
pre getByRole 6457.684077
after getByRole 7683.902226
pre getByText 7693.771436
after getByText 7698.411979

In general for me getByRole is slower it's 1 / 2 sec vs 5ms with the other queries

@atsikov
Copy link

atsikov commented Jul 7, 2020

eps1lon/dom-accessibility-api#323
This PR improves getByRole performance by up to 20% by removing one extra getComputedStyle call

@franksimon90
Copy link

Hey guys I don't know if anybody else opened a new issue but today I had this exact issue, one of my test suite started to have systematic timeouts after updating to new version of testing library and updating my queries to getByRole
I run a bit of profiling and those are my findings

Let's say I have an Input component similar to this

<Input
  {...el.input}
  placeholder="My El"
  data-testid="MyEl"
/>

In my tests I'm running this:

console.log('pre getByTestId', performance.now())
screen.getByTestId('MyEl')
console.log('after getByTestId', performance.now())
console.log('pre getByRole', performance.now())
screen.getByRole('textbox', { name: /My El/ })
console.log('after getByRole', performance.now())
console.log('pre getByText', performance.now())
screen.getByText(/My El/)
console.log('after getByText', performance.now())

Those are my results before without configure({ defaultHidden: true })

pre getByTestId 10829.295586
after getByTestId 10835.550812
pre getByRole 10836.638695
after getByRole 12147.583377
pre getByText 12148.72231
after getByText 12153.527319

Those are the results after configure({ defaultHidden: true })

pre getByTestId 6450.390944
after getByTestId 6454.832352
pre getByRole 6457.684077
after getByRole 7683.902226
pre getByText 7693.771436
after getByText 7698.411979

In general for me getByRole is slower it's 1 / 2 sec vs 5ms with the other queries

Same for me, more than 2 sec in some cases, depends of test, and component implementation. Even if I don't consider it really important, difference is there, it's a fact.

I'm just worried about this sentence in the documentation clearly stating that getByRole ...should be your top preference for just about everything. There's not much you can't get with this (if you can't, it's possible your UI is inaccessible).

I have been more than once in the position of seen MRs get rejected, or at least, heavily commented, about wrong query usage on RTL, without any real performance consideration (mainly because some developers don't take the time to test this). It's possible to make this difference noted in the doc, at least until its get improved @kentcdodds? Can save some time of internal discussions for some teams.

@kentcdodds
Copy link
Member

I personally think a PR to the docs is warranted. Something like: "NOTE: some have experienced poor performance with the role query, learn more: link"

rmainwork pushed a commit to seas-computing/course-planner that referenced this issue Jul 19, 2021
The usage of getAllbyRole().filter seemed to have a cumulative timeout
effect where the tests would pass when run in isolation with .only, but
when run as part of the full test suite would then timeout and fail.

The solution was to use within() to narrow down the search space of the
test function, making the test run faster and preventing the timeout
issue.

More info on this here: testing-library/dom-testing-library#552
@arahansen
Copy link

I personally think a PR to the docs is warranted

Were the docs ever updated to reflect performance impact of byRole? If not, I would be open to adding these docs

@franksimon90
Copy link

@arahansen I didn't take the task at the end, because I'm not sure about how useful is going to be. Even if benchmarks here have some point, after a few more talks about it, turns out that small sized teams or alone individuals are probably going to assume that the doc is talking about a huge performance impact (even if you used the right wording there, or even if you provide the link), and they're going to do it maybe without any real performance assessment on the test stages. That means, final result could be people opting-out of getByRole (and all that implies) for not a really outstanding practical reason.

@Doeke
Copy link

Doeke commented Dec 13, 2021

I came here after realizing there's a huge performance difference between getByRole and other query methods. Using getByText immediately makes my tests run 8x faster. I understand the reasons for recommending getByRole but that much additional time spent waiting for tests adds up fast for a large project. I think the docs should be up front about this so people can make their informed decisions about what method to use.

@atsikov
Copy link

atsikov commented Jan 19, 2022

For those who use styled-components a lot, such wrapper could improve getByRole performance by leaving only a few css rules used by @testing-library and thus significantly reducing <style> size

import { ReactNode } from 'react'
import { StyleSheetManager, StylisPlugin } from 'styled-components'

const ALLOWED_RULES = ['display', 'visibility', 'pointer-events']

const simplifiedStylesPlugin: StylisPlugin = (context, content) => {
  if (context === 1) {
    if (!ALLOWED_RULES.some(rule => content.toString().startsWith(`${rule}:`))) {
      return ''
    }
  }

  return undefined
}

export const withSimplifiedStyles = (component: ReactNode) => (
  <StyleSheetManager stylisPlugins={[simplifiedStylesPlugin]} disableVendorPrefixes>
    {component}
  </StyleSheetManager>
)

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

Successfully merging a pull request may close this issue.