[Flight] Dedupe objects serialized as Debug Models in a separate set (#33583)
Stacked on #33539. Stores dedupes of `renderConsoleValue` in a separate set. This allows us to dedupe objects safely since we can't write objects using this algorithm if they might also be referenced by the "real" serialization. Also renamed it to `renderDebugModel` since it's not just for console anymore.
Sebastian Markbåge committed
Jun 20, 2025 at 13:36 UTC
ed077194b5b76df6f8fdbf805e1b895e2deb5a07
3 files changed
+151
-113
packages/react-client/src/ReactFlightClient.js
+9
-1
@@ -266,7 +266,11 @@ ReactPromise.prototype.then = function <T>(
266
initializeModuleChunk(chunk);
267
break;
268
}
269
- if (__DEV__ && enableAsyncDebugInfo) {
269
+ if (
270
+ __DEV__ &&
271
+ enableAsyncDebugInfo &&
272
+ (typeof resolve !== 'function' || !(resolve: any).isReactInternalListener)
273
+ ) {
274
// Because only native Promises get picked up when we're awaiting we need to wrap
275
// this in a native Promise in DEV. This means that these callbacks are no longer sync
276
// but the lazy initialization is still sync and the .value can be inspected after,
@@ -1052,6 +1056,10 @@ function waitForReference<T>(
1056
}
1057
}
1058
}
1059
+ // Use to avoid the microtask resolution in DEV.
1060
+ if (__DEV__ && enableAsyncDebugInfo) {
1061
+ (fulfill: any).isReactInternalListener = true;
1062
+ }
1063
1064
function reject(error: mixed): void {
1065
if (handler.errored) {
packages/react-client/src/__tests__/ReactFlight-test.js
+4
-13
@@ -3328,19 +3328,10 @@ describe('ReactFlight', () => {
3328
await ReactNoopFlightClient.read(transport);
3329
3330
expect(mockConsoleLog).toHaveBeenCalledTimes(1);
3331
- // TODO: Support cyclic objects in console encoding.
3332
- // expect(mockConsoleLog.mock.calls[0][0]).toBe('hi');
3333
- // const cyclic2 = mockConsoleLog.mock.calls[0][1].cyclic;
3334
- // expect(cyclic2).not.toBe(cyclic); // Was serialized and therefore cloned
3335
- // expect(cyclic2.cycle).toBe(cyclic2);
3336
- expect(mockConsoleLog.mock.calls[0][0]).toBe(
3337
- 'Unknown Value: React could not send it from the server.',
3338
- );
3339
- expect(mockConsoleLog.mock.calls[0][1].message).toBe(
3340
- 'Converting circular structure to JSON\n' +
3341
- " --> starting at object with constructor 'Object'\n" +
3342
- " --- property 'cycle' closes the circle",
3343
- );
3331
+ expect(mockConsoleLog.mock.calls[0][0]).toBe('hi');
3332
+ const cyclic2 = mockConsoleLog.mock.calls[0][1].cyclic;
3333
+ expect(cyclic2).not.toBe(cyclic); // Was serialized and therefore cloned
3334
+ expect(cyclic2.cycle).toBe(cyclic2);
3335
});
3336
3337
// @gate !__DEV__ || enableComponentPerformanceTrack
packages/react-server/src/ReactFlightServer.js
+138
-99
@@ -448,6 +448,7 @@ export type Request = {
448
environmentName: () => string,
449
filterStackFrame: (url: string, functionName: string) => boolean,
450
didWarnForKey: null | WeakSet<ReactComponentInfo>,
451
+ writtenDebugObjects: WeakMap<Reference, string>,
452
};
453
454
const {
@@ -567,6 +568,7 @@ function RequestInstance(
568
? defaultFilterStackFrame
569
: filterStackFrame;
570
this.didWarnForKey = null;
571
+ this.writtenDebugObjects = new WeakMap();
572
}
573
574
let timeOrigin: number;
@@ -2438,7 +2440,7 @@ function serializeConsoleMap(
2440
counter: {objectLimit: number},
2441
map: Map<ReactClientValue, ReactClientValue>,
2442
): string {
2441
- // Like serializeMap but for renderConsoleValue.
2443
+ // Like serializeMap but for renderDebugModel.
2444
const entries = Array.from(map);
2445
// The Map itself doesn't take up any space but the outlined object does.
2446
counter.objectLimit++;
@@ -2456,7 +2458,7 @@ function serializeConsoleMap(
2458
doNotLimit.add(value);
2459
}
2460
}
2459
- const id = outlineConsoleValue(request, counter, entries);
2461
+ const id = outlineDebugModel(request, counter, entries);
2462
return '$Q' + id.toString(16);
2463
}
2464
@@ -2465,7 +2467,7 @@ function serializeConsoleSet(
2467
counter: {objectLimit: number},
2468
set: Set<ReactClientValue>,
2469
): string {
2468
- // Like serializeMap but for renderConsoleValue.
2470
+ // Like serializeMap but for renderDebugModel.
2471
const entries = Array.from(set);
2472
// The Set itself doesn't take up any space but the outlined object does.
2473
counter.objectLimit++;
@@ -2477,7 +2479,7 @@ function serializeConsoleSet(
2479
doNotLimit.add(entry);
2480
}
2481
}
2480
- const id = outlineConsoleValue(request, counter, entries);
2482
+ const id = outlineDebugModel(request, counter, entries);
2483
return '$W' + id.toString(16);
2484
}
2485
@@ -3535,27 +3537,7 @@ function emitDebugChunk(
3537
);
3538
}
3539
3538
- // We use the console encoding so that we can dedupe objects but don't necessarily
3539
- // use the full serialization that requires a task.
3540
- const counter = {objectLimit: 500};
3541
- function replacer(
3542
- this:
3543
- | {+[key: string | number]: ReactClientValue}
3544
- | $ReadOnlyArray<ReactClientValue>,
3545
- parentPropertyName: string,
3546
- value: ReactClientValue,
3547
- ): ReactJSONValue {
3548
- return renderConsoleValue(
3549
- request,
3550
- counter,
3551
- this,
3552
- parentPropertyName,
3553
- value,
3554
- );
3555
- }
3556
-
3557
- // $FlowFixMe[incompatible-type] stringify can return null
3558
- const json: string = stringify(debugInfo, replacer);
3540
+ const json: string = serializeDebugModel(request, 500, debugInfo);
3541
const row = serializeRowHeader('D', id) + json + '\n';
3542
const processedChunk = stringToChunk(row);
3543
request.completedRegularChunks.push(processedChunk);
@@ -3573,7 +3555,7 @@ function outlineComponentInfo(
3555
);
3556
}
3557
3576
- if (request.writtenObjects.has(componentInfo)) {
3558
+ if (request.writtenDebugObjects.has(componentInfo)) {
3559
// Already written
3560
return;
3561
}
@@ -3625,8 +3607,11 @@ function outlineComponentInfo(
3607
// $FlowFixMe[cannot-write]
3608
componentDebugInfo.props = componentInfo.props;
3609
3628
- const id = outlineConsoleValue(request, counter, componentDebugInfo);
3629
- request.writtenObjects.set(componentInfo, serializeByValueID(id));
3610
+ const id = outlineDebugModel(request, counter, componentDebugInfo);
3611
+ const ref = serializeByValueID(id);
3612
+ request.writtenDebugObjects.set(componentInfo, ref);
3613
+ // We also store this in the main dedupe set so that it can be referenced by inline React Elements.
3614
+ request.writtenObjects.set(componentInfo, ref);
3615
}
3616
3617
function emitIOInfoChunk(
@@ -3651,22 +3636,6 @@ function emitIOInfoChunk(
3636
if (stack) {
3637
objectLimit += stack.length;
3638
}
3654
- const counter = {objectLimit};
3655
- function replacer(
3656
- this:
3657
- | {+[key: string | number]: ReactClientValue}
3658
- | $ReadOnlyArray<ReactClientValue>,
3659
- parentPropertyName: string,
3660
- value: ReactClientValue,
3661
- ): ReactJSONValue {
3662
- return renderConsoleValue(
3663
- request,
3664
- counter,
3665
- this,
3666
- parentPropertyName,
3667
- value,
3668
- );
3669
- }
3639
3640
const relativeStartTimestamp = start - request.timeOrigin;
3641
const relativeEndTimestamp = end - request.timeOrigin;
@@ -3687,8 +3656,7 @@ function emitIOInfoChunk(
3656
// $FlowFixMe[cannot-write]
3657
debugIOInfo.owner = owner;
3658
}
3690
- // $FlowFixMe[incompatible-type] stringify can return null
3691
- const json: string = stringify(debugIOInfo, replacer);
3659
+ const json: string = serializeDebugModel(request, objectLimit, debugIOInfo);
3660
const row = id.toString(16) + ':J' + json + '\n';
3661
const processedChunk = stringToChunk(row);
3662
request.completedRegularChunks.push(processedChunk);
@@ -3727,14 +3695,14 @@ function outlineIOInfo(request: Request, ioInfo: ReactIOInfo): void {
3695
owner,
3696
debugStack,
3697
);
3730
- request.writtenObjects.set(ioInfo, serializeByValueID(id));
3698
+ request.writtenDebugObjects.set(ioInfo, serializeByValueID(id));
3699
}
3700
3701
function serializeIONode(
3702
request: Request,
3703
ioNode: IONode | PromiseNode,
3704
): string {
3737
- const existingRef = request.writtenObjects.get(ioNode);
3705
+ const existingRef = request.writtenDebugObjects.get(ioNode);
3706
if (existingRef !== undefined) {
3707
// Already written
3708
return existingRef;
@@ -3777,7 +3745,7 @@ function serializeIONode(
3745
stack,
3746
);
3747
const ref = serializeByValueID(id);
3780
- request.writtenObjects.set(ioNode, ref);
3748
+ request.writtenDebugObjects.set(ioNode, ref);
3749
return ref;
3750
}
3751
@@ -3834,9 +3802,11 @@ function serializeEval(source: string): string {
3802
return '$E' + source;
3803
}
3804
3805
+let debugModelRoot: mixed = null;
3806
+let debugNoOutline: mixed = null;
3807
// This is a forked version of renderModel which should never error, never suspend and is limited
3808
// in the depth it can encode.
3839
-function renderConsoleValue(
3809
+function renderDebugModel(
3810
request: Request,
3811
counter: {objectLimit: number},
3812
parent:
@@ -3877,11 +3847,57 @@ function renderConsoleValue(
3847
}
3848
}
3849
3850
+ const writtenDebugObjects = request.writtenDebugObjects;
3851
+ const existingDebugReference = writtenDebugObjects.get(value);
3852
+ if (existingDebugReference !== undefined) {
3853
+ if (debugModelRoot === value) {
3854
+ // This is the ID we're currently emitting so we need to write it
3855
+ // once but if we discover it again, we refer to it by id.
3856
+ debugModelRoot = null;
3857
+ } else {
3858
+ // We've already emitted this as a debug object. We favor that version if available.
3859
+ return existingDebugReference;
3860
+ }
3861
+ } else if (parentPropertyName.indexOf(':') === -1) {
3862
+ // TODO: If the property name contains a colon, we don't dedupe. Escape instead.
3863
+ const parentReference = writtenDebugObjects.get(parent);
3864
+ if (parentReference !== undefined) {
3865
+ // If the parent has a reference, we can refer to this object indirectly
3866
+ // through the property name inside that parent.
3867
+ let propertyName = parentPropertyName;
3868
+ if (isArray(parent) && parent[0] === REACT_ELEMENT_TYPE) {
3869
+ // For elements, we've converted it to an array but we'll have converted
3870
+ // it back to an element before we read the references so the property
3871
+ // needs to be aliased.
3872
+ switch (parentPropertyName) {
3873
+ case '1':
3874
+ propertyName = 'type';
3875
+ break;
3876
+ case '2':
3877
+ propertyName = 'key';
3878
+ break;
3879
+ case '3':
3880
+ propertyName = 'props';
3881
+ break;
3882
+ case '4':
3883
+ propertyName = '_owner';
3884
+ break;
3885
+ }
3886
+ }
3887
+ writtenDebugObjects.set(value, parentReference + ':' + propertyName);
3888
+ } else if (debugNoOutline !== value) {
3889
+ // If this isn't the root object (like meta data) and we don't have an id for it, outline
3890
+ // it so that we can dedupe it by reference later.
3891
+ const outlinedId = outlineDebugModel(request, counter, value);
3892
+ return serializeByValueID(outlinedId);
3893
+ }
3894
+ }
3895
+
3896
const writtenObjects = request.writtenObjects;
3897
const existingReference = writtenObjects.get(value);
3898
if (existingReference !== undefined) {
3883
- // We've already emitted this as a real object, so we can
3884
- // just refer to that by its existing reference.
3899
+ // We've already emitted this as a real object, so we can refer to that by its existing reference.
3900
+ // This might be slightly different serialization than what renderDebugModel would've produced.
3901
return existingReference;
3902
}
3903
@@ -3943,7 +3959,7 @@ function renderConsoleValue(
3959
switch (thenable.status) {
3960
case 'fulfilled': {
3961
return serializePromiseID(
3946
- outlineConsoleValue(request, counter, thenable.value),
3962
+ outlineDebugModel(request, counter, thenable.value),
3963
);
3964
}
3965
case 'rejected': {
@@ -4105,8 +4121,8 @@ function renderConsoleValue(
4121
}
4122
4123
// Serialize the body of the function as an eval so it can be printed.
4108
- const writtenObjects = request.writtenObjects;
4109
- const existingReference = writtenObjects.get(value);
4124
+ const writtenDebugObjects = request.writtenDebugObjects;
4125
+ const existingReference = writtenDebugObjects.get(value);
4126
if (existingReference !== undefined) {
4127
// We've already emitted this function, so we can
4128
// just refer to that by its existing reference.
@@ -4122,7 +4138,7 @@ function renderConsoleValue(
4138
const processedChunk = encodeReferenceChunk(request, id, serializedValue);
4139
request.completedRegularChunks.push(processedChunk);
4140
const reference = serializeByValueID(id);
4125
- writtenObjects.set(value, reference);
4141
+ writtenDebugObjects.set(value, reference);
4142
return reference;
4143
}
4144
@@ -4152,7 +4168,51 @@ function renderConsoleValue(
4168
return 'unknown type ' + typeof value;
4169
}
4170
4155
-function outlineConsoleValue(
4171
+function serializeDebugModel(
4172
+ request: Request,
4173
+ objectLimit: number,
4174
+ model: mixed,
4175
+): string {
4176
+ const counter = {objectLimit: objectLimit};
4177
+
4178
+ function replacer(
4179
+ this:
4180
+ | {+[key: string | number]: ReactClientValue}
4181
+ | $ReadOnlyArray<ReactClientValue>,
4182
+ parentPropertyName: string,
4183
+ value: ReactClientValue,
4184
+ ): ReactJSONValue {
4185
+ try {
4186
+ return renderDebugModel(
4187
+ request,
4188
+ counter,
4189
+ this,
4190
+ parentPropertyName,
4191
+ value,
4192
+ );
4193
+ } catch (x) {
4194
+ return (
4195
+ 'Unknown Value: React could not send it from the server.\n' + x.message
4196
+ );
4197
+ }
4198
+ }
4199
+
4200
+ const prevNoOutline = debugNoOutline;
4201
+ debugNoOutline = model;
4202
+ try {
4203
+ // $FlowFixMe[incompatible-cast] stringify can return null
4204
+ return (stringify(model, replacer): string);
4205
+ } catch (x) {
4206
+ // $FlowFixMe[incompatible-cast] stringify can return null
4207
+ return (stringify(
4208
+ 'Unknown Value: React could not send it from the server.\n' + x.message,
4209
+ ): string);
4210
+ } finally {
4211
+ debugNoOutline = prevNoOutline;
4212
+ }
4213
+}
4214
+
4215
+function outlineDebugModel(
4216
request: Request,
4217
counter: {objectLimit: number},
4218
model: ReactClientValue,
@@ -4161,7 +4221,7 @@ function outlineConsoleValue(
4221
// These errors should never make it into a build so we don't need to encode them in codes.json
4222
// eslint-disable-next-line react-internal/prod-error-codes
4223
throw new Error(
4164
- 'outlineConsoleValue should never be called in production mode. This is a bug in React.',
4224
+ 'outlineDebugModel should never be called in production mode. This is a bug in React.',
4225
);
4226
}
4227
@@ -4178,7 +4238,7 @@ function outlineConsoleValue(
4238
value: ReactClientValue,
4239
): ReactJSONValue {
4240
try {
4181
- return renderConsoleValue(
4241
+ return renderDebugModel(
4242
request,
4243
counter,
4244
this,
@@ -4192,6 +4252,13 @@ function outlineConsoleValue(
4252
}
4253
}
4254
4255
+ const id = request.nextChunkId++;
4256
+ const prevModelRoot = debugModelRoot;
4257
+ debugModelRoot = model;
4258
+ if (typeof model === 'object' && model !== null) {
4259
+ // Future references can refer to this object by id.
4260
+ request.writtenDebugObjects.set(model, serializeByValueID(id));
4261
+ }
4262
let json: string;
4263
try {
4264
// $FlowFixMe[incompatible-cast] stringify can return null
@@ -4201,10 +4268,11 @@ function outlineConsoleValue(
4268
json = (stringify(
4269
'Unknown Value: React could not send it from the server.\n' + x.message,
4270
): string);
4271
+ } finally {
4272
+ debugModelRoot = prevModelRoot;
4273
}
4274
4275
request.pendingChunks++;
4207
- const id = request.nextChunkId++;
4276
const row = id.toString(16) + ':' + json + '\n';
4277
const processedChunk = stringToChunk(row);
4278
request.completedRegularChunks.push(processedChunk);
@@ -4226,29 +4294,6 @@ function emitConsoleChunk(
4294
);
4295
}
4296
4229
- const counter = {objectLimit: 500};
4230
- function replacer(
4231
- this:
4232
- | {+[key: string | number]: ReactClientValue}
4233
- | $ReadOnlyArray<ReactClientValue>,
4234
- parentPropertyName: string,
4235
- value: ReactClientValue,
4236
- ): ReactJSONValue {
4237
- try {
4238
- return renderConsoleValue(
4239
- request,
4240
- counter,
4241
- this,
4242
- parentPropertyName,
4243
- value,
4244
- );
4245
- } catch (x) {
4246
- return (
4247
- 'Unknown Value: React could not send it from the server.\n' + x.message
4248
- );
4249
- }
4250
- }
4251
-
4297
// Ensure the owner is already outlined.
4298
if (owner != null) {
4299
outlineComponentInfo(request, owner);
@@ -4259,22 +4304,16 @@ function emitConsoleChunk(
4304
const payload = [methodName, stackTrace, owner, env];
4305
// $FlowFixMe[method-unbinding]
4306
payload.push.apply(payload, args);
4262
- let json: string;
4263
- try {
4264
- // $FlowFixMe[incompatible-type] stringify can return null
4265
- json = stringify(payload, replacer);
4266
- } catch (x) {
4267
- json = stringify(
4268
- [
4269
- methodName,
4270
- stackTrace,
4271
- owner,
4272
- env,
4273
- 'Unknown Value: React could not send it from the server.',
4274
- x,
4275
- ],
4276
- replacer,
4277
- );
4307
+ let json = serializeDebugModel(request, 500, payload);
4308
+ if (json[0] !== '[') {
4309
+ // This looks like an error. Try a simpler object.
4310
+ json = serializeDebugModel(request, 500, [
4311
+ methodName,
4312
+ stackTrace,
4313
+ owner,
4314
+ env,
4315
+ 'Unknown Value: React could not send it from the server.',
4316
+ ]);
4317
}
4318
const row = ':W' + json + '\n';
4319
const processedChunk = stringToChunk(row);