<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>sigh.dev - Scott Cooper&apos;s dev blog - #performance</title><description>sigh.dev is Scott Cooper&apos;s dev blog about TypeScript, React, San Francisco, and the web. - Posts tagged with &quot;performance&quot;</description><link>https://sigh.dev/</link><item><title>Making React Testing Library Tests 43% Faster</title><link>https://sigh.dev/posts/making-react-testing-library-faster/</link><guid isPermaLink="true">https://sigh.dev/posts/making-react-testing-library-faster/</guid><description>Three fixes made one React test file 43% faster without changing the tests.</description><pubDate>Thu, 20 Aug 2026 07:00:00 GMT</pubDate><content:encoded>&lt;p&gt;React Testing Library’s &lt;code&gt;getByRole&lt;/code&gt; is the correct way to test a form. It checks that fields have the roles and accessible names a user relies on, so a passing test tells me the form is at least minimally accessible and labeled correctly. That takes more work than &lt;code&gt;querySelector&lt;/code&gt;: it has to find candidates, work out their implicit roles, filter inaccessible elements, and calculate accessible names. On a large DOM, that can be a lot of work.&lt;/p&gt;
&lt;p&gt;It’s Sentry’s annual HackWeek, and alongside my more standard project I wanted to burn some GPT-5.6 Sol tokens on something useful. I started poking at one expensive React test file to see how fast I could make it without rewriting it. No replacing &lt;code&gt;getByRole&lt;/code&gt; with &lt;code&gt;getByTestId&lt;/code&gt;. No swapping out &lt;code&gt;userEvent&lt;/code&gt;. The tests should stay exactly the same while the libraries underneath them get faster.&lt;/p&gt;
&lt;h2&gt;&lt;a class=&quot;not-prose&quot; href=&quot;#the-result&quot;&gt;The result&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I used &lt;a href=&quot;https://github.com/getsentry/sentry/blob/c73856753969efc2e12f13363c4db17a3b80849c/static/gsAdmin/components/provisionSubscriptionAction.spec.tsx&quot; rel=&quot;noreferrer noopener&quot; target=&quot;_blank&quot;&gt;a real Sentry test file&lt;/a&gt; built around a large form.&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Setup&lt;/th&gt;&lt;th&gt;Time&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Sentry’s current jsdom 26 setup&lt;/td&gt;&lt;td&gt;12.41s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;jsdom 30 before these changes&lt;/td&gt;&lt;td&gt;17.18s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;With the merged label and event changes&lt;/td&gt;&lt;td&gt;12.09s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;With the DOMSelector fast path too&lt;/td&gt;&lt;td&gt;&lt;strong&gt;9.77s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Together, the three library changes made the jsdom 30 version &lt;strong&gt;43% faster&lt;/strong&gt;. The final result was also &lt;strong&gt;21% faster than the current jsdom 26 setup&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;&lt;a class=&quot;not-prose&quot; href=&quot;#using-codex&quot;&gt;Using Codex&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I started with a vague prompt. I’ve found Sol works well when given a lofty goal:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I need you to find a greater than 20% performance gain in running &lt;code&gt;getByRole&lt;/code&gt; on a larger DOM.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Codex came back with an 81% microbenchmark win from indexing implicit roles by tag. Great, except the benchmark was basically designed around the code it had just made faster. I had it patch the change directly into Sentry’s &lt;code&gt;node_modules&lt;/code&gt; and run it there. It did nothing. I then asked how much time the file actually spent inside role queries. The answer was less than 1% of the runtime, so even an 81% improvement there was not going to matter.&lt;/p&gt;
&lt;p&gt;Along the way I had to steer Codex away from:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;treating a microbenchmark win as the final result&lt;/li&gt;
&lt;li&gt;splitting the test file so Jest could spread it across more workers&lt;/li&gt;
&lt;li&gt;rewriting the tests to use cheaper queries or interactions&lt;/li&gt;
&lt;li&gt;hacking up React’s development runtime for a change I could never land&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The change had to speed up the machinery underneath the tests, and it had to belong somewhere I could actually send it.&lt;/p&gt;
&lt;p&gt;I pointed Codex at the subscription form test instead. This one spent about 29% of its time in role queries. Profiling led to jsdom rescanning the document for &lt;code&gt;input.labels&lt;/code&gt;. We traced the behavior past &lt;code&gt;dom-accessibility-api&lt;/code&gt;, which only asks the browser for &lt;code&gt;.labels&lt;/code&gt;, to the jsdom code doing the repeated scans. That became the first jsdom fix.&lt;/p&gt;
&lt;p&gt;From there I kept Sentry and each library in separate checkouts. Codex patched Sentry’s installed dependencies for quick A/B tests. If an idea survived in Sentry, it moved into the repository that owned the code and got its own tests and benchmark. We repeated that loop for the event-path and selector fixes.&lt;/p&gt;
&lt;p&gt;By the second pull request, I had Codex read the feedback maintainers had left on earlier changes to the same files. We used that to check whether the code matched the repository’s patterns, whether the benchmark only showed the best case, and which correctness cases the tests needed to cover. That produced smaller changes, broader benchmarks, and better tests before opening the pull request.&lt;/p&gt;
&lt;p&gt;My job was to make it prove each win in a real test, kill the weak ideas, and keep asking where the fix should actually live.&lt;/p&gt;
&lt;h2&gt;&lt;a class=&quot;not-prose&quot; href=&quot;#stop-scanning-every-label-over-and-over&quot;&gt;Stop scanning every label over and over&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The biggest win came from how jsdom handled &lt;code&gt;input.labels&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Testing Library calculates accessible names when you write something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;screen.&lt;/span&gt;&lt;span&gt;getByRole&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;textbox&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, { name&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &apos;&lt;/span&gt;&lt;span&gt;Email&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Calculating that name can read the &lt;code&gt;labels&lt;/code&gt; property for every candidate input. Before &lt;a href=&quot;https://github.com/jsdom/jsdom/pull/4237&quot; rel=&quot;noreferrer noopener&quot; target=&quot;_blank&quot;&gt;this change&lt;/a&gt;, every input walked the entire DOM root independently to find its labels.&lt;/p&gt;
&lt;p&gt;If a form had 100 controls, jsdom could scan the same DOM 100 times during one query, changing only the control it was looking for.&lt;/p&gt;
&lt;p&gt;The fix builds one label-to-control index for the current root and shares it between all the controls. When the DOM changes, jsdom throws the index away and rebuilds it the next time someone needs it. The live &lt;code&gt;labels&lt;/code&gt; collections still behave like they should.&lt;/p&gt;
&lt;p&gt;Reading the labels for 100 controls went from &lt;strong&gt;60.52ms to 0.67ms&lt;/strong&gt;, about &lt;strong&gt;91 times faster&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;&lt;a class=&quot;not-prose&quot; href=&quot;#the-selector-fast-path-was-never-fast&quot;&gt;The selector fast path was never fast&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;jsdom uses &lt;a href=&quot;https://github.com/asamuzaK/domSelector&quot; rel=&quot;noreferrer noopener&quot; target=&quot;_blank&quot;&gt;DOMSelector&lt;/a&gt; for selector matching. DOMSelector has a fast path for the selectors it supports in &lt;code&gt;matches()&lt;/code&gt;, but jsdom has two JavaScript objects representing the document: its internal implementation object and the public &lt;code&gt;document&lt;/code&gt; wrapper.&lt;/p&gt;
&lt;p&gt;The fast-path check compared those two objects with &lt;code&gt;===&lt;/code&gt;. They can never be equal. Every supported &lt;code&gt;matches()&lt;/code&gt; call coming from jsdom fell through to the slower general-purpose matcher.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/asamuzaK/domSelector/pull/309&quot; rel=&quot;noreferrer noopener&quot; target=&quot;_blank&quot;&gt;The fix&lt;/a&gt; teaches DOMSelector that the wrapper and implementation object are the same document before doing the comparison.&lt;/p&gt;
&lt;p&gt;The matcher benchmark took &lt;strong&gt;89% less time&lt;/strong&gt;. A larger &lt;code&gt;getByRole(&apos;button&apos;)&lt;/code&gt; benchmark took &lt;strong&gt;42% less time&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Testing Library and jsdom call &lt;code&gt;matches()&lt;/code&gt; all over the place. The fast selector already existed; jsdom just never reached it.&lt;/p&gt;
&lt;h2&gt;&lt;a class=&quot;not-prose&quot; href=&quot;#events-should-not-keep-searching-the-same-path&quot;&gt;Events should not keep searching the same path&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Dispatching an event means building a path from the target through its ancestors. jsdom then has to work out the correct &lt;code&gt;event.target&lt;/code&gt; at every stop, including shadow DOM cases.&lt;/p&gt;
&lt;p&gt;Before &lt;a href=&quot;https://github.com/jsdom/jsdom/pull/4242&quot; rel=&quot;noreferrer noopener&quot; target=&quot;_blank&quot;&gt;this change&lt;/a&gt;, every stop searched backward through the event path to find its target. A deeper tree meant a longer path and more repeated searching through that path. jsdom also prepared listener state for elements that did not have a listener for that event.&lt;/p&gt;
&lt;p&gt;The fix records the effective target while building the path, then reads it directly during dispatch. It also skips the listener setup when nothing is listening for that event.&lt;/p&gt;
&lt;p&gt;Event throughput improved by &lt;strong&gt;12% to 36%&lt;/strong&gt;, depending on the depth of the tree and how many elements had listeners.&lt;/p&gt;
&lt;p&gt;That matters for React tests because &lt;code&gt;userEvent&lt;/code&gt; does not dispatch one event and call it a day. A normal interaction can produce a small parade of pointer, mouse, focus, input, and click events. Saving work on every event adds up across a test suite without changing how any listener sees the event.&lt;/p&gt;
&lt;h2&gt;&lt;a class=&quot;not-prose&quot; href=&quot;#what-this-means-for-your-tests&quot;&gt;What this means for your tests&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Your test suite probably will not see the same improvement. This file happened to hit all three hot paths: a large labeled form, lots of semantic Testing Library queries, and plenty of user interactions.&lt;/p&gt;
&lt;p&gt;These changes matter most for tests with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;large forms with many labels and controls&lt;/li&gt;
&lt;li&gt;lots of &lt;code&gt;getByRole&lt;/code&gt; queries using accessible names&lt;/li&gt;
&lt;li&gt;deep rendered DOM trees&lt;/li&gt;
&lt;li&gt;many &lt;code&gt;userEvent&lt;/code&gt; or &lt;code&gt;fireEvent&lt;/code&gt; interactions&lt;/li&gt;
&lt;li&gt;libraries that make heavy use of &lt;code&gt;matches()&lt;/code&gt; in jsdom&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As of this post, the &lt;a href=&quot;https://github.com/jsdom/jsdom/pull/4237&quot; rel=&quot;noreferrer noopener&quot; target=&quot;_blank&quot;&gt;label cache&lt;/a&gt; and &lt;a href=&quot;https://github.com/jsdom/jsdom/pull/4242&quot; rel=&quot;noreferrer noopener&quot; target=&quot;_blank&quot;&gt;event-path&lt;/a&gt; changes have landed in jsdom, but neither is in a published release yet. The &lt;a href=&quot;https://github.com/asamuzaK/domSelector/pull/309&quot; rel=&quot;noreferrer noopener&quot; target=&quot;_blank&quot;&gt;DOMSelector fast-path fix&lt;/a&gt; is still open.&lt;/p&gt;
&lt;p&gt;Thanks to jsdom maintainer &lt;a href=&quot;https://github.com/domenic&quot; rel=&quot;noreferrer noopener&quot; target=&quot;_blank&quot;&gt;Domenic Denicola&lt;/a&gt; for reviewing the vibecoded slop and merging both jsdom changes.&lt;/p&gt;
&lt;figure data-breakout=&quot;wide&quot;&gt;&lt;a class=&quot;content-image-link&quot; href=&quot;./label-bureaucracy.png&quot; rel=&quot;noreferrer noopener&quot; target=&quot;_blank&quot;&gt;&lt;img alt=&quot;Four office workers searching a wall of filing cabinets&quot; title=&quot;Click to open in new tab&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1672&quot; height=&quot;941&quot; src=&quot;/_astro/label-bureaucracy.8JtL1BbV_Z1tYbiE.webp&quot; srcset=&quot;/_astro/label-bureaucracy.8JtL1BbV_ZNwYzG.webp 640w, /_astro/label-bureaucracy.8JtL1BbV_s2Wra.webp 750w, /_astro/label-bureaucracy.8JtL1BbV_ZvyLQD.webp 828w, /_astro/label-bureaucracy.8JtL1BbV_17vez0.webp 1080w, /_astro/label-bureaucracy.8JtL1BbV_gSOx2.webp 1280w, /_astro/label-bureaucracy.8JtL1BbV_ZJMoIs.webp 1668w, /_astro/label-bureaucracy.8JtL1BbV_Z1tYbiE.webp 1672w&quot; /&gt;&lt;/a&gt;&lt;figcaption class=&quot;image-subtext&quot;&gt;AI-generated image of four office workers searching a wall of filing cabinets&lt;/figcaption&gt;&lt;/figure&gt;</content:encoded><category>ai</category><category>vibecoding</category><category>typescript</category><category>testing</category><category>performance</category></item></channel></rss>