From c6db19f9cdec34bca3625a483a2f85181193b885 Mon Sep 17 00:00:00 2001 From: Sophie Alpert Date: Tue, 18 Apr 2023 20:52:03 -0700 Subject: [PATCH] [Flight] Serialize Date (#26622) This is kind of annoying because Date implements toJSON so JSON.stringify turns it into a string before calling our replacer function. --- .../react-client/src/ReactFlightClient.js | 4 ++ .../src/ReactFlightReplyClient.js | 27 +++++++- .../src/__tests__/ReactFlight-test.js | 69 ++++++++++++++----- .../src/__tests__/ReactFlightDOMReply-test.js | 9 +++ .../src/ReactFlightReplyServer.js | 4 ++ .../react-server/src/ReactFlightServer.js | 24 ++++++- 6 files changed, 118 insertions(+), 19 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 05ecf17268..85b20a4c1f 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -580,6 +580,10 @@ export function parseModelString( // Special encoding for `undefined` which can't be serialized as JSON otherwise. return undefined; } + case 'D': { + // Date + return new Date(Date.parse(value.substring(2))); + } case 'n': { // BigInt return BigInt(value.substring(2)); diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index eb3d8f83e6..224af305d6 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -101,6 +101,12 @@ function serializeUndefined(): string { return '$undefined'; } +function serializeDateFromDateJSON(dateJSON: string): string { + // JSON.stringify automatically calls Date.prototype.toJSON which calls toISOString. + // We need only tack on a $D prefix. + return '$D' + dateJSON; +} + function serializeBigInt(n: bigint): string { return '$n' + n.toString(10); } @@ -133,10 +139,16 @@ export function processReply( value: ReactServerValue, ): ReactJSONValue { const parent = this; + + // Make sure that `parent[key]` wasn't JSONified before `value` was passed to us if (__DEV__) { // $FlowFixMe[incompatible-use] - const originalValue = this[key]; - if (typeof originalValue === 'object' && originalValue !== value) { + const originalValue = parent[key]; + if ( + typeof originalValue === 'object' && + originalValue !== value && + !(originalValue instanceof Date) + ) { if (objectName(originalValue) !== 'Object') { console.error( 'Only plain objects can be passed to Server Functions from the Client. ' + @@ -266,6 +278,17 @@ export function processReply( } if (typeof value === 'string') { + // TODO: Maybe too clever. If we support URL there's no similar trick. + if (value[value.length - 1] === 'Z') { + // Possibly a Date, whose toJSON automatically calls toISOString + // $FlowFixMe[incompatible-use] + const originalValue = parent[key]; + // $FlowFixMe[method-unbinding] + if (originalValue instanceof Date) { + return serializeDateFromDateJSON(value); + } + } + return escapeStringValue(value); } diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 9fc2da89a0..cb24048227 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -306,6 +306,23 @@ describe('ReactFlight', () => { ); }); + it('can transport Date', async () => { + function ComponentClient({prop}) { + return `prop: ${prop.toISOString()}`; + } + const Component = clientReference(ComponentClient); + + const model = ; + + const transport = ReactNoopFlightServer.render(model); + + await act(async () => { + ReactNoop.render(await ReactNoopFlightClient.read(transport)); + }); + + expect(ReactNoop).toMatchRenderedOutput('prop: 2009-02-13T23:31:30.123Z'); + }); + it('can render a lazy component as a shared component on the server', async () => { function SharedComponent({text}) { return ( @@ -675,28 +692,39 @@ describe('ReactFlight', () => { }); it('should warn in DEV if a toJSON instance is passed to a host component', () => { + const obj = { + toJSON() { + return 123; + }, + }; expect(() => { - const transport = ReactNoopFlightServer.render( - , - ); + const transport = ReactNoopFlightServer.render(); ReactNoopFlightClient.read(transport); }).toErrorDev( 'Only plain objects can be passed to Client Components from Server Components. ' + - 'Date objects are not supported.', + 'Objects with toJSON methods are not supported. ' + + 'Convert it manually to a simple value before passing it to props.\n' + + ' \n' + + ' ^^^^^^^^^^^^^^^^^^^^', {withoutStack: true}, ); }); it('should warn in DEV if a toJSON instance is passed to a host component child', () => { + class MyError extends Error { + toJSON() { + return 123; + } + } expect(() => { const transport = ReactNoopFlightServer.render( -
Current date: {new Date()}
, +
Womp womp: {new MyError('spaghetti')}
, ); ReactNoopFlightClient.read(transport); }).toErrorDev( - 'Date objects cannot be rendered as text children. Try formatting it using toString().\n' + - '
Current date: {Date}
\n' + - ' ^^^^^^', + 'Error objects cannot be rendered as text children. Try formatting it using toString().\n' + + '
Womp womp: {Error}
\n' + + ' ^^^^^^^', {withoutStack: true}, ); }); @@ -728,37 +756,46 @@ describe('ReactFlight', () => { }); it('should warn in DEV if a toJSON instance is passed to a Client Component', () => { + const obj = { + toJSON() { + return 123; + }, + }; function ClientImpl({value}) { return
{value}
; } const Client = clientReference(ClientImpl); expect(() => { - const transport = ReactNoopFlightServer.render( - , - ); + const transport = ReactNoopFlightServer.render(); ReactNoopFlightClient.read(transport); }).toErrorDev( 'Only plain objects can be passed to Client Components from Server Components. ' + - 'Date objects are not supported.', + 'Objects with toJSON methods are not supported.', {withoutStack: true}, ); }); it('should warn in DEV if a toJSON instance is passed to a Client Component child', () => { + const obj = { + toJSON() { + return 123; + }, + }; function ClientImpl({children}) { return
{children}
; } const Client = clientReference(ClientImpl); expect(() => { const transport = ReactNoopFlightServer.render( - Current date: {new Date()}, + Current date: {obj}, ); ReactNoopFlightClient.read(transport); }).toErrorDev( 'Only plain objects can be passed to Client Components from Server Components. ' + - 'Date objects are not supported.\n' + - ' <>Current date: {Date}\n' + - ' ^^^^^^', + 'Objects with toJSON methods are not supported. ' + + 'Convert it manually to a simple value before passing it to props.\n' + + ' <>Current date: {{toJSON: function}}\n' + + ' ^^^^^^^^^^^^^^^^^^^^', {withoutStack: true}, ); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js index b49c158721..59af378829 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js @@ -188,4 +188,13 @@ describe('ReactFlightDOMReply', () => { expect(formDataA2.get('greeting')).toBe('hello'); expect(formDataB2.get('greeting')).toBe('hi'); }); + + it('can pass a Date as a reply', async () => { + const d = new Date(1234567890123); + const body = await ReactServerDOMClient.encodeReply(d); + const d2 = await ReactServerDOMServer.decodeReply(body, webpackServerMap); + + expect(d).toEqual(d2); + expect(d % 1000).toEqual(123); // double-check the milliseconds made it through + }); }); diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index c3d5bef11c..4ba9b8785f 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -447,6 +447,10 @@ function parseModelString( // Special encoding for `undefined` which can't be serialized as JSON otherwise. return undefined; } + case 'D': { + // Date + return new Date(Date.parse(value.substring(2))); + } case 'n': { // BigInt return BigInt(value.substring(2)); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 17741ad786..dc37f750ae 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -571,6 +571,12 @@ function serializeUndefined(): string { return '$undefined'; } +function serializeDateFromDateJSON(dateJSON: string): string { + // JSON.stringify automatically calls Date.prototype.toJSON which calls toISOString. + // We need only tack on a $D prefix. + return '$D' + dateJSON; +} + function serializeBigInt(n: bigint): string { return '$n' + n.toString(10); } @@ -687,10 +693,15 @@ export function resolveModelToJSON( key: string, value: ReactClientValue, ): ReactJSONValue { + // Make sure that `parent[key]` wasn't JSONified before `value` was passed to us if (__DEV__) { // $FlowFixMe[incompatible-use] const originalValue = parent[key]; - if (typeof originalValue === 'object' && originalValue !== value) { + if ( + typeof originalValue === 'object' && + originalValue !== value && + !(originalValue instanceof Date) + ) { if (objectName(originalValue) !== 'Object') { const jsxParentType = jsxChildrenParents.get(parent); if (typeof jsxParentType === 'string') { @@ -892,6 +903,17 @@ export function resolveModelToJSON( } if (typeof value === 'string') { + // TODO: Maybe too clever. If we support URL there's no similar trick. + if (value[value.length - 1] === 'Z') { + // Possibly a Date, whose toJSON automatically calls toISOString + // $FlowFixMe[incompatible-use] + const originalValue = parent[key]; + // $FlowFixMe[method-unbinding] + if (originalValue instanceof Date) { + return serializeDateFromDateJSON(value); + } + } + return escapeStringValue(value); }