Skip to content

Commit 76a6dbc

Browse files
authored
[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
1 parent 35e53b4 commit 76a6dbc

14 files changed

+140
-65
lines changed

packages/react-client/src/ReactFlightClient.js

+18
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,13 @@ function createErrorChunk(response: Response, error: Error): ErroredChunk {
132132
return new Chunk(ERRORED, error, response);
133133
}
134134

135+
function createInitializedChunk<T>(
136+
response: Response,
137+
value: T,
138+
): InitializedChunk<T> {
139+
return new Chunk(INITIALIZED, value, response);
140+
}
141+
135142
function wakeChunk(listeners: null | Array<() => mixed>) {
136143
if (listeners !== null) {
137144
for (let i = 0; i < listeners.length; i++) {
@@ -373,6 +380,17 @@ export function resolveModule(
373380
}
374381
}
375382

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+
376394
export function resolveError(
377395
response: Response,
378396
id: number,

packages/react-client/src/ReactFlightClientStream.js

+15-12
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {Response} from './ReactFlightClientHostConfigStream';
1212
import {
1313
resolveModule,
1414
resolveModel,
15+
resolveSymbol,
1516
resolveError,
1617
createResponse as createResponseBase,
1718
parseModelString,
@@ -32,26 +33,28 @@ function processFullRow(response: Response, row: string): void {
3233
return;
3334
}
3435
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);
3543
switch (tag) {
3644
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);
4146
return;
4247
}
4348
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));
4854
return;
4955
}
5056
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);
5558
resolveError(response, id, errorInfo.message, errorInfo.stack);
5659
return;
5760
}

