@samitouri / QOS-React-1 / commits / 654e387d7e

[Flight] Serialize Server Components Props in DEV (#31105)

This allows us to show props in React DevTools when inspecting a Server Component. I currently drastically limit the object depth that's serialized since this is very implicit and you can have heavy objects on the server. We previously was using the general outlineModel to outline ReactComponentInfo but we weren't consistently using it everywhere which could cause some bugs with the parsing when it got deduped on the client. It also lead to the weird feature detect of `isReactComponent`. It also meant that this serialization was using the plain serialization instead of `renderConsoleValue` which means we couldn't safely serialize arbitrary debug info that isn't serializable there. So the main change here is to call `outlineComponentInfo` and have that always write every "Server Component" instance as outlined and in a way that lets its props be serialized using `renderConsoleValue`. <img width="1150" alt="Screenshot 2024-10-01 at 1 25 05 AM" src="https://github.com/user-attachments/assets/f6e7811d-51a3-46b9-bbe0-1b8276849ed4">

Sebastian Markbåge committed Sep 30, 2024 at 22:39 UTC 654e387d7eac113ddbf85f8a9029d1af7117679e
7 files changed +245 -97
packages/react-client/src/ReactFlightClient.js
+16 -5
@@ -2640,11 +2640,22 @@ function processFullStringRow(
2640 }
2641 case 68 /* "D" */: {
2642 if (__DEV__) {
2643 - const debugInfo: ReactComponentInfo | ReactAsyncInfo = parseModel(
2644 - response,
2645 - row,
2646 - );
2647 - resolveDebugInfo(response, id, debugInfo);
2643 + const chunk: ResolvedModelChunk<ReactComponentInfo | ReactAsyncInfo> =
2644 + createResolvedModelChunk(response, row);
2645 + initializeModelChunk(chunk);
2646 + const initializedChunk: SomeChunk<ReactComponentInfo | ReactAsyncInfo> =
2647 + chunk;
2648 + if (initializedChunk.status === INITIALIZED) {
2649 + resolveDebugInfo(response, id, initializedChunk.value);
2650 + } else {
2651 + // TODO: This is not going to resolve in the right order if there's more than one.
2652 + chunk.then(
2653 + v => resolveDebugInfo(response, id, v),
2654 + e => {
2655 + // Ignore debug info errors for now. Unnecessary noise.
2656 + },
2657 + );
2658 + }
2659 return;
2660 }
2661 // Fallthrough to share the error with Console entries.
packages/react-client/src/__tests__/ReactFlight-test.js
+33
@@ -308,6 +308,10 @@ describe('ReactFlight', () => {
308 stack: gate(flag => flag.enableOwnerStacks)
309 ? ' in Object.<anonymous> (at **)'
310 : undefined,
311 + props: {
312 + firstName: 'Seb',
313 + lastName: 'Smith',
314 + },
315 },
316 ]
317 : undefined,
@@ -347,6 +351,10 @@ describe('ReactFlight', () => {
351 stack: gate(flag => flag.enableOwnerStacks)
352 ? ' in Object.<anonymous> (at **)'
353 : undefined,
354 + props: {
355 + firstName: 'Seb',
356 + lastName: 'Smith',
357 + },
358 },
359 ]
360 : undefined,
@@ -2665,6 +2673,9 @@ describe('ReactFlight', () => {
2673 stack: gate(flag => flag.enableOwnerStacks)
2674 ? ' in Object.<anonymous> (at **)'
2675 : undefined,
2676 + props: {
2677 + transport: expect.arrayContaining([]),
2678 + },
2679 },
2680 ]
2681 : undefined,
@@ -2683,6 +2694,7 @@ describe('ReactFlight', () => {
2694 stack: gate(flag => flag.enableOwnerStacks)
2695 ? ' in Object.<anonymous> (at **)'
2696 : undefined,
2697 + props: {},
2698 },
2699 ]
2700 : undefined,
@@ -2698,6 +2710,7 @@ describe('ReactFlight', () => {
2710 stack: gate(flag => flag.enableOwnerStacks)
2711 ? ' in myLazy (at **)\n in lazyInitializer (at **)'
2712 : undefined,
2713 + props: {},
2714 },
2715 ]
2716 : undefined,
@@ -2713,6 +2726,7 @@ describe('ReactFlight', () => {
2726 stack: gate(flag => flag.enableOwnerStacks)
2727 ? ' in Object.<anonymous> (at **)'
2728 : undefined,
2729 + props: {},
2730 },
2731 ]
2732 : undefined,
@@ -2787,6 +2801,9 @@ describe('ReactFlight', () => {
2801 stack: gate(flag => flag.enableOwnerStacks)
2802 ? ' in Object.<anonymous> (at **)'
2803 : undefined,
2804 + props: {
2805 + transport: expect.arrayContaining([]),
2806 + },
2807 },
2808 ]
2809 : undefined,
@@ -2804,6 +2821,9 @@ describe('ReactFlight', () => {
2821 stack: gate(flag => flag.enableOwnerStacks)
2822 ? ' in ServerComponent (at **)'
2823 : undefined,
2824 + props: {
2825 + children: {},
2826 + },
2827 },
2828 ]
2829 : undefined,
@@ -2820,6 +2840,7 @@ describe('ReactFlight', () => {
2840 stack: gate(flag => flag.enableOwnerStacks)
2841 ? ' in Object.<anonymous> (at **)'
2842 : undefined,
2843 + props: {},
2844 },
2845 ]
2846 : undefined,
@@ -2978,6 +2999,7 @@ describe('ReactFlight', () => {
2999 stack: gate(flag => flag.enableOwnerStacks)
3000 ? ' in Object.<anonymous> (at **)'
3001 : undefined,
3002 + props: {},
3003 },
3004 {
3005 env: 'B',
@@ -3108,6 +3130,9 @@ describe('ReactFlight', () => {
3130 stack: gate(flag => flag.enableOwnerStacks)
3131 ? ' in Object.<anonymous> (at **)'
3132 : undefined,
3133 + props: {
3134 + firstName: 'Seb',
3135 + },
3136 };
3137 expect(getDebugInfo(greeting)).toEqual([
3138 greetInfo,
@@ -3119,6 +3144,14 @@ describe('ReactFlight', () => {
3144 stack: gate(flag => flag.enableOwnerStacks)
3145 ? ' in Greeting (at **)'
3146 : undefined,
3147 + props: {
3148 + children: expect.objectContaining({
3149 + type: 'span',
3150 + props: {
3151 + children: ['Hello, ', 'Seb'],
3152 + },
3153 + }),
3154 + },
3155 },
3156 ]);
3157 // The owner that created the span was the outer server component.
packages/react-devtools-shared/src/backend/fiber/renderer.js
+1 -2
@@ -4348,8 +4348,7 @@ export function attach(
4348 const componentInfo = virtualInstance.data;
4349 const key =
4350 typeof componentInfo.key === 'string' ? componentInfo.key : null;
4351 - const props = null; // TODO: Track props on ReactComponentInfo;
4352 -
4351 + const props = componentInfo.props == null ? null : componentInfo.props;
4352 const owners: null | Array<SerializedElement> =
4353 getOwnersListFromInstance(virtualInstance);
4354
packages/react-devtools-shared/src/hydration.js
+51 -7
@@ -216,16 +216,19 @@ export function dehydrate(
216 if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
217 return createDehydrated(type, true, data, cleaned, path);
218 }
219 - return data.map((item, i) =>
220 - dehydrate(
221 - item,
219 + const arr: Array<Object> = [];
220 + for (let i = 0; i < data.length; i++) {
221 + arr[i] = dehydrateKey(
222 + data,
223 + i,
224 cleaned,
225 unserializable,
226 path.concat([i]),
227 isPathAllowed,
228 isPathAllowedCheck ? 1 : level + 1,
227 - ),
228 - );
229 + );
230 + }
231 + return arr;
232
233 case 'html_all_collection':
234 case 'typed_array':
@@ -311,8 +314,9 @@ export function dehydrate(
314 } = {};
315 getAllEnumerableKeys(data).forEach(key => {
316 const name = key.toString();
314 - object[name] = dehydrate(
315 - data[key],
317 + object[name] = dehydrateKey(
318 + data,
319 + key,
320 cleaned,
321 unserializable,
322 path.concat([name]),
@@ -373,6 +377,46 @@ export function dehydrate(
377 }
378 }
379
380 +function dehydrateKey(
381 + parent: Object,
382 + key: number | string | symbol,
383 + cleaned: Array<Array<string | number>>,
384 + unserializable: Array<Array<string | number>>,
385 + path: Array<string | number>,
386 + isPathAllowed: (path: Array<string | number>) => boolean,
387 + level: number = 0,
388 +): $PropertyType<DehydratedData, 'data'> {
389 + try {
390 + return dehydrate(
391 + parent[key],
392 + cleaned,
393 + unserializable,
394 + path,
395 + isPathAllowed,
396 + level,
397 + );
398 + } catch (error) {
399 + let preview = '';
400 + if (
401 + typeof error === 'object' &&
402 + error !== null &&
403 + typeof error.stack === 'string'
404 + ) {
405 + preview = error.stack;
406 + } else if (typeof error === 'string') {
407 + preview = error;
408 + }
409 + cleaned.push(path);
410 + return {
411 + inspectable: false,
412 + preview_short: '[Exception]',
413 + preview_long: preview ? '[Exception: ' + preview + ']' : '[Exception]',
414 + name: preview,
415 + type: 'unknown',
416 + };
417 + }
418 +}
419 +
420 export function fillInPath(
421 object: Object,
422 data: DehydratedData,
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+5 -3
@@ -709,7 +709,7 @@ describe('ReactFlightDOMBrowser', () => {
709 expect(container.innerHTML).toBe(expectedHtml);
710
711 if (__DEV__) {
712 - const resolvedPath1b = await response.value[0].props.children[1]._payload;
712 + const resolvedPath1b = response.value[0].props.children[1];
713
714 expect(resolvedPath1b._owner).toEqual(
715 expect.objectContaining({
@@ -1028,8 +1028,10 @@ describe('ReactFlightDOMBrowser', () => {
1028 expect(flightResponse).toContain('(loading everything)');
1029 expect(flightResponse).toContain('(loading sidebar)');
1030 expect(flightResponse).toContain('(loading posts)');
1031 - expect(flightResponse).not.toContain(':friends:');
1032 - expect(flightResponse).not.toContain(':name:');
1031 + if (!__DEV__) {
1032 + expect(flightResponse).not.toContain(':friends:');
1033 + expect(flightResponse).not.toContain(':name:');
1034 + }
1035
1036 await serverAct(() => {
1037 resolveFriends();
packages/react-server/src/ReactFlightServer.js
+138 -80
@@ -1148,14 +1148,20 @@ function renderFunctionComponent<Props>(
1148 ? null
1149 : filterStackTrace(request, task.debugStack, 1);
1150 // $FlowFixMe[cannot-write]
1151 + componentDebugInfo.props = props;
1152 + // $FlowFixMe[cannot-write]
1153 componentDebugInfo.debugStack = task.debugStack;
1154 // $FlowFixMe[cannot-write]
1155 componentDebugInfo.debugTask = task.debugTask;
1156 + } else {
1157 + // $FlowFixMe[cannot-write]
1158 + componentDebugInfo.props = props;
1159 }
1160 // We outline this model eagerly so that we can refer to by reference as an owner.
1161 // If we had a smarter way to dedupe we might not have to do this if there ends up
1162 // being no references to this as an owner.
1158 - outlineModel(request, componentDebugInfo);
1163 +
1164 + outlineComponentInfo(request, componentDebugInfo);
1165 emitDebugChunk(request, componentDebugID, componentDebugInfo);
1166
1167 // We've emitted the latest environment for this task so we track that.
@@ -1582,6 +1588,13 @@ function renderClientElement(
1588 } else if (keyPath !== null) {
1589 key = keyPath + ',' + key;
1590 }
1591 + if (__DEV__) {
1592 + if (task.debugOwner !== null) {
1593 + // Ensure we outline this owner if it is the first time we see it.
1594 + // So that we can refer to it directly.
1595 + outlineComponentInfo(request, task.debugOwner);
1596 + }
1597 + }
1598 const element = __DEV__
1599 ? enableOwnerStacks
1600 ? [
@@ -1702,6 +1715,7 @@ function renderElement(
1715 task.debugStack === null
1716 ? null
1717 : filterStackTrace(request, task.debugStack, 1),
1718 + props: props,
1719 debugStack: task.debugStack,
1720 debugTask: task.debugTask,
1721 };
@@ -2128,7 +2142,7 @@ function serializeSet(request: Request, set: Set<ReactClientValue>): string {
2142
2143 function serializeConsoleMap(
2144 request: Request,
2131 - counter: {objectCount: number},
2145 + counter: {objectLimit: number},
2146 map: Map<ReactClientValue, ReactClientValue>,
2147 ): string {
2148 // Like serializeMap but for renderConsoleValue.
@@ -2139,7 +2153,7 @@ function serializeConsoleMap(
2153
2154 function serializeConsoleSet(
2155 request: Request,
2142 - counter: {objectCount: number},
2156 + counter: {objectLimit: number},
2157 set: Set<ReactClientValue>,
2158 ): string {
2159 // Like serializeMap but for renderConsoleValue.
@@ -2263,23 +2277,6 @@ function escapeStringValue(value: string): string {
2277 }
2278 }
2279
2266 -function isReactComponentInfo(value: any): boolean {
2267 - // TODO: We don't currently have a brand check on ReactComponentInfo. Reconsider.
2268 - return (
2269 - ((typeof value.debugTask === 'object' &&
2270 - value.debugTask !== null &&
2271 - // $FlowFixMe[method-unbinding]
2272 - typeof value.debugTask.run === 'function') ||
2273 - value.debugStack instanceof Error) &&
2274 - (enableOwnerStacks
2275 - ? isArray((value: any).stack) || (value: any).stack === null
2276 - : typeof (value: any).stack === 'undefined') &&
2277 - typeof value.name === 'string' &&
2278 - typeof value.env === 'string' &&
2279 - value.owner !== undefined
2280 - );
2281 -}
2282 -
2280 let modelRoot: null | ReactClientValue = false;
2281
2282 function renderModel(
@@ -2795,25 +2792,6 @@ function renderModelDestructive(
2792 );
2793 }
2794 if (__DEV__) {
2798 - if (isReactComponentInfo(value)) {
2799 - // This looks like a ReactComponentInfo. We can't serialize the ConsoleTask object so we
2800 - // need to omit it before serializing.
2801 - const componentDebugInfo: Omit<
2802 - ReactComponentInfo,
2803 - 'debugTask' | 'debugStack',
2804 - > = {
2805 - name: (value: any).name,
2806 - env: (value: any).env,
2807 - key: (value: any).key,
2808 - owner: (value: any).owner,
2809 - };
2810 - if (enableOwnerStacks) {
2811 - // $FlowFixMe[cannot-write]
2812 - componentDebugInfo.stack = (value: any).stack;
2813 - }
2814 - return componentDebugInfo;
2815 - }
2816 -
2795 if (objectName(value) !== 'Object') {
2796 callWithDebugContextInDEV(request, task, () => {
2797 console.error(
@@ -3241,7 +3219,7 @@ function emitDebugChunk(
3219
3220 // We use the console encoding so that we can dedupe objects but don't necessarily
3221 // use the full serialization that requires a task.
3244 - const counter = {objectCount: 0};
3222 + const counter = {objectLimit: 500};
3223 function replacer(
3224 this:
3225 | {+[key: string | number]: ReactClientValue}
@@ -3265,6 +3243,61 @@ function emitDebugChunk(
3243 request.completedRegularChunks.push(processedChunk);
3244 }
3245
3246 +function outlineComponentInfo(
3247 + request: Request,
3248 + componentInfo: ReactComponentInfo,
3249 +): void {
3250 + if (!__DEV__) {
3251 + // These errors should never make it into a build so we don't need to encode them in codes.json
3252 + // eslint-disable-next-line react-internal/prod-error-codes
3253 + throw new Error(
3254 + 'outlineComponentInfo should never be called in production mode. This is a bug in React.',
3255 + );
3256 + }
3257 +
3258 + if (request.writtenObjects.has(componentInfo)) {
3259 + // Already written
3260 + return;
3261 + }
3262 +
3263 + if (componentInfo.owner != null) {
3264 + // Ensure the owner is already outlined.
3265 + outlineComponentInfo(request, componentInfo.owner);
3266 + }
3267 +
3268 + // Limit the number of objects we write to prevent emitting giant props objects.
3269 + let objectLimit = 10;
3270 + if (componentInfo.stack != null) {
3271 + // Ensure we have enough object limit to encode the stack trace.
3272 + objectLimit += componentInfo.stack.length;
3273 + }
3274 +
3275 + // We use the console encoding so that we can dedupe objects but don't necessarily
3276 + // use the full serialization that requires a task.
3277 + const counter = {objectLimit};
3278 +
3279 + // We can't serialize the ConsoleTask/Error objects so we need to omit them before serializing.
3280 + const componentDebugInfo: Omit<
3281 + ReactComponentInfo,
3282 + 'debugTask' | 'debugStack',
3283 + > = {
3284 + name: componentInfo.name,
3285 + env: componentInfo.env,
3286 + key: componentInfo.key,
3287 + owner: componentInfo.owner,
3288 + };
3289 + if (enableOwnerStacks) {
3290 + // $FlowFixMe[cannot-write]
3291 + componentDebugInfo.stack = componentInfo.stack;
3292 + }
3293 + // Ensure we serialize props after the stack to favor the stack being complete.
3294 + // $FlowFixMe[cannot-write]
3295 + componentDebugInfo.props = componentInfo.props;
3296 +
3297 + const id = outlineConsoleValue(request, counter, componentDebugInfo);
3298 + request.writtenObjects.set(componentInfo, serializeByValueID(id));
3299 +}
3300 +
3301 function emitTypedArrayChunk(
3302 request: Request,
3303 id: number,
@@ -3322,7 +3355,7 @@ function serializeEval(source: string): string {
3355 // in the depth it can encode.
3356 function renderConsoleValue(
3357 request: Request,
3325 - counter: {objectCount: number},
3358 + counter: {objectLimit: number},
3359 parent:
3360 | {+[propertyName: string | number]: ReactClientValue}
3361 | $ReadOnlyArray<ReactClientValue>,
@@ -3366,23 +3399,64 @@ function renderConsoleValue(
3399 }
3400 }
3401
3369 - if (counter.objectCount > 500) {
3402 + const writtenObjects = request.writtenObjects;
3403 + const existingReference = writtenObjects.get(value);
3404 + if (existingReference !== undefined) {
3405 + // We've already emitted this as a real object, so we can
3406 + // just refer to that by its existing reference.
3407 + return existingReference;
3408 + }
3409 +
3410 + if (counter.objectLimit <= 0) {
3411 // We've reached our max number of objects to serialize across the wire so we serialize this
3412 // as a marker so that the client can error when this is accessed by the console.
3413 return serializeLimitedObject();
3414 }
3415
3375 - counter.objectCount++;
3416 + counter.objectLimit--;
3417
3377 - const writtenObjects = request.writtenObjects;
3378 - const existingReference = writtenObjects.get(value);
3379 - // $FlowFixMe[method-unbinding]
3380 - if (typeof value.then === 'function') {
3381 - if (existingReference !== undefined) {
3382 - // We've seen this promise before, so we can just refer to the same result.
3383 - return existingReference;
3418 + switch ((value: any).$$typeof) {
3419 + case REACT_ELEMENT_TYPE: {
3420 + const element: ReactElement = (value: any);
3421 +
3422 + if (element._owner != null) {
3423 + outlineComponentInfo(request, element._owner);
3424 + }
3425 + if (enableOwnerStacks) {
3426 + let debugStack: null | ReactStackTrace = null;
3427 + if (element._debugStack != null) {
3428 + // Outline the debug stack so that it doesn't get cut off.
3429 + debugStack = filterStackTrace(request, element._debugStack, 1);
3430 + const stackId = outlineConsoleValue(
3431 + request,
3432 + {objectLimit: debugStack.length + 2},
3433 + debugStack,
3434 + );
3435 + request.writtenObjects.set(debugStack, serializeByValueID(stackId));
3436 + }
3437 + return [
3438 + REACT_ELEMENT_TYPE,
3439 + element.type,
3440 + element.key,
3441 + element.props,
3442 + element._owner,
3443 + debugStack,
3444 + element._store.validated,
3445 + ];
3446 + }
3447 +
3448 + return [
3449 + REACT_ELEMENT_TYPE,
3450 + element.type,
3451 + element.key,
3452 + element.props,
3453 + element._owner,
3454 + ];
3455 }
3456 + }
3457
3458 + // $FlowFixMe[method-unbinding]
3459 + if (typeof value.then === 'function') {
3460 const thenable: Thenable<any> = (value: any);
3461 switch (thenable.status) {
3462 case 'fulfilled': {
@@ -3416,12 +3490,6 @@ function renderConsoleValue(
3490 return serializeInfinitePromise();
3491 }
3492
3419 - if (existingReference !== undefined) {
3420 - // We've already emitted this as a real object, so we can
3421 - // just refer to that by its existing reference.
3422 - return existingReference;
3423 - }
3424 -
3493 if (isArray(value)) {
3494 return value;
3495 }
@@ -3503,25 +3571,6 @@ function renderConsoleValue(
3571 return Array.from((value: any));
3572 }
3573
3506 - if (isReactComponentInfo(value)) {
3507 - // This looks like a ReactComponentInfo. We can't serialize the ConsoleTask object so we
3508 - // need to omit it before serializing.
3509 - const componentDebugInfo: Omit<
3510 - ReactComponentInfo,
3511 - 'debugTask' | 'debugStack',
3512 - > = {
3513 - name: (value: any).name,
3514 - env: (value: any).env,
3515 - key: (value: any).key,
3516 - owner: (value: any).owner,
3517 - };
3518 - if (enableOwnerStacks) {
3519 - // $FlowFixMe[cannot-write]
3520 - componentDebugInfo.stack = (value: any).stack;
3521 - }
3522 - return componentDebugInfo;
3523 - }
3524 -
3574 // $FlowFixMe[incompatible-return]
3575 return value;
3576 }
@@ -3602,7 +3651,7 @@ function renderConsoleValue(
3651
3652 function outlineConsoleValue(
3653 request: Request,
3605 - counter: {objectCount: number},
3654 + counter: {objectLimit: number},
3655 model: ReactClientValue,
3656 ): number {
3657 if (!__DEV__) {
@@ -3629,7 +3678,9 @@ function outlineConsoleValue(
3678 value,
3679 );
3680 } catch (x) {
3632 - return 'unknown value';
3681 + return (
3682 + 'Unknown Value: React could not send it from the server.\n' + x.message
3683 + );
3684 }
3685 }
3686
@@ -3660,7 +3711,7 @@ function emitConsoleChunk(
3711 );
3712 }
3713
3663 - const counter = {objectCount: 0};
3714 + const counter = {objectLimit: 500};
3715 function replacer(
3716 this:
3717 | {+[key: string | number]: ReactClientValue}
@@ -3677,10 +3728,17 @@ function emitConsoleChunk(
3728 value,
3729 );
3730 } catch (x) {
3680 - return 'unknown value';
3731 + return (
3732 + 'Unknown Value: React could not send it from the server.\n' + x.message
3733 + );
3734 }
3735 }
3736
3737 + // Ensure the owner is already outlined.
3738 + if (owner != null) {
3739 + outlineComponentInfo(request, owner);
3740 + }
3741 +
3742 // TODO: Don't double badge if this log came from another Flight Client.
3743 const env = (0, request.environmentName)();
3744 const payload = [methodName, stackTrace, owner, env];
@@ -3704,7 +3762,7 @@ function forwardDebugInfo(
3762 // We outline this model eagerly so that we can refer to by reference as an owner.
3763 // If we had a smarter way to dedupe we might not have to do this if there ends up
3764 // being no references to this as an owner.
3707 - outlineModel(request, debugInfo[i]);
3765 + outlineComponentInfo(request, (debugInfo[i]: any));
3766 }
3767 emitDebugChunk(request, id, debugInfo[i]);
3768 }
packages/shared/ReactTypes.js
+1
@@ -193,6 +193,7 @@ export type ReactComponentInfo = {
193 +key?: null | string,
194 +owner?: null | ReactComponentInfo,
195 +stack?: null | ReactStackTrace,
196 + +props?: null | {[name: string]: mixed},
197 // Stashed Data for the Specific Execution Environment. Not part of the transport protocol
198 +debugStack?: null | Error,
199 +debugTask?: null | ConsoleTask,