Skip to content

Commit b1a59ea

Browse files
sebmarkbageAndyPengc12
authored andcommitted
[Flight] Add Support for Map and Set (facebook#26933)
We already support these in the sense that they're Iterable so they just get serialized as arrays. However, these are part of the Structured Clone algorithm [and should be supported](facebook#25687). The encoding is simply the same form as the Iterable, which is conveniently the same as the constructor argument. The difference is that now there's a separate reference to it. It's a bit awkward because for multiple reference to the same value, it'd be a new Map/Set instance for each reference. So to encode sharing, it needs one level of indirection with its own ID. That's not really a big deal for other types since they're inline anyway - but since this needs to be outlined it creates possibly two ids where there only needs to be one or zero. One variant would be to encode this in the row type. Another variant would be something like what we do for React Elements where they're arrays but tagged with a symbol. For simplicity I stick with the simple outlining for now.
1 parent 5043405 commit b1a59ea

File tree

6 files changed

+209
-34
lines changed

6 files changed

+209
-34
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,24 @@ function createServerReferenceProxy<A: Iterable<any>, T>(
535535
return proxy;
536536
}
537537

538+
function getOutlinedModel(response: Response, id: number): any {
539+
const chunk = getChunk(response, id);
540+
switch (chunk.status) {
541+
case RESOLVED_MODEL:
542+
initializeModelChunk(chunk);
543+
break;
544+
}
545+
// The status might have changed after initialization.
546+
switch (chunk.status) {
547+
case INITIALIZED: {
548+
return chunk.value;
549+
}
550+
// We always encode it first in the stream so it won't be pending.
551+
default:
552+
throw chunk.reason;
553+
}
554+
}
555+
538556
function parseModelString(
539557
response: Response,
540558
parentObject: Object,
@@ -576,22 +594,20 @@ function parseModelString(
576594
case 'F': {
577595
// Server Reference
578596
const id = parseInt(value.slice(2), 16);
579-
const chunk = getChunk(response, id);
580-
switch (chunk.status) {
581-
case RESOLVED_MODEL:
582-
initializeModelChunk(chunk);
583-
break;
584-
}
585-
// The status might have changed after initialization.
586-
switch (chunk.status) {
587-
case INITIALIZED: {
588-
const metadata = chunk.value;
589-
return createServerReferenceProxy(response, metadata);
590-
}
591-
// We always encode it first in the stream so it won't be pending.
592-
default:
593-
throw chunk.reason;
594-
}
597+
const metadata = getOutlinedModel(response, id);
598+
return createServerReferenceProxy(response, metadata);
599+
}
600+
case 'Q': {
601+
// Map
602+
const id = parseInt(value.slice(2), 16);
603+
const data = getOutlinedModel(response, id);
604+
return new Map(data);
605+
}
606+
case 'W': {
607+
// Set
608+
const id = parseInt(value.slice(2), 16);
609+
const data = getOutlinedModel(response, id);
610+
return new Set(data);
595611
}
596612
case 'I': {
597613
// $Infinity

packages/react-client/src/ReactFlightReplyClient.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,12 @@ export type ReactServerValue =
5959
| symbol
6060
| null
6161
| void
62+
| bigint
6263
| Iterable<ReactServerValue>
6364
| Array<ReactServerValue>
65+
| Map<ReactServerValue, ReactServerValue>
66+
| Set<ReactServerValue>
67+
| Date
6468
| ReactServerObject
6569
| Promise<ReactServerValue>; // Thenable<ReactServerValue>
6670

@@ -119,6 +123,14 @@ function serializeBigInt(n: bigint): string {
119123
return '$n' + n.toString(10);
120124
}
121125

126+
function serializeMapID(id: number): string {
127+
return '$Q' + id.toString(16);
128+
}
129+
130+
function serializeSetID(id: number): string {
131+
return '$W' + id.toString(16);
132+
}
133+
122134
function escapeStringValue(value: string): string {
123135
if (value[0] === '$') {
124136
// We need to escape $ prefixed strings since we use those to encode
@@ -229,6 +241,24 @@ export function processReply(
229241
});
230242
return serializeFormDataReference(refId);
231243
}
244+
if (value instanceof Map) {
245+
const partJSON = JSON.stringify(Array.from(value), resolveToJSON);
246+
if (formData === null) {
247+
formData = new FormData();
248+
}
249+
const mapId = nextPartId++;
250+
formData.append(formFieldPrefix + mapId, partJSON);
251+
return serializeMapID(mapId);
252+
}
253+
if (value instanceof Set) {
254+
const partJSON = JSON.stringify(Array.from(value), resolveToJSON);
255+
if (formData === null) {
256+
formData = new FormData();
257+
}
258+
const setId = nextPartId++;
259+
formData.append(formFieldPrefix + setId, partJSON);
260+
return serializeSetID(setId);
261+
}
232262
if (!isArray(value)) {
233263
const iteratorFn = getIteratorFn(value);
234264
if (iteratorFn) {

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,67 @@ describe('ReactFlight', () => {
323323
expect(ReactNoop).toMatchRenderedOutput('prop: 2009-02-13T23:31:30.123Z');
324324
});
325325

326+
it('can transport Map', async () => {
327+
function ComponentClient({prop}) {
328+
return `
329+
map: ${prop instanceof Map}
330+
size: ${prop.size}
331+
greet: ${prop.get('hi').greet}
332+
content: ${JSON.stringify(Array.from(prop))}
333+
`;
334+
}
335+
const Component = clientReference(ComponentClient);
336+
337+
const objKey = {obj: 'key'};
338+
const map = new Map([
339+
['hi', {greet: 'world'}],
340+
[objKey, 123],
341+
]);
342+
const model = <Component prop={map} />;
343+
344+
const transport = ReactNoopFlightServer.render(model);
345+
346+
await act(async () => {
347+
ReactNoop.render(await ReactNoopFlightClient.read(transport));
348+
});
349+
350+
expect(ReactNoop).toMatchRenderedOutput(`
351+
map: true
352+
size: 2
353+
greet: world
354+
content: [["hi",{"greet":"world"}],[{"obj":"key"},123]]
355+
`);
356+
});
357+
358+
it('can transport Set', async () => {
359+
function ComponentClient({prop}) {
360+
return `
361+
set: ${prop instanceof Set}
362+
size: ${prop.size}
363+
hi: ${prop.has('hi')}
364+
content: ${JSON.stringify(Array.from(prop))}
365+
`;
366+
}
367+
const Component = clientReference(ComponentClient);
368+
369+
const objKey = {obj: 'key'};
370+
const set = new Set(['hi', objKey]);
371+
const model = <Component prop={set} />;
372+
373+
const transport = ReactNoopFlightServer.render(model);
374+
375+
await act(async () => {
376+
ReactNoop.render(await ReactNoopFlightClient.read(transport));
377+
});
378+
379+
expect(ReactNoop).toMatchRenderedOutput(`
380+
set: true
381+
size: 2
382+
hi: true
383+
content: ["hi",{"obj":"key"}]
384+
`);
385+
});
386+
326387
it('can render a lazy component as a shared component on the server', async () => {
327388
function SharedComponent({text}) {
328389
return (

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,4 +197,32 @@ describe('ReactFlightDOMReply', () => {
197197
expect(d).toEqual(d2);
198198
expect(d % 1000).toEqual(123); // double-check the milliseconds made it through
199199
});
200+
201+
it('can pass a Map as a reply', async () => {
202+
const objKey = {obj: 'key'};
203+
const m = new Map([
204+
['hi', {greet: 'world'}],
205+
[objKey, 123],
206+
]);
207+
const body = await ReactServerDOMClient.encodeReply(m);
208+
const m2 = await ReactServerDOMServer.decodeReply(body, webpackServerMap);
209+
210+
expect(m2 instanceof Map).toBe(true);
211+
expect(m2.size).toBe(2);
212+
expect(m2.get('hi').greet).toBe('world');
213+
expect(m2).toEqual(m);
214+
});
215+
216+
it('can pass a Set as a reply', async () => {
217+
const objKey = {obj: 'key'};
218+
const s = new Set(['hi', objKey]);
219+
220+
const body = await ReactServerDOMClient.encodeReply(s);
221+
const s2 = await ReactServerDOMServer.decodeReply(body, webpackServerMap);
222+
223+
expect(s2 instanceof Set).toBe(true);
224+
expect(s2.size).toBe(2);
225+
expect(s2.has('hi')).toBe(true);
226+
expect(s2).toEqual(s);
227+
});
200228
});

packages/react-server/src/ReactFlightReplyServer.js

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,18 @@ function createModelReject<T>(chunk: SomeChunk<T>): (error: mixed) => void {
364364
return (error: mixed) => triggerErrorOnChunk(chunk, error);
365365
}
366366

367+
function getOutlinedModel(response: Response, id: number): any {
368+
const chunk = getChunk(response, id);
369+
if (chunk.status === RESOLVED_MODEL) {
370+
initializeModelChunk(chunk);
371+
}
372+
if (chunk.status !== INITIALIZED) {
373+
// We know that this is emitted earlier so otherwise it's an error.
374+
throw chunk.reason;
375+
}
376+
return chunk.value;
377+
}
378+
367379
function parseModelString(
368380
response: Response,
369381
parentObject: Object,
@@ -389,17 +401,9 @@ function parseModelString(
389401
case 'F': {
390402
// Server Reference
391403
const id = parseInt(value.slice(2), 16);
392-
const chunk = getChunk(response, id);
393-
if (chunk.status === RESOLVED_MODEL) {
394-
initializeModelChunk(chunk);
395-
}
396-
if (chunk.status !== INITIALIZED) {
397-
// We know that this is emitted earlier so otherwise it's an error.
398-
throw chunk.reason;
399-
}
400404
// TODO: Just encode this in the reference inline instead of as a model.
401405
const metaData: {id: ServerReferenceId, bound: Thenable<Array<any>>} =
402-
chunk.value;
406+
getOutlinedModel(response, id);
403407
return loadServerReference(
404408
response,
405409
metaData.id,
@@ -409,6 +413,18 @@ function parseModelString(
409413
key,
410414
);
411415
}
416+
case 'Q': {
417+
// Map
418+
const id = parseInt(value.slice(2), 16);
419+
const data = getOutlinedModel(response, id);
420+
return new Map(data);
421+
}
422+
case 'W': {
423+
// Set
424+
const id = parseInt(value.slice(2), 16);
425+
const data = getOutlinedModel(response, id);
426+
return new Set(data);
427+
}
412428
case 'K': {
413429
// FormData
414430
const stringId = value.slice(2);

packages/react-server/src/ReactFlightServer.js

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,12 @@ export type ReactClientValue =
137137
| symbol
138138
| null
139139
| void
140+
| bigint
140141
| Iterable<ReactClientValue>
141142
| Array<ReactClientValue>
143+
| Map<ReactClientValue, ReactClientValue>
144+
| Set<ReactClientValue>
145+
| Date
142146
| ReactClientObject
143147
| Promise<ReactClientValue>; // Thenable<ReactClientValue>
144148

@@ -683,6 +687,15 @@ function serializeClientReference(
683687
}
684688
}
685689

690+
function outlineModel(request: Request, value: any): number {
691+
request.pendingChunks++;
692+
const outlinedId = request.nextChunkId++;
693+
// We assume that this object doesn't suspend, but a child might.
694+
const processedChunk = processModelChunk(request, outlinedId, value);
695+
request.completedRegularChunks.push(processedChunk);
696+
return outlinedId;
697+
}
698+
686699
function serializeServerReference(
687700
request: Request,
688701
parent:
@@ -708,15 +721,7 @@ function serializeServerReference(
708721
id: getServerReferenceId(request.bundlerConfig, serverReference),
709722
bound: bound ? Promise.resolve(bound) : null,
710723
};
711-
request.pendingChunks++;
712-
const metadataId = request.nextChunkId++;
713-
// We assume that this object doesn't suspend.
714-
const processedChunk = processModelChunk(
715-
request,
716-
metadataId,
717-
serverReferenceMetadata,
718-
);
719-
request.completedRegularChunks.push(processedChunk);
724+
const metadataId = outlineModel(request, serverReferenceMetadata);
720725
writtenServerReferences.set(serverReference, metadataId);
721726
return serializeServerReferenceID(metadataId);
722727
}
@@ -735,6 +740,19 @@ function serializeLargeTextString(request: Request, text: string): string {
735740
return serializeByValueID(textId);
736741
}
737742

743+
function serializeMap(
744+
request: Request,
745+
map: Map<ReactClientValue, ReactClientValue>,
746+
): string {
747+
const id = outlineModel(request, Array.from(map));
748+
return '$Q' + id.toString(16);
749+
}
750+
751+
function serializeSet(request: Request, set: Set<ReactClientValue>): string {
752+
const id = outlineModel(request, Array.from(set));
753+
return '$W' + id.toString(16);
754+
}
755+
738756
function escapeStringValue(value: string): string {
739757
if (value[0] === '$') {
740758
// We need to escape $ prefixed strings since we use those to encode
@@ -924,6 +942,12 @@ function resolveModelToJSON(
924942
}
925943
return (undefined: any);
926944
}
945+
if (value instanceof Map) {
946+
return serializeMap(request, value);
947+
}
948+
if (value instanceof Set) {
949+
return serializeSet(request, value);
950+
}
927951
if (!isArray(value)) {
928952
const iteratorFn = getIteratorFn(value);
929953
if (iteratorFn) {

0 commit comments

Comments
 (0)