packages/react-client/src/__tests__/ReactFlight-test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ describe('ReactFlight', () => {
174174
<ErrorBoundary expectedMessage="Functions cannot be passed directly to client components because they're not serializable.">
175175
<Client transport={fn} />
176176
</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.">
178178
<Client transport={symbol} />
179179
</ErrorBoundary>
180180
<ErrorBoundary expectedMessage="Refs cannot be used in server components, nor passed to client components.">

packages/react-server/src/ReactFlightServer.js

+55-35
Original file line numberDiff line numberDiff line change
@@ -25,28 +25,20 @@ import {
2525
close,
2626
processModelChunk,
2727
processModuleChunk,
28+
processSymbolChunk,
2829
processErrorChunk,
2930
resolveModuleMetaData,
3031
isModuleReference,
3132
} from './ReactFlightServerConfig';
3233

3334
import {
3435
REACT_ELEMENT_TYPE,
35-
REACT_DEBUG_TRACING_MODE_TYPE,
3636
REACT_FORWARD_REF_TYPE,
3737
REACT_FRAGMENT_TYPE,
3838
REACT_LAZY_TYPE,
39-
REACT_LEGACY_HIDDEN_TYPE,
4039
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,
4740
} from 'shared/ReactSymbols';
4841

49-
import * as React from 'react';
5042
import ReactSharedInternals from 'shared/ReactSharedInternals';
5143
import invariant from 'shared/invariant';
5244

@@ -86,6 +78,7 @@ export type Request = {
8678
completedModuleChunks: Array<Chunk>,
8779
completedJSONChunks: Array<Chunk>,
8880
completedErrorChunks: Array<Chunk>,
81+
writtenSymbols: Map<Symbol, number>,
8982
flowing: boolean,
9083
toJSON: (key: string, value: ReactModel) => ReactJSONValue,
9184
};
@@ -107,6 +100,7 @@ export function createRequest(
107100
completedModuleChunks: [],
108101
completedJSONChunks: [],
109102
completedErrorChunks: [],
103+
writtenSymbols: new Map(),
110104
flowing: false,
111105
toJSON: function(key: string, value: ReactModel): ReactJSONValue {
112106
return resolveModelToJSON(request, this, key, value);
@@ -118,10 +112,13 @@ export function createRequest(
118112
return request;
119113
}
120114

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) {
125122
// When the ref moves to the regular props object this will implicitly
126123
// throw for functions. We could probably relax it to a DEV warning for other
127124
// cases.
@@ -135,34 +132,30 @@ function attemptResolveElement(element: React$Element<any>): ReactModel {
135132
return type(props);
136133
} else if (typeof type === 'string') {
137134
// 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];
153147
} else if (type != null && typeof type === 'object') {
154148
if (isModuleReference(type)) {
155149
// 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];
157151
}
158152
switch (type.$$typeof) {
159153
case REACT_FORWARD_REF_TYPE: {
160154
const render = type.render;
161155
return render(props, undefined);
162156
}
163157
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);
166159
}
167160
}
168161
}
@@ -399,7 +392,12 @@ export function resolveModelToJSON(
399392
const element: React$Element<any> = (value: any);
400393
try {
401394
// 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+
);
403401
} catch (x) {
404402
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
405403
// Something suspended, we'll need to create a new segment and resolve it later.
@@ -526,14 +524,26 @@ export function resolveModelToJSON(
526524
}
527525

528526
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;
529533
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. ' +
532537
'Remove %s from this object, or avoid the entire object: %s',
533538
value.description,
534539
describeKeyForErrorMessage(key),
535540
describeObjectForErrorMessage(parent),
536541
);
542+
request.pendingChunks++;
543+
const symbolId = request.nextChunkId++;
544+
emitSymbolChunk(request, symbolId, name);
545+
writtenSymbols.set(value, symbolId);
546+
return serializeByValueID(symbolId);
537547
}
538548

539549
// $FlowFixMe: bigint isn't added to Flow yet.
@@ -588,6 +598,11 @@ function emitModuleChunk(
588598
request.completedModuleChunks.push(processedChunk);
589599
}
590600

601+
function emitSymbolChunk(request: Request, id: number, name: string): void {
602+
const processedChunk = processSymbolChunk(request, id, name);
603+
request.completedModuleChunks.push(processedChunk);
604+
}
605+
591606
function retrySegment(request: Request, segment: Segment): void {
592607
const query = segment.query;
593608
let value;
@@ -604,7 +619,12 @@ function retrySegment(request: Request, segment: Segment): void {
604619
// Doing this here lets us reuse this same segment if the next component
605620
// also suspends.
606621
segment.query = () => value;
607-
value = attemptResolveElement(element);
622+
value = attemptResolveElement(
623+
element.type,
624+
element.key,
625+
element.ref,
626+
element.props,
627+
);
608628
}
609629
const processedChunk = processModelChunk(request, segment.id, value);
610630
request.completedJSONChunks.push(processedChunk);

packages/react-server/src/ReactFlightServerConfigStream.js

+10
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ export function processModuleChunk(
109109
return convertStringToBuffer(row);
110110
}
111111

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+
112122
export {
113123
scheduleWork,
114124
flushBuffered,

packages/react-transport-dom-relay/src/ReactFlightDOMRelayClient.js

+4
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
createResponse,
1616
resolveModel,
1717
resolveModule,
18+
resolveSymbol,
1819
resolveError,
1920
close,
2021
} from 'react-client/src/ReactFlightClient';
@@ -26,6 +27,9 @@ export function resolveRow(response: Response, chunk: RowEncoding): void {
2627
resolveModel(response, chunk[1], chunk[2]);
2728
} else if (chunk[0] === 'M') {
2829
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]);
2933
} else {
3034
// $FlowFixMe: Flow doesn't support disjoint unions on tuples.
3135
resolveError(response, chunk[1], chunk[2].message, chunk[2].stack);

packages/react-transport-dom-relay/src/ReactFlightDOMRelayProtocol.js

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type JSONValue =
2020
export type RowEncoding =
2121
| ['J', number, JSONValue]
2222
| ['M', number, ModuleMetaData]
23+
| ['S', number, string]
2324
| [
2425
'E',
2526
number,

packages/react-transport-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js

+8
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ export function processModuleChunk(
111111
return ['M', id, moduleMetaData];
112112
}
113113

114+
export function processSymbolChunk(
115+
request: Request,
116+
id: number,
117+
name: string,
118+
): Chunk {
119+
return ['S', id, name];
120+
}
121+
114122
export function scheduleWork(callback: () => void) {
115123
callback();
116124
}

packages/react-transport-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js

+8-5
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,14 @@ describe('ReactFlightDOMRelay', () => {
153153
foo: {
154154
bar: (
155155
<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>
161164
<div>Hello world</div>
162165
</div>
163166
),

packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js

+6-11
Original file line numberDiff line numberDiff line change
@@ -282,10 +282,6 @@ describe('ReactFlightDOM', () => {
282282
);
283283
}
284284

285-
function Placeholder({children, fallback}) {
286-
return <Suspense fallback={fallback}>{children}</Suspense>;
287-
}
288-
289285
// Model
290286
function Text({children}) {
291287
return children;
@@ -347,22 +343,21 @@ describe('ReactFlightDOM', () => {
347343
}
348344

349345
const MyErrorBoundaryClient = moduleReference(MyErrorBoundary);
350-
const PlaceholderClient = moduleReference(Placeholder);
351346

352347
function ProfileContent() {
353348
return (
354349
<>
355350
<ProfileDetails avatar={<Text>:avatar:</Text>} />
356-
<PlaceholderClient fallback={<p>(loading sidebar)</p>}>
351+
<Suspense fallback={<p>(loading sidebar)</p>}>
357352
<ProfileSidebar friends={<Friends>:friends:</Friends>} />
358-
</PlaceholderClient>
359-
<PlaceholderClient fallback={<p>(loading posts)</p>}>
353+
</Suspense>
354+
<Suspense fallback={<p>(loading posts)</p>}>
360355
<ProfilePosts posts={<Posts>:posts:</Posts>} />
361-
</PlaceholderClient>
356+
</Suspense>
362357
<MyErrorBoundaryClient>
363-
<PlaceholderClient fallback={<p>(loading games)</p>}>
358+
<Suspense fallback={<p>(loading games)</p>}>
364359
<ProfileGames games={<Games>:games:</Games>} />
365-
</PlaceholderClient>
360+
</Suspense>
366361
</MyErrorBoundaryClient>
367362
</>
368363
);

0 commit comments

Comments
 (0)