@samitouri / QOS-React / commits / 12e9579099

[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 }
packages/shared/ReactComponentInfoStack.js renamed