[Flight] Enable owner stacks on the client when replaying logs (#30473)
There's a special case that happens when we replay logs on the client because this doesn't happen within the context of any particular rendered component. So we need to reimplement things that would normally be handled by a full client like Fiber. The implementation of `getOwnerStackByComponentInfoInDev` is the simplest version since it doesn't have any client components in it so I move it to `shared/`. It's only used by Flight but both `react-server/` and `react-client/` packages. The ReactComponentInfo type is also more generic than just Flight anyway. In a follow up I still need to implement this in React DevTools when native tasks are not available so that it appends it to the console.
Sebastian Markbåge committed
Jul 31, 2024 at 07:56 UTC
12e957909948483d0eef83d1ffb2255946d0e4b0
4 files changed
+136
-57
packages/react-client/src/ReactFlightClient.js
+64
-25
@@ -73,8 +73,21 @@ import {
73
74
import getComponentNameFromType from 'shared/getComponentNameFromType';
75
76
+import {getOwnerStackByComponentInfoInDev} from 'shared/ReactComponentInfoStack';
77
+
78
import isArray from 'shared/isArray';
79
80
+import * as React from 'react';
81
+
82
+// TODO: This is an unfortunate hack. We shouldn't feature detect the internals
83
+// like this. It's just that for now we support the same build of the Flight
84
+// client both in the RSC environment, in the SSR environments as well as the
85
+// browser client. We should probably have a separate RSC build. This is DEV
86
+// only though.
87
+const ReactSharedInternals =
88
+ React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ||
89
+ React.__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
90
+
91
export type {CallServerCallback, EncodeFormActionCallback};
92
93
interface FlightStreamController {
@@ -2296,6 +2309,22 @@ function resolveDebugInfo(
2309
chunkDebugInfo.push(debugInfo);
2310
}
2311
2312
+let currentOwnerInDEV: null | ReactComponentInfo = null;
2313
+function getCurrentStackInDEV(): string {
2314
+ if (__DEV__) {
2315
+ if (enableOwnerStacks) {
2316
+ const owner: null | ReactComponentInfo = currentOwnerInDEV;
2317
+ if (owner === null) {
2318
+ return '';
2319
+ }
2320
+ return getOwnerStackByComponentInfoInDev(owner);
2321
+ }
2322
+ // We don't have Parent Stacks in Flight.
2323
+ return '';
2324
+ }
2325
+ return '';
2326
+}
2327
+
2328
function resolveConsoleEntry(
2329
response: Response,
2330
value: UninitializedModel,
@@ -2324,34 +2353,44 @@ function resolveConsoleEntry(
2353
const owner = payload[2];
2354
const env = payload[3];
2355
const args = payload.slice(4);
2327
- if (!enableOwnerStacks) {
2328
- // Printing with stack isn't really limited to owner stacks but
2329
- // we gate it behind the same flag for now while iterating.
2330
- bindToConsole(methodName, args, env)();
2331
- return;
2332
- }
2333
- const callStack = buildFakeCallStack(
2334
- response,
2335
- stackTrace,
2336
- env,
2337
- bindToConsole(methodName, args, env),
2338
- );
2339
- if (owner != null) {
2340
- const task = initializeFakeTask(response, owner, env);
2341
- initializeFakeStack(response, owner);
2342
- if (task !== null) {
2343
- task.run(callStack);
2356
+
2357
+ // There really shouldn't be anything else on the stack atm.
2358
+ const prevStack = ReactSharedInternals.getCurrentStack;
2359
+ ReactSharedInternals.getCurrentStack = getCurrentStackInDEV;
2360
+ currentOwnerInDEV = owner;
2361
+
2362
+ try {
2363
+ if (!enableOwnerStacks) {
2364
+ // Printing with stack isn't really limited to owner stacks but
2365
+ // we gate it behind the same flag for now while iterating.
2366
+ bindToConsole(methodName, args, env)();
2367
return;
2368
}
2346
- // TODO: Set the current owner so that captureOwnerStack() adds the component
2347
- // stack during the replay - if needed.
2348
- }
2349
- const rootTask = getRootTask(response, env);
2350
- if (rootTask != null) {
2351
- rootTask.run(callStack);
2352
- return;
2369
+ const callStack = buildFakeCallStack(
2370
+ response,
2371
+ stackTrace,
2372
+ env,
2373
+ bindToConsole(methodName, args, env),
2374
+ );
2375
+ if (owner != null) {
2376
+ const task = initializeFakeTask(response, owner, env);
2377
+ initializeFakeStack(response, owner);
2378
+ if (task !== null) {
2379
+ task.run(callStack);
2380
+ return;
2381
+ }
2382
+ // TODO: Set the current owner so that captureOwnerStack() adds the component
2383
+ // stack during the replay - if needed.
2384
+ }
2385
+ const rootTask = getRootTask(response, env);
2386
+ if (rootTask != null) {
2387
+ rootTask.run(callStack);
2388
+ return;
2389
+ }
2390
+ callStack();
2391
+ } finally {
2392
+ ReactSharedInternals.getCurrentStack = prevStack;
2393
}
2354
- callStack();
2394
}
2395
2396
function mergeBuffer(
packages/react-client/src/__tests__/ReactFlight-test.js
+32
-15
@@ -2918,7 +2918,7 @@ describe('ReactFlight', () => {
2918
expect(ReactNoop).toMatchRenderedOutput(<div>hi</div>);
2919
});
2920
2921
- // @gate enableServerComponentLogs && __DEV__
2921
+ // @gate enableServerComponentLogs && __DEV__ && enableOwnerStacks
2922
it('replays logs, but not onError logs', async () => {
2923
function foo() {
2924
return 'hello';
@@ -2928,12 +2928,21 @@ describe('ReactFlight', () => {
2928
throw new Error('err');
2929
}
2930
2931
+ function App() {
2932
+ return ReactServer.createElement(ServerComponent);
2933
+ }
2934
+
2935
+ let ownerStacks = [];
2936
+
2937
// These tests are specifically testing console.log.
2938
// Assign to `mockConsoleLog` so we can still inspect it when `console.log`
2939
// is overridden by the test modules. The original function will be restored
2940
// after this test finishes by `jest.restoreAllMocks()`.
2941
const mockConsoleLog = spyOnDevAndProd(console, 'log').mockImplementation(
2936
- () => {},
2942
+ () => {
2943
+ // Uses server React.
2944
+ ownerStacks.push(normalizeCodeLocInfo(ReactServer.captureOwnerStack()));
2945
+ },
2946
);
2947
2948
let transport;
@@ -2946,14 +2955,20 @@ describe('ReactFlight', () => {
2955
ReactServer = require('react');
2956
ReactNoopFlightServer = require('react-noop-renderer/flight-server');
2957
transport = ReactNoopFlightServer.render({
2949
- root: ReactServer.createElement(ServerComponent),
2958
+ root: ReactServer.createElement(App),
2959
});
2960
}).toErrorDev('err');
2961
2962
expect(mockConsoleLog).toHaveBeenCalledTimes(1);
2963
expect(mockConsoleLog.mock.calls[0][0]).toBe('hi');
2964
expect(mockConsoleLog.mock.calls[0][1].prop).toBe(123);
2965
+ expect(ownerStacks).toEqual(['\n in App (at **)']);
2966
mockConsoleLog.mockClear();
2967
+ mockConsoleLog.mockImplementation(() => {
2968
+ // Switching to client React.
2969
+ ownerStacks.push(normalizeCodeLocInfo(React.captureOwnerStack()));
2970
+ });
2971
+ ownerStacks = [];
2972
2973
// The error should not actually get logged because we're not awaiting the root
2974
// so it's not thrown but the server log also shouldn't be replayed.
@@ -2973,6 +2988,8 @@ describe('ReactFlight', () => {
2988
expect(typeof loggedFn2).toBe('function');
2989
expect(loggedFn2).not.toBe(foo);
2990
expect(loggedFn2.toString()).toBe(foo.toString());
2991
+
2992
+ expect(ownerStacks).toEqual(['\n in App (at **)']);
2993
});
2994
2995
it('uses the server component debug info as the element owner in DEV', async () => {
@@ -3159,18 +3176,18 @@ describe('ReactFlight', () => {
3176
jest.resetModules();
3177
jest.mock('react', () => React);
3178
ReactNoopFlightClient.read(transport);
3162
- assertConsoleErrorDev(
3163
- [
3164
- 'Each child in a list should have a unique "key" prop.' +
3165
- ' See https://react.dev/link/warning-keys for more information.',
3166
- 'Error objects cannot be rendered as text children. Try formatting it using toString().\n' +
3167
- ' <div>Womp womp: {Error}</div>\n' +
3168
- ' ^^^^^^^',
3169
- ],
3170
- // We should have a stack in the replay but we don't yet set the owner from the Flight replaying
3171
- // so our simulated polyfill doesn't end up getting any component stacks yet.
3172
- {withoutStack: true},
3173
- );
3179
+ assertConsoleErrorDev([
3180
+ 'Each child in a list should have a unique "key" prop.' +
3181
+ ' See https://react.dev/link/warning-keys for more information.\n' +
3182
+ ' in Bar (at **)\n' +
3183
+ ' in App (at **)',
3184
+ 'Error objects cannot be rendered as text children. Try formatting it using toString().\n' +
3185
+ ' <div>Womp womp: {Error}</div>\n' +
3186
+ ' ^^^^^^^\n' +
3187
+ ' in Foo (at **)\n' +
3188
+ ' in Bar (at **)\n' +
3189
+ ' in App (at **)',
3190
+ ]);
3191
});
3192
3193
it('can filter out stack frames of a serialized error in dev', async () => {
packages/react-server/src/ReactFlightServer.js
+40
-17
@@ -99,7 +99,7 @@ import {DefaultAsyncDispatcher} from './flight/ReactFlightAsyncDispatcher';
99
100
import {resolveOwner, setCurrentOwner} from './flight/ReactFlightCurrentOwner';
101
102
-import {getOwnerStackByComponentInfoInDev} from './flight/ReactFlightComponentStack';
102
+import {getOwnerStackByComponentInfoInDev} from 'shared/ReactComponentInfoStack';
103
104
import {
105
callComponentInDEV,
@@ -968,6 +968,7 @@ function callWithDebugContextInDEV<A, T>(
968
// a fake owner during this callback so we can get the stack trace from it.
969
// This also gets sent to the client as the owner for the replaying log.
970
const componentDebugInfo: ReactComponentInfo = {
971
+ name: '',
972
env: task.environmentName,
973
owner: task.debugOwner,
974
};
@@ -2063,6 +2064,23 @@ function escapeStringValue(value: string): string {
2064
}
2065
}
2066
2067
+function isReactComponentInfo(value: any): boolean {
2068
+ // TODO: We don't currently have a brand check on ReactComponentInfo. Reconsider.
2069
+ return (
2070
+ ((typeof value.debugTask === 'object' &&
2071
+ value.debugTask !== null &&
2072
+ // $FlowFixMe[method-unbinding]
2073
+ typeof value.debugTask.run === 'function') ||
2074
+ value.debugStack instanceof Error) &&
2075
+ (enableOwnerStacks
2076
+ ? isArray((value: any).stack)
2077
+ : typeof (value: any).stack === 'undefined') &&
2078
+ typeof value.name === 'string' &&
2079
+ typeof value.env === 'string' &&
2080
+ value.owner !== undefined
2081
+ );
2082
+}
2083
+
2084
let modelRoot: null | ReactClientValue = false;
2085
2086
function renderModel(
@@ -2574,28 +2592,15 @@ function renderModelDestructive(
2592
);
2593
}
2594
if (__DEV__) {
2577
- if (
2578
- // TODO: We don't currently have a brand check on ReactComponentInfo. Reconsider.
2579
- ((typeof value.debugTask === 'object' &&
2580
- value.debugTask !== null &&
2581
- // $FlowFixMe[method-unbinding]
2582
- typeof value.debugTask.run === 'function') ||
2583
- value.debugStack instanceof Error) &&
2584
- (enableOwnerStacks
2585
- ? isArray((value: any).stack)
2586
- : typeof (value: any).stack === 'undefined') &&
2587
- typeof value.name === 'string' &&
2588
- typeof value.env === 'string' &&
2589
- value.owner !== undefined
2590
- ) {
2595
+ if (isReactComponentInfo(value)) {
2596
// This looks like a ReactComponentInfo. We can't serialize the ConsoleTask object so we
2597
// need to omit it before serializing.
2598
const componentDebugInfo: Omit<
2599
ReactComponentInfo,
2600
'debugTask' | 'debugStack',
2601
> = {
2597
- name: value.name,
2598
- env: value.env,
2602
+ name: (value: any).name,
2603
+ env: (value: any).env,
2604
owner: (value: any).owner,
2605
};
2606
if (enableOwnerStacks) {
@@ -3259,6 +3264,24 @@ function renderConsoleValue(
3264
return Array.from((value: any));
3265
}
3266
3267
+ if (isReactComponentInfo(value)) {
3268
+ // This looks like a ReactComponentInfo. We can't serialize the ConsoleTask object so we
3269
+ // need to omit it before serializing.
3270
+ const componentDebugInfo: Omit<
3271
+ ReactComponentInfo,
3272
+ 'debugTask' | 'debugStack',
3273
+ > = {
3274
+ name: (value: any).name,
3275
+ env: (value: any).env,
3276
+ owner: (value: any).owner,
3277
+ };
3278
+ if (enableOwnerStacks) {
3279
+ // $FlowFixMe[cannot-write]
3280
+ componentDebugInfo.stack = (value: any).stack;
3281
+ }
3282
+ return componentDebugInfo;
3283
+ }
3284
+
3285
// $FlowFixMe[incompatible-return]
3286
return value;
3287
}