@samitouri / QOS-React / commits / c6db19f9cd

[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.

Sophie Alpert committed Apr 18, 2023 at 20:52 UTC c6db19f9cdec34bca3625a483a2f85181193b885
6 files changed +118 -19
packages/react-client/src/ReactFlightClient.js
+4
@@ -580,6 +580,10 @@ export function parseModelString(
580 // Special encoding for `undefined` which can't be serialized as JSON otherwise.
581 return undefined;
582 }
583 + case 'D': {
584 + // Date
585 + return new Date(Date.parse(value.substring(2)));
586 + }
587 case 'n': {
588 // BigInt
589 return BigInt(value.substring(2));
packages/react-client/src/ReactFlightReplyClient.js
+25 -2
@@ -101,6 +101,12 @@ function serializeUndefined(): string {
101 return '$undefined';
102 }
103
104 +function serializeDateFromDateJSON(dateJSON: string): string {
105 + // JSON.stringify automatically calls Date.prototype.toJSON which calls toISOString.
106 + // We need only tack on a $D prefix.
107 + return '$D' + dateJSON;
108 +}
109 +
110 function serializeBigInt(n: bigint): string {
111 return '$n' + n.toString(10);
112 }
@@ -133,10 +139,16 @@ export function processReply(
139 value: ReactServerValue,
140 ): ReactJSONValue {
141 const parent = this;
142 +
143 + // Make sure that `parent[key]` wasn't JSONified before `value` was passed to us
144 if (__DEV__) {
145 // $FlowFixMe[incompatible-use]
138 - const originalValue = this[key];
139 - if (typeof originalValue === 'object' && originalValue !== value) {
146 + const originalValue = parent[key];
147 + if (
148 + typeof originalValue === 'object' &&
149 + originalValue !== value &&
150 + !(originalValue instanceof Date)
151 + ) {
152 if (objectName(originalValue) !== 'Object') {
153 console.error(
154 'Only plain objects can be passed to Server Functions from the Client. ' +
@@ -266,6 +278,17 @@ export function processReply(
278 }
279
280 if (typeof value === 'string') {
281 + // TODO: Maybe too clever. If we support URL there's no similar trick.
282 + if (value[value.length - 1] === 'Z') {
283 + // Possibly a Date, whose toJSON automatically calls toISOString
284 + // $FlowFixMe[incompatible-use]
285 + const originalValue = parent[key];
286 + // $FlowFixMe[method-unbinding]
287 + if (originalValue instanceof Date) {
288 + return serializeDateFromDateJSON(value);
289 + }
290 + }
291 +
292 return escapeStringValue(value);
293 }
294
packages/react-client/src/__tests__/ReactFlight-test.js
+53 -16
@@ -306,6 +306,23 @@ describe('ReactFlight', () => {
306 );
307 });
308
309 + it('can transport Date', async () => {
310 + function ComponentClient({prop}) {
311 + return `prop: ${prop.toISOString()}`;
312 + }
313 + const Component = clientReference(ComponentClient);
314 +
315 + const model = <Component prop={new Date(1234567890123)} />;
316 +
317 + const transport = ReactNoopFlightServer.render(model);
318 +
319 + await act(async () => {
320 + ReactNoop.render(await ReactNoopFlightClient.read(transport));
321 + });
322 +
323 + expect(ReactNoop).toMatchRenderedOutput('prop: 2009-02-13T23:31:30.123Z');
324 + });
325 +
326 it('can render a lazy component as a shared component on the server', async () => {
327 function SharedComponent({text}) {
328 return (
@@ -675,28 +692,39 @@ describe('ReactFlight', () => {
692 });
693
694 it('should warn in DEV if a toJSON instance is passed to a host component', () => {
695 + const obj = {
696 + toJSON() {
697 + return 123;
698 + },
699 + };
700 expect(() => {
679 - const transport = ReactNoopFlightServer.render(
680 - <input value={new Date()} />,
681 - );
701 + const transport = ReactNoopFlightServer.render(<input value={obj} />);
702 ReactNoopFlightClient.read(transport);
703 }).toErrorDev(
704 'Only plain objects can be passed to Client Components from Server Components. ' +
685 - 'Date objects are not supported.',
705 + 'Objects with toJSON methods are not supported. ' +
706 + 'Convert it manually to a simple value before passing it to props.\n' +
707 + ' <input value={{toJSON: function}}>\n' +
708 + ' ^^^^^^^^^^^^^^^^^^^^',
709 {withoutStack: true},
710 );
711 });
712
713 it('should warn in DEV if a toJSON instance is passed to a host component child', () => {
714 + class MyError extends Error {
715 + toJSON() {
716 + return 123;
717 + }
718 + }
719 expect(() => {
720 const transport = ReactNoopFlightServer.render(
693 - <div>Current date: {new Date()}</div>,
721 + <div>Womp womp: {new MyError('spaghetti')}</div>,
722 );
723 ReactNoopFlightClient.read(transport);
724 }).toErrorDev(
697 - 'Date objects cannot be rendered as text children. Try formatting it using toString().\n' +
698 - ' <div>Current date: {Date}</div>\n' +
699 - ' ^^^^^^',
725 + 'Error objects cannot be rendered as text children. Try formatting it using toString().\n' +
726 + ' <div>Womp womp: {Error}</div>\n' +
727 + ' ^^^^^^^',
728 {withoutStack: true},
729 );
730 });
@@ -728,37 +756,46 @@ describe('ReactFlight', () => {
756 });
757
758 it('should warn in DEV if a toJSON instance is passed to a Client Component', () => {
759 + const obj = {
760 + toJSON() {
761 + return 123;
762 + },
763 + };
764 function ClientImpl({value}) {
765 return <div>{value}</div>;
766 }
767 const Client = clientReference(ClientImpl);
768 expect(() => {
736 - const transport = ReactNoopFlightServer.render(
737 - <Client value={new Date()} />,
738 - );
769 + const transport = ReactNoopFlightServer.render(<Client value={obj} />);
770 ReactNoopFlightClient.read(transport);
771 }).toErrorDev(
772 'Only plain objects can be passed to Client Components from Server Components. ' +
742 - 'Date objects are not supported.',
773 + 'Objects with toJSON methods are not supported.',
774 {withoutStack: true},
775 );
776 });
777
778 it('should warn in DEV if a toJSON instance is passed to a Client Component child', () => {
779 + const obj = {
780 + toJSON() {
781 + return 123;
782 + },
783 + };
784 function ClientImpl({children}) {
785 return <div>{children}</div>;
786 }
787 const Client = clientReference(ClientImpl);
788 expect(() => {
789 const transport = ReactNoopFlightServer.render(
754 - <Client>Current date: {new Date()}</Client>,
790 + <Client>Current date: {obj}</Client>,
791 );
792 ReactNoopFlightClient.read(transport);
793 }).toErrorDev(
794 'Only plain objects can be passed to Client Components from Server Components. ' +
759 - 'Date objects are not supported.\n' +
760 - ' <>Current date: {Date}</>\n' +
761 - ' ^^^^^^',
795 + 'Objects with toJSON methods are not supported. ' +
796 + 'Convert it manually to a simple value before passing it to props.\n' +
797 + ' <>Current date: {{toJSON: function}}</>\n' +
798 + ' ^^^^^^^^^^^^^^^^^^^^',
799 {withoutStack: true},
800 );
801 });
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js
+9
@@ -188,4 +188,13 @@ describe('ReactFlightDOMReply', () => {
188 expect(formDataA2.get('greeting')).toBe('hello');
189 expect(formDataB2.get('greeting')).toBe('hi');
190 });
191 +
192 + it('can pass a Date as a reply', async () => {
193 + const d = new Date(1234567890123);
194 + const body = await ReactServerDOMClient.encodeReply(d);
195 + const d2 = await ReactServerDOMServer.decodeReply(body, webpackServerMap);
196 +
197 + expect(d).toEqual(d2);
198 + expect(d % 1000).toEqual(123); // double-check the milliseconds made it through
199 + });
200 });
packages/react-server/src/ReactFlightReplyServer.js
+4
@@ -447,6 +447,10 @@ function parseModelString(
447 // Special encoding for `undefined` which can't be serialized as JSON otherwise.
448 return undefined;
449 }
450 + case 'D': {
451 + // Date
452 + return new Date(Date.parse(value.substring(2)));
453 + }
454 case 'n': {
455 // BigInt
456 return BigInt(value.substring(2));
packages/react-server/src/ReactFlightServer.js
+23 -1
@@ -571,6 +571,12 @@ function serializeUndefined(): string {
571 return '$undefined';
572 }
573
574 +function serializeDateFromDateJSON(dateJSON: string): string {
575 + // JSON.stringify automatically calls Date.prototype.toJSON which calls toISOString.
576 + // We need only tack on a $D prefix.
577 + return '$D' + dateJSON;
578 +}
579 +
580 function serializeBigInt(n: bigint): string {
581 return '$n' + n.toString(10);
582 }
@@ -687,10 +693,15 @@ export function resolveModelToJSON(
693 key: string,
694 value: ReactClientValue,
695 ): ReactJSONValue {
696 + // Make sure that `parent[key]` wasn't JSONified before `value` was passed to us
697 if (__DEV__) {
698 // $FlowFixMe[incompatible-use]
699 const originalValue = parent[key];
693 - if (typeof originalValue === 'object' && originalValue !== value) {
700 + if (
701 + typeof originalValue === 'object' &&
702 + originalValue !== value &&
703 + !(originalValue instanceof Date)
704 + ) {
705 if (objectName(originalValue) !== 'Object') {
706 const jsxParentType = jsxChildrenParents.get(parent);
707 if (typeof jsxParentType === 'string') {
@@ -892,6 +903,17 @@ export function resolveModelToJSON(
903 }
904
905 if (typeof value === 'string') {
906 + // TODO: Maybe too clever. If we support URL there's no similar trick.
907 + if (value[value.length - 1] === 'Z') {
908 + // Possibly a Date, whose toJSON automatically calls toISOString
909 + // $FlowFixMe[incompatible-use]
910 + const originalValue = parent[key];
911 + // $FlowFixMe[method-unbinding]
912 + if (originalValue instanceof Date) {
913 + return serializeDateFromDateJSON(value);
914 + }
915 + }
916 +
917 return escapeStringValue(value);
918 }
919