@samitouri / QOS-React / commits / 9d4fba0788

[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">

Sebastian Markbåge committed May 30, 2024 at 12:00 UTC 9d4fba078812de0363fe9514b943650fa479e8af
2 files changed +195 -13
packages/react-client/src/ReactFlightClient.js
+194 -5
@@ -67,8 +67,11 @@ import {
67 REACT_ELEMENT_TYPE,
68 REACT_POSTPONE_TYPE,
69 ASYNC_ITERATOR,
70 + REACT_FRAGMENT_TYPE,
71 } from 'shared/ReactSymbols';
72
73 +import getComponentNameFromType from 'shared/getComponentNameFromType';
74 +
75 export type {CallServerCallback, EncodeFormActionCallback};
76
77 interface FlightStreamController {
@@ -573,6 +576,43 @@ function nullRefGetter() {
576 }
577 }
578
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 +
616 function createElement(
617 type: mixed,
618 key: mixed,
@@ -647,11 +687,28 @@ function createElement(
687 writable: true,
688 value: stack,
689 });
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 + }
707 Object.defineProperty(element, '_debugTask', {
708 configurable: false,
709 enumerable: false,
710 writable: true,
654 - value: null,
711 + value: task,
712 });
713 }
714 // TODO: We should be freezing the element but currently, we might write into
@@ -1582,6 +1639,118 @@ function resolveHint<Code: HintCode>(
1639 dispatchHint(code, hintModel);
1640 }
1641
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 +
1754 function resolveDebugInfo(
1755 response: Response,
1756 id: number,
@@ -1594,6 +1763,10 @@ function resolveDebugInfo(
1763 'resolveDebugInfo should never be called in production mode. This is a bug in React.',
1764 );
1765 }
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);
1770 const chunk = getChunk(response, id);
1771 const chunkDebugInfo: ReactDebugInfo =
1772 chunk._debugInfo || (chunk._debugInfo = []);
@@ -1615,12 +1788,28 @@ function resolveConsoleEntry(
1788 const payload: [string, string, null | ReactComponentInfo, string, mixed] =
1789 parseModel(response, value);
1790 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];
1793 const env = payload[3];
1794 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();
1813 }
1814
1815 function mergeBuffer(
packages/react-server/src/ReactFlightServer.js
+1 -8
@@ -241,14 +241,7 @@ function patchConsole(consoleInst: typeof console, methodName: string) {
241 // Extract the stack. Not all console logs print the full stack but they have at
242 // least the line it was called from. We could optimize transfer by keeping just
243 // 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'));
245 request.pendingChunks++;
246 // We don't currently use this id for anything but we emit it so that we can later
247 // refer to previous logs in debug info to associate them with a component.