Skip to content

Commit 9d4fba0

Browse files
authored
[Flight] Eval Fake Server Component Functions to Recreate Native Stacks (#29632)
We have three kinds of stacks that we send in the RSC protocol: - The stack trace where a replayed `console.log` was called on the server. - The JSX callsite that created a Server Component which then later called another component. - The JSX callsite that created a Host or Client Component. These stack frames disappear in native stacks on the client since they're executed on the server. This evals a fake file which only has one call in it on the same line/column as the server. Then we call through these fake modules to "replay" the callstack. We then replay the `console.log` within this stack, or call `console.createTask` in this stack to recreate the stack. The main concern with this approach is the performance. It adds significant cost to create all these eval:ed functions but it should eventually balance out. This doesn't yet apply source maps to these. With source maps it'll be able to show the server source code when clicking the links. I don't love how these appear. - Because we haven't yet initialized the client module we don't have the name of the client component we're about to render yet which leads to the `<...>` task name. - The `(async)` suffix Chrome adds is still a problem. - The VMxxxx prefix is used to disambiguate which is noisy. Might be helped by source maps. - The continuation of the async stacks end up rooted somewhere in the bootstrapping of the app. This might be ok when the bootstrapping ends up ignore listed but it's kind of a problem that you can't clear the async stack. <img width="927" alt="Screenshot 2024-05-28 at 11 58 56 PM" src="https://github.com/facebook/react/assets/63648/1c9d32ce-e671-47c8-9d18-9fab3bffabd0"> <img width="431" alt="Screenshot 2024-05-28 at 11 58 07 PM" src="https://github.com/facebook/react/assets/63648/52f57518-bbed-400e-952d-6650835ac6b6"> <img width="327" alt="Screenshot 2024-05-28 at 11 58 31 PM" src="https://github.com/facebook/react/assets/63648/d311a639-79a1-457f-9a46-4f3298d07e65"> <img width="817" alt="Screenshot 2024-05-28 at 11 59 12 PM" src="https://github.com/facebook/react/assets/63648/3aefd356-acf4-4daa-bdbf-b8c8345f6d4b">
1 parent fb61a1b commit 9d4fba0

File tree

2 files changed

+195
-13
lines changed

2 files changed

+195
-13
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 194 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,11 @@ import {
6767
REACT_ELEMENT_TYPE,
6868
REACT_POSTPONE_TYPE,
6969
ASYNC_ITERATOR,
70+
REACT_FRAGMENT_TYPE,
7071
} from 'shared/ReactSymbols';
7172

73+
import getComponentNameFromType from 'shared/getComponentNameFromType';
74+
7275
export type {CallServerCallback, EncodeFormActionCallback};
7376

7477
interface FlightStreamController {
@@ -573,6 +576,43 @@ function nullRefGetter() {
573576
}
574577
}
575578

579+
function getServerComponentTaskName(componentInfo: ReactComponentInfo): string {
580+
return '<' + (componentInfo.name || '...') + '>';
581+
}
582+
583+
function getTaskName(type: mixed): string {
584+
if (type === REACT_FRAGMENT_TYPE) {
585+
return '<>';
586+
}
587+
if (typeof type === 'function') {
588+
// This is a function so it must have been a Client Reference that resolved to
589+
// a function. We use "use client" to indicate that this is the boundary into
590+
// the client. There should only be one for any given owner chain.
591+
return '"use client"';
592+
}
593+
if (
594+
typeof type === 'object' &&
595+
type !== null &&
596+
type.$$typeof === REACT_LAZY_TYPE
597+
) {
598+
if (type._init === readChunk) {
599+
// This is a lazy node created by Flight. It is probably a client reference.
600+
// We use the "use client" string to indicate that this is the boundary into
601+
// the client. There will only be one for any given owner chain.
602+
return '"use client"';
603+
}
604+
// We don't want to eagerly initialize the initializer in DEV mode so we can't
605+
// call it to extract the type so we don't know the type of this component.
606+
return '<...>';
607+
}
608+
try {
609+
const name = getComponentNameFromType(type);
610+
return name ? '<' + name + '>' : '<...>';
611+
} catch (x) {
612+
return '<...>';
613+
}
614+
}
615+
576616
function createElement(
577617
type: mixed,
578618
key: mixed,
@@ -647,11 +687,28 @@ function createElement(
647687
writable: true,
648688
value: stack,
649689
});
690+
691+
let task: null | ConsoleTask = null;
692+
if (supportsCreateTask && stack !== null) {
693+
const createTaskFn = (console: any).createTask.bind(
694+
console,
695+
getTaskName(type),
696+
);
697+
const callStack = buildFakeCallStack(stack, createTaskFn);
698+
// This owner should ideally have already been initialized to avoid getting
699+
// user stack frames on the stack.
700+
const ownerTask = owner === null ? null : initializeFakeTask(owner);
701+
if (ownerTask === null) {
702+
task = callStack();
703+
} else {
704+
task = ownerTask.run(callStack);
705+
}
706+
}
650707
Object.defineProperty(element, '_debugTask', {
651708
configurable: false,
652709
enumerable: false,
653710
writable: true,
654-
value: null,
711+
value: task,
655712
});
656713
}
657714
// TODO: We should be freezing the element but currently, we might write into
@@ -1582,6 +1639,118 @@ function resolveHint<Code: HintCode>(
15821639
dispatchHint(code, hintModel);
15831640
}
15841641

