-
Notifications
You must be signed in to change notification settings - Fork 48.8k
[Flight] Encode React Elements in Replies as Temporary References #28564
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
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 |
---|---|---|
|
@@ -14,6 +14,9 @@ import type { | |
RejectedThenable, | ||
ReactCustomFormAction, | ||
} from 'shared/ReactTypes'; | ||
import type {LazyComponent} from 'react/src/ReactLazy'; | ||
import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences'; | ||
|
||
import {enableRenderableContext} from 'shared/ReactFeatureFlags'; | ||
|
||
import { | ||
|
@@ -30,6 +33,8 @@ import { | |
objectName, | ||
} from 'shared/ReactSerializationErrors'; | ||
|
||
import {writeTemporaryReference} from './ReactFlightTemporaryReferences'; | ||
|
||
import isArray from 'shared/isArray'; | ||
import getPrototypeOf from 'shared/getPrototypeOf'; | ||
|
||
|
@@ -84,9 +89,9 @@ export type ReactServerValue = | |
|
||
type ReactServerObject = {+[key: string]: ReactServerValue}; | ||
|
||
// function serializeByValueID(id: number): string { | ||
// return '$' + id.toString(16); | ||
// } | ||
function serializeByValueID(id: number): string { | ||
return '$' + id.toString(16); | ||
} | ||
|
||
function serializePromiseID(id: number): string { | ||
return '$@' + id.toString(16); | ||
|
@@ -96,6 +101,10 @@ function serializeServerReferenceID(id: number): string { | |
return '$F' + id.toString(16); | ||
} | ||
|
||
function serializeTemporaryReferenceID(id: number): string { | ||
return '$T' + id.toString(16); | ||
} | ||
|
||
function serializeSymbolReference(name: string): string { | ||
return '$S' + name; | ||
} | ||
|
@@ -158,6 +167,7 @@ function escapeStringValue(value: string): string { | |
export function processReply( | ||
root: ReactServerValue, | ||
formFieldPrefix: string, | ||
temporaryReferences: void | TemporaryReferenceSet, | ||
resolve: (string | FormData) => void, | ||
reject: (error: mixed) => void, | ||
): void { | ||
|
@@ -206,6 +216,81 @@ export function processReply( | |
} | ||
|
||
if (typeof value === 'object') { | ||
switch ((value: any).$$typeof) { | ||
case REACT_ELEMENT_TYPE: { | ||
if (temporaryReferences === undefined) { | ||
throw new Error( | ||
'React Element cannot be passed to Server Functions from the Client without a ' + | ||
'temporary reference set. Pass a TemporaryReferenceSet to the options.' + | ||
(__DEV__ ? describeObjectForErrorMessage(parent, key) : ''), | ||
); | ||
} | ||
return serializeTemporaryReferenceID( | ||
writeTemporaryReference(temporaryReferences, value), | ||
); | ||
} | ||
case REACT_LAZY_TYPE: { | ||
// Resolve lazy as if it wasn't here. In the future this will be encoded as a Promise. | ||
const lazy: LazyComponent<any, any> = (value: any); | ||
const payload = lazy._payload; | ||
const init = lazy._init; | ||
if (formData === null) { | ||
// Upgrade to use FormData to allow us to stream this value. | ||
formData = new FormData(); | ||
} | ||
pendingParts++; | ||
try { | ||
const resolvedModel = init(payload); | ||
// We always outline this as a separate part even though we could inline it | ||
// because it ensures a more deterministic encoding. | ||
const lazyId = nextPartId++; | ||
const partJSON = JSON.stringify(resolvedModel, 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(formFieldPrefix + lazyId, partJSON); | ||
return serializeByValueID(lazyId); | ||
} catch (x) { | ||
if ( | ||
typeof x === 'object' && | ||
x !== null && | ||
typeof x.then === 'function' | ||
) { | ||
// Suspended | ||
pendingParts++; | ||
const lazyId = nextPartId++; | ||
const thenable: Thenable<any> = (x: any); | ||
const retry = function () { | ||
// While the first promise resolved, its value isn't necessarily what we'll | ||
// resolve into because we might suspend again. | ||
try { | ||
const partJSON = JSON.stringify(value, 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(formFieldPrefix + lazyId, partJSON); | ||
pendingParts--; | ||
if (pendingParts === 0) { | ||
resolve(data); | ||
} | ||
} catch (reason) { | ||
reject(reason); | ||
} | ||
}; | ||
thenable.then(retry, retry); | ||
return serializeByValueID(lazyId); | ||
} else { | ||
// In the future we could consider serializing this as an error | ||
// that throws on the server instead. | ||
reject(x); | ||
return null; | ||
} | ||
} finally { | ||
pendingParts--; | ||
} | ||
} | ||
} | ||
|
||
// $FlowFixMe[method-unbinding] | ||
if (typeof value.then === 'function') { | ||
// We assume that any object with a .then property is a "Thenable" type, | ||
|
@@ -219,14 +304,18 @@ export function processReply( | |
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(formFieldPrefix + promiseId, partJSON); | ||
pendingParts--; | ||
if (pendingParts === 0) { | ||
resolve(data); | ||
try { | ||
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. was this supposed to always be wrapped in try? I don't quite follow why this change required the try now but it didn't before 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. Yes, I think it was an oversight because the .then reject wouldn't catch the case when a promise's body wasn't able to be serialized and threw. |
||
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(formFieldPrefix + promiseId, partJSON); | ||
pendingParts--; | ||
if (pendingParts === 0) { | ||
resolve(data); | ||
} | ||
} catch (reason) { | ||
reject(reason); | ||
} | ||
}, | ||
reason => { | ||
|
@@ -288,23 +377,19 @@ export function processReply( | |
proto !== ObjectPrototype && | ||
(proto === null || getPrototypeOf(proto) !== null) | ||
) { | ||
throw new Error( | ||
'Only plain objects, and a few built-ins, can be passed to Server Actions. ' + | ||
'Classes or null prototypes are not supported.', | ||
if (temporaryReferences === undefined) { | ||
throw new Error( | ||
'Only plain objects, and a few built-ins, can be passed to Server Actions. ' + | ||
'Classes or null prototypes are not supported.', | ||
); | ||
} | ||
// We can serialize class instances as temporary references. | ||
return serializeTemporaryReferenceID( | ||
writeTemporaryReference(temporaryReferences, value), | ||
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. Anything we can't serialize, we can still encode as a temporary reference. Unfortunately, for some things like the stuff below, we only warn in DEV that we can't serialize it so for any of those, we don't put it as a temporary reference. So any of those are really invalid in both cases. |
||
); | ||
} | ||
if (__DEV__) { | ||
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 ( | ||
if ( | ||
(value: any).$$typeof === | ||
(enableRenderableContext ? REACT_CONTEXT_TYPE : REACT_PROVIDER_TYPE) | ||
) { | ||
|
@@ -382,9 +467,14 @@ export function processReply( | |
formData.set(formFieldPrefix + 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 (temporaryReferences === undefined) { | ||
throw new Error( | ||
'Client Functions cannot be passed directly to Server Functions. ' + | ||
'Only Functions passed from the Server can be passed back again.', | ||
); | ||
} | ||
return serializeTemporaryReferenceID( | ||
writeTemporaryReference(temporaryReferences, value), | ||
); | ||
} | ||
|
||
|
@@ -443,6 +533,7 @@ function encodeFormData(reference: any): Thenable<FormData> { | |
processReply( | ||
reference, | ||
'', | ||
undefined, // TODO: This means React Elements can't be used as state in progressive enhancement. | ||
(body: string | FormData) => { | ||
if (typeof body === 'string') { | ||
const data = new FormData(); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
/** | ||
* 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 | ||
*/ | ||
|
||
interface Reference {} | ||
|
||
export opaque type TemporaryReferenceSet = Array<Reference>; | ||
|
||
export function createTemporaryReferenceSet(): TemporaryReferenceSet { | ||
return []; | ||
} | ||
|
||
export function writeTemporaryReference( | ||
set: TemporaryReferenceSet, | ||
object: Reference, | ||
): number { | ||
// We always create a new entry regardless if we've already written the same | ||
// object. This ensures that we always generate a deterministic encoding of | ||
// each slot in the reply for cacheability. | ||
const newId = set.length; | ||
set.push(object); | ||
return newId; | ||
} | ||
|
||
export function readTemporaryReference( | ||
set: TemporaryReferenceSet, | ||
id: number, | ||
): Reference { | ||
if (id < 0 || id >= set.length) { | ||
throw new Error( | ||
"The RSC response contained a reference that doesn't exist in the temporary reference set. " + | ||
'Always pass the matching set that was used to create the reply when parsing its response.', | ||
); | ||
} | ||
return set[id]; | ||
} |
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.
Saw that the type is
void | TemporaryReferenceSet
and in the other place we're checking with=== undefined
. Is the== null
expected?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.
Yea, I just do it since it's a public API and a user might still pass
null
incorrectly but really we should probably be more strongly opinionated about not doing that.