Skip to content

Commit 151cce3

Browse files
authored
Track Stack of JSX Calls (#29032)
This is the first step to experimenting with a new type of stack traces behind the `enableOwnerStacks` flag - in DEV only. The idea is to generate stacks that are more like if the JSX was a direct call even though it's actually a lazy call. Not only can you see which exact JSX call line number generated the erroring component but if that's inside an abstraction function, which function called that function and if it's a component, which component generated that component. For this to make sense it really need to be the "owner" stack rather than the parent stack like we do for other component stacks. On one hand it has more precise information but on the other hand it also loses context. For most types of problems the owner stack is the most useful though since it tells you which component rendered this component. The problem with the platform in its current state is that there's two ways to deal with stacks: 1) `new Error().stack` 2) `console.createTask()` The nice thing about `new Error().stack` is that we can extract the frames and piece them together in whatever way we want. That is great for constructing custom UIs like error dialogs. Unfortunately, we can't take custom stacks and set them in the native UIs like Chrome DevTools. The nice thing about `console.createTask()` is that the resulting stacks are natively integrated into the Chrome DevTools in the console and the breakpoint debugger. They also automatically follow source mapping and ignoreLists. The downside is that there's no way to extract the async stack outside the native UI itself so this information cannot be used for custom UIs like errors dialogs. It also means we can't collect this on the server and then pass it to the client for server components. The solution here is that we use both techniques and collect both an `Error` object and a `Task` object for every JSX call. The main concern about this approach is the performance so that's the main thing to test. It's certainly too slow for production but it might also be too slow even for DEV. This first PR doesn't actually use the stacks yet. It just collects them as the first step. The next step is to start utilizing this information in error printing etc. For RSC we pass the stack along across over the wire. This can be concatenated on the client following the owner path to create an owner stack leading back into the server. We'll later use this information to restore fake frames on the client for native integration. Since this information quickly gets pretty heavy if we include all frames, we strip out the top frame. We also strip out everything below the functions that call into user space in the Flight runtime. To do this we need to figure out the frames that represents calling out into user space. The resulting stack is typically just the one frame inside the owner component's JSX callsite. I also eagerly strip out things we expect to be ignoreList:ed anyway - such as `node_modules` and Node.js internals.
1 parent 04b0588 commit 151cce3

17 files changed

+483
-70
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
enablePostpone,
4444
enableRefAsProp,
4545
enableFlightReadableStream,
46+
enableOwnerStacks,
4647
} from 'shared/ReactFeatureFlags';
4748

4849
import {
@@ -563,6 +564,7 @@ function createElement(
563564
key: mixed,
564565
props: mixed,
565566
owner: null | ReactComponentInfo, // DEV-only
567+
stack: null | string, // DEV-only
566568
): React$Element<any> {
567569
let element: any;
568570
if (__DEV__ && enableRefAsProp) {
@@ -623,6 +625,23 @@ function createElement(
623625
writable: true,
624626
value: null,
625627
});
628+
if (enableOwnerStacks) {
629+
Object.defineProperty(element, '_debugStack', {
630+
configurable: false,
631+
enumerable: false,
632+
writable: true,
633+
value: {stack: stack},
634+
});
635+
Object.defineProperty(element, '_debugTask', {
636+
configurable: false,
637+
enumerable: false,
638+
writable: true,
639+
value: null,
640+
});
641+
}
642+
// TODO: We should be freezing the element but currently, we might write into
643+
// _debugInfo later. We could move it into _store which remains mutable.
644+
Object.freeze(element.props);
626645
}
627646
return element;
628647
}
@@ -1003,6 +1022,7 @@ function parseModelTuple(
10031022
tuple[2],
10041023
tuple[3],
10051024
__DEV__ ? (tuple: any)[4] : null,
1025+
__DEV__ && enableOwnerStacks ? (tuple: any)[5] : null,
10061026
);
10071027
}
10081028
return value;

