@samitouri / QOS-React-1 / commits / b229f540e2

[Flight] Emit debug info for a Server Component (#28272)

This adds a new DEV-only row type `D` for DebugInfo. If we see this in prod, that's an error. It can contain extra debug information about the Server Components (or Promises) that were compiled away during the server render. It's DEV-only since this can contain sensitive information (similar to errors) and since it'll be a lot of data, but it's worth using the same stream for simplicity rather than a side-channel. In this first pass it's just the Server Component's name but I'll keep adding more debug info to the stream, and it won't always just be a Server Component's stack frame. Each row can get more debug rows data streaming in as it resolves and renders multiple server components in a row. The data structure is just a side-channel and it would be perfectly fine to ignore the D rows and it would behave the same as prod. With this data structure though the data is associated with the row ID / chunk, so you can't have inline meta data. This means that an inline Server Component that doesn't get an ID otherwise will need to be outlined. The way I outline Server Components is using a direct reference where it's synchronous though so on the client side it behaves the same (i.e. there's no lazy wrapper in this case). In most cases the `_debugInfo` is on the Promises that we yield and we also expose this on the `React.Lazy` wrappers. In the case where it's a synchronous render it might attach this data to Elements or Arrays (fragments) too. In a future PR I'll wire this information up with Fiber to stash it in the Fiber data structures so that DevTools can pick it up. This property and the information in it is not limited to Server Components. The name of the property that we look for probably shouldn't be `_debugInfo` since it's semi-public. Should consider the name we use for that. If it's a synchronous render that returns a string or number (text node) then we don't have anywhere to attach them to. We could add a `React.Lazy` wrapper for those but I chose to prioritize keeping the data structure untouched. Can be useful if you use Server Components to render data instead of React Nodes.

Sebastian Markbåge committed Feb 8, 2024 at 08:01 UTC b229f540e2da91370611945f9875e00a96196df6
10 files changed +223 -11
packages/react-client/src/ReactFlightClient.js
+85 -3
@@ -76,11 +76,15 @@ const RESOLVED_MODULE = 'resolved_module';
76 const INITIALIZED = 'fulfilled';
77 const ERRORED = 'rejected';
78
79 +// Dev-only
80 +type ReactDebugInfo = Array<{+name?: string}>;
81 +
82 type PendingChunk<T> = {
83 status: 'pending',
84 value: null | Array<(T) => mixed>,
85 reason: null | Array<(mixed) => mixed>,
86 _response: Response,
87 + _debugInfo?: null | ReactDebugInfo,
88 then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
89 };
90 type BlockedChunk<T> = {
@@ -88,6 +92,7 @@ type BlockedChunk<T> = {
92 value: null | Array<(T) => mixed>,
93 reason: null | Array<(mixed) => mixed>,
94 _response: Response,
95 + _debugInfo?: null | ReactDebugInfo,
96 then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
97 };
98 type CyclicChunk<T> = {
@@ -95,6 +100,7 @@ type CyclicChunk<T> = {
100 value: null | Array<(T) => mixed>,
101 reason: null | Array<(mixed) => mixed>,
102 _response: Response,
103 + _debugInfo?: null | ReactDebugInfo,
104 then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
105 };
106 type ResolvedModelChunk<T> = {
@@ -102,6 +108,7 @@ type ResolvedModelChunk<T> = {
108 value: UninitializedModel,
109 reason: null,
110 _response: Response,
111 + _debugInfo?: null | ReactDebugInfo,
112 then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
113 };
114 type ResolvedModuleChunk<T> = {
@@ -109,6 +116,7 @@ type ResolvedModuleChunk<T> = {
116 value: ClientReference<T>,
117 reason: null,
118 _response: Response,
119 + _debugInfo?: null | ReactDebugInfo,
120 then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
121 };
122 type InitializedChunk<T> = {
@@ -116,6 +124,7 @@ type InitializedChunk<T> = {
124 value: T,
125 reason: null,
126 _response: Response,
127 + _debugInfo?: null | ReactDebugInfo,
128 then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
129 };
130 type ErroredChunk<T> = {
@@ -123,6 +132,7 @@ type ErroredChunk<T> = {
132 value: null,
133 reason: mixed,
134 _response: Response,
135 + _debugInfo?: null | ReactDebugInfo,
136 then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
137 };
138 type SomeChunk<T> =
@@ -140,6 +150,9 @@ function Chunk(status: any, value: any, reason: any, response: Response) {
150 this.value = value;
151 this.reason = reason;
152 this._response = response;
153 + if (__DEV__) {
154 + this._debugInfo = null;
155 + }
156 }
157 // We subclass Promise.prototype so that we get other methods like .catch
158 Chunk.prototype = (Object.create(Promise.prototype): any);
@@ -475,6 +488,13 @@ function createElement(
488 writable: true,
489 value: true, // This element has already been validated on the server.
490 });
491 + // debugInfo contains Server Component debug information.
492 + Object.defineProperty(element, '_debugInfo', {
493 + configurable: false,
494 + enumerable: false,
495 + writable: true,
496 + value: null,
497 + });
498 }
499 return element;
500 }
@@ -487,6 +507,12 @@ function createLazyChunkWrapper<T>(
507 _payload: chunk,
508 _init: readChunk,
509 };
510 + if (__DEV__) {
511 + // Ensure we have a live array to track future debug info.
512 + const chunkDebugInfo: ReactDebugInfo =
513 + chunk._debugInfo || (chunk._debugInfo = []);
514 + lazyType._debugInfo = chunkDebugInfo;
515 + }
516 return lazyType;
517 }
518
@@ -682,7 +708,33 @@ function parseModelString(
708 // The status might have changed after initialization.
709 switch (chunk.status) {
710 case INITIALIZED:
685 - return chunk.value;
711 + const chunkValue = chunk.value;
712 + if (__DEV__ && chunk._debugInfo) {
713 + // If we have a direct reference to an object that was rendered by a synchronous
714 + // server component, it might have some debug info about how it was rendered.
715 + // We forward this to the underlying object. This might be a React Element or
716 + // an Array fragment.
717 + // If this was a string / number return value we lose the debug info. We choose
718 + // that tradeoff to allow sync server components to return plain values and not
719 + // use them as React Nodes necessarily. We could otherwise wrap them in a Lazy.
720 + if (
721 + typeof chunkValue === 'object' &&
722 + chunkValue !== null &&
723 + (Array.isArray(chunkValue) ||
724 + chunkValue.$$typeof === REACT_ELEMENT_TYPE) &&
725 + !chunkValue._debugInfo
726 + ) {
727 + // We should maybe use a unique symbol for arrays but this is a React owned array.
728 + // $FlowFixMe[prop-missing]: This should be added to elements.
729 + Object.defineProperty(chunkValue, '_debugInfo', {
730 + configurable: false,
731 + enumerable: false,
732 + writable: true,
733 + value: chunk._debugInfo,
734 + });
735 + }
736 + }
737 + return chunkValue;
738 case PENDING:
739 case BLOCKED:
740 case CYCLIC:
@@ -959,6 +1011,24 @@ function resolveHint<Code: HintCode>(
1011 dispatchHint(code, hintModel);
1012 }
1013
1014 +function resolveDebugInfo(
1015 + response: Response,
1016 + id: number,
1017 + debugInfo: {name: string},
1018 +): void {
1019 + if (!__DEV__) {
1020 + // These errors should never make it into a build so we don't need to encode them in codes.json
1021 + // eslint-disable-next-line react-internal/prod-error-codes
1022 + throw new Error(
1023 + 'resolveDebugInfo should never be called in production mode. This is a bug in React.',
1024 + );
1025 + }
1026 + const chunk = getChunk(response, id);
1027 + const chunkDebugInfo: ReactDebugInfo =
1028 + chunk._debugInfo || (chunk._debugInfo = []);
1029 + chunkDebugInfo.push(debugInfo);
1030 +}
1031 +
1032 function mergeBuffer(
1033 buffer: Array<Uint8Array>,
1034 lastChunk: Uint8Array,
@@ -1052,7 +1122,7 @@ function processFullRow(
1122 case 70 /* "F" */:
1123 resolveTypedArray(response, id, buffer, chunk, Float32Array, 4);
1124 return;
1055 - case 68 /* "D" */:
1125 + case 100 /* "d" */:
1126 resolveTypedArray(response, id, buffer, chunk, Float64Array, 8);
1127 return;
1128 case 78 /* "N" */:
@@ -1102,6 +1172,18 @@ function processFullRow(
1172 resolveText(response, id, row);
1173 return;
1174 }
1175 + case 68 /* "D" */: {
1176 + if (__DEV__) {
1177 + const debugInfo = JSON.parse(row);
1178 + resolveDebugInfo(response, id, debugInfo);
1179 + return;
1180 + }
1181 + throw new Error(
1182 + 'Failed to read a RSC payload created by a development version of React ' +
1183 + 'on the server while using a production version on the client. Always use ' +
1184 + 'matching versions on the server and the client.',
1185 + );
1186 + }
1187 case 80 /* "P" */: {
1188 if (enablePostpone) {
1189 if (__DEV__) {
@@ -1165,7 +1247,7 @@ export function processBinaryChunk(
1247 resolvedRowTag === 76 /* "L" */ ||
1248 resolvedRowTag === 108 /* "l" */ ||
1249 resolvedRowTag === 70 /* "F" */ ||
1168 - resolvedRowTag === 68 /* "D" */ ||
1250 + resolvedRowTag === 100 /* "d" */ ||
1251 resolvedRowTag === 78 /* "N" */ ||
1252 resolvedRowTag === 109 /* "m" */ ||
1253 resolvedRowTag === 86)) /* "V" */
packages/react-client/src/__tests__/ReactFlight-test.js
+30
@@ -186,12 +186,42 @@ describe('ReactFlight', () => {
186 await act(async () => {
187 const rootModel = await ReactNoopFlightClient.read(transport);
188 const greeting = rootModel.greeting;
189 + expect(greeting._debugInfo).toEqual(
190 + __DEV__ ? [{name: 'Greeting'}] : undefined,
191 + );
192 ReactNoop.render(greeting);
193 });
194
195 expect(ReactNoop).toMatchRenderedOutput(<span>Hello, Seb Smith</span>);
196 });
197
198 + it('can render a shared forwardRef Component', async () => {
199 + const Greeting = React.forwardRef(function Greeting(
200 + {firstName, lastName},
201 + ref,
202 + ) {
203 + return (
204 + <span ref={ref}>
205 + Hello, {firstName} {lastName}
206 + </span>
207 + );
208 + });
209 +
210 + const root = <Greeting firstName="Seb" lastName="Smith" />;
211 +
212 + const transport = ReactNoopFlightServer.render(root);
213 +
214 + await act(async () => {
215 + const promise = ReactNoopFlightClient.read(transport);
216 + expect(promise._debugInfo).toEqual(
217 + __DEV__ ? [{name: 'Greeting'}] : undefined,
218 + );
219 + ReactNoop.render(await promise);
220 + });
221 +
222 + expect(ReactNoop).toMatchRenderedOutput(<span>Hello, Seb Smith</span>);
223 + });
224 +
225 it('can render an iterable as an array', async () => {
226 function ItemListClient(props) {
227 return <span>{props.items}</span>;
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+2 -1
@@ -286,7 +286,8 @@ describe('ReactFlightDOMEdge', () => {
286 <ServerComponent recurse={20} />,
287 );
288 const serializedContent = await readResult(stream);
289 - expect(serializedContent.length).toBeLessThan(150);
289 + const expectedDebugInfoSize = __DEV__ ? 30 * 20 : 0;
290 + expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize);
291 });
292
293 // @gate enableBinaryFlight
packages/react-server/src/ReactFlightHooks.js
+5 -2
@@ -37,8 +37,11 @@ export function prepareToUseHooksForComponent(
37 thenableState = prevThenableState;
38 }
39
40 -export function getThenableStateAfterSuspending(): null | ThenableState {
41 - const state = thenableState;
40 +export function getThenableStateAfterSuspending(): ThenableState {
41 + // If you use() to Suspend this should always exist but if you throw a Promise instead,
42 + // which is not really supported anymore, it will be empty. We use the empty set as a
43 + // marker to know if this was a replay of the same component or first attempt.
44 + const state = thenableState || createThenableState();
45 thenableState = null;
46 return state;
47 }
packages/react-server/src/ReactFlightServer.js
+78 -2
@@ -491,6 +491,23 @@ function renderFunctionComponent<Props>(
491 const prevThenableState = task.thenableState;
492 task.thenableState = null;
493
494 + if (__DEV__) {
495 + if (debugID === null) {
496 + // We don't have a chunk to assign debug info. We need to outline this
497 + // component to assign it an ID.
498 + return outlineTask(request, task);
499 + } else if (prevThenableState !== null) {
500 + // This is a replay and we've already emitted the debug info of this component
501 + // in the first pass. We skip emitting a duplicate line.
502 + } else {
503 + // This is a new component in the same task so we can emit more debug info.
504 + const componentName =
505 + (Component: any).displayName || Component.name || '';
506 + request.pendingChunks++;
507 + emitDebugChunk(request, debugID, {name: componentName});
508 + }
509 + }
510 +
511 prepareToUseHooksForComponent(prevThenableState);
512 // The secondArg is always undefined in Server Components since refs error early.
513 const secondArg = undefined;
@@ -605,6 +622,29 @@ function renderClientElement(
622 return element;
623 }
624
625 +// The chunk ID we're currently rendering that we can assign debug data to.
626 +let debugID: null | number = null;
627 +
628 +function outlineTask(request: Request, task: Task): ReactJSONValue {
629 + const newTask = createTask(
630 + request,
631 + task.model, // the currently rendering element
632 + task.keyPath, // unlike outlineModel this one carries along context
633 + task.implicitSlot,
634 + request.abortableTasks,
635 + );
636 +
637 + retryTask(request, newTask);
638 + if (newTask.status === COMPLETED) {
639 + // We completed synchronously so we can refer to this by reference. This
640 + // makes it behaves the same as prod during deserialization.
641 + return serializeByValueID(newTask.id);
642 + }
643 + // This didn't complete synchronously so it wouldn't have even if we didn't
644 + // outline it, so this would reduce to a lazy reference even in prod.
645 + return serializeLazyID(newTask.id);
646 +}
647 +
648 function renderElement(
649 request: Request,
650 task: Task,
@@ -632,7 +672,7 @@ function renderElement(
672 // This is a reference to a Client Component.
673 return renderClientElement(task, type, key, props);
674 }
635 - // This is a server-side component.
675 + // This is a Server Component.
676 return renderFunctionComponent(request, task, key, type, props);
677 } else if (typeof type === 'string') {
678 // This is a host element. E.g. HTML.
@@ -1306,7 +1346,7 @@ function renderModelDestructive(
1346 }
1347 if (value instanceof Float64Array) {
1348 // double
1309 - return serializeTypedArray(request, 'D', value);
1349 + return serializeTypedArray(request, 'd', value);
1350 }
1351 if (value instanceof BigInt64Array) {
1352 // number
@@ -1606,6 +1646,25 @@ function emitModelChunk(request: Request, id: number, json: string): void {
1646 request.completedRegularChunks.push(processedChunk);
1647 }
1648
1649 +function emitDebugChunk(
1650 + request: Request,
1651 + id: number,
1652 + debugInfo: {name: string},
1653 +): void {
1654 + if (!__DEV__) {
1655 + // These errors should never make it into a build so we don't need to encode them in codes.json
1656 + // eslint-disable-next-line react-internal/prod-error-codes
1657 + throw new Error(
1658 + 'emitDebugChunk should never be called in production mode. This is a bug in React.',
1659 + );
1660 + }
1661 + // $FlowFixMe[incompatible-type] stringify can return null
1662 + const json: string = stringify(debugInfo);
1663 + const row = serializeRowHeader('D', id) + json + '\n';
1664 + const processedChunk = stringToChunk(row);
1665 + request.completedRegularChunks.push(processedChunk);
1666 +}
1667 +
1668 const emptyRoot = {};
1669
1670 function retryTask(request: Request, task: Task): void {
@@ -1614,12 +1673,19 @@ function retryTask(request: Request, task: Task): void {
1673 return;
1674 }
1675
1676 + const prevDebugID = debugID;
1677 +
1678 try {
1679 // Track the root so we know that we have to emit this object even though it
1680 // already has an ID. This is needed because we might see this object twice
1681 // in the same toJSON if it is cyclic.
1682 modelRoot = task.model;
1683
1684 + if (__DEV__) {
1685 + // Track the ID of the current task so we can assign debug info to this id.
1686 + debugID = task.id;
1687 + }
1688 +
1689 // We call the destructive form that mutates this task. That way if something
1690 // suspends again, we can reuse the same task instead of spawning a new one.
1691 const resolvedModel = renderModelDestructive(
@@ -1630,6 +1696,12 @@ function retryTask(request: Request, task: Task): void {
1696 task.model,
1697 );
1698
1699 + if (__DEV__) {
1700 + // We're now past rendering this task and future renders will spawn new tasks for their
1701 + // debug info.
1702 + debugID = null;
1703 + }
1704 +
1705 // Track the root again for the resolved object.
1706 modelRoot = resolvedModel;
1707
@@ -1684,6 +1756,10 @@ function retryTask(request: Request, task: Task): void {
1756 task.status = ERRORED;
1757 const digest = logRecoverableError(request, x);
1758 emitErrorChunk(request, task.id, digest, x);
1759 + } finally {
1760 + if (__DEV__) {
1761 + debugID = prevDebugID;
1762 + }
1763 }
1764 }
1765
packages/react/src/ReactElementProd.js
+7
@@ -170,6 +170,13 @@ function ReactElement(type, key, ref, owner, props) {
170 writable: true,
171 value: false,
172 });
173 + // debugInfo contains Server Component debug information.
174 + Object.defineProperty(element, '_debugInfo', {
175 + configurable: false,
176 + enumerable: false,
177 + writable: true,
178 + value: null,
179 + });
180 if (Object.freeze) {
181 Object.freeze(element.props);
182 Object.freeze(element);
packages/react/src/ReactLazy.js
+1
@@ -46,6 +46,7 @@ export type LazyComponent<T, P> = {
46 $$typeof: symbol | number,
47 _payload: P,
48 _init: (payload: P) => T,
49 + _debugInfo?: null | Array<{+name?: string}>,
50 };
51
52 function lazyInitializer<T>(payload: Payload<T>): T {
packages/react/src/__tests__/ReactFetch-test.js
+6 -2
@@ -60,7 +60,7 @@ describe('ReactFetch', () => {
60 cache = ReactServer.cache;
61 });
62
63 - async function render(Component) {
63 + function render(Component) {
64 const stream = ReactServerDOMServer.renderToReadableStream(<Component />);
65 return ReactServerDOMClient.createFromReadableStream(stream);
66 }
@@ -82,7 +82,11 @@ describe('ReactFetch', () => {
82 const text = use(response.text());
83 return text;
84 }
85 - expect(await render(Component)).toMatchInlineSnapshot(`"GET world []"`);
85 + const promise = render(Component);
86 + expect(await promise).toMatchInlineSnapshot(`"GET world []"`);
87 + expect(promise._debugInfo).toEqual(
88 + __DEV__ ? [{name: 'Component'}] : undefined,
89 + );
90 expect(fetchCount).toBe(1);
91 });
92
packages/react/src/jsx/ReactJSXElement.js
+7
@@ -170,6 +170,13 @@ function ReactElement(type, key, ref, self, source, owner, props) {
170 writable: true,
171 value: false,
172 });
173 + // debugInfo contains Server Component debug information.
174 + Object.defineProperty(element, '_debugInfo', {
175 + configurable: false,
176 + enumerable: false,
177 + writable: true,
178 + value: null,
179 + });
180 if (Object.freeze) {
181 Object.freeze(element.props);
182 Object.freeze(element);
scripts/error-codes/codes.json
+2 -1
@@ -488,5 +488,6 @@
488 "500": "React expected a headers state to exist when emitEarlyPreloads was called but did not find it. This suggests emitEarlyPreloads was called more than once per request. This is a bug in React.",
489 "501": "The render was aborted with postpone when the shell is incomplete. Reason: %s",
490 "502": "Cannot read a Client Context from a Server Component.",
491 - "503": "Cannot use() an already resolved Client Reference."
491 + "503": "Cannot use() an already resolved Client Reference.",
492 + "504": "Failed to read a RSC payload created by a development version of React on the server while using a production version on the client. Always use matching versions on the server and the client."
493 }