Skip to content

Commit 88b00de

Browse files
authored
Update stack diffing algorithm in describeNativeComponentFrame (#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: #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.
1 parent 52d542a commit 88b00de

File tree

1 file changed

+139
-65
lines changed

1 file changed

+139
-65
lines changed

packages/shared/ReactComponentStackFrame.js

Lines changed: 139 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ if (__DEV__) {
6060
componentFrameCache = new PossiblyWeakMap<Function, string>();
6161
}
6262

63+
/**
64+
* Leverages native browser/VM stack frames to get proper details (e.g.
65+
* filename, line + col number) for a single component in a component stack. We
66+
* do this by:
67+
* (1) throwing and catching an error in the function - this will be our
68+
* control error.
69+
* (2) calling the component which will eventually throw an error that we'll
70+
* catch - this will be our sample error.
71+
* (3) diffing the control and sample error stacks to find the stack frame
72+
* which represents our component.
73+
*/
6374
export function describeNativeComponentFrame(
6475
fn: Function,
6576
construct: boolean,
@@ -76,89 +87,152 @@ export function describeNativeComponentFrame(
7687
}
7788
}
7889

79-
let control;
80-
8190
reentry = true;
8291
const previousPrepareStackTrace = Error.prepareStackTrace;
8392
// $FlowFixMe[incompatible-type] It does accept undefined.
8493
Error.prepareStackTrace = undefined;
8594
let previousDispatcher;
95+
8696
if (__DEV__) {
8797
previousDispatcher = ReactCurrentDispatcher.current;
8898
// Set the dispatcher in DEV because this might be call in the render function
8999
// for warnings.
90100
ReactCurrentDispatcher.current = null;
91101
disableLogs();
92102
}
93-
try {
94-
// This should throw.
95-
if (construct) {
96-
// Something should be setting the props in the constructor.
97-
const Fake = function () {
98-
throw Error();
99-
};
100-
// $FlowFixMe[prop-missing]
101-
Object.defineProperty(Fake.prototype, 'props', {
102-
set: function () {
103-
// We use a throwing setter instead of frozen or non-writable props
104-
// because that won't throw in a non-strict mode function.
105-
throw Error();
106-
},
107-
});
108-
if (typeof Reflect === 'object' && Reflect.construct) {
109-
// We construct a different control for this case to include any extra
110-
// frames added by the construct call.
111-
try {
112-
Reflect.construct(Fake, []);
113-
} catch (x) {
114-
control = x;
103+
104+
/**
105+
* Finding a common stack frame between sample and control errors can be
106+
* tricky given the different types and levels of stack trace truncation from
107+
* different JS VMs. So instead we'll attempt to control what that common
108+
* frame should be through this object method:
109+
* Having both the sample and control errors be in the function under the
110+
* `DescribeNativeComponentFrameRoot` property, + setting the `name` and
111+
* `displayName` properties of the function ensures that a stack
112+
* frame exists that has the method name `DescribeNativeComponentFrameRoot` in
113+
* it for both control and sample stacks.
114+
*/
115+
const RunInRootFrame = {
116+
DetermineComponentFrameRoot(): [?string, ?string] {
117+
let control;
118+
try {
119+
// This should throw.
120+
if (construct) {
121+
// Something should be setting the props in the constructor.
122+
const Fake = function () {
123+
throw Error();
124+
};
125+
// $FlowFixMe[prop-missing]
126+
Object.defineProperty(Fake.prototype, 'props', {
127+
set: function () {
128+
// We use a throwing setter instead of frozen or non-writable props
129+
// because that won't throw in a non-strict mode function.
130+
throw Error();
131+
},
132+
});
133+
if (typeof Reflect === 'object' && Reflect.construct) {
134+
// We construct a different control for this case to include any extra
135+
// frames added by the construct call.
136+
try {
137+
Reflect.construct(Fake, []);
138+
} catch (x) {
139+
control = x;
140+
}
141+
Reflect.construct(fn, [], Fake);
142+
} else {
143+
try {
144+
Fake.call();
145+
} catch (x) {
146+
control = x;
147+
}
148+
// $FlowFixMe[prop-missing] found when upgrading Flow
149+
fn.call(Fake.prototype);
150+
}
151+
} else {
152+
try {
153+
throw Error();
154+
} catch (x) {
155+
control = x;
156+
}
157+
// TODO(luna): This will currently only throw if the function component
158+
// tries to access React/ReactDOM/props. We should probably make this throw
159+
// in simple components too
160+
const maybePromise = fn();
161+
162+
// If the function component returns a promise, it's likely an async
163+
// component, which we don't yet support. Attach a noop catch handler to
164+
// silence the error.
165+
// TODO: Implement component stacks for async client components?
166+
if (maybePromise && typeof maybePromise.catch === 'function') {
167+
maybePromise.catch(() => {});
168+
}
115169
}
116-
Reflect.construct(fn, [], Fake);
117-
} else {
118-
try {
119-
Fake.call();
120-
} catch (x) {
121-
control = x;
170+
} catch (sample) {
171+
// This is inlined manually because closure doesn't do it for us.
172+
if (sample && control && typeof sample.stack === 'string') {
173+
return [sample.stack, control.stack];
122174
}
123-
// $FlowFixMe[prop-missing] found when upgrading Flow
124-
fn.call(Fake.prototype);
125175
}
126-
} else {
127-
try {
128-
throw Error();
129-
} catch (x) {
130-
control = x;
131-
}
132-
// TODO(luna): This will currently only throw if the function component
133-
// tries to access React/ReactDOM/props. We should probably make this throw
134-
// in simple components too
135-
const maybePromise = fn();
176+
return [null, null];
177+
},
178+
};
179+
// $FlowFixMe[prop-missing]
180+
RunInRootFrame.DetermineComponentFrameRoot.displayName =
181+
'DetermineComponentFrameRoot';
182+
const namePropDescriptor = Object.getOwnPropertyDescriptor(
183+
RunInRootFrame.DetermineComponentFrameRoot,
184+
'name',
185+
);
186+
// Before ES6, the `name` property was not configurable.
187+
if (namePropDescriptor && namePropDescriptor.configurable) {
188+
// V8 utilizes a function's `name` property when generating a stack trace.
189+
Object.defineProperty(
190+
RunInRootFrame.DetermineComponentFrameRoot,
191+
// Configurable properties can be updated even if its writable descriptor
192+
// is set to `false`.
193+
// $FlowFixMe[cannot-write]
194+
'name',
195+
{value: 'DetermineComponentFrameRoot'},
196+
);
197+
}
136198

137-
// If the function component returns a promise, it's likely an async
138-
// component, which we don't yet support. Attach a noop catch handler to
139-
// silence the error.
140-
// TODO: Implement component stacks for async client components?
141-
if (maybePromise && typeof maybePromise.catch === 'function') {
142-
maybePromise.catch(() => {});
143-
}
144-
}
145-
} catch (sample) {
146-
// This is inlined manually because closure doesn't do it for us.
147-
if (sample && control && typeof sample.stack === 'string') {
199+
try {
200+
const [sampleStack, controlStack] =
201+
RunInRootFrame.DetermineComponentFrameRoot();
202+
if (sampleStack && controlStack) {
148203
// This extracts the first frame from the sample that isn't also in the control.
149204
// Skipping one frame that we assume is the frame that calls the two.
150-
const sampleLines = sample.stack.split('\n');
151-
const controlLines = control.stack.split('\n');
152-
let s = sampleLines.length - 1;
153-
let c = controlLines.length - 1;
154-
while (s >= 1 && c >= 0 && sampleLines[s] !== controlLines[c]) {
155-
// We expect at least one stack frame to be shared.
156-
// Typically this will be the root most one. However, stack frames may be
157-
// cut off due to maximum stack limits. In this case, one maybe cut off
158-
// earlier than the other. We assume that the sample is longer or the same
159-
// and there for cut off earlier. So we should find the root most frame in
160-
// the sample somewhere in the control.
161-
c--;
205+
const sampleLines = sampleStack.split('\n');
206+
const controlLines = controlStack.split('\n');
207+
let s = 0;
208+
let c = 0;
209+
while (
210+
s < sampleLines.length &&
211+
!sampleLines[s].includes('DetermineComponentFrameRoot')
212+
) {
213+
s++;
214+
}
215+
while (
216+
c < controlLines.length &&
217+
!controlLines[c].includes('DetermineComponentFrameRoot')
218+
) {
219+
c++;
220+
}
221+
// We couldn't find our intentionally injected common root frame, attempt
222+
// to find another common root frame by search from the bottom of the
223+
// control stack...
224+
if (s === sampleLines.length || c === controlLines.length) {
225+
s = sampleLines.length - 1;
226+
c = controlLines.length - 1;
227+
while (s >= 1 && c >= 0 && sampleLines[s] !== controlLines[c]) {
228+
// We expect at least one stack frame to be shared.
229+
// Typically this will be the root most one. However, stack frames may be
230+
// cut off due to maximum stack limits. In this case, one maybe cut off
231+
// earlier than the other. We assume that the sample is longer or the same
232+
// and there for cut off earlier. So we should find the root most frame in
233+
// the sample somewhere in the control.
234+
c--;
235+
}
162236
}
163237
for (; s >= 1 && c >= 0; s--, c--) {
164238
// Next we find the first one that isn't the same which should be the

0 commit comments

Comments
 (0)