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

[Flight] Resolve models before JSON.stringify (#36795)

Move the toJSON handling out of the JSON.stringify replacer path and into an explicit recursive resolution step that uses v8's optimized single-arg JSON.stringify call. This has been pulled out of the original implementation in https://github.com/react/react/pull/36053 on the advice of https://github.com/react/react/pull/36181 (thanks @unstubbable) ### NB: \_\_proto\_\_ The only difference is surrounding the treatment of `{}` vs `Object.create(null)`. https://github.com/react/react/pull/36053 uses the latter, which avoids `__proto__` issues, but is slower in microbenchmarks due to v8 semantics. https://github.com/react/react/pull/36181 uses the former (`{}`) which is faster in micro benchmarks but doesn't special-case `__proto__` according to spec. This PR does both (`{}` and special case), with no measurable performance difference that I could produce. --- The rest of this PR's description is reproduced from https://github.com/react/react/pull/36181: --- ### Problem When serializing a Flight chunk, `emitChunk` currently calls `JSON.stringify(value, task.toJSON)`. The `task.toJSON` replacer is called for every key-value pair in the serialized JSON. While the logic inside the replacer is lightweight, the C++ to JavaScript boundary crossing on every node adds up — V8's `JSON.stringify` is implemented in C++, and calling back into JavaScript for every property incurs overhead that scales with the number of keys in the output. ### Change Replace the replacer with a two-step process: 1. `resolveModel()` recursively walks the rendered value, calling `renderModel()` on each child — doing the same transformation the replacer used to do, but entirely in JavaScript without C++ boundary crossings. 2. `JSON.stringify()` is called with no replacer, staying entirely in C++. The `resolveModel` walk also replicates `JSON.stringify`'s `toJSON` semantics for `Date` objects. ### Results Measured using the Flight SSR benchmark fixture (#36180) on a dashboard app with ~25 components, 200 product rows (~325KB Flight payload). Tested across Node 20, 22, and 24. - **`bench:bare`** (in-process, no script injection): Flight+Fizz sync median improves by **~4-5%** consistently across all three Node versions. - **`bench:server`** (HTTP, c=1): Flight+Fizz sync throughput improves by **~3-6%** across Node versions. Async results vary between runs but trend positive. ### Future opportunity While the immediate performance improvement is moderate, this change also sets up a potential future optimization: a Flight mode that renders to an object instead of a stream ([#36143 (comment)](https://github.com/facebook/react/issues/36143#issuecomment-4155701790)). Since `resolveModel()` already produces a plain JS object tree before `JSON.stringify` is called, this intermediate representation could potentially be passed to the SSR client without the serialization-deserialization roundtrip that the current stream-based approach requires. closes #36181

Michael Hart committed Jun 16, 2026 at 18:01 UTC ad78e251e2f64638a326c038fe130b5ed00a2726
2 files changed +168 -55
packages/react-client/src/__tests__/ReactFlight-test.js
+49
@@ -2085,6 +2085,55 @@ describe('ReactFlight', () => {
2085 ]);
2086 });
2087
2088 + it('should serialize an own __proto__ property nested among siblings without disturbing them', async () => {
2089 + // `__proto__` here is a real own enumerable data property (not the
2090 + // prototype). It sits between sibling keys and holds an object value, which
2091 + // is the case most likely to regress if the serializer used a plain
2092 + // `obj.__proto__ = value` assignment: that would hit the prototype setter,
2093 + // dropping the key and mutating the holder's prototype instead.
2094 + const value = {a: 1};
2095 + Object.defineProperty(value, '__proto__', {
2096 + value: {nested: true},
2097 + enumerable: true,
2098 + writable: true,
2099 + configurable: true,
2100 + });
2101 + value.b = 2;
2102 +
2103 + const transport = ReactNoopFlightServer.render(value);
2104 + assertConsoleErrorDev([
2105 + 'Expected not to serialize an object with own property `__proto__`. ' +
2106 + 'When parsed this property will be omitted.\n' +
2107 + ' {a: 1, __proto__: {nested: true}, b: 2}\n' +
2108 + ' ^^^^^^^^^^^^^^',
2109 + ]);
2110 +
2111 + const decoder = new TextDecoder();
2112 + const payload = transport
2113 + .map(chunk => (typeof chunk === 'string' ? chunk : decoder.decode(chunk)))
2114 + .join('');
2115 + // The legacy key is serialized as ordinary data, in source order, with its
2116 + // object value intact and without clobbering its sibling properties.
2117 + expect(payload).toContain('"a":1,"__proto__":{"nested":true},"b":2');
2118 +
2119 + const model = await ReactNoopFlightClient.read(transport);
2120 + assertConsoleErrorDev([
2121 + 'Expected not to serialize an object with own property `__proto__`. ' +
2122 + 'When parsed this property will be omitted.\n' +
2123 + ' {a: 1, __proto__: {nested: true}, b: 2}\n' +
2124 + ' ^^^^^^^^^^^^^^\n' +
2125 + ' in (at **)',
2126 + ]);
2127 + // On the client the legacy key is omitted, but its siblings survive intact
2128 + // and the holder's prototype is untouched.
2129 + expect(Object.prototype.hasOwnProperty.call(model, '__proto__')).toBe(
2130 + false,
2131 + );
2132 + expect(Object.getPrototypeOf(model)).toBe(Object.prototype);
2133 + expect(model.a).toBe(1);
2134 + expect(model.b).toBe(2);
2135 + });
2136 +
2137 it('should NOT warn in DEV for key getters', () => {
2138 const transport = ReactNoopFlightServer.render(<div key="a" />);
2139 ReactNoopFlightClient.read(transport);
packages/react-server/src/ReactFlightServer.js
+119 -55
@@ -530,7 +530,6 @@ type Task = {
530 status: 0 | 1 | 3 | 4 | 5,
531 model: ReactClientValue,
532 ping: () => void,
533 - toJSON: (key: string, value: ReactClientValue) => ReactJSONValue,
533 keyPath: ReactKey, // parent server component keys
534 implicitSlot: boolean, // true if the root server component of this sequence had a null key
535 formatContext: FormatContext, // an approximate parent context from host components
@@ -2761,55 +2760,6 @@ function createTask(
2760 implicitSlot,
2761 formatContext: formatContext,
2762 ping: () => pingTask(request, task),
2764 - toJSON: function (
2765 - this:
2766 - | {+[key: string | number]: ReactClientValue}
2767 - | $ReadOnlyArray<ReactClientValue>,
2768 - parentPropertyName: string,
2769 - value: ReactClientValue,
2770 - ): ReactJSONValue {
2771 - const parent = this;
2772 - // Make sure that `parent[parentPropertyName]` wasn't JSONified before `value` was passed to us
2773 - if (__DEV__) {
2774 - // $FlowFixMe[incompatible-use]
2775 - const originalValue = parent[parentPropertyName];
2776 - if (
2777 - typeof originalValue === 'object' &&
2778 - originalValue !== value &&
2779 - !(originalValue instanceof Date)
2780 - ) {
2781 - // Call with the server component as the currently rendering component
2782 - // for context.
2783 - callWithDebugContextInDEV(request, task, () => {
2784 - if (objectName(originalValue) !== 'Object') {
2785 - const jsxParentType = jsxChildrenParents.get(parent);
2786 - if (typeof jsxParentType === 'string') {
2787 - console.error(
2788 - '%s objects cannot be rendered as text children. Try formatting it using toString().%s',
2789 - objectName(originalValue),
2790 - describeObjectForErrorMessage(parent, parentPropertyName),
2791 - );
2792 - } else {
2793 - console.error(
2794 - 'Only plain objects can be passed to Client Components from Server Components. ' +
2795 - '%s objects are not supported.%s',
2796 - objectName(originalValue),
2797 - describeObjectForErrorMessage(parent, parentPropertyName),
2798 - );
2799 - }
2800 - } else {
2801 - console.error(
2802 - 'Only plain objects can be passed to Client Components from Server Components. ' +
2803 - 'Objects with toJSON methods are not supported. Convert it manually ' +
2804 - 'to a simple value before passing it to props.%s',
2805 - describeObjectForErrorMessage(parent, parentPropertyName),
2806 - );
2807 - }
2808 - });
2809 - }
2810 - }
2811 - return renderModel(request, task, parent, parentPropertyName, value);
2812 - },
2763 thenableState: null,
2764 } as Omit<
2765 Task,
@@ -2837,6 +2787,117 @@ function createTask(
2787 return task;
2788 }
2789
2790 +function resolveModel(
2791 + request: Request,
2792 + task: Task,
2793 + parent:
2794 + | {+[key: string | number]: ReactClientValue}
2795 + | $ReadOnlyArray<ReactClientValue>,
2796 + parentPropertyName: string,
2797 + value: ReactClientValue,
2798 +): ReactJSONValue {
2799 + // Replicate JSON.stringify's toJSON semantics: if the value has a toJSON
2800 + // method, call it first. In practice this only matters for Date objects whose
2801 + // toJSON calls toISOString. Custom toJSON objects are not supported and will
2802 + // trigger a DEV warning below.
2803 + let jsonValue: ReactClientValue = value;
2804 + if (
2805 + value !== null &&
2806 + typeof value === 'object' &&
2807 + // $FlowFixMe[method-unbinding]
2808 + typeof value.toJSON === 'function'
2809 + ) {
2810 + // $FlowFixMe[incompatible-use]
2811 + jsonValue = value.toJSON(parentPropertyName);
2812 + }
2813 +
2814 + if (__DEV__) {
2815 + // $FlowFixMe[incompatible-use]
2816 + const originalValue = parent[parentPropertyName];
2817 + if (
2818 + typeof originalValue === 'object' &&
2819 + originalValue !== jsonValue &&
2820 + !(originalValue instanceof Date)
2821 + ) {
2822 + // Call with the server component as the currently rendering component
2823 + // for context.
2824 + callWithDebugContextInDEV(request, task, () => {
2825 + if (objectName(originalValue) !== 'Object') {
2826 + const jsxParentType = jsxChildrenParents.get(parent);
2827 + if (typeof jsxParentType === 'string') {
2828 + console.error(
2829 + '%s objects cannot be rendered as text children. Try formatting it using toString().%s',
2830 + objectName(originalValue),
2831 + describeObjectForErrorMessage(parent, parentPropertyName),
2832 + );
2833 + } else {
2834 + console.error(
2835 + 'Only plain objects can be passed to Client Components from Server Components. ' +
2836 + '%s objects are not supported.%s',
2837 + objectName(originalValue),
2838 + describeObjectForErrorMessage(parent, parentPropertyName),
2839 + );
2840 + }
2841 + } else {
2842 + console.error(
2843 + 'Only plain objects can be passed to Client Components from Server Components. ' +
2844 + 'Objects with toJSON methods are not supported. Convert it manually ' +
2845 + 'to a simple value before passing it to props.%s',
2846 + describeObjectForErrorMessage(parent, parentPropertyName),
2847 + );
2848 + }
2849 + });
2850 + }
2851 + }
2852 +
2853 + const rendered = renderModel(
2854 + request,
2855 + task,
2856 + parent,
2857 + parentPropertyName,
2858 + jsonValue,
2859 + );
2860 +
2861 + if (rendered === null || typeof rendered !== 'object') {
2862 + return rendered;
2863 + }
2864 +
2865 + if (isArray(rendered)) {
2866 + const resolved: Array<ReactJSONValue> = [];
2867 + for (let i = 0; i < rendered.length; i++) {
2868 + resolved[i] = resolveModel(request, task, rendered, '' + i, rendered[i]);
2869 + }
2870 + return resolved;
2871 + }
2872 +
2873 + // Use `{}` for fast properties; `__proto__` is handled below because simple
2874 + // assignment would hit Object.prototype's setter instead of creating a key.
2875 + const resolved: {[key: string]: ReactJSONValue} = {} as any;
2876 + for (const key in rendered) {
2877 + if (hasOwnProperty.call(rendered, key)) {
2878 + const resolvedValue = resolveModel(
2879 + request,
2880 + task,
2881 + rendered,
2882 + key,
2883 + rendered[key],
2884 + );
2885 + if (key === __PROTO__) {
2886 + // Match JSON's ordinary data-property semantics for this legacy key.
2887 + Object.defineProperty(resolved, key, {
2888 + value: resolvedValue,
2889 + enumerable: true,
2890 + writable: true,
2891 + configurable: true,
2892 + });
2893 + } else {
2894 + resolved[key] = resolvedValue;
2895 + }
2896 + }
2897 + }
2898 + return resolved;
2899 +}
2900 +
2901 function serializeByValueID(id: number): string {
2902 return '$' + id.toString(16);
2903 }
@@ -3618,7 +3679,7 @@ function renderModelDestructive(
3679 // TODO: Pop this. Since we currently don't have a point where we can pop the stack
3680 // this debug information will be used for errors inside sibling properties that
3681 // are not elements. Leading to the wrong attribution on the server. We could fix
3621 - // that if we switch to a proper stack instead of JSON.stringify's trampoline.
3682 + // that if we switch to a proper stack instead of resolveModel's recursive walk.
3683 // Attribution on the client is still correct since it has a pop.
3684 }
3685
@@ -5816,8 +5877,11 @@ function emitChunk(
5877 return;
5878 }
5879 // For anything else we need to try to serialize it using JSON.
5880 + // We resolve the model tree first in pure JS to avoid the C++->JS boundary
5881 + // overhead of JSON.stringify's replacer callback.
5882 + const resolvedModel = resolveModel(request, task, {'': value}, '', value);
5883 // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
5820 - const json: string = stringify(value, task.toJSON);
5884 + const json: string = stringify(resolvedModel);
5885 emitModelChunk(request, task.id, json);
5886 }
5887
@@ -5863,7 +5927,7 @@ function retryTask(request: Request, task: Task): void {
5927 try {
5928 // Track the root so we know that we have to emit this object even though it
5929 // already has an ID. This is needed because we might see this object twice
5866 - // in the same toJSON if it is cyclic.
5930 + // in the same resolveModel walk if it is cyclic.
5931 modelRoot = task.model;
5932
5933 if (__DEV__) {
@@ -5922,8 +5986,8 @@ function retryTask(request: Request, task: Task): void {
5986 // This is simulating what the JSON loop would do if this was part of it.
5987 emitChunk(request, task, resolvedModel);
5988 } else {
5925 - // If the value is a string, it means it's a terminal value and we already escaped it
5926 - // We don't need to escape it again so it's not passed the toJSON replacer.
5989 + // If the value is a string, it means it's a terminal value and we already escaped it.
5990 + // We don't need to escape it again so it's not passed through resolveModel.
5991 // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
5992 const json: string = stringify(resolvedModel);
5993 emitModelChunk(request, task.id, json);