Skip to content

Commit c6db19f

Browse files
authored
[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.
1 parent 96fd2fb commit c6db19f

File tree

6 files changed

+118
-19
lines changed

6 files changed

+118
-19
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,10 @@ export function parseModelString(
580580
// Special encoding for `undefined` which can't be serialized as JSON otherwise.
581581
return undefined;
582582
}
583+
case 'D': {
584+
// Date
585+
return new Date(Date.parse(value.substring(2)));
586+
}
583587
case 'n': {
584588
// BigInt
585589
return BigInt(value.substring(2));

packages/react-client/src/ReactFlightReplyClient.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ function serializeUndefined(): string {
101101
return '$undefined';
102102
}
103103

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+
104110
function serializeBigInt(n: bigint): string {
105111
return '$n' + n.toString(10);
106112
}
@@ -133,10 +139,16 @@ export function processReply(
133139
value: ReactServerValue,
134140
): ReactJSONValue {
135141
const parent = this;
142+
143+
// Make sure that `parent[key]` wasn't JSONified before `value` was passed to us
136144
if (__DEV__) {
137145
// $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+
) {
140152
if (objectName(originalValue) !== 'Object') {
141153
console.error(
142154
'Only plain objects can be passed to Server Functions from the Client. ' +
@@ -266,6 +278,17 @@ export function processReply(
266278
}
267279

268280
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+
269292
return escapeStringValue(value);
270293
}
271294

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

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,23 @@ describe('ReactFlight', () => {
306306
);
307307
});
308308

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+
309326
it('can render a lazy component as a shared component on the server', async () => {
310327
function SharedComponent({text}) {
311328
return (
@@ -675,28 +692,39 @@ describe('ReactFlight', () => {
675692
});
676693

677694
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+
};
678700
expect(() => {
679-
const transport = ReactNoopFlightServer.render(
680-
<input value={new Date()} />,
681-
);
701+
const transport = ReactNoopFlightServer.render(<input value={obj} />);
682702
ReactNoopFlightClient.read(transport);
683703
}).toErrorDev(
684704
'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+
' ^^^^^^^^^^^^^^^^^^^^',
686709
{withoutStack: true},
687710
);
688711
});
689712

690713
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+
}
691719
expect(() => {
692720
const transport = ReactNoopFlightServer.render(
693-
<div>Current date: {new Date()}</div>,
721+
<div>Womp womp: {new MyError('spaghetti')}</div>,
694722
);
695723
ReactNoopFlightClient.read(transport);
696724
}).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+
' ^^^^^^^',
700728
{withoutStack: true},
701729
);
702730
});
@@ -728,37 +756,46 @@ describe('ReactFlight', () => {
728756
});
729757

730758
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+
};
731764
function ClientImpl({value}) {
732765
return <div>{value}</div>;
733766
}
734767
const Client = clientReference(ClientImpl);
735768
expect(() => {
736-
const transport = ReactNoopFlightServer.render(
737-
<Client value={new Date()} />,
738-
);
769+
const transport = ReactNoopFlightServer.render(<Client value={obj} />);
739770
ReactNoopFlightClient.read(transport);
740771
}).toErrorDev(
741772
'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.',
743774
{withoutStack: true},
744775
);
745776
});
746777

747778
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+
};
748784
function ClientImpl({children}) {
749785
return <div>{children}</div>;
750786
}
751787
const Client = clientReference(ClientImpl);
752788
expect(() => {
753789
const transport = ReactNoopFlightServer.render(
754-
<Client>Current date: {new Date()}</Client>,
790+
<Client>Current date: {obj}</Client>,
755791
);
756792
ReactNoopFlightClient.read(transport);
757793
}).toErrorDev(
758794
'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+
' ^^^^^^^^^^^^^^^^^^^^',
762799
{withoutStack: true},
763800
);
764801
});

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,4 +188,13 @@ describe('ReactFlightDOMReply', () => {
188188
expect(formDataA2.get('greeting')).toBe('hello');
189189
expect(formDataB2.get('greeting')).toBe('hi');
190190
});
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+
});
191200
});

packages/react-server/src/ReactFlightReplyServer.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,10 @@ function parseModelString(
447447
// Special encoding for `undefined` which can't be serialized as JSON otherwise.
448448
return undefined;
449449
}
450+
case 'D': {
451+
// Date
452+
return new Date(Date.parse(value.substring(2)));
453+
}
450454
case 'n': {
451455
// BigInt
452456
return BigInt(value.substring(2));

packages/react-server/src/ReactFlightServer.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,12 @@ function serializeUndefined(): string {
571571
return '$undefined';
572572
}
573573

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+
574580
function serializeBigInt(n: bigint): string {
575581
return '$n' + n.toString(10);
576582
}
@@ -687,10 +693,15 @@ export function resolveModelToJSON(
687693
key: string,
688694
value: ReactClientValue,
689695
): ReactJSONValue {
696+
// Make sure that `parent[key]` wasn't JSONified before `value` was passed to us
690697
if (__DEV__) {
691698
// $FlowFixMe[incompatible-use]
692699
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+
) {
694705
if (objectName(originalValue) !== 'Object') {
695706
const jsxParentType = jsxChildrenParents.get(parent);
696707
if (typeof jsxParentType === 'string') {
@@ -892,6 +903,17 @@ export function resolveModelToJSON(
892903
}
893904

894905
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+
895917
return escapeStringValue(value);
896918
}
897919

0 commit comments

Comments
 (0)