@samitouri / QOS-React-1 / commits / ed077194b5

[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);