1642+
// eslint-disable-next-line react-internal/no-production-logging
1643+
const supportsCreateTask =
1644+
__DEV__ && enableOwnerStacks && !!(console: any).createTask;
1645+
1646+
const taskCache: null | WeakMap<
1647+
ReactComponentInfo | ReactAsyncInfo,
1648+
ConsoleTask,
1649+
> = supportsCreateTask ? new WeakMap() : null;
1650+
1651+
type FakeFunction<T> = (FakeFunction<T>) => T;
1652+
const fakeFunctionCache: Map<string, FakeFunction<any>> = __DEV__
1653+
? new Map()
1654+
: (null: any);
1655+
1656+
function createFakeFunction<T>(
1657+
name: string,
1658+
filename: string,
1659+
line: number,
1660+
col: number,
1661+
): FakeFunction<T> {
1662+
// This creates a fake copy of a Server Module. It represents a module that has already
1663+
// executed on the server but we re-execute a blank copy for its stack frames on the client.
1664+
1665+
const comment =
1666+
'/* This module was rendered by a Server Component. Turn on Source Maps to see the server source. */';
1667+
1668+
// We generate code where the call is at the line and column of the server executed code.
1669+
// This allows us to use the original source map as the source map of this fake file to
1670+
// point to the original source.
1671+
let code;
1672+
if (line <= 1) {
1673+
code = '_=>' + ' '.repeat(col < 4 ? 0 : col - 4) + '_()\n' + comment + '\n';
1674+
} else {
1675+
code =
1676+
comment +
1677+
'\n'.repeat(line - 2) +
1678+
'_=>\n' +
1679+
' '.repeat(col < 1 ? 0 : col - 1) +
1680+
'_()\n';
1681+
}
1682+
1683+
if (filename) {
1684+
code += '//# sourceURL=' + filename;
1685+
}
1686+
1687+
// eslint-disable-next-line no-eval
1688+
const fn: FakeFunction<T> = (0, eval)(code);
1689+
// $FlowFixMe[cannot-write]
1690+
Object.defineProperty(fn, 'name', {value: name || '(anonymous)'});
1691+
// $FlowFixMe[prop-missing]
1692+
fn.displayName = name;
1693+
return fn;
1694+
}
1695+
1696+
const frameRegExp =
1697+
/^ {3} at (?:(.+) \(([^\)]+):(\d+):(\d+)\)|([^\)]+):(\d+):(\d+))$/;
1698+
1699+
function buildFakeCallStack<T>(stack: string, innerCall: () => T): () => T {
1700+
const frames = stack.split('\n');
1701+
let callStack = innerCall;
1702+
for (let i = 0; i < frames.length; i++) {
1703+
const frame = frames[i];
1704+
let fn = fakeFunctionCache.get(frame);
1705+
if (fn === undefined) {
1706+
const parsed = frameRegExp.exec(frame);
1707+
if (!parsed) {
1708+
// We assume the server returns a V8 compatible stack trace.
1709+
continue;
1710+
}
1711+
const name = parsed[1] || '';
1712+
const filename = parsed[2] || parsed[5] || '';
1713+
const line = +(parsed[3] || parsed[6]);
1714+
const col = +(parsed[4] || parsed[7]);
1715+
fn = createFakeFunction(name, filename, line, col);
1716+
}
1717+
callStack = fn.bind(null, callStack);
1718+
}
1719+
return callStack;
1720+
}
1721+
1722+
function initializeFakeTask(
1723+
debugInfo: ReactComponentInfo | ReactAsyncInfo,
1724+
): null | ConsoleTask {
1725+
if (taskCache === null || typeof debugInfo.stack !== 'string') {
1726+
return null;
1727+
}
1728+
const componentInfo: ReactComponentInfo = (debugInfo: any); // Refined
1729+
const stack: string = debugInfo.stack;
1730+
const cachedEntry = taskCache.get((componentInfo: any));
1731+
if (cachedEntry !== undefined) {
1732+
return cachedEntry;
1733+
}
1734+
1735+
const ownerTask =
1736+
componentInfo.owner == null
1737+
? null
1738+
: initializeFakeTask(componentInfo.owner);
1739+
1740+
// eslint-disable-next-line react-internal/no-production-logging
1741+
const createTaskFn = (console: any).createTask.bind(
1742+
console,
1743+
getServerComponentTaskName(componentInfo),
1744+
);
1745+
const callStack = buildFakeCallStack(stack, createTaskFn);
1746+
1747+
if (ownerTask === null) {
1748+
return callStack();
1749+
} else {
1750+
return ownerTask.run(callStack);
1751+
}
1752+
}
1753+
15851754
function resolveDebugInfo(
15861755
response: Response,
15871756
id: number,
@@ -1594,6 +1763,10 @@ function resolveDebugInfo(
15941763
'resolveDebugInfo should never be called in production mode. This is a bug in React.',
15951764
);
15961765
}
1766+
// We eagerly initialize the fake task because this resolving happens outside any
1767+
// render phase so we're not inside a user space stack at this point. If we waited
1768+
// to initialize it when we need it, we might be inside user code.
1769+
initializeFakeTask(debugInfo);
15971770
const chunk = getChunk(response, id);
15981771
const chunkDebugInfo: ReactDebugInfo =
15991772
chunk._debugInfo || (chunk._debugInfo = []);
@@ -1615,12 +1788,28 @@ function resolveConsoleEntry(
16151788
const payload: [string, string, null | ReactComponentInfo, string, mixed] =
16161789
parseModel(response, value);
16171790
const methodName = payload[0];
1618-
// TODO: Restore the fake stack before logging.
1619-
// const stackTrace = payload[1];
1620-
// const owner = payload[2];
1791+
const stackTrace = payload[1];
1792+
const owner = payload[2];
16211793
const env = payload[3];
16221794
const args = payload.slice(4);
1623-
printToConsole(methodName, args, env);
1795+
if (!enableOwnerStacks) {
1796+
// Printing with stack isn't really limited to owner stacks but
1797+
// we gate it behind the same flag for now while iterating.
1798+
printToConsole(methodName, args, env);
1799+
return;
1800+
}
1801+
const callStack = buildFakeCallStack(
1802+
stackTrace,
1803+
printToConsole.bind(null, methodName, args, env),
1804+
);
1805+
if (owner != null) {
1806+
const task = initializeFakeTask(owner);
1807+
if (task !== null) {
1808+
task.run(callStack);
1809+
return;
1810+
}
1811+
}
1812+
callStack();
16241813
}
16251814

16261815
function mergeBuffer(

packages/react-server/src/ReactFlightServer.js

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -241,14 +241,7 @@ function patchConsole(consoleInst: typeof console, methodName: string) {
241241
// Extract the stack. Not all console logs print the full stack but they have at
242242
// least the line it was called from. We could optimize transfer by keeping just
243243
// one stack frame but keeping it simple for now and include all frames.
244-
let stack = filterDebugStack(new Error('react-stack-top-frame'));
245-
const firstLine = stack.indexOf('\n');
246-
if (firstLine === -1) {
247-
stack = '';
248-
} else {
249-
// Skip the console wrapper itself.
250-
stack = stack.slice(firstLine + 1);
251-
}
244+
const stack = filterDebugStack(new Error('react-stack-top-frame'));
252245
request.pendingChunks++;
253246
// We don't currently use this id for anything but we emit it so that we can later
254247
// refer to previous logs in debug info to associate them with a component.

0 commit comments

Comments
 (0)