Skip to content
This repository has been archived by the owner on Oct 1, 2024. It is now read-only.

[react-testing] improved performance by making descendants calculations lazy for element children #1812

Merged
merged 1 commit into from
Apr 7, 2021

Conversation

melnikov-s
Copy link
Contributor

@melnikov-s melnikov-s commented Apr 1, 2021

Description

Fixes (issue #)

N/A

Type of change

I've been looking into a flaky slow test and doing some profiling and noticed that one of the significant bottlenecks is found in react-testing specifically when attempting to flatten a react fiber linked list into an array of elements. It seems like determining the descendants of each child is an exponential time algorithm. This has a pretty big impact on tests with big fiber trees. I've changed the descendants to be lazily evaluated and derived from the children though grabbing the root descendants still goes through the entire tree which is slow. Additionally I hand optimized some code, for example the spread operator when compiled with typescript to es5 into a spread call seems to be a rather big culprit of performance issues in this computational heavy process.

Performing the above optimization made a significant improvement to the test in question (which had a really big fiber tree ~80 in depth) :

Before:
Screen Shot 2021-04-01 at 9 18 39 AM

After:

Screen Shot 2021-04-01 at 9 19 47 AM

For other tests with smaller fiber trees the improvement is a more modest 10-20%.

Before:
Screen Shot 2021-04-01 at 9 35 06 AM

After:
Screen Shot 2021-04-01 at 9 36 02 AM

This should improve unit tests times across the board.

Note: A change in descendants order is a breaking one. I sketched out the order in this PR and it seems to be the same as before, but something worth verifying for the reviewers.

  • Patch: Bug (non-breaking change which fixes an issue)
  • Minor: New feature (non-breaking change which adds functionality)
  • Major: Breaking change (fix or feature that would cause existing functionality to not work as expected)

Checklist

  • I have added a changelog entry, prefixed by the type of change noted above (Documentation fix and Test update does not need a changelog as we do not publish new version)

@melnikov-s melnikov-s requested review from lemonmade and a team April 1, 2021 15:39
@melnikov-s melnikov-s force-pushed the react-testing-lazy-descendants branch from f990cdf to a9bbc71 Compare April 1, 2021 15:41
@melnikov-s melnikov-s force-pushed the react-testing-lazy-descendants branch from 53096fb to 56b6bf3 Compare April 1, 2021 20:26
@atesgoral atesgoral requested a review from a team April 1, 2021 20:52
@melnikov-s melnikov-s force-pushed the react-testing-lazy-descendants branch 2 times, most recently from ad264bd to 0ee3586 Compare April 5, 2021 14:20
Copy link
Contributor

@marutypes marutypes left a comment

Choose a reason for hiding this comment

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

The code changes look good to me and the perf improvements are very exciting, we should definitely test this in web (or another big codebase) so we can be sure it doesn't secretly break anything though.

@@ -284,52 +286,40 @@ function defaultRender(element: React.ReactElement<unknown>) {
return element;
}

function flatten(
element: Fiber,
function fiberToElement(
Copy link
Contributor

Choose a reason for hiding this comment

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

+1 for rename, the preact version of the library I did similar renames :)

function getDescendants(element: any) {
const descendants: Element<unknown>[] = [];
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < element.allChildren.length; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if we could change our compilation settings for this package to have the foreach version of this not get transpiled, this is a node library, and node definitely supports foreach.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've tried targeting ES2015 and changing that function to a more simplified:

function getDescendants(element: any) {
  const descendants: Element<unknown>[] = [];
  for (const child of element.allChildren) {
    if (typeof child !== 'string') {
      descendants.push(child, ...child.elementDescendants);
    }
  }

  return descendants;
}

but it is unfortunately considerably slower :(

for of with spread operator:

Screen Shot 2021-04-05 at 11 42 26 AM

for loop with no spread:

Screen Shot 2021-04-05 at 11 42 05 AM

(There's a slight variance between runs. I run these tests several times, wait for them to stabilize and take the lowest run time)

@melnikov-s
Copy link
Contributor Author

melnikov-s commented Apr 5, 2021

Things are looking stable. I've ran all tsx tests in web and here are the results:

Before: main
Screen Shot 2021-04-05 at 2 55 58 PM

After: react-testing-lazy-descendants
Screen Shot 2021-04-05 at 4 54 58 PM

The failures are the same for both and unrelated to this change, the run on the main branch has an additional timeout result which is why it has one more failure.

One thing I've noticed is that there's a test Globe.test.tsx that's consistently passing on main and has intermittent failures on this branch. It has something to do with timing as the behavior should be the same between the two branches. I've added an artificial delay to the fiberToElement function and sure enough got the tests to consistently pass. Globe.tsx has a setTimeout(fn, 0) to flip a loading state for whatever reason so I think using jest fake timers and advancing that timer manually should fix the issue. Other then that, it's looking good.

@melnikov-s melnikov-s force-pushed the react-testing-lazy-descendants branch from c848115 to 296b9b3 Compare April 7, 2021 13:18
@melnikov-s
Copy link
Contributor Author

Found another optimization, was looking into why the depth is so large in web and noticed that our main AppContext component is some 25 providers deep. I've added an inner wrapper so that we can get a reference to the root element without first having to traverse and build elements for all the providers which will be thrown away anyway as soon as root is resolved.

Here's the latest run:

Screen Shot 2021-04-06 at 4 27 44 PM

Here are some flamegraphs of a single test highlighting the optimized functions

before:

Screen Shot 2021-04-07 at 9 03 05 AM

after:

Screen Shot 2021-04-07 at 9 02 49 AM

Looks like any significant overhead from react-testing has been eliminated

@atesgoral
Copy link
Contributor

@melnikov-s HUGE!

Copy link
Contributor

@marutypes marutypes left a comment

Choose a reason for hiding this comment

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

@vsumner confirmed with a beta that the lazy evaluation change doesn't cause regressions in web, so that is good by me.

If you like you could pull the new er optimization into another PR and we can test that one with a beta too, and get the original one shipped ASAP

@melnikov-s
Copy link
Contributor Author

If you like you could pull the new er optimization into another PR and we can test that one with a beta too, and get the original one shipped ASAP

Thanks, #1821

Copy link
Collaborator

@vsumner vsumner left a comment

Choose a reason for hiding this comment

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

@melnikov-s could you please add a changelog entry? And then feel free to merge and I can cut a new release.

@vsumner vsumner force-pushed the react-testing-lazy-descendants branch from 9b84566 to 0674baa Compare April 7, 2021 18:01
@melnikov-s
Copy link
Contributor Author

@melnikov-s could you please add a changelog entry? And then feel free to merge and I can cut a new release.

Will do, is this a breaking change that requires a major version bump? I'm noticing that Element is exported from the module and I've changed it's constructor signature.

@marutypes
Copy link
Contributor

@melnikov-s I don't think its intended that folks manually construct elements, rather they can just use Element for type annotations in stuff like custom matchers if they need to, so my bias would be towards not doing a major bump

that said it is technically correct to do a major one I suppose

@melnikov-s melnikov-s force-pushed the react-testing-lazy-descendants branch from 0674baa to 4172922 Compare April 7, 2021 18:20
@melnikov-s
Copy link
Contributor Author

melnikov-s commented Apr 7, 2021

@TheMallen Yeah, I assumed it was for types. We might want to do export type { Element }; at some point to solidify that (or if we decide to bump it to major).

@melnikov-s melnikov-s merged commit 70f3045 into main Apr 7, 2021
@melnikov-s melnikov-s deleted the react-testing-lazy-descendants branch April 7, 2021 18:34
@vsumner vsumner temporarily deployed to production April 7, 2021 19:20 Inactive
@dahukish dahukish deployed to plugin-quilt April 9, 2021 19:26 Active
@shopify-shipit shopify-shipit bot temporarily deployed to gem June 23, 2021 20:18 Inactive
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants