[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.
This commit is contained in:
Sophie Alpert 2023-04-18 20:52:03 -07:00 committed by GitHub
parent 96fd2fb726
commit c6db19f9cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 118 additions and 19 deletions

View File

@ -580,6 +580,10 @@ export function parseModelString(
// Special encoding for `undefined` which can't be serialized as JSON otherwise. // Special encoding for `undefined` which can't be serialized as JSON otherwise.
return undefined; return undefined;
} }
case 'D': {
// Date
return new Date(Date.parse(value.substring(2)));
}
case 'n': { case 'n': {
// BigInt // BigInt
return BigInt(value.substring(2)); return BigInt(value.substring(2));

View File

@ -101,6 +101,12 @@ function serializeUndefined(): string {
return '$undefined'; 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 { function serializeBigInt(n: bigint): string {
return '$n' + n.toString(10); return '$n' + n.toString(10);
} }
@ -133,10 +139,16 @@ export function processReply(
value: ReactServerValue, value: ReactServerValue,
): ReactJSONValue { ): ReactJSONValue {
const parent = this; const parent = this;
// Make sure that `parent[key]` wasn't JSONified before `value` was passed to us
if (__DEV__) { if (__DEV__) {
// $FlowFixMe[incompatible-use] // $FlowFixMe[incompatible-use]
const originalValue = this[key]; const originalValue = parent[key];
if (typeof originalValue === 'object' && originalValue !== value) { if (
typeof originalValue === 'object' &&
originalValue !== value &&
!(originalValue instanceof Date)
) {
if (objectName(originalValue) !== 'Object') { if (objectName(originalValue) !== 'Object') {
console.error( console.error(
'Only plain objects can be passed to Server Functions from the Client. ' + 'Only plain objects can be passed to Server Functions from the Client. ' +
@ -266,6 +278,17 @@ export function processReply(
} }
if (typeof value === 'string') { 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); return escapeStringValue(value);
} }

View File

@ -306,6 +306,23 @@ describe('ReactFlight', () => {
); );
}); });
it('can transport Date', async () => {
function ComponentClient({prop}) {
return `prop: ${prop.toISOString()}`;
}
const Component = clientReference(ComponentClient);
const model = <Component prop={new Date(1234567890123)} />;
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 () => { it('can render a lazy component as a shared component on the server', async () => {
function SharedComponent({text}) { function SharedComponent({text}) {
return ( return (
@ -675,28 +692,39 @@ describe('ReactFlight', () => {
}); });
it('should warn in DEV if a toJSON instance is passed to a host component', () => { it('should warn in DEV if a toJSON instance is passed to a host component', () => {
const obj = {
toJSON() {
return 123;
},
};
expect(() => { expect(() => {
const transport = ReactNoopFlightServer.render( const transport = ReactNoopFlightServer.render(<input value={obj} />);
<input value={new Date()} />,
);
ReactNoopFlightClient.read(transport); ReactNoopFlightClient.read(transport);
}).toErrorDev( }).toErrorDev(
'Only plain objects can be passed to Client Components from Server Components. ' + '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' +
' <input value={{toJSON: function}}>\n' +
' ^^^^^^^^^^^^^^^^^^^^',
{withoutStack: true}, {withoutStack: true},
); );
}); });
it('should warn in DEV if a toJSON instance is passed to a host component child', () => { it('should warn in DEV if a toJSON instance is passed to a host component child', () => {
class MyError extends Error {
toJSON() {
return 123;
}
}
expect(() => { expect(() => {
const transport = ReactNoopFlightServer.render( const transport = ReactNoopFlightServer.render(
<div>Current date: {new Date()}</div>, <div>Womp womp: {new MyError('spaghetti')}</div>,
); );
ReactNoopFlightClient.read(transport); ReactNoopFlightClient.read(transport);
}).toErrorDev( }).toErrorDev(
'Date objects cannot be rendered as text children. Try formatting it using toString().\n' + 'Error objects cannot be rendered as text children. Try formatting it using toString().\n' +
' <div>Current date: {Date}</div>\n' + ' <div>Womp womp: {Error}</div>\n' +
' ^^^^^^', ' ^^^^^^^',
{withoutStack: true}, {withoutStack: true},
); );
}); });
@ -728,37 +756,46 @@ describe('ReactFlight', () => {
}); });
it('should warn in DEV if a toJSON instance is passed to a Client Component', () => { it('should warn in DEV if a toJSON instance is passed to a Client Component', () => {
const obj = {
toJSON() {
return 123;
},
};
function ClientImpl({value}) { function ClientImpl({value}) {
return <div>{value}</div>; return <div>{value}</div>;
} }
const Client = clientReference(ClientImpl); const Client = clientReference(ClientImpl);
expect(() => { expect(() => {
const transport = ReactNoopFlightServer.render( const transport = ReactNoopFlightServer.render(<Client value={obj} />);
<Client value={new Date()} />,
);
ReactNoopFlightClient.read(transport); ReactNoopFlightClient.read(transport);
}).toErrorDev( }).toErrorDev(
'Only plain objects can be passed to Client Components from Server Components. ' + '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}, {withoutStack: true},
); );
}); });
it('should warn in DEV if a toJSON instance is passed to a Client Component child', () => { it('should warn in DEV if a toJSON instance is passed to a Client Component child', () => {
const obj = {
toJSON() {
return 123;
},
};
function ClientImpl({children}) { function ClientImpl({children}) {
return <div>{children}</div>; return <div>{children}</div>;
} }
const Client = clientReference(ClientImpl); const Client = clientReference(ClientImpl);
expect(() => { expect(() => {
const transport = ReactNoopFlightServer.render( const transport = ReactNoopFlightServer.render(
<Client>Current date: {new Date()}</Client>, <Client>Current date: {obj}</Client>,
); );
ReactNoopFlightClient.read(transport); ReactNoopFlightClient.read(transport);
}).toErrorDev( }).toErrorDev(
'Only plain objects can be passed to Client Components from Server Components. ' + 'Only plain objects can be passed to Client Components from Server Components. ' +
'Date objects are not supported.\n' + 'Objects with toJSON methods are not supported. ' +
' <>Current date: {Date}</>\n' + 'Convert it manually to a simple value before passing it to props.\n' +
' ^^^^^^', ' <>Current date: {{toJSON: function}}</>\n' +
' ^^^^^^^^^^^^^^^^^^^^',
{withoutStack: true}, {withoutStack: true},
); );
}); });

View File

@ -188,4 +188,13 @@ describe('ReactFlightDOMReply', () => {
expect(formDataA2.get('greeting')).toBe('hello'); expect(formDataA2.get('greeting')).toBe('hello');
expect(formDataB2.get('greeting')).toBe('hi'); 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
});
}); });

View File

@ -447,6 +447,10 @@ function parseModelString(
// Special encoding for `undefined` which can't be serialized as JSON otherwise. // Special encoding for `undefined` which can't be serialized as JSON otherwise.
return undefined; return undefined;
} }
case 'D': {
// Date
return new Date(Date.parse(value.substring(2)));
}
case 'n': { case 'n': {
// BigInt // BigInt
return BigInt(value.substring(2)); return BigInt(value.substring(2));

View File

@ -571,6 +571,12 @@ function serializeUndefined(): string {
return '$undefined'; 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 { function serializeBigInt(n: bigint): string {
return '$n' + n.toString(10); return '$n' + n.toString(10);
} }
@ -687,10 +693,15 @@ export function resolveModelToJSON(
key: string, key: string,
value: ReactClientValue, value: ReactClientValue,
): ReactJSONValue { ): ReactJSONValue {
// Make sure that `parent[key]` wasn't JSONified before `value` was passed to us
if (__DEV__) { if (__DEV__) {
// $FlowFixMe[incompatible-use] // $FlowFixMe[incompatible-use]
const originalValue = parent[key]; const originalValue = parent[key];
if (typeof originalValue === 'object' && originalValue !== value) { if (
typeof originalValue === 'object' &&
originalValue !== value &&
!(originalValue instanceof Date)
) {
if (objectName(originalValue) !== 'Object') { if (objectName(originalValue) !== 'Object') {
const jsxParentType = jsxChildrenParents.get(parent); const jsxParentType = jsxChildrenParents.get(parent);
if (typeof jsxParentType === 'string') { if (typeof jsxParentType === 'string') {
@ -892,6 +903,17 @@ export function resolveModelToJSON(
} }
if (typeof value === 'string') { 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); return escapeStringValue(value);
} }