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

Have describeNativeComponentFrame account for truncation of error stack traces in the middle #26999

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 86 additions & 53 deletions packages/shared/ReactComponentStackFrame.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,59 +137,13 @@ export function describeNativeComponentFrame(
} catch (sample) {
// This is inlined manually because closure doesn't do it for us.
if (sample && control && typeof sample.stack === 'string') {
// This extracts the first frame from the sample that isn't also in the control.
// Skipping one frame that we assume is the frame that calls the two.
const sampleLines = sample.stack.split('\n');
const controlLines = control.stack.split('\n');
let s = sampleLines.length - 1;
let c = controlLines.length - 1;
while (s >= 1 && c >= 0 && sampleLines[s] !== controlLines[c]) {
// We expect at least one stack frame to be shared.
// Typically this will be the root most one. However, stack frames may be
// cut off due to maximum stack limits. In this case, one maybe cut off
// earlier than the other. We assume that the sample is longer or the same
// and there for cut off earlier. So we should find the root most frame in
// the sample somewhere in the control.
c--;
}
for (; s >= 1 && c >= 0; s--, c--) {
// Next we find the first one that isn't the same which should be the
// frame that called our sample function and the control.
if (sampleLines[s] !== controlLines[c]) {
// In V8, the first line is describing the message but other VMs don't.
// If we're about to return the first line, and the control is also on the same
// line, that's a pretty good indicator that our sample threw at same line as
// the control. I.e. before we entered the sample frame. So we ignore this result.
// This can happen if you passed a class to function component, or non-function.
if (s !== 1 || c !== 1) {
do {
s--;
c--;
// We may still have similar intermediate frames from the construct call.
// The next one that isn't the same should be our match though.
if (c < 0 || sampleLines[s] !== controlLines[c]) {
// V8 adds a "new" prefix for native classes. Let's remove it to make it prettier.
let frame = '\n' + sampleLines[s].replace(' at new ', ' at ');

// If our component frame is labeled "<anonymous>"
// but we have a user-provided "displayName"
// splice it in to make the stack more readable.
if (fn.displayName && frame.includes('<anonymous>')) {
frame = frame.replace('<anonymous>', fn.displayName);
}

if (__DEV__) {
if (typeof fn === 'function') {
componentFrameCache.set(fn, frame);
}
}
// Return the line we found.
return frame;
}
} while (s >= 1 && c >= 0);
}
break;
}
const frame = determineComponentFrameFromStack(
fn,
sample.stack,
control.stack,
);
if (frame) {
return frame;
}
}
} finally {
Expand All @@ -211,6 +165,85 @@ export function describeNativeComponentFrame(
return syntheticFrame;
}

// Used for cases where the Hermes VM truncates the stack trace in the middle.
// The RegExp is for matching the "... skipping " line here:
// https://github.com/facebook/hermes/blob/df07cf713a84a4434c83c08cede38ba438dc6aca/lib/VM/JSError.cpp#L694
const MIDDLE_TRUNCATION_REGEX = /\n\s+\.\.\.\sskipping/;

export function determineComponentFrameFromStack(
fn: Function,
sampleStack: string,
controlStack: string,
): ?string {
// Hermes VM currently truncates stack traces in the *middle* as opposed to
// the bottom, so we remove the "... skipping {x} lines" line and everything
// below it first, to mimick a scenario where the stack is truncated at the
// bottom.
const sampleStackMatch = MIDDLE_TRUNCATION_REGEX.exec(sampleStack);
if (sampleStackMatch != null) {
sampleStack = sampleStack.slice(0, sampleStackMatch.index);
}
const controlStackMatch = MIDDLE_TRUNCATION_REGEX.exec(controlStack);
if (controlStackMatch != null) {
controlStack = controlStack.slice(0, controlStackMatch.index);
}

// This extracts the first frame from the sample that isn't also in the control.
// Skipping one frame that we assume is the frame that calls the two.
const sampleLines = sampleStack.split('\n');
const controlLines = controlStack.split('\n');
let s = sampleLines.length - 1;
let c = controlLines.length - 1;
while (s >= 1 && c >= 0 && sampleLines[s] !== controlLines[c]) {
// We expect at least one stack frame to be shared.
// Typically this will be the root most one. However, stack frames may be
// cut off due to maximum stack limits. In this case, one maybe cut off
// earlier than the other. We assume that the sample is longer or the same
// and there for cut off earlier. So we should find the root most frame in
// the sample somewhere in the control.
c--;
}
for (; s >= 1 && c >= 0; s--, c--) {
// Next we find the first one that isn't the same which should be the
// frame that called our sample function and the control.
if (sampleLines[s] !== controlLines[c]) {
// In V8, the first line is describing the message but other VMs don't.
// If we're about to return the first line, and the control is also on the same
// line, that's a pretty good indicator that our sample threw at same line as
// the control. I.e. before we entered the sample frame. So we ignore this result.
// This can happen if you passed a class to function component, or non-function.
if (s !== 1 || c !== 1) {
do {
s--;
c--;
// We may still have similar intermediate frames from the construct call.
// The next one that isn't the same should be our match though.
if (c < 0 || sampleLines[s] !== controlLines[c]) {
// V8 adds a "new" prefix for native classes. Let's remove it to make it prettier.
let frame = '\n' + sampleLines[s].replace(' at new ', ' at ');

// If our component frame is labeled "<anonymous>"
// but we have a user-provided "displayName"
// splice it in to make the stack more readable.
if (fn.displayName && frame.includes('<anonymous>')) {
frame = frame.replace('<anonymous>', fn.displayName);
}

if (__DEV__) {
if (typeof fn === 'function') {
componentFrameCache.set(fn, frame);
}
}
// Return the line we found.
return frame;
}
} while (s >= 1 && c >= 0);
}
break;
}
}
}

const BEFORE_SLASH_RE = /^(.*)[\\\/]/;

function describeComponentFrame(
Expand Down
171 changes: 171 additions & 0 deletions packages/shared/__tests__/determineComponentFrameFromStack-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
*/

'use strict';

let determineComponentFrameFromStack;

describe('Determine component stack trace line', () => {
const initialStackTraceLimit = Error.stackTraceLimit;

beforeEach(() => {
// Reset V8's stack trace limit in-between tests
Error.stackTraceLimit = initialStackTraceLimit;

// Reset displayName property
delete TestComponent.displayName;

determineComponentFrameFromStack =
require('../ReactComponentStackFrame').determineComponentFrameFromStack;
});

function TestComponent() {
throw new Error('TestComponent called');
}

// Runs a function within nested `times` alternating function calls. This is
// done so we get longer, more interesting/differing traces between sample and
// control stack traces in our tests below.
function flipFlop(times: number, fn: Function): void {
function flip() {
if (times > 0) {
times--;
flop();
} else {
fn();
}
}
function flop() {
if (times > 0) {
times--;
flip();
} else {
fn();
}
}
}

it('should determine the component in a normal (i.e. not truncated) stack trace', () => {
let controlStack;
try {
throw new Error();
} catch (controlError) {
controlStack = controlError.stack;
}
try {
TestComponent();
} catch (sampleError) {
expect(
determineComponentFrameFromStack(
TestComponent,
sampleError.stack,
controlStack,
),
).toMatch('at TestComponent (');
}
});

it('replaces <anonymous> labels with the component displayName', () => {
let controlStack;
try {
throw new Error();
} catch (controlError) {
controlStack = controlError.stack;
}
try {
TestComponent();
} catch (sampleError) {
const sampleStack = sampleError.stack.replace(
'at TestComponent (',
'at <anonymous> (',
);
// If displayName is not set, then nothing should be modified
expect(
determineComponentFrameFromStack(
TestComponent,
sampleStack,
controlStack,
),
).toMatch('at <anonymous> (');

// Setting a displayName property should replace <anonymous>
TestComponent.displayName = 'TestComponent';
expect(
determineComponentFrameFromStack(
TestComponent,
sampleStack,
controlStack,
),
).toMatch('at TestComponent (');
}
});

it('should determine the component in a bottom-truncated stack trace', () => {
Error.stackTraceLimit = 10;
flipFlop(20, () => {
let controlStack;
try {
throw new Error();
} catch (controlError) {
controlStack = controlError.stack;
// Ensure V8 is actually truncating traces. The `+1` here is for the
// line containing the error message.
expect(controlStack.split('\n')).toHaveLength(
Error.stackTraceLimit + 1,
);
}
try {
TestComponent();
} catch (sampleError) {
expect(
determineComponentFrameFromStack(
TestComponent,
sampleError.stack,
controlStack,
),
).toMatch('at TestComponent (');
}
});
});

it('should determine the component in a middle-truncated stack trace', () => {
function truncateTraceFromMiddle(trace: string, numLines: number): string {
const lines = trace.split('\n');
const partLines = Math.floor(numLines / 2);
const first = lines.slice(0, partLines);
first.push(`\n ... skipping ${numLines} frames`);
const last = lines.slice(-partLines);
return first.concat(last).join('\n');
}

flipFlop(20, () => {
let controlStack;
try {
throw new Error();
} catch (controlError) {
// Sanity check.
expect(controlError.stack.split('\n').length > 10).toEqual(true);
controlStack = truncateTraceFromMiddle(controlError.stack, 10);
}
try {
TestComponent();
} catch (sampleError) {
// Sanity check.
expect(sampleError.stack.split('\n').length > 10).toEqual(true);
expect(
determineComponentFrameFromStack(
TestComponent,
truncateTraceFromMiddle(sampleError.stack, 10),
controlStack,
),
).toMatch('at TestComponent (');
}
});
});
});