Skip to content

Commit 65c5048

Browse files
committed
Enable owner stacks on the client when replaying logs
1 parent e8df0cf commit 65c5048

File tree

4 files changed

+136
-57
lines changed

4 files changed

+136
-57
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,21 @@ import {
7373

7474
import getComponentNameFromType from 'shared/getComponentNameFromType';
7575

76+
import {getOwnerStackByComponentInfoInDev} from 'shared/ReactComponentInfoStack';
77+
7678
import isArray from 'shared/isArray';
7779

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+
7891
export type {CallServerCallback, EncodeFormActionCallback};
7992

8093
interface FlightStreamController {
@@ -2282,6 +2295,22 @@ function resolveDebugInfo(
22822295
chunkDebugInfo.push(debugInfo);
22832296
}
22842297

2298+
let currentOwnerInDEV: null | ReactComponentInfo = null;
2299+
function getCurrentStackInDEV(): string {
2300+
if (__DEV__) {
2301+
if (enableOwnerStacks) {
2302+
const owner: null | ReactComponentInfo = currentOwnerInDEV;
2303+
if (owner === null) {
2304+
return '';
2305+
}
2306+
return getOwnerStackByComponentInfoInDev(owner);
2307+
}
2308+
// We don't have Parent Stacks in Flight.
2309+
return '';
2310+
}
2311+
return '';
2312+
}
2313+
22852314
function resolveConsoleEntry(
22862315
response: Response,
22872316
value: UninitializedModel,
@@ -2310,34 +2339,44 @@ function resolveConsoleEntry(
23102339
const owner = payload[2];
23112340
const env = payload[3];
23122341
const args = payload.slice(4);
2313-
if (!enableOwnerStacks) {
2314-
// Printing with stack isn't really limited to owner stacks but
2315-
// we gate it behind the same flag for now while iterating.
2316-
bindToConsole(methodName, args, env)();
2317-
return;
2318-
}
2319-
const callStack = buildFakeCallStack(
2320-
response,
2321-
stackTrace,
2322-
env,
2323-
bindToConsole(methodName, args, env),
2324-
);
2325-
if (owner != null) {
2326-
const task = initializeFakeTask(response, owner, env);
2327-
initializeFakeStack(response, owner);
2328-
if (task !== null) {
2329-
task.run(callStack);
2342+
2343+
// There really shouldn't be anything else on the stack atm.
2344+
const prevStack = ReactSharedInternals.getCurrentStack;
2345+
ReactSharedInternals.getCurrentStack = getCurrentStackInDEV;
2346+
currentOwnerInDEV = owner;
2347+
2348+
try {
2349+
if (!enableOwnerStacks) {
2350+
// Printing with stack isn't really limited to owner stacks but
2351+
// we gate it behind the same flag for now while iterating.
2352+
bindToConsole(methodName, args, env)();
23302353
return;
23312354
}
2332-
// TODO: Set the current owner so that captureOwnerStack() adds the component
2333-
// stack during the replay - if needed.
2334-
}
2335-
const rootTask = getRootTask(response, env);
2336-
if (rootTask != null) {
2337-
rootTask.run(callStack);
2338-
return;
2355+
const callStack = buildFakeCallStack(
2356+
response,
2357+
stackTrace,
2358+
env,
2359+
bindToConsole(methodName, args, env),
2360+
);
2361+
if (owner != null) {
2362+
const task = initializeFakeTask(response, owner, env);
2363+
initializeFakeStack(response, owner);
2364+
if (task !== null) {
2365+
task.run(callStack);
2366+
return;
2367+
}
2368+
// TODO: Set the current owner so that captureOwnerStack() adds the component
2369+
// stack during the replay - if needed.
2370+
}
2371+
const rootTask = getRootTask(response, env);
2372+
if (rootTask != null) {
2373+
rootTask.run(callStack);
2374+
return;
2375+
}
2376+
callStack();
2377+
} finally {
2378+
ReactSharedInternals.getCurrentStack = prevStack;
23392379
}
2340-
callStack();
23412380
}
23422381

23432382
function mergeBuffer(

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

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2918,7 +2918,7 @@ describe('ReactFlight', () => {
29182918
expect(ReactNoop).toMatchRenderedOutput(<div>hi</div>);
29192919
});
29202920

2921-
// @gate enableServerComponentLogs && __DEV__
2921+
// @gate enableServerComponentLogs && __DEV__ && enableOwnerStacks
29222922
it('replays logs, but not onError logs', async () => {
29232923
function foo() {
29242924
return 'hello';
@@ -2928,12 +2928,21 @@ describe('ReactFlight', () => {
29282928
throw new Error('err');
29292929
}
29302930

2931+
function App() {
2932+
return ReactServer.createElement(ServerComponent);
2933+
}
2934+
2935+
let ownerStacks = [];
2936+
29312937
// These tests are specifically testing console.log.
29322938
// Assign to `mockConsoleLog` so we can still inspect it when `console.log`
29332939
// is overridden by the test modules. The original function will be restored
29342940
// after this test finishes by `jest.restoreAllMocks()`.
29352941
const mockConsoleLog = spyOnDevAndProd(console, 'log').mockImplementation(
2936-
() => {},
2942+
() => {
2943+
// Uses server React.
2944+
ownerStacks.push(normalizeCodeLocInfo(ReactServer.captureOwnerStack()));
2945+
},
29372946
);
29382947

29392948
let transport;
@@ -2946,14 +2955,20 @@ describe('ReactFlight', () => {
29462955
ReactServer = require('react');
29472956
ReactNoopFlightServer = require('react-noop-renderer/flight-server');
29482957
transport = ReactNoopFlightServer.render({
2949-
root: ReactServer.createElement(ServerComponent),
2958+
root: ReactServer.createElement(App),
29502959
});
29512960
}).toErrorDev('err');
29522961

29532962
expect(mockConsoleLog).toHaveBeenCalledTimes(1);
29542963
expect(mockConsoleLog.mock.calls[0][0]).toBe('hi');
29552964
expect(mockConsoleLog.mock.calls[0][1].prop).toBe(123);
2965+
expect(ownerStacks).toEqual(['\n in App (at **)']);
29562966
mockConsoleLog.mockClear();
2967+
mockConsoleLog.mockImplementation(() => {
2968+
// Switching to client React.
2969+
ownerStacks.push(normalizeCodeLocInfo(React.captureOwnerStack()));
2970+
});
2971+
ownerStacks = [];
29572972

29582973
// The error should not actually get logged because we're not awaiting the root
29592974
// so it's not thrown but the server log also shouldn't be replayed.
@@ -2973,6 +2988,8 @@ describe('ReactFlight', () => {
29732988
expect(typeof loggedFn2).toBe('function');
29742989
expect(loggedFn2).not.toBe(foo);
29752990
expect(loggedFn2.toString()).toBe(foo.toString());
2991+
2992+
expect(ownerStacks).toEqual(['\n in App (at **)']);
29762993
});
29772994

29782995
it('uses the server component debug info as the element owner in DEV', async () => {
@@ -3159,18 +3176,18 @@ describe('ReactFlight', () => {
31593176
jest.resetModules();
31603177
jest.mock('react', () => React);
31613178
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+
]);
31743191
});
31753192

31763193
it('can filter out stack frames of a serialized error in dev', async () => {

packages/react-server/src/ReactFlightServer.js

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ import {DefaultAsyncDispatcher} from './flight/ReactFlightAsyncDispatcher';
9999

100100
import {resolveOwner, setCurrentOwner} from './flight/ReactFlightCurrentOwner';
101101

102-
import {getOwnerStackByComponentInfoInDev} from './flight/ReactFlightComponentStack';
102+
import {getOwnerStackByComponentInfoInDev} from 'shared/ReactComponentInfoStack';
103103

104104
import {
105105
callComponentInDEV,
@@ -968,6 +968,7 @@ function callWithDebugContextInDEV<A, T>(
968968
// a fake owner during this callback so we can get the stack trace from it.
969969
// This also gets sent to the client as the owner for the replaying log.
970970
const componentDebugInfo: ReactComponentInfo = {
971+
name: '',
971972
env: task.environmentName,
972973
owner: task.debugOwner,
973974
};
@@ -2063,6 +2064,23 @@ function escapeStringValue(value: string): string {
20632064
}
20642065
}
20652066

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+
20662084
let modelRoot: null | ReactClientValue = false;
20672085

20682086
function renderModel(
@@ -2574,28 +2592,15 @@ function renderModelDestructive(
25742592
);
25752593
}
25762594
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)) {
25912596
// This looks like a ReactComponentInfo. We can't serialize the ConsoleTask object so we
25922597
// need to omit it before serializing.
25932598
const componentDebugInfo: Omit<
25942599
ReactComponentInfo,
25952600
'debugTask' | 'debugStack',
25962601
> = {
2597-
name: value.name,
2598-
env: value.env,
2602+
name: (value: any).name,
2603+
env: (value: any).env,
25992604
owner: (value: any).owner,
26002605
};
26012606
if (enableOwnerStacks) {
@@ -3259,6 +3264,24 @@ function renderConsoleValue(
32593264
return Array.from((value: any));
32603265
}
32613266

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+
32623285
// $FlowFixMe[incompatible-return]
32633286
return value;
32643287
}

0 commit comments

Comments
 (0)