[Flight] Don't limit objects that are children of special types (#31160)
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.
Sebastian Markbåge committed
Oct 10, 2024 at 07:36 UTC
566b0b0f14356f6e1a13722cac005c2f94f4c3f9
2 files changed
+140
-17
packages/react-client/src/__tests__/ReactFlight-test.js
+92
@@ -3435,4 +3435,96 @@ describe('ReactFlight', () => {
3435
);
3436
expect(caughtError.digest).toBe('digest("my-error")');
3437
});
3438
+
3439
+ // @gate __DEV__
3440
+ it('can render deep but cut off JSX in debug info', async () => {
3441
+ function createDeepJSX(n) {
3442
+ if (n <= 0) {
3443
+ return null;
3444
+ }
3445
+ return <div>{createDeepJSX(n - 1)}</div>;
3446
+ }
3447
+
3448
+ function ServerComponent(props) {
3449
+ return <div>not using props</div>;
3450
+ }
3451
+
3452
+ const transport = ReactNoopFlightServer.render({
3453
+ root: (
3454
+ <ServerComponent>
3455
+ {createDeepJSX(100) /* deper than objectLimit */}
3456
+ </ServerComponent>
3457
+ ),
3458
+ });
3459
+
3460
+ await act(async () => {
3461
+ const rootModel = await ReactNoopFlightClient.read(transport);
3462
+ const root = rootModel.root;
3463
+ const children = root._debugInfo[0].props.children;
3464
+ expect(children.type).toBe('div');
3465
+ expect(children.props.children.type).toBe('div');
3466
+ ReactNoop.render(root);
3467
+ });
3468
+
3469
+ expect(ReactNoop).toMatchRenderedOutput(<div>not using props</div>);
3470
+ });
3471
+
3472
+ // @gate __DEV__
3473
+ it('can render deep but cut off Map/Set in debug info', async () => {
3474
+ function createDeepMap(n) {
3475
+ if (n <= 0) {
3476
+ return null;
3477
+ }
3478
+ const map = new Map();
3479
+ map.set('key', createDeepMap(n - 1));
3480
+ return map;
3481
+ }
3482
+
3483
+ function createDeepSet(n) {
3484
+ if (n <= 0) {
3485
+ return null;
3486
+ }
3487
+ const set = new Set();
3488
+ set.add(createDeepSet(n - 1));
3489
+ return set;
3490
+ }
3491
+
3492
+ function ServerComponent(props) {
3493
+ return <div>not using props</div>;
3494
+ }
3495
+
3496
+ const transport = ReactNoopFlightServer.render({
3497
+ set: (
3498
+ <ServerComponent
3499
+ set={createDeepSet(100) /* deper than objectLimit */}
3500
+ />
3501
+ ),
3502
+ map: (
3503
+ <ServerComponent
3504
+ map={createDeepMap(100) /* deper than objectLimit */}
3505
+ />
3506
+ ),
3507
+ });
3508
+
3509
+ await act(async () => {
3510
+ const rootModel = await ReactNoopFlightClient.read(transport);
3511
+ const set = rootModel.set._debugInfo[0].props.set;
3512
+ const map = rootModel.map._debugInfo[0].props.map;
3513
+ expect(set instanceof Set).toBe(true);
3514
+ expect(set.size).toBe(1);
3515
+ // eslint-disable-next-line no-for-of-loops/no-for-of-loops
3516
+ for (const entry of set) {
3517
+ expect(entry instanceof Set).toBe(true);
3518
+ break;
3519
+ }
3520
+
3521
+ expect(map instanceof Map).toBe(true);
3522
+ expect(map.size).toBe(1);
3523
+ expect(map.get('key') instanceof Map).toBe(true);
3524
+
3525
+ ReactNoop.render(rootModel.set);
3526
+ });
3527
+
3528
+ expect(ReactNoop).toMatchRenderedOutput(<div>not using props</div>);
3529
+ });
3530
});
packages/react-server/src/ReactFlightServer.js
+48
-17
@@ -137,6 +137,9 @@ import binaryToComparableString from 'shared/binaryToComparableString';
137
138
import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable';
139
140
+// DEV-only set containing internal objects that should not be limited and turned into getters.
141
+const doNotLimit: WeakSet<Reference> = __DEV__ ? new WeakSet() : (null: any);
142
+
143
function defaultFilterStackFrame(
144
filename: string,
145
functionName: string,
@@ -2153,6 +2156,22 @@ function serializeConsoleMap(
2156
): string {
2157
// Like serializeMap but for renderConsoleValue.
2158
const entries = Array.from(map);
2159
+ // The Map itself doesn't take up any space but the outlined object does.
2160
+ counter.objectLimit++;
2161
+ for (let i = 0; i < entries.length; i++) {
2162
+ // Outline every object entry in case we run out of space to serialize them.
2163
+ // Because we can't mark these values as limited.
2164
+ const entry = entries[i];
2165
+ doNotLimit.add(entry);
2166
+ const key = entry[0];
2167
+ const value = entry[1];
2168
+ if (typeof key === 'object' && key !== null) {
2169
+ doNotLimit.add(key);
2170
+ }
2171
+ if (typeof value === 'object' && value !== null) {
2172
+ doNotLimit.add(value);
2173
+ }
2174
+ }
2175
const id = outlineConsoleValue(request, counter, entries);
2176
return '$Q' + id.toString(16);
2177
}
@@ -2164,6 +2183,16 @@ function serializeConsoleSet(
2183
): string {
2184
// Like serializeMap but for renderConsoleValue.
2185
const entries = Array.from(set);
2186
+ // The Set itself doesn't take up any space but the outlined object does.
2187
+ counter.objectLimit++;
2188
+ for (let i = 0; i < entries.length; i++) {
2189
+ // Outline every object entry in case we run out of space to serialize them.
2190
+ // Because we can't mark these values as limited.
2191
+ const entry = entries[i];
2192
+ if (typeof entry === 'object' && entry !== null) {
2193
+ doNotLimit.add(entry);
2194
+ }
2195
+ }
2196
const id = outlineConsoleValue(request, counter, entries);
2197
return '$W' + id.toString(16);
2198
}
@@ -3376,20 +3405,15 @@ function renderConsoleValue(
3405
parentPropertyName: string,
3406
value: ReactClientValue,
3407
): ReactJSONValue {
3379
- // Make sure that `parent[parentPropertyName]` wasn't JSONified before `value` was passed to us
3380
- // $FlowFixMe[incompatible-use]
3381
- const originalValue = parent[parentPropertyName];
3382
- if (
3383
- typeof originalValue === 'object' &&
3384
- originalValue !== value &&
3385
- !(originalValue instanceof Date)
3386
- ) {
3387
- }
3388
-
3408
if (value === null) {
3409
return null;
3410
}
3411
3412
+ // Special Symbol, that's very common.
3413
+ if (value === REACT_ELEMENT_TYPE) {
3414
+ return '$';
3415
+ }
3416
+
3417
if (typeof value === 'object') {
3418
if (isClientReference(value)) {
3419
// We actually have this value on the client so we could import it.
@@ -3421,7 +3445,7 @@ function renderConsoleValue(
3445
return existingReference;
3446
}
3447
3424
- if (counter.objectLimit <= 0) {
3448
+ if (counter.objectLimit <= 0 && !doNotLimit.has(value)) {
3449
// We've reached our max number of objects to serialize across the wire so we serialize this
3450
// as a marker so that the client can error when this is accessed by the console.
3451
return serializeLimitedObject();
@@ -3441,13 +3465,12 @@ function renderConsoleValue(
3465
if (element._debugStack != null) {
3466
// Outline the debug stack so that it doesn't get cut off.
3467
debugStack = filterStackTrace(request, element._debugStack, 1);
3444
- const stackId = outlineConsoleValue(
3445
- request,
3446
- {objectLimit: debugStack.length + 2},
3447
- debugStack,
3448
- );
3449
- request.writtenObjects.set(debugStack, serializeByValueID(stackId));
3468
+ doNotLimit.add(debugStack);
3469
+ for (let i = 0; i < debugStack.length; i++) {
3470
+ doNotLimit.add(debugStack[i]);
3471
+ }
3472
}
3473
+ doNotLimit.add(element.props);
3474
return [
3475
REACT_ELEMENT_TYPE,
3476
element.type,
@@ -3592,6 +3615,9 @@ function renderConsoleValue(
3615
if (typeof value === 'string') {
3616
if (value[value.length - 1] === 'Z') {
3617
// Possibly a Date, whose toJSON automatically calls toISOString
3618
+ // Make sure that `parent[parentPropertyName]` wasn't JSONified before `value` was passed to us
3619
+ // $FlowFixMe[incompatible-use]
3620
+ const originalValue = parent[parentPropertyName];
3621
if (originalValue instanceof Date) {
3622
return serializeDateFromDateJSON(value);
3623
}
@@ -3680,6 +3706,11 @@ function outlineConsoleValue(
3706
);
3707
}
3708
3709
+ if (typeof model === 'object' && model !== null) {
3710
+ // We can't limit outlined values.
3711
+ doNotLimit.add(model);
3712
+ }
3713
+
3714
function replacer(
3715
this:
3716
| {+[key: string | number]: ReactClientValue}