packages/react-client/src/__tests__/ReactFlight-test.js

Lines changed: 127 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,24 @@ if (typeof File === 'undefined' || typeof FormData === 'undefined') {
2121
function normalizeCodeLocInfo(str) {
2222
return (
2323
str &&
24-
str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) {
25-
return '\n in ' + name + (/\d/.test(m) ? ' (at **)' : '');
24+
str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) {
25+
return ' in ' + name + (/\d/.test(m) ? ' (at **)' : '');
2626
})
2727
);
2828
}
2929

30+
function getDebugInfo(obj) {
31+
const debugInfo = obj._debugInfo;
32+
if (debugInfo) {
33+
for (let i = 0; i < debugInfo.length; i++) {
34+
if (typeof debugInfo[i].stack === 'string') {
35+
debugInfo[i].stack = normalizeCodeLocInfo(debugInfo[i].stack);
36+
}
37+
}
38+
}
39+
return debugInfo;
40+
}
41+
3042
const heldValues = [];
3143
let finalizationCallback;
3244
function FinalizationRegistryMock(callback) {
@@ -221,8 +233,19 @@ describe('ReactFlight', () => {
221233
await act(async () => {
222234
const rootModel = await ReactNoopFlightClient.read(transport);
223235
const greeting = rootModel.greeting;
224-
expect(greeting._debugInfo).toEqual(
225-
__DEV__ ? [{name: 'Greeting', env: 'Server', owner: null}] : undefined,
236+
expect(getDebugInfo(greeting)).toEqual(
237+
__DEV__
238+
? [
239+
{
240+
name: 'Greeting',
241+
env: 'Server',
242+
owner: null,
243+
stack: gate(flag => flag.enableOwnerStacks)
244+
? ' in Object.<anonymous> (at **)'
245+
: undefined,
246+
},
247+
]
248+
: undefined,
226249
);
227250
ReactNoop.render(greeting);
228251
});
@@ -248,8 +271,19 @@ describe('ReactFlight', () => {
248271

249272
await act(async () => {
250273
const promise = ReactNoopFlightClient.read(transport);
251-
expect(promise._debugInfo).toEqual(
252-
__DEV__ ? [{name: 'Greeting', env: 'Server', owner: null}] : undefined,
274+
expect(getDebugInfo(promise)).toEqual(
275+
__DEV__
276+
? [
277+
{
278+
name: 'Greeting',
279+
env: 'Server',
280+
owner: null,
281+
stack: gate(flag => flag.enableOwnerStacks)
282+
? ' in Object.<anonymous> (at **)'
283+
: undefined,
284+
},
285+
]
286+
: undefined,
253287
);
254288
ReactNoop.render(await promise);
255289
});
@@ -2233,9 +2267,11 @@ describe('ReactFlight', () => {
22332267
return <span>!</span>;
22342268
}
22352269

2236-
const lazy = React.lazy(async () => ({
2237-
default: <ThirdPartyLazyComponent />,
2238-
}));
2270+
const lazy = React.lazy(async function myLazy() {
2271+
return {
2272+
default: <ThirdPartyLazyComponent />,
2273+
};
2274+
});
22392275

22402276
function ThirdPartyComponent() {
22412277
return <span>stranger</span>;
@@ -2269,31 +2305,61 @@ describe('ReactFlight', () => {
22692305

22702306
await act(async () => {
22712307
const promise = ReactNoopFlightClient.read(transport);
2272-
expect(promise._debugInfo).toEqual(
2308+
expect(getDebugInfo(promise)).toEqual(
22732309
__DEV__
2274-
? [{name: 'ServerComponent', env: 'Server', owner: null}]
2310+
? [
2311+
{
2312+
name: 'ServerComponent',
2313+
env: 'Server',
2314+
owner: null,
2315+
stack: gate(flag => flag.enableOwnerStacks)
2316+
? ' in Object.<anonymous> (at **)'
2317+
: undefined,
2318+
},
2319+
]
22752320
: undefined,
22762321
);
22772322
const result = await promise;
22782323
const thirdPartyChildren = await result.props.children[1];
22792324
// We expect the debug info to be transferred from the inner stream to the outer.
2280-
expect(thirdPartyChildren[0]._debugInfo).toEqual(
2325+
expect(getDebugInfo(thirdPartyChildren[0])).toEqual(
22812326
__DEV__
2282-
? [{name: 'ThirdPartyComponent', env: 'third-party', owner: null}]
2327+
? [
2328+
{
2329+
name: 'ThirdPartyComponent',
2330+
env: 'third-party',
2331+
owner: null,
2332+
stack: gate(flag => flag.enableOwnerStacks)
2333+
? ' in Object.<anonymous> (at **)'
2334+
: undefined,
2335+
},
2336+
]
22832337
: undefined,
22842338
);
2285-
expect(thirdPartyChildren[1]._debugInfo).toEqual(
2339+
expect(getDebugInfo(thirdPartyChildren[1])).toEqual(
22862340
__DEV__
2287-
? [{name: 'ThirdPartyLazyComponent', env: 'third-party', owner: null}]
2341+
? [
2342+
{
2343+
name: 'ThirdPartyLazyComponent',
2344+
env: 'third-party',
2345+
owner: null,
2346+
stack: gate(flag => flag.enableOwnerStacks)
2347+
? ' in myLazy (at **)\n in lazyInitializer (at **)'
2348+
: undefined,
2349+
},
2350+
]
22882351
: undefined,
22892352
);
2290-
expect(thirdPartyChildren[2]._debugInfo).toEqual(
2353+
expect(getDebugInfo(thirdPartyChildren[2])).toEqual(
22912354
__DEV__
22922355
? [
22932356
{
22942357
name: 'ThirdPartyFragmentComponent',
22952358
env: 'third-party',
22962359
owner: null,
2360+
stack: gate(flag => flag.enableOwnerStacks)
2361+
? ' in Object.<anonymous> (at **)'
2362+
: undefined,
22972363
},
22982364
]
22992365
: undefined,
@@ -2357,24 +2423,47 @@ describe('ReactFlight', () => {
23572423

23582424
await act(async () => {
23592425
const promise = ReactNoopFlightClient.read(transport);
2360-
expect(promise._debugInfo).toEqual(
2426+
expect(getDebugInfo(promise)).toEqual(
23612427
__DEV__
2362-
? [{name: 'ServerComponent', env: 'Server', owner: null}]
2428+
? [
2429+
{
2430+
name: 'ServerComponent',
2431+
env: 'Server',
2432+
owner: null,
2433+
stack: gate(flag => flag.enableOwnerStacks)
2434+
? ' in Object.<anonymous> (at **)'
2435+
: undefined,
2436+
},
2437+
]
23632438
: undefined,
23642439
);
23652440
const result = await promise;
23662441
const thirdPartyFragment = await result.props.children;
2367-
expect(thirdPartyFragment._debugInfo).toEqual(
2368-
__DEV__ ? [{name: 'Keyed', env: 'Server', owner: null}] : undefined,
2442+
expect(getDebugInfo(thirdPartyFragment)).toEqual(
2443+
__DEV__
2444+
? [
2445+
{
2446+
name: 'Keyed',
2447+
env: 'Server',
2448+
owner: null,
2449+
stack: gate(flag => flag.enableOwnerStacks)
2450+
? ' in ServerComponent (at **)'
2451+
: undefined,
2452+
},
2453+
]
2454+
: undefined,
23692455
);
23702456
// We expect the debug info to be transferred from the inner stream to the outer.
2371-
expect(thirdPartyFragment.props.children._debugInfo).toEqual(
2457+
expect(getDebugInfo(thirdPartyFragment.props.children)).toEqual(
23722458
__DEV__
23732459
? [
23742460
{
23752461
name: 'ThirdPartyAsyncIterableComponent',
23762462
env: 'third-party',
23772463
owner: null,
2464+
stack: gate(flag => flag.enableOwnerStacks)
2465+
? ' in Object.<anonymous> (at **)'
2466+
: undefined,
23782467
},
23792468
]
23802469
: undefined,
@@ -2467,10 +2556,24 @@ describe('ReactFlight', () => {
24672556
// We've rendered down to the span.
24682557
expect(greeting.type).toBe('span');
24692558
if (__DEV__) {
2470-
const greetInfo = {name: 'Greeting', env: 'Server', owner: null};
2471-
expect(greeting._debugInfo).toEqual([
2559+
const greetInfo = {
2560+
name: 'Greeting',
2561+
env: 'Server',
2562+
owner: null,
2563+
stack: gate(flag => flag.enableOwnerStacks)
2564+
? ' in Object.<anonymous> (at **)'
2565+
: undefined,
2566+
};
2567+
expect(getDebugInfo(greeting)).toEqual([
24722568
greetInfo,
2473-
{name: 'Container', env: 'Server', owner: greetInfo},
2569+
{
2570+
name: 'Container',
2571+
env: 'Server',
2572+
owner: greetInfo,
2573+
stack: gate(flag => flag.enableOwnerStacks)
2574+
? ' in Greeting (at **)'
2575+
: undefined,
2576+
},
24742577
]);
24752578
// The owner that created the span was the outer server component.
24762579
// We expect the debug info to be referentially equal to the owner.

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ describe('ReactFlightDOMEdge', () => {
263263

264264
const serializedContent = await readResult(stream1);
265265

266-
expect(serializedContent.length).toBeLessThan(400);
266+
expect(serializedContent.length).toBeLessThan(410);
267267
expect(timesRendered).toBeLessThan(5);
268268

269269
const model = await ReactServerDOMClient.createFromReadableStream(stream2, {
@@ -296,7 +296,7 @@ describe('ReactFlightDOMEdge', () => {
296296
const [stream1, stream2] = passThrough(stream).tee();
297297

298298
const serializedContent = await readResult(stream1);
299-
expect(serializedContent.length).toBeLessThan(400);
299+
expect(serializedContent.length).toBeLessThan(__DEV__ ? 590 : 400);
300300
expect(timesRendered).toBeLessThan(5);
301301

302302
const model = await ReactServerDOMClient.createFromReadableStream(stream2, {
@@ -324,7 +324,7 @@ describe('ReactFlightDOMEdge', () => {
324324
<ServerComponent recurse={20} />,
325325
);
326326
const serializedContent = await readResult(stream);
327-
const expectedDebugInfoSize = __DEV__ ? 64 * 20 : 0;
327+
const expectedDebugInfoSize = __DEV__ ? 300 * 20 : 0;
328328
expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize);
329329
});
330330

@@ -742,10 +742,18 @@ describe('ReactFlightDOMEdge', () => {
742742
// We've rendered down to the span.
743743
expect(greeting.type).toBe('span');
744744
if (__DEV__) {
745-
const greetInfo = {name: 'Greeting', env: 'Server', owner: null};
745+
const greetInfo = expect.objectContaining({
746+
name: 'Greeting',
747+
env: 'Server',
748+
owner: null,
749+
});
746750
expect(lazyWrapper._debugInfo).toEqual([
747751
greetInfo,
748-
{name: 'Container', env: 'Server', owner: greetInfo},
752+
expect.objectContaining({
753+
name: 'Container',
754+
env: 'Server',
755+
owner: greetInfo,
756+
}),
749757
]);
750758
// The owner that created the span was the outer server component.
751759
// We expect the debug info to be referentially equal to the owner.

0 commit comments

Comments
 (0)