Skip to content

Commit cc0b6cd

Browse files
committed
[Flight] Encode React Elements in Replies as Temporary References (#28564)
Currently you can accidentally pass React Element to a Server Action. It warns but in prod it actually works because we can encode the symbol and otherwise it's mostly a plain object. It only works if you only pass host components and no function props etc. which makes it potentially error later. The first thing this does it just early hard error for elements. I made Lazy work by unwrapping though since that will be replaced by Promises later which works. Our protocol is not fully symmetric in that elements flow from Server -> Client. Only the Server can resolve Components and only the client should really be able to receive host components. It's not intended that a Server can actually do something with them other than passing them to the client. In the case of a Reply, we expect the client to be stateful. It's waiting for a response. So anything we can't serialize we can still pass by reference to an in memory object. So I introduce the concept of a TemporaryReferenceSet which is an opaque object that you create before encoding the reply. This then stashes any unserializable values in this set and encode the slot by id. When a new response from the Action then returns we pass the same temporary set into the parser which can then restore the objects. This lets you pass a value by reference to the server and back into another slot. For example it can be used to render children inside a parent tree from a server action: ``` export async function Component({ children }) { "use server"; return <div>{children}</div>; } ``` (You wouldn't normally do this due to the waterfalls but for advanced cases.) A common scenario where this comes up accidentally today is in `useActionState`. ``` export function action(state, formData) { "use server"; if (errored) { return <div>This action <strong>errored</strong></div>; } return null; } ``` ``` const [errors, formAction] = useActionState(action); return <div>{errors}<div>; ``` It feels like I'm just passing the JSX from server to client. However, because `useActionState` also sends the previous state *back* to the server this should not actually be valid. Before this PR this actually worked accidentally. You get a DEV warning but it used to work in prod. Once you do something like pass a client reference it won't work tho. We could perhaps make client references work by stashing where we got them from but it wouldn't work with all possible JSX. By adding temporary references to the action implementation this will work again - on the client. It'll also be more efficient since we don't send back the JSX content that you shouldn't introspect on the server anyway. However, a flaw here is that the progressive enhancement of this case won't work because we can't use temporary references for progressive enhancement since there's no in memory stash. What is worse is that it won't error if you hydrate. ~It also will error late in the example above because the first state is "undefined" so invoking the form once works - it errors on the second attempt when it tries to send the error state back again.~ It actually errors on the first invocation because we need to eagerly serialize "previous state" into the form. So at least that's better. I think maybe the solution to this particular pattern would be to allow JSX to serialize if you have no temporary reference set, and remember client references so that client references can be returned back to the server as client references. That way anything you could send from the server could also be returned to the server. But it would only deopt to serializing it for progressive enhancement. The consequence of that would be that there's a lot of JSX that might accidentally seem like it should work but it's only if you've gotten it from the server before that it works. This would have to have pair them somehow though since you can't take a client reference from one implementation of Flight and use it with another. DiffTrain build for [83409a1](83409a1)
1 parent 8cc2b72 commit cc0b6cd

10 files changed

+88
-21
lines changed

compiled/facebook-www/REVISION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0aab065eb3250a9714a62dc05587cbb571da7f71
1+
83409a1fdd14b2e5b33c587935a7ef552607780f

compiled/facebook-www/ReactDOMTesting-prod.modern.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17142,7 +17142,7 @@ Internals.Events = [
1714217142
var devToolsConfig$jscomp$inline_1781 = {
1714317143
findFiberByHostInstance: getClosestInstanceFromNode,
1714417144
bundleType: 0,
17145-
version: "18.3.0-www-modern-aa2fdf61",
17145+
version: "18.3.0-www-modern-68b315be",
1714617146
rendererPackageName: "react-dom"
1714717147
};
1714817148
var internals$jscomp$inline_2151 = {
@@ -17173,7 +17173,7 @@ var internals$jscomp$inline_2151 = {
1717317173
scheduleRoot: null,
1717417174
setRefreshHandler: null,
1717517175
getCurrentFiber: null,
17176-
reconcilerVersion: "18.3.0-www-modern-aa2fdf61"
17176+
reconcilerVersion: "18.3.0-www-modern-68b315be"
1717717177
};
1717817178
if ("undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__) {
1717917179
var hook$jscomp$inline_2152 = __REACT_DEVTOOLS_GLOBAL_HOOK__;
@@ -17582,4 +17582,4 @@ exports.useFormState = function (action, initialState, permalink) {
1758217582
exports.useFormStatus = function () {
1758317583
return ReactCurrentDispatcher$2.current.useHostTransitionStatus();
1758417584
};
17585-
exports.version = "18.3.0-www-modern-aa2fdf61";
17585+
exports.version = "18.3.0-www-modern-68b315be";

compiled/facebook-www/ReactFlightDOMClient-dev.modern.js

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,17 @@ if (__DEV__) {
257257
var REACT_ELEMENT_TYPE = Symbol.for("react.element");
258258
var REACT_LAZY_TYPE = Symbol.for("react.lazy");
259259

260+
function readTemporaryReference(set, id) {
261+
if (id < 0 || id >= set.length) {
262+
throw new Error(
263+
"The RSC response contained a reference that doesn't exist in the temporary reference set. " +
264+
"Always pass the matching set that was used to create the reply when parsing its response."
265+
);
266+
}
267+
268+
return set[id];
269+
}
270+
260271
var knownServerReferences = new WeakMap(); // Serializable values
261272

262273
function registerServerReference(proxy, reference, encodeFormAction) {
@@ -784,19 +795,35 @@ if (__DEV__) {
784795
return createServerReferenceProxy(response, metadata);
785796
}
786797

798+
case "T": {
799+
// Temporary Reference
800+
var _id3 = parseInt(value.slice(2), 16);
801+
802+
var temporaryReferences = response._tempRefs;
803+
804+
if (temporaryReferences == null) {
805+
throw new Error(
806+
"Missing a temporary reference set but the RSC response returned a temporary reference. " +
807+
"Pass a temporaryReference option with the set that was used with the reply."
808+
);
809+
}
810+
811+
return readTemporaryReference(temporaryReferences, _id3);
812+
}
813+
787814
case "Q": {
788815
// Map
789-
var _id3 = parseInt(value.slice(2), 16);
816+
var _id4 = parseInt(value.slice(2), 16);
790817

791-
var data = getOutlinedModel(response, _id3);
818+
var data = getOutlinedModel(response, _id4);
792819
return new Map(data);
793820
}
794821

795822
case "W": {
796823
// Set
797-
var _id4 = parseInt(value.slice(2), 16);
824+
var _id5 = parseInt(value.slice(2), 16);
798825

799-
var _data = getOutlinedModel(response, _id4);
826+
var _data = getOutlinedModel(response, _id5);
800827

801828
return new Set(_data);
802829
}
@@ -853,9 +880,9 @@ if (__DEV__) {
853880

854881
default: {
855882
// We assume that anything else is a reference ID.
856-
var _id5 = parseInt(value.slice(1), 16);
883+
var _id6 = parseInt(value.slice(1), 16);
857884

858-
var _chunk2 = getChunk(response, _id5);
885+
var _chunk2 = getChunk(response, _id6);
859886

860887
switch (_chunk2.status) {
861888
case RESOLVED_MODEL:
@@ -950,7 +977,8 @@ if (__DEV__) {
950977
moduleLoading,
951978
callServer,
952979
encodeFormAction,
953-
nonce
980+
nonce,
981+
temporaryReferences
954982
) {
955983
var chunks = new Map();
956984
var response = {
@@ -966,7 +994,8 @@ if (__DEV__) {
966994
_rowID: 0,
967995
_rowTag: 0,
968996
_rowLength: 0,
969-
_buffer: []
997+
_buffer: [],
998+
_tempRefs: temporaryReferences
970999
}; // Don't inline this call because it causes closure to outline the call above.
9711000

9721001
response._fromJSON = createFromJSONCallback(response);

compiled/facebook-www/ReactFlightDOMClient-prod.modern.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,18 @@ function parseModelString(response, parentObject, key, value) {
269269
(parentObject = getOutlinedModel(response, parentObject)),
270270
createServerReferenceProxy(response, parentObject)
271271
);
272+
case "T":
273+
parentObject = parseInt(value.slice(2), 16);
274+
response = response._tempRefs;
275+
if (null == response)
276+
throw Error(
277+
"Missing a temporary reference set but the RSC response returned a temporary reference. Pass a temporaryReference option with the set that was used with the reply."
278+
);
279+
if (0 > parentObject || parentObject >= response.length)
280+
throw Error(
281+
"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."
282+
);
283+
return response[parentObject];
272284
case "Q":
273285
return (
274286
(parentObject = parseInt(value.slice(2), 16)),
@@ -570,7 +582,8 @@ exports.createFromReadableStream = function (stream, options) {
570582
_rowID: 0,
571583
_rowTag: 0,
572584
_rowLength: 0,
573-
_buffer: []
585+
_buffer: [],
586+
_tempRefs: void 0
574587
};
575588
options._fromJSON = createFromJSONCallback(options);
576589
startReadingFromStream(options, stream);

compiled/facebook-www/ReactFlightDOMServer-dev.modern.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,15 @@ if (__DEV__) {
434434
var supportsRequestStorage = false;
435435
var requestStorage = null;
436436

437+
var TEMPORARY_REFERENCE_TAG = Symbol.for("react.temporary.reference"); // eslint-disable-next-line no-unused-vars
438+
439+
function isTemporaryReference(reference) {
440+
return reference.$$typeof === TEMPORARY_REFERENCE_TAG;
441+
}
442+
function resolveTemporaryReferenceID(temporaryReference) {
443+
return temporaryReference.$$id;
444+
}
445+
437446
// ATTENTION
438447
// When adding new symbols to this file,
439448
// Please consider also adding to 'react-devtools-shared/src/backend/ReactSymbols'
@@ -1644,7 +1653,7 @@ if (__DEV__) {
16441653
}
16451654

16461655
if (typeof type === "function") {
1647-
if (isClientReference(type)) {
1656+
if (isClientReference(type) || isTemporaryReference(type)) {
16481657
// This is a reference to a Client Component.
16491658
return renderClientElement(task, type, key, props);
16501659
} // This is a Server Component.
@@ -1814,6 +1823,10 @@ if (__DEV__) {
18141823
return "$F" + id.toString(16);
18151824
}
18161825

1826+
function serializeTemporaryReferenceID(id) {
1827+
return "$T" + id;
1828+
}
1829+
18171830
function serializeSymbolReference(name) {
18181831
return "$S" + name;
18191832
}
@@ -1942,6 +1955,11 @@ if (__DEV__) {
19421955
return serializeServerReferenceID(metadataId);
19431956
}
19441957

1958+
function serializeTemporaryReference(request, temporaryReference) {
1959+
var id = resolveTemporaryReferenceID(temporaryReference);
1960+
return serializeTemporaryReferenceID(id);
1961+
}
1962+
19451963
function serializeLargeTextString(request, text) {
19461964
request.pendingChunks += 2;
19471965
var textId = request.nextChunkId++;
@@ -2377,6 +2395,10 @@ if (__DEV__) {
23772395
return serializeServerReference(request, value);
23782396
}
23792397

2398+
if (isTemporaryReference(value)) {
2399+
return serializeTemporaryReference(request, value);
2400+
}
2401+
23802402
if (/^on[A-Z]/.test(parentPropertyName)) {
23812403
throw new Error(
23822404
"Event handlers cannot be passed to Client Component props." +
@@ -2700,6 +2722,10 @@ if (__DEV__) {
27002722
parentPropertyName,
27012723
value
27022724
);
2725+
}
2726+
2727+
if (isTemporaryReference(value)) {
2728+
return serializeTemporaryReference(request, value);
27032729
} // Serialize the body of the function as an eval so it can be printed.
27042730
// $FlowFixMe[method-unbinding]
27052731

compiled/facebook-www/ReactFlightDOMServer-prod.modern.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ function trimOptions(options) {
164164
((hasProperties = !0), (trimmed[key] = options[key]));
165165
return hasProperties ? trimmed : null;
166166
}
167-
var REACT_ELEMENT_TYPE = Symbol.for("react.element"),
167+
var TEMPORARY_REFERENCE_TAG = Symbol.for("react.temporary.reference"),
168+
REACT_ELEMENT_TYPE = Symbol.for("react.element"),
168169
REACT_FRAGMENT_TYPE = Symbol.for("react.fragment"),
169170
REACT_CONTEXT_TYPE = Symbol.for("react.context"),
170171
REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref"),
@@ -595,7 +596,7 @@ function renderElement(request, task, type, key, ref, props) {
595596
"Refs cannot be used in Server Components, nor passed to Client Components."
596597
);
597598
if ("function" === typeof type)
598-
return isClientReference(type)
599+
return isClientReference(type) || type.$$typeof === TEMPORARY_REFERENCE_TAG
599600
? renderClientElement(task, type, key, props)
600601
: renderFunctionComponent(request, task, key, type, props);
601602
if ("string" === typeof type)

compiled/facebook-www/ReactServer-dev.modern.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2809,7 +2809,7 @@ if (__DEV__) {
28092809
console["error"](error);
28102810
};
28112811

2812-
var ReactVersion = "18.3.0-www-modern-1e9c6c79";
2812+
var ReactVersion = "18.3.0-www-modern-9f10bc05";
28132813

28142814
// Patch fetch
28152815
var Children = {

compiled/facebook-www/ReactServer-prod.modern.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,4 +524,4 @@ exports.useId = function () {
524524
exports.useMemo = function (create, deps) {
525525
return ReactCurrentDispatcher.current.useMemo(create, deps);
526526
};
527-
exports.version = "18.3.0-www-modern-7ba66f9a";
527+
exports.version = "18.3.0-www-modern-e9e628b6";

compiled/facebook-www/ReactTestRenderer-dev.modern.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26056,7 +26056,7 @@ if (__DEV__) {
2605626056
return root;
2605726057
}
2605826058

26059-
var ReactVersion = "18.3.0-www-modern-05b3f7fc";
26059+
var ReactVersion = "18.3.0-www-modern-34ddd034";
2606026060

2606126061
// Might add PROFILE later.
2606226062

compiled/facebook-www/__test_utils__/ReactAllWarnings.js

Lines changed: 0 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)