@samitouri / QOS-React-2 / commits / b7e2157922

[Flight] Handle errors during JSON stringify of console values (#31391)

When we serialize debug info we should never error even though we don't currently support everything being serialized. Since it's non-essential dev information. We already handle errors in the replacer but not when errors happen in the JSON algorithm itself - such as cyclic errors. We should ideally support cyclic objects but regardless we should gracefully handle the errors.

Sebastian Markbåge committed Oct 31, 2024 at 16:47 UTC b7e21579220042c0a60179e2f40f121684e637eb
2 files changed +86 -4
packages/react-client/src/__tests__/ReactFlight-test.js
+59
@@ -3146,6 +3146,65 @@ describe('ReactFlight', () => {
3146 expect(ownerStacks).toEqual(['\n in App (at **)']);
3147 });
3148
3149 + // @gate enableServerComponentLogs && __DEV__
3150 + it('replays logs with cyclic objects', async () => {
3151 + const cyclic = {cycle: null};
3152 + cyclic.cycle = cyclic;
3153 +
3154 + function ServerComponent() {
3155 + console.log('hi', {cyclic});
3156 + return null;
3157 + }
3158 +
3159 + function App() {
3160 + return ReactServer.createElement(ServerComponent);
3161 + }
3162 +
3163 + // These tests are specifically testing console.log.
3164 + // Assign to `mockConsoleLog` so we can still inspect it when `console.log`
3165 + // is overridden by the test modules. The original function will be restored
3166 + // after this test finishes by `jest.restoreAllMocks()`.
3167 + const mockConsoleLog = spyOnDevAndProd(console, 'log').mockImplementation(
3168 + () => {},
3169 + );
3170 +
3171 + // Reset the modules so that we get a new overridden console on top of the
3172 + // one installed by expect. This ensures that we still emit console.error
3173 + // calls.
3174 + jest.resetModules();
3175 + jest.mock('react', () => require('react/react.react-server'));
3176 + ReactServer = require('react');
3177 + ReactNoopFlightServer = require('react-noop-renderer/flight-server');
3178 + const transport = ReactNoopFlightServer.render({
3179 + root: ReactServer.createElement(App),
3180 + });
3181 +
3182 + expect(mockConsoleLog).toHaveBeenCalledTimes(1);
3183 + expect(mockConsoleLog.mock.calls[0][0]).toBe('hi');
3184 + expect(mockConsoleLog.mock.calls[0][1].cyclic).toBe(cyclic);
3185 + mockConsoleLog.mockClear();
3186 + mockConsoleLog.mockImplementation(() => {});
3187 +
3188 + // The error should not actually get logged because we're not awaiting the root
3189 + // so it's not thrown but the server log also shouldn't be replayed.
3190 + await ReactNoopFlightClient.read(transport);
3191 +
3192 + expect(mockConsoleLog).toHaveBeenCalledTimes(1);
3193 + // TODO: Support cyclic objects in console encoding.
3194 + // expect(mockConsoleLog.mock.calls[0][0]).toBe('hi');
3195 + // const cyclic2 = mockConsoleLog.mock.calls[0][1].cyclic;
3196 + // expect(cyclic2).not.toBe(cyclic); // Was serialized and therefore cloned
3197 + // expect(cyclic2.cycle).toBe(cyclic2);
3198 + expect(mockConsoleLog.mock.calls[0][0]).toBe(
3199 + 'Unknown Value: React could not send it from the server.',
3200 + );
3201 + expect(mockConsoleLog.mock.calls[0][1].message).toBe(
3202 + 'Converting circular structure to JSON\n' +
3203 + " --> starting at object with constructor 'Object'\n" +
3204 + " --- property 'cycle' closes the circle",
3205 + );
3206 + });
3207 +
3208 it('uses the server component debug info as the element owner in DEV', async () => {
3209 function Container({children}) {
3210 return children;
packages/react-server/src/ReactFlightServer.js
+27 -4
@@ -3750,8 +3750,16 @@ function outlineConsoleValue(
3750 }
3751 }
3752
3753 - // $FlowFixMe[incompatible-type] stringify can return null
3754 - const json: string = stringify(model, replacer);
3753 + let json: string;
3754 + try {
3755 + // $FlowFixMe[incompatible-cast] stringify can return null
3756 + json = (stringify(model, replacer): string);
3757 + } catch (x) {
3758 + // $FlowFixMe[incompatible-cast] stringify can return null
3759 + json = (stringify(
3760 + 'Unknown Value: React could not send it from the server.\n' + x.message,
3761 + ): string);
3762 + }
3763
3764 request.pendingChunks++;
3765 const id = request.nextChunkId++;
@@ -3810,8 +3818,23 @@ function emitConsoleChunk(
3818 const payload = [methodName, stackTrace, owner, env];
3819 // $FlowFixMe[method-unbinding]
3820 payload.push.apply(payload, args);
3813 - // $FlowFixMe[incompatible-type] stringify can return null
3814 - const json: string = stringify(payload, replacer);
3821 + let json: string;
3822 + try {
3823 + // $FlowFixMe[incompatible-type] stringify can return null
3824 + json = stringify(payload, replacer);
3825 + } catch (x) {
3826 + json = stringify(
3827 + [
3828 + methodName,
3829 + stackTrace,
3830 + owner,
3831 + env,
3832 + 'Unknown Value: React could not send it from the server.',
3833 + x,
3834 + ],
3835 + replacer,
3836 + );
3837 + }
3838 const row = serializeRowHeader('W', id) + json + '\n';
3839 const processedChunk = stringToChunk(row);
3840 request.completedRegularChunks.push(processedChunk);