Skip to content

Commit

Permalink
[Flight] Don't limit objects that are children of special types (#31160)
Browse files Browse the repository at this point in the history
We can't make a special getter to mark the boundary of deep
serialization (which can be used for lazy loading in the future) when
the parent object is a special object that we parse with
getOutlinedModel. Such as Map/Set and JSX.

This marks the objects that are direct children of those as not possible
to limit.

I don't love this solution since ideally it would maybe be more local to
the serialization of a specific object.

It also means that very deep trees of only Map/Set never get cut off.
Maybe we should instead override the `get()` and enumeration methods on
these instead somehow.

It's important to have it be a getter though because that's the
mechanism that lets us lazy-load more depth in the future.
  • Loading branch information
sebmarkbage authored Oct 10, 2024
1 parent 131ae81 commit 566b0b0
Show file tree
Hide file tree
Showing 2 changed files with 140 additions and 17 deletions.
92 changes: 92 additions & 0 deletions packages/react-client/src/__tests__/ReactFlight-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3435,4 +3435,96 @@ describe('ReactFlight', () => {
);
expect(caughtError.digest).toBe('digest("my-error")');
});

// @gate __DEV__
it('can render deep but cut off JSX in debug info', async () => {
function createDeepJSX(n) {
if (n <= 0) {
return null;
}
return <div>{createDeepJSX(n - 1)}</div>;
}

function ServerComponent(props) {
return <div>not using props</div>;
}

const transport = ReactNoopFlightServer.render({
root: (
<ServerComponent>
{createDeepJSX(100) /* deper than objectLimit */}
</ServerComponent>
),
});

await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
const root = rootModel.root;
const children = root._debugInfo[0].props.children;
expect(children.type).toBe('div');
expect(children.props.children.type).toBe('div');
ReactNoop.render(root);
});

expect(ReactNoop).toMatchRenderedOutput(<div>not using props</div>);
});

// @gate __DEV__
it('can render deep but cut off Map/Set in debug info', async () => {
function createDeepMap(n) {
if (n <= 0) {
return null;
}
const map = new Map();
map.set('key', createDeepMap(n - 1));
return map;
}

function createDeepSet(n) {
if (n <= 0) {
return null;
}
const set = new Set();
set.add(createDeepSet(n - 1));
return set;
}

function ServerComponent(props) {
return <div>not using props</div>;
}

const transport = ReactNoopFlightServer.render({
set: (
<ServerComponent
set={createDeepSet(100) /* deper than objectLimit */}
/>
),
map: (
<ServerComponent
map={createDeepMap(100) /* deper than objectLimit */}
/>
),
});

await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
const set = rootModel.set._debugInfo[0].props.set;
const map = rootModel.map._debugInfo[0].props.map;
expect(set instanceof Set).toBe(true);
expect(set.size).toBe(1);
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const entry of set) {
expect(entry instanceof Set).toBe(true);
break;
}

expect(map instanceof Map).toBe(true);
expect(map.size).toBe(1);
expect(map.get('key') instanceof Map).toBe(true);

ReactNoop.render(rootModel.set);
});

expect(ReactNoop).toMatchRenderedOutput(<div>not using props</div>);
});
});
65 changes: 48 additions & 17 deletions packages/react-server/src/ReactFlightServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ import binaryToComparableString from 'shared/binaryToComparableString';

import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable';

// DEV-only set containing internal objects that should not be limited and turned into getters.
const doNotLimit: WeakSet<Reference> = __DEV__ ? new WeakSet() : (null: any);

function defaultFilterStackFrame(
filename: string,
functionName: string,
Expand Down Expand Up @@ -2153,6 +2156,22 @@ function serializeConsoleMap(
): string {
// Like serializeMap but for renderConsoleValue.
const entries = Array.from(map);
// The Map itself doesn't take up any space but the outlined object does.
counter.objectLimit++;
for (let i = 0; i < entries.length; i++) {
// Outline every object entry in case we run out of space to serialize them.
// Because we can't mark these values as limited.
const entry = entries[i];
doNotLimit.add(entry);
const key = entry[0];
const value = entry[1];
if (typeof key === 'object' && key !== null) {
doNotLimit.add(key);
}
if (typeof value === 'object' && value !== null) {
doNotLimit.add(value);
}
}
const id = outlineConsoleValue(request, counter, entries);
return '$Q' + id.toString(16);
}
Expand All @@ -2164,6 +2183,16 @@ function serializeConsoleSet(
): string {
// Like serializeMap but for renderConsoleValue.
const entries = Array.from(set);
// The Set itself doesn't take up any space but the outlined object does.
counter.objectLimit++;
for (let i = 0; i < entries.length; i++) {
// Outline every object entry in case we run out of space to serialize them.
// Because we can't mark these values as limited.
const entry = entries[i];
if (typeof entry === 'object' && entry !== null) {
doNotLimit.add(entry);
}
}
const id = outlineConsoleValue(request, counter, entries);
return '$W' + id.toString(16);
}
Expand Down Expand Up @@ -3376,20 +3405,15 @@ function renderConsoleValue(
parentPropertyName: string,
value: ReactClientValue,
): ReactJSONValue {
// Make sure that `parent[parentPropertyName]` wasn't JSONified before `value` was passed to us
// $FlowFixMe[incompatible-use]
const originalValue = parent[parentPropertyName];
if (
typeof originalValue === 'object' &&
originalValue !== value &&
!(originalValue instanceof Date)
) {
}

if (value === null) {
return null;
}

// Special Symbol, that's very common.
if (value === REACT_ELEMENT_TYPE) {
return '$';
}

if (typeof value === 'object') {
if (isClientReference(value)) {
// We actually have this value on the client so we could import it.
Expand Down Expand Up @@ -3421,7 +3445,7 @@ function renderConsoleValue(
return existingReference;
}

if (counter.objectLimit <= 0) {
if (counter.objectLimit <= 0 && !doNotLimit.has(value)) {
// We've reached our max number of objects to serialize across the wire so we serialize this
// as a marker so that the client can error when this is accessed by the console.
return serializeLimitedObject();
Expand All @@ -3441,13 +3465,12 @@ function renderConsoleValue(
if (element._debugStack != null) {
// Outline the debug stack so that it doesn't get cut off.
debugStack = filterStackTrace(request, element._debugStack, 1);
const stackId = outlineConsoleValue(
request,
{objectLimit: debugStack.length + 2},
debugStack,
);
request.writtenObjects.set(debugStack, serializeByValueID(stackId));
doNotLimit.add(debugStack);
for (let i = 0; i < debugStack.length; i++) {
doNotLimit.add(debugStack[i]);
}
}
doNotLimit.add(element.props);
return [
REACT_ELEMENT_TYPE,
element.type,
Expand Down Expand Up @@ -3592,6 +3615,9 @@ function renderConsoleValue(
if (typeof value === 'string') {
if (value[value.length - 1] === 'Z') {
// Possibly a Date, whose toJSON automatically calls toISOString
// Make sure that `parent[parentPropertyName]` wasn't JSONified before `value` was passed to us
// $FlowFixMe[incompatible-use]
const originalValue = parent[parentPropertyName];
if (originalValue instanceof Date) {
return serializeDateFromDateJSON(value);
}
Expand Down Expand Up @@ -3680,6 +3706,11 @@ function outlineConsoleValue(
);
}

if (typeof model === 'object' && model !== null) {
// We can't limit outlined values.
doNotLimit.add(model);
}

function replacer(
this:
| {+[key: string | number]: ReactClientValue}
Expand Down

0 comments on commit 566b0b0

Please sign in to comment.