Skip to content

Commit

Permalink
Reverse engineer original stack frames when virtual frames are re-se…
Browse files Browse the repository at this point in the history
…rialized (facebook#30416)

Stacked on facebook#30410.

If we've parsed another RSC stream on the server from a different RSC
server, while using `findSourceMapURL`, the Flight Client ends up adding
a `rsc://React/` prefix and a numeric suffix to the URL. It's a virtual
file that represents the virtual eval:ed frame in that environment.

If we then see that same stack again, we'd serialize a virtual frame to
another virtual. Meaning `findSourceMapURL` on the client would see the
virtual frame of the intermediate server and it would have to strip it
to figure out what source map to use.

This PR strips it in the Server if we see a virtual frame. At each new
client it always refers to the original stack.

We don't have to do this. We could leave it to each `findSourceMapURL`
implementation and `captureOwnerStack` parser to recursively strip each
layer. It could maybe be useful to have the environment name in the
virtual frame to know which server to look for the source map in.
  • Loading branch information
sebmarkbage committed Jul 22, 2024
1 parent 43dac1e commit f83615a
Show file tree
Hide file tree
Showing 2 changed files with 121 additions and 2 deletions.
107 changes: 106 additions & 1 deletion packages/react-client/src/__tests__/ReactFlight-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,14 @@ describe('ReactFlight', () => {
this.props.expectedMessage,
);
expect(this.state.error.digest).toBe('a dev digest');
expect(this.state.error.environmentName).toBe('Server');
expect(this.state.error.environmentName).toBe(
this.props.expectedEnviromentName || 'Server',
);
if (this.props.expectedErrorStack !== undefined) {
expect(this.state.error.stack).toContain(
this.props.expectedErrorStack,
);
}
} else {
expect(this.state.error.message).toBe(
'An error occurred in the Server Components render. The specific message is omitted in production' +
Expand Down Expand Up @@ -2603,6 +2610,104 @@ describe('ReactFlight', () => {
);
});

it('preserves error stacks passed through server-to-server with source maps', async () => {
async function ServerComponent({transport}) {
// This is a Server Component that receives other Server Components from a third party.
const thirdParty = ReactServer.use(
ReactNoopFlightClient.read(transport, {
findSourceMapURL(url) {
// By giving a source map url we're saying that we can't use the original
// file as the sourceURL, which gives stack traces a rsc://React/ prefix.
return 'source-map://' + url;
},
}),
);
// This will throw a third-party error inside the first-party server component.
await thirdParty.model;
return 'Should never render';
}

async function bar() {
throw new Error('third-party-error');
}

async function foo() {
await bar();
}

const rejectedPromise = foo();

const thirdPartyTransport = ReactNoopFlightServer.render(
{model: rejectedPromise},
{
environmentName: 'third-party',
onError(x) {
if (__DEV__) {
return 'a dev digest';
}
return `digest("${x.message}")`;
},
},
);

let originalError;
try {
await rejectedPromise;
} catch (x) {
originalError = x;
}
expect(originalError.message).toBe('third-party-error');

const transport = ReactNoopFlightServer.render(
<ServerComponent transport={thirdPartyTransport} />,
{
onError(x) {
if (__DEV__) {
return 'a dev digest';
}
return x.digest; // passthrough
},
},
);

await 0;
await 0;
await 0;

const expectedErrorStack = originalError.stack
// Test only the first rows since there's a lot of noise after that is eliminated.
.split('\n')
.slice(0, 4)
.join('\n')
.replaceAll(
' (/',
gate(flags => flags.enableOwnerStacks) ? ' (file:///' : ' (/',
); // The eval will end up normalizing these

let sawReactPrefix = false;
await act(async () => {
ReactNoop.render(
<ErrorBoundary
expectedMessage="third-party-error"
expectedEnviromentName="third-party"
expectedErrorStack={expectedErrorStack}>
{ReactNoopFlightClient.read(transport, {
findSourceMapURL(url) {
if (url.startsWith('rsc://React/')) {
// We don't expect to see any React prefixed URLs here.
sawReactPrefix = true;
}
// My not giving a source map, we should leave it intact.
return null;
},
})}
</ErrorBoundary>,
);
});

expect(sawReactPrefix).toBe(false);
});

it('can change the environment name inside a component', async () => {
let env = 'A';
function Component(props) {
Expand Down
16 changes: 15 additions & 1 deletion packages/react-server/src/ReactFlightServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,21 @@ function filterStackTrace(error: Error, skipFrames: number): ReactStackTrace {
// to save bandwidth even in DEV. We'll also replay these stacks on the client so by
// stripping them early we avoid that overhead. Otherwise we'd normally just rely on
// the DevTools or framework's ignore lists to filter them out.
return parseStackTrace(error, skipFrames).filter(isNotExternal);
const stack = parseStackTrace(error, skipFrames).filter(isNotExternal);
for (let i = 0; i < stack.length; i++) {
const callsite = stack[i];
const url = callsite[1];
if (url.startsWith('rsc://React/')) {
// This callsite is a virtual fake callsite that came from another Flight client.
// We need to reverse it back into the original location by stripping its prefix
// and suffix.
const suffixIdx = url.lastIndexOf('?');
if (suffixIdx > -1) {
callsite[1] = url.slice(12, suffixIdx);
}
}
}
return stack;
}

initAsyncDebugInfo();
Expand Down

0 comments on commit f83615a

Please sign in to comment.