[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