Reverse engineer original stack frames when virtual frames are re-serialized (#30416)

Stacked on #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.
This commit is contained in:
Sebastian Markbåge 2024-07-22 18:50:14 -04:00 committed by GitHub
parent 43dac1ee8d
commit f83615ad30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 121 additions and 2 deletions

View File

@ -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' +
@ -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) {

View File

@ -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();