From 76a6dbcb9ad93cf1e18606cb9280a1afc206b153 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?=
Date: Tue, 10 Nov 2020 22:56:50 -0500
Subject: [PATCH] =?UTF-8?q?[Flight]=20Encode=20Symbols=20as=20special=20ro?=
=?UTF-8?q?ws=20that=20can=20be=20referenced=20by=20models=20=E2=80=A6=20(?=
=?UTF-8?q?#20171)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Encode Symbols as special rows that can be referenced by models
If a symbol was extracted from Symbol.for(...) then we can reliably
recreate the same symbol on the client.
S123:"react.suspense"
M456:{mySymbol: '$123'}
This doesn't suffer from the XSS problem because you have to write actual
code to create one of these symbols. That problem is only a problem because
values pass through common other usages of JSON which are not secure.
Since React encodes its built-ins as symbols, we can now use them as long
as its props are serializable. Like Suspense.
* Refactor resolution to avoid memo hack
Going through createElement isn't quite equivalent for ref and key in props.
* Reuse symbol ids that have already been written earlier in the stream
---
.../react-client/src/ReactFlightClient.js | 18 ++++
.../src/ReactFlightClientStream.js | 27 +++---
.../src/__tests__/ReactFlight-test.js | 2 +-
.../react-server/src/ReactFlightServer.js | 90 +++++++++++--------
.../src/ReactFlightServerConfigStream.js | 10 +++
.../src/ReactFlightDOMRelayClient.js | 4 +
.../src/ReactFlightDOMRelayProtocol.js | 1 +
.../ReactFlightDOMRelayServerHostConfig.js | 8 ++
.../ReactFlightDOMRelay-test.internal.js | 13 +--
.../src/__tests__/ReactFlightDOM-test.js | 17 ++--
.../src/ReactFlightNativeRelayClient.js | 4 +
.../src/ReactFlightNativeRelayProtocol.js | 1 +
.../ReactFlightNativeRelayServerHostConfig.js | 8 ++
scripts/error-codes/codes.json | 2 +-
14 files changed, 140 insertions(+), 65 deletions(-)
diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js
index 67e6ee9bf374f..983e3edc93802 100644
--- a/packages/react-client/src/ReactFlightClient.js
+++ b/packages/react-client/src/ReactFlightClient.js
@@ -132,6 +132,13 @@ function createErrorChunk(response: Response, error: Error): ErroredChunk {
return new Chunk(ERRORED, error, response);
}
+function createInitializedChunk(
+ response: Response,
+ value: T,
+): InitializedChunk {
+ return new Chunk(INITIALIZED, value, response);
+}
+
function wakeChunk(listeners: null | Array<() => mixed>) {
if (listeners !== null) {
for (let i = 0; i < listeners.length; i++) {
@@ -373,6 +380,17 @@ export function resolveModule(
}
}
+export function resolveSymbol(
+ response: Response,
+ id: number,
+ name: string,
+): void {
+ const chunks = response._chunks;
+ // We assume that we'll always emit the symbol before anything references it
+ // to save a few bytes.
+ chunks.set(id, createInitializedChunk(response, Symbol.for(name)));
+}
+
export function resolveError(
response: Response,
id: number,
diff --git a/packages/react-client/src/ReactFlightClientStream.js b/packages/react-client/src/ReactFlightClientStream.js
index 4bb493a66ddb1..9f07d8cc999ad 100644
--- a/packages/react-client/src/ReactFlightClientStream.js
+++ b/packages/react-client/src/ReactFlightClientStream.js
@@ -12,6 +12,7 @@ import type {Response} from './ReactFlightClientHostConfigStream';
import {
resolveModule,
resolveModel,
+ resolveSymbol,
resolveError,
createResponse as createResponseBase,
parseModelString,
@@ -32,26 +33,28 @@ function processFullRow(response: Response, row: string): void {
return;
}
const tag = row[0];
+ // When tags that are not text are added, check them here before
+ // parsing the row as text.
+ // switch (tag) {
+ // }
+ const colon = row.indexOf(':', 1);
+ const id = parseInt(row.substring(1, colon), 16);
+ const text = row.substring(colon + 1);
switch (tag) {
case 'J': {
- const colon = row.indexOf(':', 1);
- const id = parseInt(row.substring(1, colon), 16);
- const json = row.substring(colon + 1);
- resolveModel(response, id, json);
+ resolveModel(response, id, text);
return;
}
case 'M': {
- const colon = row.indexOf(':', 1);
- const id = parseInt(row.substring(1, colon), 16);
- const json = row.substring(colon + 1);
- resolveModule(response, id, json);
+ resolveModule(response, id, text);
+ return;
+ }
+ case 'S': {
+ resolveSymbol(response, id, JSON.parse(text));
return;
}
case 'E': {
- const colon = row.indexOf(':', 1);
- const id = parseInt(row.substring(1, colon), 16);
- const json = row.substring(colon + 1);
- const errorInfo = JSON.parse(json);
+ const errorInfo = JSON.parse(text);
resolveError(response, id, errorInfo.message, errorInfo.stack);
return;
}
diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js
index 3d4ccf5c459f1..e0947c68811d2 100644
--- a/packages/react-client/src/__tests__/ReactFlight-test.js
+++ b/packages/react-client/src/__tests__/ReactFlight-test.js
@@ -174,7 +174,7 @@ describe('ReactFlight', () => {
-
+
diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js
index 56842b7b2f40c..34404544efbdc 100644
--- a/packages/react-server/src/ReactFlightServer.js
+++ b/packages/react-server/src/ReactFlightServer.js
@@ -25,6 +25,7 @@ import {
close,
processModelChunk,
processModuleChunk,
+ processSymbolChunk,
processErrorChunk,
resolveModuleMetaData,
isModuleReference,
@@ -32,21 +33,12 @@ import {
import {
REACT_ELEMENT_TYPE,
- REACT_DEBUG_TRACING_MODE_TYPE,
REACT_FORWARD_REF_TYPE,
REACT_FRAGMENT_TYPE,
REACT_LAZY_TYPE,
- REACT_LEGACY_HIDDEN_TYPE,
REACT_MEMO_TYPE,
- REACT_OFFSCREEN_TYPE,
- REACT_PROFILER_TYPE,
- REACT_SCOPE_TYPE,
- REACT_STRICT_MODE_TYPE,
- REACT_SUSPENSE_TYPE,
- REACT_SUSPENSE_LIST_TYPE,
} from 'shared/ReactSymbols';
-import * as React from 'react';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import invariant from 'shared/invariant';
@@ -86,6 +78,7 @@ export type Request = {
completedModuleChunks: Array,
completedJSONChunks: Array,
completedErrorChunks: Array,
+ writtenSymbols: Map,
flowing: boolean,
toJSON: (key: string, value: ReactModel) => ReactJSONValue,
};
@@ -107,6 +100,7 @@ export function createRequest(
completedModuleChunks: [],
completedJSONChunks: [],
completedErrorChunks: [],
+ writtenSymbols: new Map(),
flowing: false,
toJSON: function(key: string, value: ReactModel): ReactJSONValue {
return resolveModelToJSON(request, this, key, value);
@@ -118,10 +112,13 @@ export function createRequest(
return request;
}
-function attemptResolveElement(element: React$Element): ReactModel {
- const type = element.type;
- const props = element.props;
- if (element.ref !== null && element.ref !== undefined) {
+function attemptResolveElement(
+ type: any,
+ key: null | React$Key,
+ ref: mixed,
+ props: any,
+): ReactModel {
+ if (ref !== null && ref !== undefined) {
// When the ref moves to the regular props object this will implicitly
// throw for functions. We could probably relax it to a DEV warning for other
// cases.
@@ -135,25 +132,22 @@ function attemptResolveElement(element: React$Element): ReactModel {
return type(props);
} else if (typeof type === 'string') {
// This is a host element. E.g. HTML.
- return [REACT_ELEMENT_TYPE, type, element.key, element.props];
- } else if (
- type === REACT_FRAGMENT_TYPE ||
- type === REACT_STRICT_MODE_TYPE ||
- type === REACT_PROFILER_TYPE ||
- type === REACT_SCOPE_TYPE ||
- type === REACT_DEBUG_TRACING_MODE_TYPE ||
- type === REACT_LEGACY_HIDDEN_TYPE ||
- type === REACT_OFFSCREEN_TYPE ||
- // TODO: These are temporary shims
- // and we'll want a different behavior.
- type === REACT_SUSPENSE_TYPE ||
- type === REACT_SUSPENSE_LIST_TYPE
- ) {
- return element.props.children;
+ return [REACT_ELEMENT_TYPE, type, key, props];
+ } else if (typeof type === 'symbol') {
+ if (type === REACT_FRAGMENT_TYPE) {
+ // For key-less fragments, we add a small optimization to avoid serializing
+ // it as a wrapper.
+ // TODO: If a key is specified, we should propagate its key to any children.
+ // Same as if a server component has a key.
+ return props.children;
+ }
+ // This might be a built-in React component. We'll let the client decide.
+ // Any built-in works as long as its props are serializable.
+ return [REACT_ELEMENT_TYPE, type, key, props];
} else if (type != null && typeof type === 'object') {
if (isModuleReference(type)) {
// This is a reference to a client component.
- return [REACT_ELEMENT_TYPE, type, element.key, element.props];
+ return [REACT_ELEMENT_TYPE, type, key, props];
}
switch (type.$$typeof) {
case REACT_FORWARD_REF_TYPE: {
@@ -161,8 +155,7 @@ function attemptResolveElement(element: React$Element): ReactModel {
return render(props, undefined);
}
case REACT_MEMO_TYPE: {
- const nextChildren = React.createElement(type.type, element.props);
- return attemptResolveElement(nextChildren);
+ return attemptResolveElement(type.type, key, ref, props);
}
}
}
@@ -399,7 +392,12 @@ export function resolveModelToJSON(
const element: React$Element = (value: any);
try {
// Attempt to render the server component.
- value = attemptResolveElement(element);
+ value = attemptResolveElement(
+ element.type,
+ element.key,
+ element.ref,
+ element.props,
+ );
} catch (x) {
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
// Something suspended, we'll need to create a new segment and resolve it later.
@@ -526,14 +524,26 @@ export function resolveModelToJSON(
}
if (typeof value === 'symbol') {
+ const writtenSymbols = request.writtenSymbols;
+ const existingId = writtenSymbols.get(value);
+ if (existingId !== undefined) {
+ return serializeByValueID(existingId);
+ }
+ const name = value.description;
invariant(
- false,
- 'Symbol values (%s) cannot be passed to client components. ' +
+ Symbol.for(name) === value,
+ 'Only global symbols received from Symbol.for(...) can be passed to client components. ' +
+ 'The symbol Symbol.for(%s) cannot be found among global symbols. ' +
'Remove %s from this object, or avoid the entire object: %s',
value.description,
describeKeyForErrorMessage(key),
describeObjectForErrorMessage(parent),
);
+ request.pendingChunks++;
+ const symbolId = request.nextChunkId++;
+ emitSymbolChunk(request, symbolId, name);
+ writtenSymbols.set(value, symbolId);
+ return serializeByValueID(symbolId);
}
// $FlowFixMe: bigint isn't added to Flow yet.
@@ -588,6 +598,11 @@ function emitModuleChunk(
request.completedModuleChunks.push(processedChunk);
}
+function emitSymbolChunk(request: Request, id: number, name: string): void {
+ const processedChunk = processSymbolChunk(request, id, name);
+ request.completedModuleChunks.push(processedChunk);
+}
+
function retrySegment(request: Request, segment: Segment): void {
const query = segment.query;
let value;
@@ -604,7 +619,12 @@ function retrySegment(request: Request, segment: Segment): void {
// Doing this here lets us reuse this same segment if the next component
// also suspends.
segment.query = () => value;
- value = attemptResolveElement(element);
+ value = attemptResolveElement(
+ element.type,
+ element.key,
+ element.ref,
+ element.props,
+ );
}
const processedChunk = processModelChunk(request, segment.id, value);
request.completedJSONChunks.push(processedChunk);
diff --git a/packages/react-server/src/ReactFlightServerConfigStream.js b/packages/react-server/src/ReactFlightServerConfigStream.js
index 01c0dea4c0c0e..12241493e57fa 100644
--- a/packages/react-server/src/ReactFlightServerConfigStream.js
+++ b/packages/react-server/src/ReactFlightServerConfigStream.js
@@ -109,6 +109,16 @@ export function processModuleChunk(
return convertStringToBuffer(row);
}
+export function processSymbolChunk(
+ request: Request,
+ id: number,
+ name: string,
+): Chunk {
+ const json = stringify(name);
+ const row = serializeRowHeader('S', id) + json + '\n';
+ return convertStringToBuffer(row);
+}
+
export {
scheduleWork,
flushBuffered,
diff --git a/packages/react-transport-dom-relay/src/ReactFlightDOMRelayClient.js b/packages/react-transport-dom-relay/src/ReactFlightDOMRelayClient.js
index b42f52097f547..6ea0b861aa7a9 100644
--- a/packages/react-transport-dom-relay/src/ReactFlightDOMRelayClient.js
+++ b/packages/react-transport-dom-relay/src/ReactFlightDOMRelayClient.js
@@ -15,6 +15,7 @@ import {
createResponse,
resolveModel,
resolveModule,
+ resolveSymbol,
resolveError,
close,
} from 'react-client/src/ReactFlightClient';
@@ -26,6 +27,9 @@ export function resolveRow(response: Response, chunk: RowEncoding): void {
resolveModel(response, chunk[1], chunk[2]);
} else if (chunk[0] === 'M') {
resolveModule(response, chunk[1], chunk[2]);
+ } else if (chunk[0] === 'S') {
+ // $FlowFixMe: Flow doesn't support disjoint unions on tuples.
+ resolveSymbol(response, chunk[1], chunk[2]);
} else {
// $FlowFixMe: Flow doesn't support disjoint unions on tuples.
resolveError(response, chunk[1], chunk[2].message, chunk[2].stack);
diff --git a/packages/react-transport-dom-relay/src/ReactFlightDOMRelayProtocol.js b/packages/react-transport-dom-relay/src/ReactFlightDOMRelayProtocol.js
index 263161d53852d..94f73b0d7a4ac 100644
--- a/packages/react-transport-dom-relay/src/ReactFlightDOMRelayProtocol.js
+++ b/packages/react-transport-dom-relay/src/ReactFlightDOMRelayProtocol.js
@@ -20,6 +20,7 @@ export type JSONValue =
export type RowEncoding =
| ['J', number, JSONValue]
| ['M', number, ModuleMetaData]
+ | ['S', number, string]
| [
'E',
number,
diff --git a/packages/react-transport-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js b/packages/react-transport-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js
index d3fc134176a96..70c2e957596cf 100644
--- a/packages/react-transport-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js
+++ b/packages/react-transport-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js
@@ -111,6 +111,14 @@ export function processModuleChunk(
return ['M', id, moduleMetaData];
}
+export function processSymbolChunk(
+ request: Request,
+ id: number,
+ name: string,
+): Chunk {
+ return ['S', id, name];
+}
+
export function scheduleWork(callback: () => void) {
callback();
}
diff --git a/packages/react-transport-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js b/packages/react-transport-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js
index 092e12abac9d0..fd0d327fc8e68 100644
--- a/packages/react-transport-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js
+++ b/packages/react-transport-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js
@@ -153,11 +153,14 @@ describe('ReactFlightDOMRelay', () => {
foo: {
bar: (
- {'Fragment child'}
- {'Profiler child'}
- {'StrictMode child'}
- {'Suspense child'}
- {['SuspenseList row 1', 'SuspenseList row 2']}
+ Fragment child
+
Profiler child
+
StrictMode child
+
Suspense child
+
+ {'SuspenseList row 1'}
+ {'SuspenseList row 2'}
+
Hello world
),
diff --git a/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js
index 04910ff98e615..d8568c661c2af 100644
--- a/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+++ b/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js
@@ -282,10 +282,6 @@ describe('ReactFlightDOM', () => {
);
}
- function Placeholder({children, fallback}) {
- return {children};
- }
-
// Model
function Text({children}) {
return children;
@@ -347,22 +343,21 @@ describe('ReactFlightDOM', () => {
}
const MyErrorBoundaryClient = moduleReference(MyErrorBoundary);
- const PlaceholderClient = moduleReference(Placeholder);
function ProfileContent() {
return (
<>
:avatar:} />
- (loading sidebar)
}>
+ (loading sidebar)}>
:friends:} />
-
- (loading posts)}>
+
+ (loading posts)}>
:posts:} />
-
+
- (loading games)}>
+ (loading games)}>
:games:} />
-
+
>
);
diff --git a/packages/react-transport-native-relay/src/ReactFlightNativeRelayClient.js b/packages/react-transport-native-relay/src/ReactFlightNativeRelayClient.js
index 94222c0ca7136..42b9efa73b471 100644
--- a/packages/react-transport-native-relay/src/ReactFlightNativeRelayClient.js
+++ b/packages/react-transport-native-relay/src/ReactFlightNativeRelayClient.js
@@ -15,6 +15,7 @@ import {
createResponse,
resolveModel,
resolveModule,
+ resolveSymbol,
resolveError,
close,
} from 'react-client/src/ReactFlightClient';
@@ -26,6 +27,9 @@ export function resolveRow(response: Response, chunk: RowEncoding): void {
resolveModel(response, chunk[1], chunk[2]);
} else if (chunk[0] === 'M') {
resolveModule(response, chunk[1], chunk[2]);
+ } else if (chunk[0] === 'S') {
+ // $FlowFixMe: Flow doesn't support disjoint unions on tuples.
+ resolveSymbol(response, chunk[1], chunk[2]);
} else {
// $FlowFixMe: Flow doesn't support disjoint unions on tuples.
resolveError(response, chunk[1], chunk[2].message, chunk[2].stack);
diff --git a/packages/react-transport-native-relay/src/ReactFlightNativeRelayProtocol.js b/packages/react-transport-native-relay/src/ReactFlightNativeRelayProtocol.js
index 5689f8c0f974c..75f6db8039ab0 100644
--- a/packages/react-transport-native-relay/src/ReactFlightNativeRelayProtocol.js
+++ b/packages/react-transport-native-relay/src/ReactFlightNativeRelayProtocol.js
@@ -20,6 +20,7 @@ export type JSONValue =
export type RowEncoding =
| ['J', number, JSONValue]
| ['M', number, ModuleMetaData]
+ | ['S', number, string]
| [
'E',
number,
diff --git a/packages/react-transport-native-relay/src/ReactFlightNativeRelayServerHostConfig.js b/packages/react-transport-native-relay/src/ReactFlightNativeRelayServerHostConfig.js
index bd28ee0337521..c8566c960f0de 100644
--- a/packages/react-transport-native-relay/src/ReactFlightNativeRelayServerHostConfig.js
+++ b/packages/react-transport-native-relay/src/ReactFlightNativeRelayServerHostConfig.js
@@ -111,6 +111,14 @@ export function processModuleChunk(
return ['M', id, moduleMetaData];
}
+export function processSymbolChunk(
+ request: Request,
+ id: number,
+ name: string,
+): Chunk {
+ return ['S', id, name];
+}
+
export function scheduleWork(callback: () => void) {
callback();
}
diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json
index a86c8bc32812c..269b7d2787727 100644
--- a/scripts/error-codes/codes.json
+++ b/scripts/error-codes/codes.json
@@ -364,7 +364,7 @@
"373": "This Hook is not supported in Server Components.",
"374": "Event handlers cannot be passed to client component props. Remove %s from these props if possible: %s\nIf you need interactivity, consider converting part of this to a client component.",
"375": "Functions cannot be passed directly to client components because they're not serializable. Remove %s (%s) from this object, or avoid the entire object: %s",
- "376": "Symbol values (%s) cannot be passed to client components. Remove %s from this object, or avoid the entire object: %s",
+ "376": "Only global symbols received from Symbol.for(...) can be passed to client components. The symbol Symbol.for(%s) cannot be found among global symbols. Remove %s from this object, or avoid the entire object: %s",
"377": "BigInt (%s) is not yet supported in client component props. Remove %s from this object or use a plain number instead: %s",
"378": "Type %s is not supported in client component props. Remove %s from this object, or avoid the entire object: %s",
"379": "Refs cannot be used in server components, nor passed to client components."