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

Update stack diffing algorithm in describeNativeComponentFrame #27132

Merged
merged 6 commits into from
Nov 8, 2023

Conversation

KarimP
Copy link
Contributor

@KarimP KarimP commented Jul 19, 2023

Summary

There's a bug with the existing stack comparison algorithm in describeNativeComponentFrame — specifically how it attempts to find a common root frame between the control and sample stacks. This PR attempts to fix that bug by injecting a frame that can have a guaranteed string in it for us to search for in both stacks to find a common root.

Brief Background/How it works now

Right now describeNativeComponentFrame does the following to leverage native browser/VM stack frames to get details (e.g. script path, row and col #s) for a single component:

  1. Throwing and catching a control error in the function
  2. Calling the component which should eventually throw an error (most of the time), that we'll catch as our sample error.
  3. Diffing the stacks in the control and sample errors to find the line which should represent our component call.

What's broken

To account for potential stack trace truncation, the stack diffing algorithm first attempts to find a common "root" frame by inspecting the earliest frame of the sample stack and searching for an identical frame in the control stack starting from the bottom. However, there are a couple of scenarios which I've hit that cause the above approach to not work correctly.

First, it's possible that for render passes of extremely large component trees to have a lot of repeating internal react function calls, which can result in an incorrect common or "root" frame found. Here's a small example from a stack trace using React Fizz for SSR.
Our control frame can look like this:

Error:
    at Fake (...)
    at construct (native)
    at describeNativeComponentFrame (...)
    at describeClassComponentFrame (...)
    at getStackByComponentStackNode (...)
    at getCurrentStackInDEV (...)
    at renderNodeDestructive (...)
    at renderElement (...)
    at renderNodeDestructiveImpl (...) // <-- Actual common root frame with the sample stack
    at renderNodeDestructive (...)
    at renderElement (...)
    at renderNodeDestructiveImpl (...) // <-- Incorrectly chosen common root frame
    at renderNodeDestructive (...)

And our sample stack can look like this:

Error:
    at set (...)
    at PureComponent (...)
    at call (native)
    at apply (native)
    at ErrorBoundary (...)
    at construct (native)
    at describeNativeComponentFrame (...)
    at describeClassComponentFrame (...)
    at getStackByComponentStackNode (...)
    at getCurrentStackInDEV (...)
    at renderNodeDestructive (...)
    at renderElement (...)
    at renderNodeDestructiveImpl (...) // <-- Root frame that's common in the control stack

Here you can see that the earliest trace in the sample stack, the renderNodeDestructiveImpl call, can exactly match with multiple renderNodeDestructiveImpl calls in the control stack (including file path and line + col #s). Currently the algorithm will chose the earliest/last frame with the renderNodeDestructiveImpl call (which is the second last frame in our control stack), which is incorrect. The actual matching frame in the control stack is the latest or first frame (when traversing from the top) with the renderNodeDestructiveImpl call. This leads to the rest of the stack diffing associating an incorrect frame (at getStackByComponentStackNode (...)) for the component.

Another issue with this approach is that it assumes all VMs will truncate stack traces at the bottom, which isn't the case for the Hermes VM which truncates stack traces in the middle, placing a

    at renderNodeDestructiveImpl (...)
    ... skipping {n} frames
    at renderNodeDestructive (...)

line in the middle of the stack trace for all stacks that contain more than 100 traces. This causes stack traces for React Native apps using the Hermes VM to potentially break for large component trees. Although for this specific case with Hermes, it's possible to account for this by either manually grepping and removing the ... skipping line and everything below it (see draft PR: #26999), or by implementing the non-standard prepareStackTrace API which Hermes also supports to manually generate a stack trace that truncates from the bottom (example implementation).

The Fix

I found different ways to go about fixing this. The first was to search for a common stack frame starting from the top/latest frame. It's a relatively small change (see implementation), although it is less performant by being n^2 (albeit with n realistically being <= 5 here). It's also a bit more buggy for class components given that different VMs insert a different amount of additional lines for new/construct calls...

Another fix would be to actually implement a longest common substring algorithm, which can also be roughly n^2 time (assuming the longest common substring between control and sample will be most of the sample frame).

The fix I ended up going with was have the lines that throw the control error and the lines that call/instantiate the component be inside a distinct method under an object property ("DescribeNativeComponentFrameRoot"). All major VMs (Safari's JavaScriptCore, Firefox's SpiderMonkey, V8, Hermes, and Bun) should display the object property name their stack trace. I've also set the name and displayName properties for method as well to account for minification, any advanced optimizations (e.g. key crushing), and VM inconsistencies (both Bun and Safari seem to exclusively use the value under displayName and not name in traces for methods defined under an object's own property...).

We can then find this "common" frame by simply finding the line that has our special method name ("DescribeNativeComponentFrameRoot"), and the rest of the code to determine the actual component line works as expected. If by any chance we don't find a frame with our special method name in either control or sample stack traces, we then revert back to the existing approach mentioned above by searching for the last line of the sample frame in the control frame.

How did you test this change?

  1. There are bunch of existing tests that ensure a properly formatted component trace is logged for certain scenarios, so I ensured the existing full test suite passed
  2. I threw an error in a component that's deep in the component hierarchy of a large React app (facebook) to ensure there's stack trace truncation, and ensured the correct component stack trace was logged for Chrome, Safari, and Firefox, and with and without minification.
  3. Ran a large React app (facebook) on the Hermes VM, threw an error in a component that's deep in the component hierarchy, and ensured that component frames are generated despite stack traces being truncated in the middle.

- Attempt have a predefined "root" or common frame between sample and
  control frames
@react-sizebot
Copy link

react-sizebot commented Jul 19, 2023

Comparing: 94d5b5b...38abf48

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.min.js +0.35% 167.45 kB 168.03 kB +0.33% 52.15 kB 52.32 kB
oss-experimental/react-dom/cjs/react-dom.production.min.js +0.33% 176.11 kB 176.69 kB +0.31% 54.84 kB 55.01 kB
facebook-www/ReactDOM-prod.classic.js +0.35% 563.33 kB 565.31 kB +0.30% 99.28 kB 99.58 kB
facebook-www/ReactDOM-prod.modern.js +0.36% 547.06 kB 549.04 kB +0.31% 96.35 kB 96.65 kB
oss-experimental/react/cjs/react-jsx-dev-runtime.development.js +6.96% 43.32 kB 46.34 kB +6.53% 12.65 kB 13.47 kB
oss-stable-semver/react/cjs/react-jsx-dev-runtime.development.js +6.96% 43.35 kB 46.36 kB +6.53% 12.65 kB 13.48 kB
oss-stable/react/cjs/react-jsx-dev-runtime.development.js +6.96% 43.35 kB 46.36 kB +6.53% 12.65 kB 13.48 kB
facebook-www/JSXDEVRuntime-dev.modern.js +6.87% 45.15 kB 48.25 kB +6.71% 12.82 kB 13.68 kB
facebook-www/JSXDEVRuntime-dev.classic.js +6.87% 45.15 kB 48.25 kB +6.71% 12.82 kB 13.68 kB
oss-experimental/react/cjs/react-jsx-runtime.development.js +6.87% 43.92 kB 46.93 kB +6.45% 12.82 kB 13.65 kB
oss-stable-semver/react/cjs/react-jsx-runtime.development.js +6.86% 43.94 kB 46.96 kB +6.46% 12.83 kB 13.65 kB
oss-stable/react/cjs/react-jsx-runtime.development.js +6.86% 43.94 kB 46.96 kB +6.46% 12.83 kB 13.65 kB
oss-stable-semver/react/cjs/react.shared-subset.development.js +3.56% 84.72 kB 87.74 kB +3.72% 23.50 kB 24.37 kB
oss-stable/react/cjs/react.shared-subset.development.js +3.56% 84.75 kB 87.76 kB +3.72% 23.53 kB 24.41 kB
oss-experimental/react/cjs/react.shared-subset.development.js +3.49% 86.40 kB 89.42 kB +3.73% 24.00 kB 24.90 kB
oss-stable-semver/react/cjs/react.development.js +3.02% 99.79 kB 102.80 kB +3.22% 26.94 kB 27.81 kB
oss-stable/react/cjs/react.development.js +3.02% 99.81 kB 102.83 kB +3.22% 26.97 kB 27.84 kB
oss-experimental/react/cjs/react.development.js +2.95% 102.28 kB 105.30 kB +3.04% 27.60 kB 28.44 kB
oss-stable-semver/react/umd/react.development.js +2.55% 122.92 kB 126.05 kB +2.66% 31.78 kB 32.63 kB
oss-stable/react/umd/react.development.js +2.55% 122.95 kB 126.08 kB +2.65% 31.81 kB 32.66 kB
oss-experimental/react/umd/react.development.js +2.49% 125.52 kB 128.65 kB +2.69% 32.43 kB 33.30 kB
facebook-www/React-dev.modern.js +2.46% 125.87 kB 128.97 kB +2.79% 33.36 kB 34.29 kB
facebook-www/React-dev.classic.js +2.44% 126.97 kB 130.07 kB +2.86% 33.61 kB 34.57 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-experimental/react/cjs/react-jsx-dev-runtime.development.js +6.96% 43.32 kB 46.34 kB +6.53% 12.65 kB 13.47 kB
oss-stable-semver/react/cjs/react-jsx-dev-runtime.development.js +6.96% 43.35 kB 46.36 kB +6.53% 12.65 kB 13.48 kB
oss-stable/react/cjs/react-jsx-dev-runtime.development.js +6.96% 43.35 kB 46.36 kB +6.53% 12.65 kB 13.48 kB
facebook-www/JSXDEVRuntime-dev.modern.js +6.87% 45.15 kB 48.25 kB +6.71% 12.82 kB 13.68 kB
facebook-www/JSXDEVRuntime-dev.classic.js +6.87% 45.15 kB 48.25 kB +6.71% 12.82 kB 13.68 kB
oss-experimental/react/cjs/react-jsx-runtime.development.js +6.87% 43.92 kB 46.93 kB +6.45% 12.82 kB 13.65 kB
oss-stable-semver/react/cjs/react-jsx-runtime.development.js +6.86% 43.94 kB 46.96 kB +6.46% 12.83 kB 13.65 kB
oss-stable/react/cjs/react-jsx-runtime.development.js +6.86% 43.94 kB 46.96 kB +6.46% 12.83 kB 13.65 kB
oss-stable-semver/react/cjs/react.shared-subset.development.js +3.56% 84.72 kB 87.74 kB +3.72% 23.50 kB 24.37 kB
oss-stable/react/cjs/react.shared-subset.development.js +3.56% 84.75 kB 87.76 kB +3.72% 23.53 kB 24.41 kB
oss-experimental/react/cjs/react.shared-subset.development.js +3.49% 86.40 kB 89.42 kB +3.73% 24.00 kB 24.90 kB
oss-stable-semver/react/cjs/react.development.js +3.02% 99.79 kB 102.80 kB +3.22% 26.94 kB 27.81 kB
oss-stable/react/cjs/react.development.js +3.02% 99.81 kB 102.83 kB +3.22% 26.97 kB 27.84 kB
oss-experimental/react/cjs/react.development.js +2.95% 102.28 kB 105.30 kB +3.04% 27.60 kB 28.44 kB
oss-stable-semver/react/umd/react.development.js +2.55% 122.92 kB 126.05 kB +2.66% 31.78 kB 32.63 kB
oss-stable/react/umd/react.development.js +2.55% 122.95 kB 126.08 kB +2.65% 31.81 kB 32.66 kB
oss-experimental/react/umd/react.development.js +2.49% 125.52 kB 128.65 kB +2.69% 32.43 kB 33.30 kB
facebook-www/React-dev.modern.js +2.46% 125.87 kB 128.97 kB +2.79% 33.36 kB 34.29 kB
facebook-www/React-dev.classic.js +2.44% 126.97 kB 130.07 kB +2.86% 33.61 kB 34.57 kB
oss-stable-semver/react-server/cjs/react-server.development.js +1.69% 178.91 kB 181.93 kB +2.02% 41.49 kB 42.32 kB
oss-stable/react-server/cjs/react-server.development.js +1.69% 178.91 kB 181.93 kB +2.02% 41.49 kB 42.32 kB
oss-experimental/react-server/cjs/react-server.development.js +1.53% 196.55 kB 199.57 kB +1.82% 45.60 kB 46.43 kB
oss-stable-semver/react-dom/cjs/react-dom-server.bun.development.js +0.82% 366.51 kB 369.52 kB +1.03% 80.69 kB 81.53 kB
oss-stable/react-dom/cjs/react-dom-server.bun.development.js +0.82% 366.53 kB 369.55 kB +1.03% 80.72 kB 81.55 kB
oss-stable-semver/react-dom/cjs/react-dom-server.browser.development.js +0.82% 369.27 kB 372.28 kB +1.03% 81.59 kB 82.43 kB
oss-stable/react-dom/cjs/react-dom-server.browser.development.js +0.82% 369.29 kB 372.31 kB +1.03% 81.61 kB 82.46 kB
oss-stable-semver/react-dom/cjs/react-dom-server-legacy.browser.development.js +0.82% 369.34 kB 372.36 kB +1.04% 81.15 kB 82.00 kB
oss-stable/react-dom/cjs/react-dom-server-legacy.browser.development.js +0.82% 369.37 kB 372.38 kB +1.04% 81.18 kB 82.03 kB
oss-stable-semver/react-dom/cjs/react-dom-server.edge.development.js +0.82% 369.68 kB 372.69 kB +1.04% 81.71 kB 82.55 kB
oss-stable/react-dom/cjs/react-dom-server.edge.development.js +0.82% 369.70 kB 372.72 kB +1.03% 81.73 kB 82.58 kB
oss-stable-semver/react-dom/cjs/react-dom-server.node.development.js +0.81% 370.75 kB 373.77 kB +1.03% 81.63 kB 82.47 kB
oss-stable/react-dom/cjs/react-dom-server.node.development.js +0.81% 370.78 kB 373.79 kB +1.03% 81.65 kB 82.50 kB
oss-stable-semver/react-dom/cjs/react-dom-server-legacy.node.development.js +0.81% 371.20 kB 374.21 kB +1.04% 81.61 kB 82.45 kB
oss-stable/react-dom/cjs/react-dom-server-legacy.node.development.js +0.81% 371.22 kB 374.24 kB +1.04% 81.64 kB 82.48 kB
facebook-www/ReactDOMServerStreaming-dev.modern.js +0.81% 382.80 kB 385.90 kB +1.04% 84.23 kB 85.11 kB
oss-stable-semver/react-dom/umd/react-dom-server.browser.development.js +0.81% 387.07 kB 390.20 kB +1.04% 82.52 kB 83.38 kB
oss-stable/react-dom/umd/react-dom-server.browser.development.js +0.81% 387.10 kB 390.23 kB +1.04% 82.55 kB 83.41 kB
oss-stable-semver/react-dom/umd/react-dom-server-legacy.browser.development.js +0.81% 387.16 kB 390.29 kB +1.05% 82.07 kB 82.93 kB
oss-stable/react-dom/umd/react-dom-server-legacy.browser.development.js +0.81% 387.19 kB 390.32 kB +1.05% 82.10 kB 82.96 kB
facebook-www/ReactDOMServer-dev.modern.js +0.80% 388.10 kB 391.20 kB +1.01% 85.50 kB 86.36 kB
facebook-www/ReactDOMServer-dev.classic.js +0.78% 395.53 kB 398.63 kB +0.99% 87.14 kB 88.01 kB
oss-experimental/react-dom/cjs/react-dom-server.bun.development.js +0.77% 389.80 kB 392.81 kB +0.99% 85.64 kB 86.48 kB
oss-experimental/react-dom/cjs/react-dom-server-legacy.browser.development.js +0.76% 396.30 kB 399.31 kB +0.97% 87.63 kB 88.48 kB
oss-experimental/react-dom/cjs/react-dom-server-legacy.node.development.js +0.76% 398.15 kB 401.17 kB +0.97% 88.09 kB 88.94 kB
oss-experimental/react-dom/umd/react-dom-server-legacy.browser.development.js +0.75% 415.21 kB 418.34 kB +0.99% 88.95 kB 89.83 kB
oss-experimental/react-dom/cjs/react-dom-server.node.development.js +0.75% 399.93 kB 402.95 kB +0.97% 87.22 kB 88.07 kB
oss-experimental/react-dom/cjs/react-dom-server.browser.development.js +0.75% 402.35 kB 405.36 kB +0.96% 88.36 kB 89.22 kB
oss-experimental/react-dom/cjs/react-dom-server.edge.development.js +0.75% 402.76 kB 405.77 kB +0.98% 88.48 kB 89.35 kB
oss-experimental/react-dom/umd/react-dom-server.browser.development.js +0.74% 421.56 kB 424.69 kB +0.94% 89.72 kB 90.56 kB
oss-stable-semver/react-art/cjs/react-art.production.min.js +0.62% 94.35 kB 94.93 kB +0.59% 29.03 kB 29.20 kB
oss-stable/react-art/cjs/react-art.production.min.js +0.62% 94.40 kB 94.98 kB +0.59% 29.06 kB 29.23 kB
facebook-www/ReactART-prod.modern.js +0.59% 335.39 kB 337.38 kB +0.53% 57.04 kB 57.34 kB
oss-experimental/react-art/cjs/react-art.production.min.js +0.58% 99.84 kB 100.43 kB +0.61% 30.59 kB 30.78 kB
facebook-www/ReactART-prod.classic.js +0.57% 346.39 kB 348.37 kB +0.49% 58.94 kB 59.23 kB
oss-stable-semver/react-test-renderer/cjs/react-test-renderer.production.min.js +0.56% 103.40 kB 103.98 kB +0.61% 31.76 kB 31.95 kB
oss-stable/react-test-renderer/cjs/react-test-renderer.production.min.js +0.56% 103.46 kB 104.04 kB +0.62% 31.78 kB 31.98 kB
oss-experimental/react-test-renderer/cjs/react-test-renderer.production.min.js +0.56% 103.58 kB 104.16 kB +0.60% 31.83 kB 32.02 kB
oss-stable-semver/react-test-renderer/umd/react-test-renderer.production.min.js +0.56% 103.79 kB 104.37 kB +0.54% 32.19 kB 32.37 kB
oss-stable/react-test-renderer/umd/react-test-renderer.production.min.js +0.56% 103.84 kB 104.42 kB +0.54% 32.22 kB 32.39 kB
oss-experimental/react-test-renderer/umd/react-test-renderer.production.min.js +0.56% 103.97 kB 104.55 kB +0.56% 32.26 kB 32.45 kB
oss-stable-semver/react-reconciler/cjs/react-reconciler.production.min.js +0.53% 110.52 kB 111.10 kB +0.57% 33.68 kB 33.87 kB
oss-stable/react-reconciler/cjs/react-reconciler.production.min.js +0.53% 110.54 kB 111.13 kB +0.57% 33.70 kB 33.90 kB
oss-experimental/react-reconciler/cjs/react-reconciler.production.min.js +0.50% 116.57 kB 117.15 kB +0.54% 35.53 kB 35.72 kB
oss-stable-semver/react-reconciler/cjs/react-reconciler.profiling.min.js +0.49% 119.54 kB 120.12 kB +0.54% 35.92 kB 36.11 kB
oss-stable/react-reconciler/cjs/react-reconciler.profiling.min.js +0.49% 119.56 kB 120.14 kB +0.54% 35.94 kB 36.14 kB
oss-experimental/react-reconciler/cjs/react-reconciler.profiling.min.js +0.46% 125.59 kB 126.17 kB +0.54% 37.78 kB 37.98 kB
oss-stable-semver/react-art/umd/react-art.production.min.js +0.44% 131.64 kB 132.22 kB +0.43% 41.20 kB 41.38 kB
oss-stable/react-art/umd/react-art.production.min.js +0.44% 131.69 kB 132.27 kB +0.42% 41.22 kB 41.40 kB
oss-experimental/react-art/umd/react-art.production.min.js +0.42% 137.11 kB 137.69 kB +0.46% 42.72 kB 42.91 kB
facebook-www/ReactTestRenderer-dev.modern.js +0.38% 805.72 kB 808.82 kB +0.50% 175.03 kB 175.91 kB
facebook-www/ReactTestRenderer-dev.classic.js +0.38% 805.72 kB 808.82 kB +0.50% 175.03 kB 175.91 kB
oss-stable-semver/react-test-renderer/cjs/react-test-renderer.development.js +0.38% 787.36 kB 790.38 kB +0.49% 172.78 kB 173.64 kB
oss-stable/react-test-renderer/cjs/react-test-renderer.development.js +0.38% 787.39 kB 790.40 kB +0.50% 172.82 kB 173.68 kB
oss-experimental/react-test-renderer/cjs/react-test-renderer.development.js +0.38% 787.76 kB 790.77 kB +0.50% 172.90 kB 173.76 kB
oss-stable-semver/react-test-renderer/umd/react-test-renderer.development.js +0.38% 824.63 kB 827.76 kB +0.48% 174.59 kB 175.44 kB
oss-stable/react-test-renderer/umd/react-test-renderer.development.js +0.38% 824.65 kB 827.79 kB +0.48% 174.62 kB 175.47 kB
oss-experimental/react-test-renderer/umd/react-test-renderer.development.js +0.38% 825.04 kB 828.18 kB +0.48% 174.71 kB 175.56 kB
oss-stable-semver/react-art/cjs/react-art.development.js +0.37% 805.36 kB 808.38 kB +0.48% 176.32 kB 177.16 kB
oss-stable/react-art/cjs/react-art.development.js +0.37% 805.39 kB 808.40 kB +0.47% 176.35 kB 177.19 kB
facebook-www/ReactDOM-prod.modern.js +0.36% 547.06 kB 549.04 kB +0.31% 96.35 kB 96.65 kB
oss-experimental/react-art/cjs/react-art.development.js +0.36% 838.22 kB 841.24 kB +0.46% 182.53 kB 183.37 kB
facebook-www/ReactDOM-prod.classic.js +0.35% 563.33 kB 565.31 kB +0.30% 99.28 kB 99.58 kB
facebook-www/ReactDOMTesting-prod.modern.js +0.35% 563.60 kB 565.58 kB +0.30% 100.51 kB 100.81 kB
oss-stable-semver/react-dom/umd/react-dom.production.min.js +0.35% 167.23 kB 167.81 kB +0.31% 52.44 kB 52.61 kB
oss-stable/react-dom/umd/react-dom.production.min.js +0.35% 167.31 kB 167.89 kB +0.32% 52.47 kB 52.64 kB
oss-stable-semver/react-dom/cjs/react-dom.production.min.js +0.35% 167.37 kB 167.95 kB +0.34% 52.12 kB 52.29 kB
oss-stable/react-dom/cjs/react-dom.production.min.js +0.35% 167.45 kB 168.03 kB +0.33% 52.15 kB 52.32 kB
facebook-www/ReactDOM-profiling.modern.js +0.34% 577.58 kB 579.56 kB +0.30% 100.93 kB 101.24 kB
facebook-www/ReactDOMTesting-prod.classic.js +0.34% 578.15 kB 580.13 kB +0.30% 103.01 kB 103.32 kB
facebook-www/ReactART-dev.modern.js +0.34% 906.26 kB 909.36 kB +0.45% 193.54 kB 194.42 kB
oss-stable-semver/react-art/umd/react-art.development.js +0.34% 920.20 kB 923.33 kB +0.44% 195.37 kB 196.22 kB
oss-stable/react-art/umd/react-art.development.js +0.34% 920.22 kB 923.35 kB +0.44% 195.39 kB 196.24 kB
facebook-www/ReactART-dev.classic.js +0.34% 917.46 kB 920.56 kB +0.44% 195.85 kB 196.72 kB
oss-stable-semver/react-reconciler/cjs/react-reconciler.development.js +0.33% 901.33 kB 904.35 kB +0.44% 193.71 kB 194.56 kB
oss-stable/react-reconciler/cjs/react-reconciler.development.js +0.33% 901.36 kB 904.37 kB +0.44% 193.74 kB 194.59 kB
facebook-www/ReactDOM-profiling.classic.js +0.33% 593.93 kB 595.91 kB +0.29% 103.81 kB 104.11 kB
oss-experimental/react-dom/umd/react-dom.production.min.js +0.33% 175.88 kB 176.46 kB +0.30% 55.11 kB 55.28 kB
oss-experimental/react-dom/cjs/react-dom.production.min.js +0.33% 176.11 kB 176.69 kB +0.31% 54.84 kB 55.01 kB
oss-stable-semver/react-dom/umd/react-dom.profiling.min.js +0.33% 176.22 kB 176.80 kB +0.33% 54.82 kB 55.00 kB
oss-stable/react-dom/umd/react-dom.profiling.min.js +0.33% 176.30 kB 176.88 kB +0.32% 54.85 kB 55.03 kB
oss-stable-semver/react-dom/cjs/react-dom.profiling.min.js +0.33% 177.01 kB 177.59 kB +0.32% 54.57 kB 54.75 kB
oss-stable/react-dom/cjs/react-dom.profiling.min.js +0.33% 177.09 kB 177.67 kB +0.32% 54.59 kB 54.77 kB
oss-experimental/react-art/umd/react-art.development.js +0.33% 954.64 kB 957.77 kB +0.39% 201.61 kB 202.40 kB
oss-experimental/react-reconciler/cjs/react-reconciler.development.js +0.32% 937.52 kB 940.53 kB +0.44% 200.87 kB 201.75 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.production.min.js +0.32% 182.32 kB 182.90 kB +0.30% 57.21 kB 57.38 kB
oss-experimental/react-dom/umd/react-dom.profiling.min.js +0.31% 184.88 kB 185.46 kB +0.29% 57.48 kB 57.64 kB
oss-experimental/react-dom/cjs/react-dom.profiling.min.js +0.31% 185.76 kB 186.34 kB +0.30% 57.27 kB 57.44 kB
oss-stable-semver/react-dom/cjs/react-dom.development.js +0.24% 1,275.49 kB 1,278.50 kB +0.29% 281.43 kB 282.24 kB
oss-stable/react-dom/cjs/react-dom.development.js +0.24% 1,275.52 kB 1,278.53 kB +0.29% 281.46 kB 282.27 kB
oss-stable-semver/react-dom/umd/react-dom.development.js +0.23% 1,337.07 kB 1,340.20 kB +0.29% 284.35 kB 285.19 kB
oss-stable/react-dom/umd/react-dom.development.js +0.23% 1,337.09 kB 1,340.22 kB +0.30% 284.38 kB 285.22 kB
oss-experimental/react-dom/cjs/react-dom.development.js +0.23% 1,330.58 kB 1,333.60 kB +0.29% 293.32 kB 294.17 kB
oss-experimental/react-dom/umd/react-dom.development.js +0.22% 1,394.71 kB 1,397.84 kB +0.30% 296.34 kB 297.22 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.development.js +0.22% 1,348.64 kB 1,351.65 kB +0.28% 297.70 kB 298.55 kB
facebook-www/ReactDOM-dev.modern.js +0.22% 1,401.15 kB 1,404.25 kB +0.29% 303.44 kB 304.31 kB
facebook-www/ReactDOMTesting-dev.modern.js +0.22% 1,419.49 kB 1,422.59 kB +0.28% 307.85 kB 308.71 kB
facebook-www/ReactDOM-dev.classic.js +0.22% 1,429.05 kB 1,432.15 kB +0.28% 309.00 kB 309.87 kB
facebook-www/ReactDOMTesting-dev.classic.js +0.21% 1,447.40 kB 1,450.50 kB +0.27% 313.45 kB 314.30 kB

Generated by 🚫 dangerJS against 38abf48

// Before ES6, the `name` property was not configurable.
if (
// $FlowFixMe[method-unbinding]
Object.getOwnPropertyDescriptor(this.DetermineComponentFrameRoot)
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm should this be Object.getOwnPropertyDescriptor(this.DetermineComponentFrameRoot, 'name')?

Also JS q -- if the name property is not configurable, does that mean it should already hold the value DetermineComponentFrameRoot?

Copy link
Contributor Author

@KarimP KarimP Jul 19, 2023

Choose a reason for hiding this comment

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

Oh yup, I should specify 'name' there!

the name property is not configurable, does that mean it should already hold the value DetermineComponentFrameRoot

I think it depends on the JS VM? But yes for most the name property should automatically be set to the function name defined in code. However setting both name and displayName dynamically in code can account for any post-processing that might get done to the code. For example, it's possible for:

RunInRootFrame.DetermineComponentFrameRoot =
  function DetermineComponentFrameRoot() {
    ...
  };

to get minified to:

a.DetermineComponentFrameRoot = function b() {
  ...
};

In this case, I think most VMs would set the name property here to "b". Pre-ES6 it wouldn't be possible to change that. ES6 onwards the name property is not writable (so we can't directly set it via property access), but is configurable, so we can change it via Object.defineProperty. Also some VMs might specify both the function name and method name in stack traces, so e.g. V8 would have a stack trace line like:

    at a.b [as DetermineComponentFrameRoot] 

But it's not guaranteed for all VMs.

@mofeiZ
Copy link
Contributor

mofeiZ commented Jul 19, 2023

From circleci build-lint, it looks like React's umd bundles use ES5 (eslintrc).

@acdlite this might be a noob q, but what is stopping us from using ES6 features?

@KarimP
Copy link
Contributor Author

KarimP commented Jul 20, 2023

I removed the use of classes and switched over to having the function that throws the control and sample errors be under an object property. I initially chose a class method because the Closure compiler would elide my initial attempts to separate the code that throws control and sample errors to a different function, but now with my current changes that doesn't appear to be case... (I assume it might have to do with me setting the name and displayName properties on the function).

@Error75757575
Copy link

🌹

@mofeiZ
Copy link
Contributor

mofeiZ commented Aug 2, 2023

This looks good to me! I'd love for someone else to take a look before stamping -- @acdlite @gnoff are you familiar with this code (or know someone who is?)

const sampleLines = sampleStack.split('\n');
const controlLines = controlStack.split('\n');
let s = sampleLines.findIndex(line =>
line.includes('DetermineComponentFrameRoot'),
Copy link
Collaborator

Choose a reason for hiding this comment

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

I’d find some alternative methods to use here because newer methods tend to be slower. We don’t use the names elsewhere so compress worse. We also convert this away from arrow function so it’ll be longer than it looks.

There’s also the risk of older environments not supporting it.

Maybe just a plain loop.

Copy link
Collaborator

@sebmarkbage sebmarkbage left a comment

Choose a reason for hiding this comment

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

I think the approach generally makes sense.

@mofeiZ mofeiZ merged commit 88b00de into facebook:main Nov 8, 2023
36 checks passed
github-actions bot pushed a commit that referenced this pull request Nov 8, 2023
## Summary

There's a bug with the existing stack comparison algorithm in
`describeNativeComponentFrame` — specifically how it attempts to find a
common root frame between the control and sample stacks. This PR
attempts to fix that bug by injecting a frame that can have a guaranteed
string in it for us to search for in both stacks to find a common root.

## Brief Background/How it works now

Right now `describeNativeComponentFrame` does the following to leverage
native browser/VM stack frames to get details (e.g. script path, row and
col #s) for a single component:
1. Throwing and catching a control error in the function
2. Calling the component which should eventually throw an error (most of
the time), that we'll catch as our sample error.
3. Diffing the stacks in the control and sample errors to find the line
which should represent our component call.

## What's broken

To account for potential stack trace truncation, the stack diffing
algorithm first attempts to find a common "root" frame by inspecting the
earliest frame of the sample stack and searching for an identical frame
in the control stack starting from the bottom. However, there are a
couple of scenarios which I've hit that cause the above approach to not
work correctly.

First, it's possible that for render passes of extremely large component
trees to have a lot of repeating internal react function calls, which
can result in an incorrect common or "root" frame found. Here's a small
example from a stack trace using React Fizz for SSR.
Our control frame can look like this:
```
Error:
    at Fake (...)
    at construct (native)
    at describeNativeComponentFrame (...)
    at describeClassComponentFrame (...)
    at getStackByComponentStackNode (...)
    at getCurrentStackInDEV (...)
    at renderNodeDestructive (...)
    at renderElement (...)
    at renderNodeDestructiveImpl (...) // <-- Actual common root frame with the sample stack
    at renderNodeDestructive (...)
    at renderElement (...)
    at renderNodeDestructiveImpl (...) // <-- Incorrectly chosen common root frame
    at renderNodeDestructive (...)
```

And our sample stack can look like this:
```
Error:
    at set (...)
    at PureComponent (...)
    at call (native)
    at apply (native)
    at ErrorBoundary (...)
    at construct (native)
    at describeNativeComponentFrame (...)
    at describeClassComponentFrame (...)
    at getStackByComponentStackNode (...)
    at getCurrentStackInDEV (...)
    at renderNodeDestructive (...)
    at renderElement (...)
    at renderNodeDestructiveImpl (...) // <-- Root frame that's common in the control stack
```

Here you can see that the earliest trace in the sample stack, the
`renderNodeDestructiveImpl` call, can exactly match with multiple
`renderNodeDestructiveImpl` calls in the control stack (including file
path and line + col #s). Currently the algorithm will chose the
earliest/last frame with the `renderNodeDestructiveImpl` call (which is
the second last frame in our control stack), which is incorrect. The
actual matching frame in the control stack is the latest or first frame
(when traversing from the top) with the `renderNodeDestructiveImpl`
call. This leads to the rest of the stack diffing associating an
incorrect frame (`at getStackByComponentStackNode (...)`) for the
component.

Another issue with this approach is that it assumes all VMs will
truncate stack traces at the *bottom*, [which isn't the case for the
Hermes
VM](https://github.com/facebook/hermes/blob/df07cf713a84a4434c83c08cede38ba438dc6aca/lib/VM/JSError.cpp#L688-L699)
which **truncates stack traces in the middle**, placing a

```
    at renderNodeDestructiveImpl (...)
    ... skipping {n} frames
    at renderNodeDestructive (...)
```

line in the middle of the stack trace for all stacks that contain more
than 100 traces. This causes stack traces for React Native apps using
the Hermes VM to potentially break for large component trees. Although
for this specific case with Hermes, it's possible to account for this by
either manually grepping and removing the `... skipping` line and
everything below it (see draft PR: #26999), or by implementing the
non-standard `prepareStackTrace` API which Hermes also supports to
manually generate a stack trace that truncates from the bottom ([example
implementation](main...KarimP:react:component-stack-hermes-fix)).

## The Fix

I found different ways to go about fixing this. The first was to search
for a common stack frame starting from the top/latest frame. It's a
relatively small change ([see
implementation](main...KarimP:react:component-stack-fix-2)),
although it is less performant by being n^2 (albeit with `n`
realistically being <= 5 here). It's also a bit more buggy for class
components given that different VMs insert a different amount of
additional lines for new/construct calls...

Another fix would be to actually implement a [longest common
substring](https://en.wikipedia.org/wiki/Longest_common_substring)
algorithm, which can also be roughly n^2 time (assuming the longest
common substring between control and sample will be most of the sample
frame).

The fix I ended up going with was have the lines that throw the control
error and the lines that call/instantiate the component be inside a
distinct method under an object property
(`"DescribeNativeComponentFrameRoot"`). All major VMs (Safari's
JavaScriptCore, Firefox's SpiderMonkey, V8, Hermes, and Bun) should
display the object property name their stack trace. I've also set the
`name` and `displayName` properties for method as well to account for
minification, any advanced optimizations (e.g. key crushing), and VM
inconsistencies (both Bun and Safari seem to exclusively use the value
under `displayName` and not `name` in traces for methods defined under
an object's own property...).

We can then find this "common" frame by simply finding the line that has
our special method name (`"DescribeNativeComponentFrameRoot"`), and the
rest of the code to determine the actual component line works as
expected. If by any chance we don't find a frame with our special method
name in either control or sample stack traces, we then revert back to
the existing approach mentioned above by searching for the last line of
the sample frame in the control frame.

## How did you test this change?

1. There are bunch of existing tests that ensure a properly formatted
component trace is logged for certain scenarios, so I ensured the
existing full test suite passed
2. I threw an error in a component that's deep in the component
hierarchy of a large React app (facebook) to ensure there's stack trace
truncation, and ensured the correct component stack trace was logged for
Chrome, Safari, and Firefox, and with and without minification.
3. Ran a large React app (facebook) on the Hermes VM, threw an error in
a component that's deep in the component hierarchy, and ensured that
component frames are generated despite stack traces being truncated in
the middle.

DiffTrain build for [88b00de](88b00de)
kodiakhq bot pushed a commit to vercel/next.js that referenced this pull request Nov 8, 2023
EdisonVan pushed a commit to EdisonVan/react that referenced this pull request Apr 15, 2024
…ook#27132)

## Summary

There's a bug with the existing stack comparison algorithm in
`describeNativeComponentFrame` — specifically how it attempts to find a
common root frame between the control and sample stacks. This PR
attempts to fix that bug by injecting a frame that can have a guaranteed
string in it for us to search for in both stacks to find a common root.

## Brief Background/How it works now

Right now `describeNativeComponentFrame` does the following to leverage
native browser/VM stack frames to get details (e.g. script path, row and
col #s) for a single component:
1. Throwing and catching a control error in the function
2. Calling the component which should eventually throw an error (most of
the time), that we'll catch as our sample error.
3. Diffing the stacks in the control and sample errors to find the line
which should represent our component call.

## What's broken

To account for potential stack trace truncation, the stack diffing
algorithm first attempts to find a common "root" frame by inspecting the
earliest frame of the sample stack and searching for an identical frame
in the control stack starting from the bottom. However, there are a
couple of scenarios which I've hit that cause the above approach to not
work correctly.

First, it's possible that for render passes of extremely large component
trees to have a lot of repeating internal react function calls, which
can result in an incorrect common or "root" frame found. Here's a small
example from a stack trace using React Fizz for SSR.
Our control frame can look like this:
```
Error:
    at Fake (...)
    at construct (native)
    at describeNativeComponentFrame (...)
    at describeClassComponentFrame (...)
    at getStackByComponentStackNode (...)
    at getCurrentStackInDEV (...)
    at renderNodeDestructive (...)
    at renderElement (...)
    at renderNodeDestructiveImpl (...) // <-- Actual common root frame with the sample stack
    at renderNodeDestructive (...)
    at renderElement (...)
    at renderNodeDestructiveImpl (...) // <-- Incorrectly chosen common root frame
    at renderNodeDestructive (...)
```

And our sample stack can look like this:
```
Error:
    at set (...)
    at PureComponent (...)
    at call (native)
    at apply (native)
    at ErrorBoundary (...)
    at construct (native)
    at describeNativeComponentFrame (...)
    at describeClassComponentFrame (...)
    at getStackByComponentStackNode (...)
    at getCurrentStackInDEV (...)
    at renderNodeDestructive (...)
    at renderElement (...)
    at renderNodeDestructiveImpl (...) // <-- Root frame that's common in the control stack
```

Here you can see that the earliest trace in the sample stack, the
`renderNodeDestructiveImpl` call, can exactly match with multiple
`renderNodeDestructiveImpl` calls in the control stack (including file
path and line + col #s). Currently the algorithm will chose the
earliest/last frame with the `renderNodeDestructiveImpl` call (which is
the second last frame in our control stack), which is incorrect. The
actual matching frame in the control stack is the latest or first frame
(when traversing from the top) with the `renderNodeDestructiveImpl`
call. This leads to the rest of the stack diffing associating an
incorrect frame (`at getStackByComponentStackNode (...)`) for the
component.

Another issue with this approach is that it assumes all VMs will
truncate stack traces at the *bottom*, [which isn't the case for the
Hermes
VM](https://github.com/facebook/hermes/blob/df07cf713a84a4434c83c08cede38ba438dc6aca/lib/VM/JSError.cpp#L688-L699)
which **truncates stack traces in the middle**, placing a

```
    at renderNodeDestructiveImpl (...)
    ... skipping {n} frames
    at renderNodeDestructive (...)
```

line in the middle of the stack trace for all stacks that contain more
than 100 traces. This causes stack traces for React Native apps using
the Hermes VM to potentially break for large component trees. Although
for this specific case with Hermes, it's possible to account for this by
either manually grepping and removing the `... skipping` line and
everything below it (see draft PR: facebook#26999), or by implementing the
non-standard `prepareStackTrace` API which Hermes also supports to
manually generate a stack trace that truncates from the bottom ([example
implementation](facebook/react@main...KarimP:react:component-stack-hermes-fix)).

## The Fix

I found different ways to go about fixing this. The first was to search
for a common stack frame starting from the top/latest frame. It's a
relatively small change ([see
implementation](facebook/react@main...KarimP:react:component-stack-fix-2)),
although it is less performant by being n^2 (albeit with `n`
realistically being <= 5 here). It's also a bit more buggy for class
components given that different VMs insert a different amount of
additional lines for new/construct calls...

Another fix would be to actually implement a [longest common
substring](https://en.wikipedia.org/wiki/Longest_common_substring)
algorithm, which can also be roughly n^2 time (assuming the longest
common substring between control and sample will be most of the sample
frame).

The fix I ended up going with was have the lines that throw the control
error and the lines that call/instantiate the component be inside a
distinct method under an object property
(`"DescribeNativeComponentFrameRoot"`). All major VMs (Safari's
JavaScriptCore, Firefox's SpiderMonkey, V8, Hermes, and Bun) should
display the object property name their stack trace. I've also set the
`name` and `displayName` properties for method as well to account for
minification, any advanced optimizations (e.g. key crushing), and VM
inconsistencies (both Bun and Safari seem to exclusively use the value
under `displayName` and not `name` in traces for methods defined under
an object's own property...).

We can then find this "common" frame by simply finding the line that has
our special method name (`"DescribeNativeComponentFrameRoot"`), and the
rest of the code to determine the actual component line works as
expected. If by any chance we don't find a frame with our special method
name in either control or sample stack traces, we then revert back to
the existing approach mentioned above by searching for the last line of
the sample frame in the control frame.

## How did you test this change?

1. There are bunch of existing tests that ensure a properly formatted
component trace is logged for certain scenarios, so I ensured the
existing full test suite passed
2. I threw an error in a component that's deep in the component
hierarchy of a large React app (facebook) to ensure there's stack trace
truncation, and ensured the correct component stack trace was logged for
Chrome, Safari, and Firefox, and with and without minification.
3. Ran a large React app (facebook) on the Hermes VM, threw an error in
a component that's deep in the component hierarchy, and ensured that
component frames are generated despite stack traces being truncated in
the middle.
bigfootjon pushed a commit that referenced this pull request Apr 18, 2024
## Summary

There's a bug with the existing stack comparison algorithm in
`describeNativeComponentFrame` — specifically how it attempts to find a
common root frame between the control and sample stacks. This PR
attempts to fix that bug by injecting a frame that can have a guaranteed
string in it for us to search for in both stacks to find a common root.

## Brief Background/How it works now

Right now `describeNativeComponentFrame` does the following to leverage
native browser/VM stack frames to get details (e.g. script path, row and
col #s) for a single component:
1. Throwing and catching a control error in the function
2. Calling the component which should eventually throw an error (most of
the time), that we'll catch as our sample error.
3. Diffing the stacks in the control and sample errors to find the line
which should represent our component call.

## What's broken

To account for potential stack trace truncation, the stack diffing
algorithm first attempts to find a common "root" frame by inspecting the
earliest frame of the sample stack and searching for an identical frame
in the control stack starting from the bottom. However, there are a
couple of scenarios which I've hit that cause the above approach to not
work correctly.

First, it's possible that for render passes of extremely large component
trees to have a lot of repeating internal react function calls, which
can result in an incorrect common or "root" frame found. Here's a small
example from a stack trace using React Fizz for SSR.
Our control frame can look like this:
```
Error:
    at Fake (...)
    at construct (native)
    at describeNativeComponentFrame (...)
    at describeClassComponentFrame (...)
    at getStackByComponentStackNode (...)
    at getCurrentStackInDEV (...)
    at renderNodeDestructive (...)
    at renderElement (...)
    at renderNodeDestructiveImpl (...) // <-- Actual common root frame with the sample stack
    at renderNodeDestructive (...)
    at renderElement (...)
    at renderNodeDestructiveImpl (...) // <-- Incorrectly chosen common root frame
    at renderNodeDestructive (...)
```

And our sample stack can look like this:
```
Error:
    at set (...)
    at PureComponent (...)
    at call (native)
    at apply (native)
    at ErrorBoundary (...)
    at construct (native)
    at describeNativeComponentFrame (...)
    at describeClassComponentFrame (...)
    at getStackByComponentStackNode (...)
    at getCurrentStackInDEV (...)
    at renderNodeDestructive (...)
    at renderElement (...)
    at renderNodeDestructiveImpl (...) // <-- Root frame that's common in the control stack
```

Here you can see that the earliest trace in the sample stack, the
`renderNodeDestructiveImpl` call, can exactly match with multiple
`renderNodeDestructiveImpl` calls in the control stack (including file
path and line + col #s). Currently the algorithm will chose the
earliest/last frame with the `renderNodeDestructiveImpl` call (which is
the second last frame in our control stack), which is incorrect. The
actual matching frame in the control stack is the latest or first frame
(when traversing from the top) with the `renderNodeDestructiveImpl`
call. This leads to the rest of the stack diffing associating an
incorrect frame (`at getStackByComponentStackNode (...)`) for the
component.

Another issue with this approach is that it assumes all VMs will
truncate stack traces at the *bottom*, [which isn't the case for the
Hermes
VM](https://github.com/facebook/hermes/blob/df07cf713a84a4434c83c08cede38ba438dc6aca/lib/VM/JSError.cpp#L688-L699)
which **truncates stack traces in the middle**, placing a

```
    at renderNodeDestructiveImpl (...)
    ... skipping {n} frames
    at renderNodeDestructive (...)
```

line in the middle of the stack trace for all stacks that contain more
than 100 traces. This causes stack traces for React Native apps using
the Hermes VM to potentially break for large component trees. Although
for this specific case with Hermes, it's possible to account for this by
either manually grepping and removing the `... skipping` line and
everything below it (see draft PR: #26999), or by implementing the
non-standard `prepareStackTrace` API which Hermes also supports to
manually generate a stack trace that truncates from the bottom ([example
implementation](main...KarimP:react:component-stack-hermes-fix)).

## The Fix

I found different ways to go about fixing this. The first was to search
for a common stack frame starting from the top/latest frame. It's a
relatively small change ([see
implementation](main...KarimP:react:component-stack-fix-2)),
although it is less performant by being n^2 (albeit with `n`
realistically being <= 5 here). It's also a bit more buggy for class
components given that different VMs insert a different amount of
additional lines for new/construct calls...

Another fix would be to actually implement a [longest common
substring](https://en.wikipedia.org/wiki/Longest_common_substring)
algorithm, which can also be roughly n^2 time (assuming the longest
common substring between control and sample will be most of the sample
frame).

The fix I ended up going with was have the lines that throw the control
error and the lines that call/instantiate the component be inside a
distinct method under an object property
(`"DescribeNativeComponentFrameRoot"`). All major VMs (Safari's
JavaScriptCore, Firefox's SpiderMonkey, V8, Hermes, and Bun) should
display the object property name their stack trace. I've also set the
`name` and `displayName` properties for method as well to account for
minification, any advanced optimizations (e.g. key crushing), and VM
inconsistencies (both Bun and Safari seem to exclusively use the value
under `displayName` and not `name` in traces for methods defined under
an object's own property...).

We can then find this "common" frame by simply finding the line that has
our special method name (`"DescribeNativeComponentFrameRoot"`), and the
rest of the code to determine the actual component line works as
expected. If by any chance we don't find a frame with our special method
name in either control or sample stack traces, we then revert back to
the existing approach mentioned above by searching for the last line of
the sample frame in the control frame.

## How did you test this change?

1. There are bunch of existing tests that ensure a properly formatted
component trace is logged for certain scenarios, so I ensured the
existing full test suite passed
2. I threw an error in a component that's deep in the component
hierarchy of a large React app (facebook) to ensure there's stack trace
truncation, and ensured the correct component stack trace was logged for
Chrome, Safari, and Firefox, and with and without minification.
3. Ran a large React app (facebook) on the Hermes VM, threw an error in
a component that's deep in the component hierarchy, and ensured that
component frames are generated despite stack traces being truncated in
the middle.

DiffTrain build for commit 88b00de.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants