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