-
Notifications
You must be signed in to change notification settings - Fork 46.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Flight Reply] Add Reply Encoding #26360
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,13 +1,5 @@ | ||
'use server'; | ||
|
||
export async function like() { | ||
return new Promise((resolve, reject) => | ||
setTimeout( | ||
() => | ||
Math.random() > 0.5 | ||
? resolve('Liked') | ||
: reject(new Error('Failed to like')), | ||
500 | ||
) | ||
); | ||
return new Promise((resolve, reject) => resolve('Liked')); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,28 +1,28 @@ | ||
import * as React from 'react'; | ||
import {Suspense} from 'react'; | ||
import ReactDOM from 'react-dom/client'; | ||
import ReactServerDOMReader from 'react-server-dom-webpack/client'; | ||
import {createFromFetch, encodeReply} from 'react-server-dom-webpack/client'; | ||
|
||
// TODO: This should be a dependency of the App but we haven't implemented CSS in Node yet. | ||
import './style.css'; | ||
|
||
let data = ReactServerDOMReader.createFromFetch( | ||
let data = createFromFetch( | ||
fetch('/', { | ||
headers: { | ||
Accept: 'text/x-component', | ||
}, | ||
}), | ||
{ | ||
callServer(id, args) { | ||
async callServer(id, args) { | ||
const response = fetch('/', { | ||
method: 'POST', | ||
headers: { | ||
Accept: 'text/x-component', | ||
'rsc-action': id, | ||
}, | ||
body: JSON.stringify(args), | ||
body: await encodeReply(args), | ||
}); | ||
return ReactServerDOMReader.createFromFetch(response); | ||
return createFromFetch(response); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do you model the resolve handling like this is a RSC render? I get that you may want to return a rendered tree but if this is just a "API" endpoint that returns JSON data or something it seems odd to process the response like it is a component tree. It's interesting that if you did render suspense boundaries you could get an object that could construct itself incrementally as bits stream in but I have a hard time wrapping my head around that as a general purpose response handler instead of something specifc to fetching and integrating flight payloads There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not just a renderer. It's a general purpose serializer with much more rich options of the payload, and more to come. So it's used as a better alternative to JSON.stringify. Since that implementation also happens to be what you'd use to render a real response which likely contains an update to the current page, it's conveniently the same thing. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. right but if I send i still have to wait 5 seconds to see the result because there is no way to expose an incomplete value and then update it to a complete value. We use Suspense boundaries for React trees and we can re-evaluate the value on each render attempt, but for a general purpose response object we'd need some similar kind of construct which groups a value into revealable. I mean we could literally use suspense but that just feels kind strange Do you have a vision for what this more generalizable type is so you can really take advantage of the unique characteristics of the flight format and runtime? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you're just describing Promises? The consumer can just There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not really anything fancy. It's just passing values that are already common place in the language so you just use it as if it was local. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So this basically allows us to stream JSON back to the client by serialising some properties as promises, which is pretty cool, I must say. 🎉 Although this is already working in
where the eager chunk is sent immediately, and the lazy chunk is sent after 5 seconds. With async iterators soon to be serialisable, I guess you could use that e.g. to render the progress of a long running task? |
||
}, | ||
} | ||
); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,278 @@ | ||
/** | ||
* Copyright (c) Meta Platforms, Inc. and affiliates. | ||
* | ||
* This source code is licensed under the MIT license found in the | ||
* LICENSE file in the root directory of this source tree. | ||
* | ||
* @flow | ||
*/ | ||
|
||
import type {Thenable} from 'shared/ReactTypes'; | ||
|
||
import {knownServerReferences} from './ReactFlightServerReferenceRegistry'; | ||
|
||
import { | ||
REACT_ELEMENT_TYPE, | ||
REACT_LAZY_TYPE, | ||
REACT_PROVIDER_TYPE, | ||
} from 'shared/ReactSymbols'; | ||
|
||
import { | ||
describeObjectForErrorMessage, | ||
isSimpleObject, | ||
objectName, | ||
} from 'shared/ReactSerializationErrors'; | ||
|
||
import isArray from 'shared/isArray'; | ||
|
||
type ReactJSONValue = | ||
| string | ||
| boolean | ||
| number | ||
| null | ||
| $ReadOnlyArray<ReactJSONValue> | ||
| ReactServerObject; | ||
|
||
export opaque type ServerReference<T> = T; | ||
|
||
// Serializable values | ||
export type ReactServerValue = | ||
// References are passed by their value | ||
| ServerReference<any> | ||
// The rest are passed as is. Sub-types can be passed in but lose their | ||
// subtype, so the receiver can only accept once of these. | ||
| string | ||
| boolean | ||
| number | ||
| symbol | ||
| null | ||
| Iterable<ReactServerValue> | ||
| Array<ReactServerValue> | ||
| ReactServerObject | ||
| Promise<ReactServerValue>; // Thenable<ReactServerValue> | ||
|
||
type ReactServerObject = {+[key: string]: ReactServerValue}; | ||
|
||
// function serializeByValueID(id: number): string { | ||
// return '$' + id.toString(16); | ||
// } | ||
|
||
function serializePromiseID(id: number): string { | ||
return '$@' + id.toString(16); | ||
} | ||
|
||
function serializeServerReferenceID(id: number): string { | ||
return '$F' + id.toString(16); | ||
} | ||
|
||
function serializeSymbolReference(name: string): string { | ||
return '$S' + name; | ||
} | ||
|
||
function escapeStringValue(value: string): string { | ||
if (value[0] === '$') { | ||
// We need to escape $ prefixed strings since we use those to encode | ||
// references to IDs and as special symbol values. | ||
return '$' + value; | ||
} else { | ||
return value; | ||
} | ||
} | ||
|
||
export function processReply( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm struggling with the framing of If we always position our names from the perspective of the client then we make a Request (client -> server) and get a Response (server -> client). If we call the analog of Request Reply then we have Reply and Response which just seems odd. I assume that calling it Reply is because Flight goes in a certain direction today and so to make a reverse flight protocol using reply seems kinda natural. But that's sort of a temporal detail (one existed before the other). If we developed both at the same time I think we might come up with Flight Response : server -> client But Flight Request may hew too closely to a regular request which is not a Flight Request so maybe we call it Flight Call since it models calling functions on the server? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The client can only reply to something that the server already sent. It's still server centric. E.g. you can't call a server function first. It's also locked in version so even if you've received a function in an earlier visit, it might not be valid anymore. You must get a new handle to something that you can reply to. Similarly the Server can pass any Client References it wants. The Client can just reply with what it has already been given and prompted with. It's not always a call to functions on the server. E.g. updates/navigations that serializes Server Context or params also contain a "reply". Request/Response as terminology doesn't make sense since it's overloaded with the HTTP request/response that happens in both directions. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is Postback a similar concept? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea but we're also going to have literal postbacks which is not this, so it'd be overloaded. |
||
root: ReactServerValue, | ||
resolve: (string | FormData) => void, | ||
reject: (error: mixed) => void, | ||
): void { | ||
let nextPartId = 1; | ||
let pendingParts = 0; | ||
let formData: null | FormData = null; | ||
|
||
function resolveToJSON( | ||
this: | ||
| {+[key: string | number]: ReactServerValue} | ||
| $ReadOnlyArray<ReactServerValue>, | ||
key: string, | ||
value: ReactServerValue, | ||
): ReactJSONValue { | ||
const parent = this; | ||
if (__DEV__) { | ||
// $FlowFixMe | ||
const originalValue = this[key]; | ||
if (typeof originalValue === 'object' && originalValue !== value) { | ||
if (objectName(originalValue) !== 'Object') { | ||
console.error( | ||
'Only plain objects can be passed to Server Functions from the Client. ' + | ||
'%s objects are not supported.%s', | ||
objectName(originalValue), | ||
describeObjectForErrorMessage(parent, key), | ||
); | ||
} else { | ||
console.error( | ||
'Only plain objects can be passed to Server Functions from the Client. ' + | ||
'Objects with toJSON methods are not supported. Convert it manually ' + | ||
'to a simple value before passing it to props.%s', | ||
describeObjectForErrorMessage(parent, key), | ||
); | ||
} | ||
} | ||
} | ||
|
||
if (value === null) { | ||
return null; | ||
} | ||
|
||
if (typeof value === 'object') { | ||
// $FlowFixMe[method-unbinding] | ||
if (typeof value.then === 'function') { | ||
// We assume that any object with a .then property is a "Thenable" type, | ||
// or a Promise type. Either of which can be represented by a Promise. | ||
if (formData === null) { | ||
// Upgrade to use FormData to allow us to stream this value. | ||
formData = new FormData(); | ||
} | ||
pendingParts++; | ||
const promiseId = nextPartId++; | ||
const thenable: Thenable<any> = (value: any); | ||
thenable.then( | ||
partValue => { | ||
const partJSON = JSON.stringify(partValue, resolveToJSON); | ||
// $FlowFixMe[incompatible-type] We know it's not null because we assigned it above. | ||
const data: FormData = formData; | ||
// eslint-disable-next-line react-internal/safe-string-coercion | ||
data.append('' + promiseId, partJSON); | ||
pendingParts--; | ||
if (pendingParts === 0) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are we going to trust the thenables to not be evil and call There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea, I think it's fair to assume that. I don't think this be used much without real promises anyway. Mainly the ones we vendor which are also real-ish. This is also the implementation on the server already. |
||
resolve(data); | ||
} | ||
}, | ||
reason => { | ||
// In the future we could consider serializing this as an error | ||
// that throws on the server instead. | ||
reject(reason); | ||
}, | ||
); | ||
return serializePromiseID(promiseId); | ||
} | ||
|
||
if (__DEV__) { | ||
if (value !== null && !isArray(value)) { | ||
// Verify that this is a simple plain object. | ||
if ((value: any).$$typeof === REACT_ELEMENT_TYPE) { | ||
console.error( | ||
'React Element cannot be passed to Server Functions from the Client.%s', | ||
describeObjectForErrorMessage(parent, key), | ||
); | ||
} else if ((value: any).$$typeof === REACT_LAZY_TYPE) { | ||
console.error( | ||
'React Lazy cannot be passed to Server Functions from the Client.%s', | ||
describeObjectForErrorMessage(parent, key), | ||
); | ||
} else if ((value: any).$$typeof === REACT_PROVIDER_TYPE) { | ||
console.error( | ||
'React Context Providers cannot be passed to Server Functions from the Client.%s', | ||
describeObjectForErrorMessage(parent, key), | ||
); | ||
} else if (objectName(value) !== 'Object') { | ||
console.error( | ||
'Only plain objects can be passed to Client Components from Server Components. ' + | ||
'%s objects are not supported.%s', | ||
objectName(value), | ||
describeObjectForErrorMessage(parent, key), | ||
); | ||
} else if (!isSimpleObject(value)) { | ||
console.error( | ||
'Only plain objects can be passed to Client Components from Server Components. ' + | ||
'Classes or other objects with methods are not supported.%s', | ||
describeObjectForErrorMessage(parent, key), | ||
); | ||
} else if (Object.getOwnPropertySymbols) { | ||
const symbols = Object.getOwnPropertySymbols(value); | ||
if (symbols.length > 0) { | ||
console.error( | ||
'Only plain objects can be passed to Client Components from Server Components. ' + | ||
'Objects with symbol properties like %s are not supported.%s', | ||
symbols[0].description, | ||
describeObjectForErrorMessage(parent, key), | ||
); | ||
} | ||
} | ||
} | ||
} | ||
|
||
// $FlowFixMe | ||
return value; | ||
} | ||
|
||
if (typeof value === 'string') { | ||
return escapeStringValue(value); | ||
} | ||
|
||
if ( | ||
typeof value === 'boolean' || | ||
typeof value === 'number' || | ||
typeof value === 'undefined' | ||
) { | ||
return value; | ||
} | ||
|
||
if (typeof value === 'function') { | ||
const metaData = knownServerReferences.get(value); | ||
if (metaData !== undefined) { | ||
const metaDataJSON = JSON.stringify(metaData, resolveToJSON); | ||
if (formData === null) { | ||
// Upgrade to use FormData to allow us to stream this value. | ||
formData = new FormData(); | ||
} | ||
// The reference to this function came from the same client so we can pass it back. | ||
const refId = nextPartId++; | ||
// eslint-disable-next-line react-internal/safe-string-coercion | ||
formData.set('' + refId, metaDataJSON); | ||
return serializeServerReferenceID(refId); | ||
} | ||
throw new Error( | ||
'Client Functions cannot be passed directly to Server Functions. ' + | ||
'Only Functions passed from the Server can be passed back again.', | ||
); | ||
} | ||
|
||
if (typeof value === 'symbol') { | ||
// $FlowFixMe `description` might be undefined | ||
const name: string = value.description; | ||
if (Symbol.for(name) !== value) { | ||
throw new Error( | ||
'Only global symbols received from Symbol.for(...) can be passed to Server Functions. ' + | ||
`The symbol Symbol.for(${ | ||
// $FlowFixMe `description` might be undefined | ||
value.description | ||
}) cannot be found among global symbols.`, | ||
); | ||
} | ||
return serializeSymbolReference(name); | ||
} | ||
|
||
if (typeof value === 'bigint') { | ||
throw new Error( | ||
`BigInt (${value}) is not yet supported as an argument to a Server Function.`, | ||
); | ||
} | ||
|
||
throw new Error( | ||
`Type ${typeof value} is not supported as an argument to a Server Function.`, | ||
); | ||
} | ||
|
||
// $FlowFixMe[incompatible-type] it's not going to be undefined because we'll encode it. | ||
const json: string = JSON.stringify(root, resolveToJSON); | ||
if (formData === null) { | ||
// If it's a simple data structure, we just use plain JSON. | ||
resolve(json); | ||
} else { | ||
// Otherwise, we use FormData to let us stream in the result. | ||
formData.set('0', json); | ||
if (pendingParts === 0) { | ||
// $FlowFixMe[incompatible-call] this has already been refined. | ||
resolve(formData); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
React Server Components: Streamingly Fast.
Streaming means good.