Skip to content

Commit

Permalink
[Flight] Serialize Server Components Props in DEV (#31105)
Browse files Browse the repository at this point in the history
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">
  • Loading branch information
sebmarkbage authored Oct 1, 2024
1 parent 326832a commit 654e387
Show file tree
Hide file tree
Showing 7 changed files with 245 additions and 97 deletions.
21 changes: 16 additions & 5 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -2640,11 +2640,22 @@ function processFullStringRow(
}
case 68 /* "D" */: {
if (__DEV__) {
const debugInfo: ReactComponentInfo | ReactAsyncInfo = parseModel(
response,
row,
);
resolveDebugInfo(response, id, debugInfo);
const chunk: ResolvedModelChunk<ReactComponentInfo | ReactAsyncInfo> =
createResolvedModelChunk(response, row);
initializeModelChunk(chunk);
const initializedChunk: SomeChunk<ReactComponentInfo | ReactAsyncInfo> =
chunk;
if (initializedChunk.status === INITIALIZED) {
resolveDebugInfo(response, id, initializedChunk.value);
} else {
// TODO: This is not going to resolve in the right order if there's more than one.
chunk.then(
v => resolveDebugInfo(response, id, v),
e => {
// Ignore debug info errors for now. Unnecessary noise.
},
);
}
return;
}
// Fallthrough to share the error with Console entries.
Expand Down
33 changes: 33 additions & 0 deletions packages/react-client/src/__tests__/ReactFlight-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@ describe('ReactFlight', () => {
stack: gate(flag => flag.enableOwnerStacks)
? ' in Object.<anonymous> (at **)'
: undefined,
props: {
firstName: 'Seb',
lastName: 'Smith',
},
},
]
: undefined,
Expand Down Expand Up @@ -347,6 +351,10 @@ describe('ReactFlight', () => {
stack: gate(flag => flag.enableOwnerStacks)
? ' in Object.<anonymous> (at **)'
: undefined,
props: {
firstName: 'Seb',
lastName: 'Smith',
},
},
]
: undefined,
Expand Down Expand Up @@ -2665,6 +2673,9 @@ describe('ReactFlight', () => {
stack: gate(flag => flag.enableOwnerStacks)
? ' in Object.<anonymous> (at **)'
: undefined,
props: {
transport: expect.arrayContaining([]),
},
},
]
: undefined,
Expand All @@ -2683,6 +2694,7 @@ describe('ReactFlight', () => {
stack: gate(flag => flag.enableOwnerStacks)
? ' in Object.<anonymous> (at **)'
: undefined,
props: {},
},
]
: undefined,
Expand All @@ -2698,6 +2710,7 @@ describe('ReactFlight', () => {
stack: gate(flag => flag.enableOwnerStacks)
? ' in myLazy (at **)\n in lazyInitializer (at **)'
: undefined,
props: {},
},
]
: undefined,
Expand All @@ -2713,6 +2726,7 @@ describe('ReactFlight', () => {
stack: gate(flag => flag.enableOwnerStacks)
? ' in Object.<anonymous> (at **)'
: undefined,
props: {},
},
]
: undefined,
Expand Down Expand Up @@ -2787,6 +2801,9 @@ describe('ReactFlight', () => {
stack: gate(flag => flag.enableOwnerStacks)
? ' in Object.<anonymous> (at **)'
: undefined,
props: {
transport: expect.arrayContaining([]),
},
},
]
: undefined,
Expand All @@ -2804,6 +2821,9 @@ describe('ReactFlight', () => {
stack: gate(flag => flag.enableOwnerStacks)
? ' in ServerComponent (at **)'
: undefined,
props: {
children: {},
},
},
]
: undefined,
Expand All @@ -2820,6 +2840,7 @@ describe('ReactFlight', () => {
stack: gate(flag => flag.enableOwnerStacks)
? ' in Object.<anonymous> (at **)'
: undefined,
props: {},
},
]
: undefined,
Expand Down Expand Up @@ -2978,6 +2999,7 @@ describe('ReactFlight', () => {
stack: gate(flag => flag.enableOwnerStacks)
? ' in Object.<anonymous> (at **)'
: undefined,
props: {},
},
{
env: 'B',
Expand Down Expand Up @@ -3108,6 +3130,9 @@ describe('ReactFlight', () => {
stack: gate(flag => flag.enableOwnerStacks)
? ' in Object.<anonymous> (at **)'
: undefined,
props: {
firstName: 'Seb',
},
};
expect(getDebugInfo(greeting)).toEqual([
greetInfo,
Expand All @@ -3119,6 +3144,14 @@ describe('ReactFlight', () => {
stack: gate(flag => flag.enableOwnerStacks)
? ' in Greeting (at **)'
: undefined,
props: {
children: expect.objectContaining({
type: 'span',
props: {
children: ['Hello, ', 'Seb'],
},
}),
},
},
]);
// The owner that created the span was the outer server component.
Expand Down
3 changes: 1 addition & 2 deletions packages/react-devtools-shared/src/backend/fiber/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4348,8 +4348,7 @@ export function attach(
const componentInfo = virtualInstance.data;
const key =
typeof componentInfo.key === 'string' ? componentInfo.key : null;
const props = null; // TODO: Track props on ReactComponentInfo;

const props = componentInfo.props == null ? null : componentInfo.props;
const owners: null | Array<SerializedElement> =
getOwnersListFromInstance(virtualInstance);

Expand Down
58 changes: 51 additions & 7 deletions packages/react-devtools-shared/src/hydration.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,16 +216,19 @@ export function dehydrate(
if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
return createDehydrated(type, true, data, cleaned, path);
}
return data.map((item, i) =>
dehydrate(
item,
const arr: Array<Object> = [];
for (let i = 0; i < data.length; i++) {
arr[i] = dehydrateKey(
data,
i,
cleaned,
unserializable,
path.concat([i]),
isPathAllowed,
isPathAllowedCheck ? 1 : level + 1,
),
);
);
}
return arr;

case 'html_all_collection':
case 'typed_array':
Expand Down Expand Up @@ -311,8 +314,9 @@ export function dehydrate(
} = {};
getAllEnumerableKeys(data).forEach(key => {
const name = key.toString();
object[name] = dehydrate(
data[key],
object[name] = dehydrateKey(
data,
key,
cleaned,
unserializable,
path.concat([name]),
Expand Down Expand Up @@ -373,6 +377,46 @@ export function dehydrate(
}
}

function dehydrateKey(
parent: Object,
key: number | string | symbol,
cleaned: Array<Array<string | number>>,
unserializable: Array<Array<string | number>>,
path: Array<string | number>,
isPathAllowed: (path: Array<string | number>) => boolean,
level: number = 0,
): $PropertyType<DehydratedData, 'data'> {
try {
return dehydrate(
parent[key],
cleaned,
unserializable,
path,
isPathAllowed,
level,
);
} catch (error) {
let preview = '';
if (
typeof error === 'object' &&
error !== null &&
typeof error.stack === 'string'
) {
preview = error.stack;
} else if (typeof error === 'string') {
preview = error;
}
cleaned.push(path);
return {
inspectable: false,
preview_short: '[Exception]',
preview_long: preview ? '[Exception: ' + preview + ']' : '[Exception]',
name: preview,
type: 'unknown',
};
}
}

export function fillInPath(
object: Object,
data: DehydratedData,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ describe('ReactFlightDOMBrowser', () => {
expect(container.innerHTML).toBe(expectedHtml);

if (__DEV__) {
const resolvedPath1b = await response.value[0].props.children[1]._payload;
const resolvedPath1b = response.value[0].props.children[1];

expect(resolvedPath1b._owner).toEqual(
expect.objectContaining({
Expand Down Expand Up @@ -1028,8 +1028,10 @@ describe('ReactFlightDOMBrowser', () => {
expect(flightResponse).toContain('(loading everything)');
expect(flightResponse).toContain('(loading sidebar)');
expect(flightResponse).toContain('(loading posts)');
expect(flightResponse).not.toContain(':friends:');
expect(flightResponse).not.toContain(':name:');
if (!__DEV__) {
expect(flightResponse).not.toContain(':friends:');
expect(flightResponse).not.toContain(':name:');
}

await serverAct(() => {
resolveFriends();
Expand Down
Loading

0 comments on commit 654e387

Please sign in to comment.