Skip to content

Commit bfb57d2

Browse files
committed
[Flight] Serialize Date
This is kind of annoying because Date implements toJSON so JSON.stringify turns it into a string before calling our replacer function.
1 parent d121c67 commit bfb57d2

File tree

6 files changed

+105
-19
lines changed

6 files changed

+105
-19
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,10 @@ export function parseModelString(
564564
// Special encoding for `undefined` which can't be serialized as JSON otherwise.
565565
return undefined;
566566
}
567+
case 'D': {
568+
// Date
569+
return new Date(Date.parse(value.substring(2)));
570+
}
567571
case 'n': {
568572
// BigInt
569573
return BigInt(value.substring(2));

packages/react-client/src/ReactFlightReplyClient.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ function serializeUndefined(): string {
7575
return '$undefined';
7676
}
7777

78+
function serializeDateFromDateJSON(dateJSON: string): string {
79+
// JSON.stringify automatically calls Date.prototype.toJSON which calls toISOString.
80+
// We need only tack on a $D prefix.
81+
return '$D' + dateJSON;
82+
}
83+
7884
function serializeBigInt(n: bigint): string {
7985
return '$n' + n.toString(10);
8086
}
@@ -106,9 +112,20 @@ export function processReply(
106112
value: ReactServerValue,
107113
): ReactJSONValue {
108114
const parent = this;
115+
116+
if (typeof value === 'string' && value[value.length - 1] === 'Z') {
117+
// Possibly a Date, whose toJSON automatically calls toISOString
118+
// $FlowFixMe[incompatible-use]
119+
const originalValue = parent[key];
120+
// $FlowFixMe[method-unbinding]
121+
if (Object.prototype.toString.call(originalValue) === '[object Date]') {
122+
return serializeDateFromDateJSON(value);
123+
}
124+
}
125+
109126
if (__DEV__) {
110127
// $FlowFixMe[incompatible-use]
111-
const originalValue = this[key];
128+
const originalValue = parent[key];
112129
if (typeof originalValue === 'object' && originalValue !== value) {
113130
if (objectName(originalValue) !== 'Object') {
114131
console.error(

packages/react-client/src/__tests__/ReactFlight-test.js

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,23 @@ describe('ReactFlight', () => {
282282
);
283283
});
284284

285+
it('can transport Date', async () => {
286+
function ComponentClient({prop}) {
287+
return `prop: ${prop.toISOString()}`;
288+
}
289+
const Component = clientReference(ComponentClient);
290+
291+
const model = <Component prop={new Date(1234567890123)} />;
292+
293+
const transport = ReactNoopFlightServer.render(model);
294+
295+
await act(async () => {
296+
ReactNoop.render(await ReactNoopFlightClient.read(transport));
297+
});
298+
299+
expect(ReactNoop).toMatchRenderedOutput('prop: 2009-02-13T23:31:30.123Z');
300+
});
301+
285302
it('can render a lazy component as a shared component on the server', async () => {
286303
function SharedComponent({text}) {
287304
return (
@@ -651,28 +668,39 @@ describe('ReactFlight', () => {
651668
});
652669

653670
it('should warn in DEV if a toJSON instance is passed to a host component', () => {
671+
const obj = {
672+
toJSON() {
673+
return 123;
674+
},
675+
};
654676
expect(() => {
655-
const transport = ReactNoopFlightServer.render(
656-
<input value={new Date()} />,
657-
);
677+
const transport = ReactNoopFlightServer.render(<input value={obj} />);
658678
ReactNoopFlightClient.read(transport);
659679
}).toErrorDev(
660680
'Only plain objects can be passed to Client Components from Server Components. ' +
661-
'Date objects are not supported.',
681+
'Objects with toJSON methods are not supported. ' +
682+
'Convert it manually to a simple value before passing it to props.\n' +
683+
' <input value={{toJSON: function}}>\n' +
684+
' ^^^^^^^^^^^^^^^^^^^^',
662685
{withoutStack: true},
663686
);
664687
});
665688

666689
it('should warn in DEV if a toJSON instance is passed to a host component child', () => {
690+
class MyError extends Error {
691+
toJSON() {
692+
return 123;
693+
}
694+
}
667695
expect(() => {
668696
const transport = ReactNoopFlightServer.render(
669-
<div>Current date: {new Date()}</div>,
697+
<div>Womp womp: {new MyError('spaghetti')}</div>,
670698
);
671699
ReactNoopFlightClient.read(transport);
672700
}).toErrorDev(
673-
'Date objects cannot be rendered as text children. Try formatting it using toString().\n' +
674-
' <div>Current date: {Date}</div>\n' +
675-
' ^^^^^^',
701+
'Error objects cannot be rendered as text children. Try formatting it using toString().\n' +
702+
' <div>Womp womp: {Error}</div>\n' +
703+
' ^^^^^^^',
676704
{withoutStack: true},
677705
);
678706
});
@@ -704,37 +732,46 @@ describe('ReactFlight', () => {
704732
});
705733

706734
it('should warn in DEV if a toJSON instance is passed to a Client Component', () => {
735+
const obj = {
736+
toJSON() {
737+
return 123;
738+
},
739+
};
707740
function ClientImpl({value}) {
708741
return <div>{value}</div>;
709742
}
710743
const Client = clientReference(ClientImpl);
711744
expect(() => {
712-
const transport = ReactNoopFlightServer.render(
713-
<Client value={new Date()} />,
714-
);
745+
const transport = ReactNoopFlightServer.render(<Client value={obj} />);
715746
ReactNoopFlightClient.read(transport);
716747
}).toErrorDev(
717748
'Only plain objects can be passed to Client Components from Server Components. ' +
718-
'Date objects are not supported.',
749+
'Objects with toJSON methods are not supported.',
719750
{withoutStack: true},
720751
);
721752
});
722753

723754
it('should warn in DEV if a toJSON instance is passed to a Client Component child', () => {
755+
const obj = {
756+
toJSON() {
757+
return 123;
758+
},
759+
};
724760
function ClientImpl({children}) {
725761
return <div>{children}</div>;
726762
}
727763
const Client = clientReference(ClientImpl);
728764
expect(() => {
729765
const transport = ReactNoopFlightServer.render(
730-
<Client>Current date: {new Date()}</Client>,
766+
<Client>Current date: {obj}</Client>,
731767
);
732768
ReactNoopFlightClient.read(transport);
733769
}).toErrorDev(
734770
'Only plain objects can be passed to Client Components from Server Components. ' +
735-
'Date objects are not supported.\n' +
736-
' <>Current date: {Date}</>\n' +
737-
' ^^^^^^',
771+
'Objects with toJSON methods are not supported. ' +
772+
'Convert it manually to a simple value before passing it to props.\n' +
773+
' <>Current date: {{toJSON: function}}</>\n' +
774+
' ^^^^^^^^^^^^^^^^^^^^',
738775
{withoutStack: true},
739776
);
740777
});

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,13 @@ describe('ReactFlightDOMReply', () => {
8282

8383
expect(n).toEqual(90071992547409910000n);
8484
});
85+
86+
it('can pass a Date as a reply', async () => {
87+
const d = new Date(1234567890123);
88+
const body = await ReactServerDOMClient.encodeReply(d);
89+
const d2 = await ReactServerDOMServer.decodeReply(body, webpackServerMap);
90+
91+
expect(d).toEqual(d2);
92+
expect(d % 1000).toEqual(123); // double-check the milliseconds made it through
93+
});
8594
});

packages/react-server/src/ReactFlightReplyServer.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,10 @@ function parseModelString(
402402
// Special encoding for `undefined` which can't be serialized as JSON otherwise.
403403
return undefined;
404404
}
405+
case 'D': {
406+
// Date
407+
return new Date(Date.parse(value.substring(2)));
408+
}
405409
case 'n': {
406410
// BigInt
407411
return BigInt(value.substring(2));

packages/react-server/src/ReactFlightServer.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,12 @@ function serializeUndefined(): string {
553553
return '$undefined';
554554
}
555555

556+
function serializeDateFromDateJSON(dateJSON: string): string {
557+
// JSON.stringify automatically calls Date.prototype.toJSON which calls toISOString.
558+
// We need only tack on a $D prefix.
559+
return '$D' + dateJSON;
560+
}
561+
556562
function serializeBigInt(n: bigint): string {
557563
return '$n' + n.toString(10);
558564
}
@@ -669,6 +675,16 @@ export function resolveModelToJSON(
669675
key: string,
670676
value: ReactClientValue,
671677
): ReactJSONValue {
678+
if (typeof value === 'string' && value[value.length - 1] === 'Z') {
679+
// Possibly a Date, whose toJSON automatically calls toISOString
680+
// $FlowFixMe[incompatible-use]
681+
const originalValue = parent[key];
682+
// $FlowFixMe[method-unbinding]
683+
if (Object.prototype.toString.call(originalValue) === '[object Date]') {
684+
return serializeDateFromDateJSON(value);
685+
}
686+
}
687+
672688
if (__DEV__) {
673689
// $FlowFixMe[incompatible-use]
674690
const originalValue = parent[key];
@@ -831,8 +847,7 @@ export function resolveModelToJSON(
831847
isInsideContextValue = false;
832848
}
833849
return (undefined: any);
834-
}
835-
if (!isArray(value)) {
850+
} else if (!isArray(value)) {
836851
const iteratorFn = getIteratorFn(value);
837852
if (iteratorFn) {
838853
return Array.from((value: any));

0 commit comments

Comments
 (0)