@samitouri / QOS-React-2 / commits / 76a6dbcb9a

[Flight] Encode Symbols as special rows that can be referenced by models … (#20171)

* 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

Sebastian Markbåge committed Nov 10, 2020 at 22:56 UTC 76a6dbcb9ad93cf1e18606cb9280a1afc206b153
14 files changed +140 -65
packages/react-client/src/ReactFlightClient.js
+18
@@ -132,6 +132,13 @@ function createErrorChunk(response: Response, error: Error): ErroredChunk {
132 return new Chunk(ERRORED, error, response);
133 }
134
135 +function createInitializedChunk<T>(
136 + response: Response,
137 + value: T,
138 +): InitializedChunk<T> {
139 + return new Chunk(INITIALIZED, value, response);
140 +}
141 +
142 function wakeChunk(listeners: null | Array<() => mixed>) {
143 if (listeners !== null) {
144 for (let i = 0; i < listeners.length; i++) {
@@ -373,6 +380,17 @@ export function resolveModule(
380 }
381 }
382
383 +export function resolveSymbol(
384 + response: Response,
385 + id: number,
386 + name: string,
387 +): void {
388 + const chunks = response._chunks;
389 + // We assume that we'll always emit the symbol before anything references it
390 + // to save a few bytes.
391 + chunks.set(id, createInitializedChunk(response, Symbol.for(name)));
392 +}
393 +
394 export function resolveError(
395 response: Response,
396 id: number,
packages/react-client/src/ReactFlightClientStream.js
+15 -12
@@ -12,6 +12,7 @@ import type {Response} from './ReactFlightClientHostConfigStream';
12 import {
13 resolveModule,
14 resolveModel,
15 + resolveSymbol,
16 resolveError,
17 createResponse as createResponseBase,
18 parseModelString,
@@ -32,26 +33,28 @@ function processFullRow(response: Response, row: string): void {
33 return;
34 }
35 const tag = row[0];
36 + // When tags that are not text are added, check them here before
37 + // parsing the row as text.
38 + // switch (tag) {
39 + // }
40 + const colon = row.indexOf(':', 1);
41 + const id = parseInt(row.substring(1, colon), 16);
42 + const text = row.substring(colon + 1);
43 switch (tag) {
44 case 'J': {
37 - const colon = row.indexOf(':', 1);
38 - const id = parseInt(row.substring(1, colon), 16);
39 - const json = row.substring(colon + 1);
40 - resolveModel(response, id, json);
45 + resolveModel(response, id, text);
46 return;
47 }
48 case 'M': {
44 - const colon = row.indexOf(':', 1);
45 - const id = parseInt(row.substring(1, colon), 16);
46 - const json = row.substring(colon + 1);
47 - resolveModule(response, id, json);
49 + resolveModule(response, id, text);
50 + return;
51 + }
52 + case 'S': {
53 + resolveSymbol(response, id, JSON.parse(text));
54 return;
55 }
56 case 'E': {
51 - const colon = row.indexOf(':', 1);
52 - const id = parseInt(row.substring(1, colon), 16);
53 - const json = row.substring(colon + 1);
54 - const errorInfo = JSON.parse(json);
57 + const errorInfo = JSON.parse(text);
58 resolveError(response, id, errorInfo.message, errorInfo.stack);
59 return;
60 }
packages/react-client/src/__tests__/ReactFlight-test.js
+1 -1
@@ -174,7 +174,7 @@ describe('ReactFlight', () => {
174 <ErrorBoundary expectedMessage="Functions cannot be passed directly to client components because they're not serializable.">
175 <Client transport={fn} />
176 </ErrorBoundary>
177 - <ErrorBoundary expectedMessage="Symbol values (foo) cannot be passed to client components.">
177 + <ErrorBoundary expectedMessage="Only global symbols received from Symbol.for(...) can be passed to client components.">
178 <Client transport={symbol} />
179 </ErrorBoundary>
180 <ErrorBoundary expectedMessage="Refs cannot be used in server components, nor passed to client components.">
packages/react-server/src/ReactFlightServer.js
+55 -35
@@ -25,6 +25,7 @@ import {
25 close,
26 processModelChunk,
27 processModuleChunk,
28 + processSymbolChunk,
29 processErrorChunk,
30 resolveModuleMetaData,
31 isModuleReference,
@@ -32,21 +33,12 @@ import {
33
34 import {
35 REACT_ELEMENT_TYPE,
35 - REACT_DEBUG_TRACING_MODE_TYPE,
36 REACT_FORWARD_REF_TYPE,
37 REACT_FRAGMENT_TYPE,
38 REACT_LAZY_TYPE,
39 - REACT_LEGACY_HIDDEN_TYPE,
39 REACT_MEMO_TYPE,
41 - REACT_OFFSCREEN_TYPE,
42 - REACT_PROFILER_TYPE,
43 - REACT_SCOPE_TYPE,
44 - REACT_STRICT_MODE_TYPE,
45 - REACT_SUSPENSE_TYPE,
46 - REACT_SUSPENSE_LIST_TYPE,
40 } from 'shared/ReactSymbols';
41
49 -import * as React from 'react';
42 import ReactSharedInternals from 'shared/ReactSharedInternals';
43 import invariant from 'shared/invariant';
44
@@ -86,6 +78,7 @@ export type Request = {
78 completedModuleChunks: Array<Chunk>,
79 completedJSONChunks: Array<Chunk>,
80 completedErrorChunks: Array<Chunk>,
81 + writtenSymbols: Map<Symbol, number>,
82 flowing: boolean,
83 toJSON: (key: string, value: ReactModel) => ReactJSONValue,
84 };
@@ -107,6 +100,7 @@ export function createRequest(
100 completedModuleChunks: [],
101 completedJSONChunks: [],
102 completedErrorChunks: [],
103 + writtenSymbols: new Map(),
104 flowing: false,
105 toJSON: function(key: string, value: ReactModel): ReactJSONValue {
106 return resolveModelToJSON(request, this, key, value);
@@ -118,10 +112,13 @@ export function createRequest(
112 return request;
113 }
114
121 -function attemptResolveElement(element: React$Element<any>): ReactModel {
122 - const type = element.type;
123 - const props = element.props;
124 - if (element.ref !== null && element.ref !== undefined) {
115 +function attemptResolveElement(
116 + type: any,
117 + key: null | React$Key,
118 + ref: mixed,
119 + props: any,
120 +): ReactModel {
121 + if (ref !== null && ref !== undefined) {
122 // When the ref moves to the regular props object this will implicitly
123 // throw for functions. We could probably relax it to a DEV warning for other
124 // cases.
@@ -135,25 +132,22 @@ function attemptResolveElement(element: React$Element<any>): ReactModel {
132 return type(props);
133 } else if (typeof type === 'string') {
134 // This is a host element. E.g. HTML.
138 - return [REACT_ELEMENT_TYPE, type, element.key, element.props];
139 - } else if (
140 - type === REACT_FRAGMENT_TYPE ||
141 - type === REACT_STRICT_MODE_TYPE ||
142 - type === REACT_PROFILER_TYPE ||
143 - type === REACT_SCOPE_TYPE ||
144 - type === REACT_DEBUG_TRACING_MODE_TYPE ||
145 - type === REACT_LEGACY_HIDDEN_TYPE ||
146 - type === REACT_OFFSCREEN_TYPE ||
147 - // TODO: These are temporary shims
148 - // and we'll want a different behavior.
149 - type === REACT_SUSPENSE_TYPE ||
150 - type === REACT_SUSPENSE_LIST_TYPE
151 - ) {
152 - return element.props.children;
135 + return [REACT_ELEMENT_TYPE, type, key, props];
136 + } else if (typeof type === 'symbol') {
137 + if (type === REACT_FRAGMENT_TYPE) {
138 + // For key-less fragments, we add a small optimization to avoid serializing
139 + // it as a wrapper.
140 + // TODO: If a key is specified, we should propagate its key to any children.
141 + // Same as if a server component has a key.
142 + return props.children;
143 + }
144 + // This might be a built-in React component. We'll let the client decide.
145 + // Any built-in works as long as its props are serializable.
146 + return [REACT_ELEMENT_TYPE, type, key, props];
147 } else if (type != null && typeof type === 'object') {
148 if (isModuleReference(type)) {
149 // This is a reference to a client component.
156 - return [REACT_ELEMENT_TYPE, type, element.key, element.props];
150 + return [REACT_ELEMENT_TYPE, type, key, props];
151 }
152 switch (type.$$typeof) {
153 case REACT_FORWARD_REF_TYPE: {
@@ -161,8 +155,7 @@ function attemptResolveElement(element: React$Element<any>): ReactModel {
155 return render(props, undefined);
156 }
157 case REACT_MEMO_TYPE: {
164 - const nextChildren = React.createElement(type.type, element.props);
165 - return attemptResolveElement(nextChildren);
158 + return attemptResolveElement(type.type, key, ref, props);
159 }
160 }
161 }
@@ -399,7 +392,12 @@ export function resolveModelToJSON(
392 const element: React$Element<any> = (value: any);
393 try {
394 // Attempt to render the server component.
402 - value = attemptResolveElement(element);
395 + value = attemptResolveElement(
396 + element.type,
397 + element.key,
398 + element.ref,
399 + element.props,
400 + );
401 } catch (x) {
402 if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
403 // Something suspended, we'll need to create a new segment and resolve it later.
@@ -526,14 +524,26 @@ export function resolveModelToJSON(
524 }
525
526 if (typeof value === 'symbol') {
527 + const writtenSymbols = request.writtenSymbols;
528 + const existingId = writtenSymbols.get(value);
529 + if (existingId !== undefined) {
530 + return serializeByValueID(existingId);
531 + }
532 + const name = value.description;
533 invariant(
530 - false,
531 - 'Symbol values (%s) cannot be passed to client components. ' +
534 + Symbol.for(name) === value,
535 + 'Only global symbols received from Symbol.for(...) can be passed to client components. ' +
536 + 'The symbol Symbol.for(%s) cannot be found among global symbols. ' +
537 'Remove %s from this object, or avoid the entire object: %s',
538 value.description,
539 describeKeyForErrorMessage(key),
540 describeObjectForErrorMessage(parent),
541 );
542 + request.pendingChunks++;
543 + const symbolId = request.nextChunkId++;
544 + emitSymbolChunk(request, symbolId, name);
545 + writtenSymbols.set(value, symbolId);
546 + return serializeByValueID(symbolId);
547 }
548
549 // $FlowFixMe: bigint isn't added to Flow yet.
@@ -588,6 +598,11 @@ function emitModuleChunk(
598 request.completedModuleChunks.push(processedChunk);
599 }
600
601 +function emitSymbolChunk(request: Request, id: number, name: string): void {
602 + const processedChunk = processSymbolChunk(request, id, name);
603 + request.completedModuleChunks.push(processedChunk);
604 +}
605 +
606 function retrySegment(request: Request, segment: Segment): void {
607 const query = segment.query;
608 let value;
@@ -604,7 +619,12 @@ function retrySegment(request: Request, segment: Segment): void {
619 // Doing this here lets us reuse this same segment if the next component
620 // also suspends.
621 segment.query = () => value;
607 - value = attemptResolveElement(element);
622 + value = attemptResolveElement(
623 + element.type,
624 + element.key,
625 + element.ref,
626 + element.props,
627 + );
628 }
629 const processedChunk = processModelChunk(request, segment.id, value);
630 request.completedJSONChunks.push(processedChunk);
packages/react-server/src/ReactFlightServerConfigStream.js
+10
@@ -109,6 +109,16 @@ export function processModuleChunk(
109 return convertStringToBuffer(row);
110 }
111
112 +export function processSymbolChunk(
113 + request: Request,
114 + id: number,
115 + name: string,
116 +): Chunk {
117 + const json = stringify(name);
118 + const row = serializeRowHeader('S', id) + json + '\n';
119 + return convertStringToBuffer(row);
120 +}
121 +
122 export {
123 scheduleWork,
124 flushBuffered,
packages/react-transport-dom-relay/src/ReactFlightDOMRelayClient.js
+4
@@ -15,6 +15,7 @@ import {
15 createResponse,
16 resolveModel,
17 resolveModule,
18 + resolveSymbol,
19 resolveError,
20 close,
21 } from 'react-client/src/ReactFlightClient';
@@ -26,6 +27,9 @@ export function resolveRow(response: Response, chunk: RowEncoding): void {
27 resolveModel(response, chunk[1], chunk[2]);
28 } else if (chunk[0] === 'M') {
29 resolveModule(response, chunk[1], chunk[2]);
30 + } else if (chunk[0] === 'S') {
31 + // $FlowFixMe: Flow doesn't support disjoint unions on tuples.
32 + resolveSymbol(response, chunk[1], chunk[2]);
33 } else {
34 // $FlowFixMe: Flow doesn't support disjoint unions on tuples.
35 resolveError(response, chunk[1], chunk[2].message, chunk[2].stack);
packages/react-transport-dom-relay/src/ReactFlightDOMRelayProtocol.js
+1
@@ -20,6 +20,7 @@ export type JSONValue =
20 export type RowEncoding =
21 | ['J', number, JSONValue]
22 | ['M', number, ModuleMetaData]
23 + | ['S', number, string]
24 | [
25 'E',
26 number,
packages/react-transport-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js
+8
@@ -111,6 +111,14 @@ export function processModuleChunk(
111 return ['M', id, moduleMetaData];
112 }
113
114 +export function processSymbolChunk(
115 + request: Request,
116 + id: number,
117 + name: string,
118 +): Chunk {
119 + return ['S', id, name];
120 +}
121 +
122 export function scheduleWork(callback: () => void) {
123 callback();
124 }
packages/react-transport-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js
+8 -5
@@ -153,11 +153,14 @@ describe('ReactFlightDOMRelay', () => {
153 foo: {
154 bar: (
155 <div>
156 - {'Fragment child'}
157 - {'Profiler child'}
158 - {'StrictMode child'}
159 - {'Suspense child'}
160 - {['SuspenseList row 1', 'SuspenseList row 2']}
156 + Fragment child
157 + <Profiler>Profiler child</Profiler>
158 + <StrictMode>StrictMode child</StrictMode>
159 + <Suspense fallback="Loading...">Suspense child</Suspense>
160 + <SuspenseList fallback="Loading...">
161 + {'SuspenseList row 1'}
162 + {'SuspenseList row 2'}
163 + </SuspenseList>
164 <div>Hello world</div>
165 </div>
166 ),
packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+6 -11
@@ -282,10 +282,6 @@ describe('ReactFlightDOM', () => {
282 );
283 }
284
285 - function Placeholder({children, fallback}) {
286 - return <Suspense fallback={fallback}>{children}</Suspense>;
287 - }
288 -
285 // Model
286 function Text({children}) {
287 return children;
@@ -347,22 +343,21 @@ describe('ReactFlightDOM', () => {
343 }
344
345 const MyErrorBoundaryClient = moduleReference(MyErrorBoundary);
350 - const PlaceholderClient = moduleReference(Placeholder);
346
347 function ProfileContent() {
348 return (
349 <>
350 <ProfileDetails avatar={<Text>:avatar:</Text>} />
356 - <PlaceholderClient fallback={<p>(loading sidebar)</p>}>
351 + <Suspense fallback={<p>(loading sidebar)</p>}>
352 <ProfileSidebar friends={<Friends>:friends:</Friends>} />
358 - </PlaceholderClient>
359 - <PlaceholderClient fallback={<p>(loading posts)</p>}>
353 + </Suspense>
354 + <Suspense fallback={<p>(loading posts)</p>}>
355 <ProfilePosts posts={<Posts>:posts:</Posts>} />
361 - </PlaceholderClient>
356 + </Suspense>
357 <MyErrorBoundaryClient>
363 - <PlaceholderClient fallback={<p>(loading games)</p>}>
358 + <Suspense fallback={<p>(loading games)</p>}>
359 <ProfileGames games={<Games>:games:</Games>} />
365 - </PlaceholderClient>
360 + </Suspense>
361 </MyErrorBoundaryClient>
362 </>
363 );
packages/react-transport-native-relay/src/ReactFlightNativeRelayClient.js
+4
@@ -15,6 +15,7 @@ import {
15 createResponse,
16 resolveModel,
17 resolveModule,
18 + resolveSymbol,
19 resolveError,
20 close,
21 } from 'react-client/src/ReactFlightClient';
@@ -26,6 +27,9 @@ export function resolveRow(response: Response, chunk: RowEncoding): void {
27 resolveModel(response, chunk[1], chunk[2]);
28 } else if (chunk[0] === 'M') {
29 resolveModule(response, chunk[1], chunk[2]);
30 + } else if (chunk[0] === 'S') {
31 + // $FlowFixMe: Flow doesn't support disjoint unions on tuples.
32 + resolveSymbol(response, chunk[1], chunk[2]);
33 } else {
34 // $FlowFixMe: Flow doesn't support disjoint unions on tuples.
35 resolveError(response, chunk[1], chunk[2].message, chunk[2].stack);
packages/react-transport-native-relay/src/ReactFlightNativeRelayProtocol.js
+1
@@ -20,6 +20,7 @@ export type JSONValue =
20 export type RowEncoding =
21 | ['J', number, JSONValue]
22 | ['M', number, ModuleMetaData]
23 + | ['S', number, string]
24 | [
25 'E',
26 number,
packages/react-transport-native-relay/src/ReactFlightNativeRelayServerHostConfig.js
+8
@@ -111,6 +111,14 @@ export function processModuleChunk(
111 return ['M', id, moduleMetaData];
112 }
113
114 +export function processSymbolChunk(
115 + request: Request,
116 + id: number,
117 + name: string,
118 +): Chunk {
119 + return ['S', id, name];
120 +}
121 +
122 export function scheduleWork(callback: () => void) {
123 callback();
124 }
scripts/error-codes/codes.json
+1 -1
@@ -364,7 +364,7 @@
364 "373": "This Hook is not supported in Server Components.",
365 "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.",
366 "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",
367 - "376": "Symbol values (%s) cannot be passed to client components. Remove %s from this object, or avoid the entire object: %s",
367 + "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",
368 "377": "BigInt (%s) is not yet supported in client component props. Remove %s from this object or use a plain number instead: %s",
369 "378": "Type %s is not supported in client component props. Remove %s from this object, or avoid the entire object: %s",
370 "379": "Refs cannot be used in server components, nor passed to client components."