@samitouri / QOS-React-2 / commits / a1c62b8a76

[Flight] Add Support for Map and Set (#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](https://github.com/facebook/react/issues/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.

Sebastian Markbåge committed Jun 27, 2023 at 17:10 UTC a1c62b8a7635c0bc51e477ba5437df9be5a9e64f
6 files changed +209 -34
packages/react-client/src/ReactFlightClient.js
+32 -16
@@ -535,6 +535,24 @@ function createServerReferenceProxy<A: Iterable<any>, T>(
535 return proxy;
536 }
537
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 +
556 function parseModelString(
557 response: Response,
558 parentObject: Object,
@@ -576,22 +594,20 @@ function parseModelString(
594 case 'F': {
595 // Server Reference
596 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);
611 }
612 case 'I': {
613 // $Infinity
packages/react-client/src/ReactFlightReplyClient.js
+30
@@ -59,8 +59,12 @@ export type ReactServerValue =
59 | symbol
60 | null
61 | void
62 + | bigint
63 | Iterable<ReactServerValue>
64 | Array<ReactServerValue>
65 + | Map<ReactServerValue, ReactServerValue>
66 + | Set<ReactServerValue>
67 + | Date
68 | ReactServerObject
69 | Promise<ReactServerValue>; // Thenable<ReactServerValue>
70
@@ -119,6 +123,14 @@ function serializeBigInt(n: bigint): string {
123 return '$n' + n.toString(10);
124 }
125
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 +
134 function escapeStringValue(value: string): string {
135 if (value[0] === '$') {
136 // We need to escape $ prefixed strings since we use those to encode
@@ -229,6 +241,24 @@ export function processReply(
241 });
242 return serializeFormDataReference(refId);
243 }
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 + }
262 if (!isArray(value)) {
263 const iteratorFn = getIteratorFn(value);
264 if (iteratorFn) {
packages/react-client/src/__tests__/ReactFlight-test.js
+61
@@ -323,6 +323,67 @@ describe('ReactFlight', () => {
323 expect(ReactNoop).toMatchRenderedOutput('prop: 2009-02-13T23:31:30.123Z');
324 });
325
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 +
387 it('can render a lazy component as a shared component on the server', async () => {
388 function SharedComponent({text}) {
389 return (
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js
+28
@@ -197,4 +197,32 @@ describe('ReactFlightDOMReply', () => {
197 expect(d).toEqual(d2);
198 expect(d % 1000).toEqual(123); // double-check the milliseconds made it through
199 });
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 + });
228 });
packages/react-server/src/ReactFlightReplyServer.js
+25 -9
@@ -364,6 +364,18 @@ function createModelReject<T>(chunk: SomeChunk<T>): (error: mixed) => void {
364 return (error: mixed) => triggerErrorOnChunk(chunk, error);
365 }
366
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 +
379 function parseModelString(
380 response: Response,
381 parentObject: Object,
@@ -389,17 +401,9 @@ function parseModelString(
401 case 'F': {
402 // Server Reference
403 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 - }
404 // TODO: Just encode this in the reference inline instead of as a model.
405 const metaData: {id: ServerReferenceId, bound: Thenable<Array<any>>} =
402 - chunk.value;
406 + getOutlinedModel(response, id);
407 return loadServerReference(
408 response,
409 metaData.id,
@@ -409,6 +413,18 @@ function parseModelString(
413 key,
414 );
415 }
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 + }
428 case 'K': {
429 // FormData
430 const stringId = value.slice(2);
packages/react-server/src/ReactFlightServer.js
+33 -9
@@ -137,8 +137,12 @@ export type ReactClientValue =
137 | symbol
138 | null
139 | void
140 + | bigint
141 | Iterable<ReactClientValue>
142 | Array<ReactClientValue>
143 + | Map<ReactClientValue, ReactClientValue>
144 + | Set<ReactClientValue>
145 + | Date
146 | ReactClientObject
147 | Promise<ReactClientValue>; // Thenable<ReactClientValue>
148
@@ -683,6 +687,15 @@ function serializeClientReference(
687 }
688 }
689
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 +
699 function serializeServerReference(
700 request: Request,
701 parent:
@@ -708,15 +721,7 @@ function serializeServerReference(
721 id: getServerReferenceId(request.bundlerConfig, serverReference),
722 bound: bound ? Promise.resolve(bound) : null,
723 };
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);
725 writtenServerReferences.set(serverReference, metadataId);
726 return serializeServerReferenceID(metadataId);
727 }
@@ -735,6 +740,19 @@ function serializeLargeTextString(request: Request, text: string): string {
740 return serializeByValueID(textId);
741 }
742
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 +
756 function escapeStringValue(value: string): string {
757 if (value[0] === '$') {
758 // We need to escape $ prefixed strings since we use those to encode
@@ -924,6 +942,12 @@ function resolveModelToJSON(
942 }
943 return (undefined: any);
944 }
945 + if (value instanceof Map) {
946 + return serializeMap(request, value);
947 + }
948 + if (value instanceof Set) {
949 + return serializeSet(request, value);
950 + }
951 if (!isArray(value)) {
952 const iteratorFn = getIteratorFn(value);
953 if (iteratorFn) {