[Flight / Flight Reply] Encode Iterator separately from Iterable (#28854)
For [`AsyncIterable`](https://github.com/facebook/react/pull/28847) we encode `AsyncIterator` as a separate tag. Previously we encoded `Iterator` as just an Array. This adds a special encoding for this. Technically this is a breaking change. This is kind of an edge case that you'd care about the difference but it becomes more important to treat these correctly for the warnings here #28853.
Sebastian Markbåge committed
Apr 21, 2024 at 12:52 UTC
bf426f9b1de6ef6adfeff30297beba96464f5efb
6 files changed
+108
-2
packages/react-client/src/ReactFlightClient.js
+16
@@ -825,6 +825,11 @@ function createFormData(
825
return formData;
826
}
827
828
+function extractIterator(response: Response, model: Array<any>): Iterator<any> {
829
+ // $FlowFixMe[incompatible-use]: This uses raw Symbols because we're extracting from a native array.
830
+ return model[Symbol.iterator]();
831
+}
832
+
833
function createModel(response: Response, model: any): any {
834
return model;
835
}
@@ -919,6 +924,17 @@ function parseModelString(
924
createFormData,
925
);
926
}
927
+ case 'i': {
928
+ // Iterator
929
+ const id = parseInt(value.slice(2), 16);
930
+ return getOutlinedModel(
931
+ response,
932
+ id,
933
+ parentObject,
934
+ key,
935
+ extractIterator,
936
+ );
937
+ }
938
case 'I': {
939
// $Infinity
940
return Infinity;
packages/react-client/src/ReactFlightReplyClient.js
+22
-1
@@ -81,7 +81,10 @@ export type ReactServerValue =
81
| null
82
| void
83
| bigint
84
+ | $AsyncIterable<ReactServerValue, ReactServerValue, void>
85
+ | $AsyncIterator<ReactServerValue, ReactServerValue, void>
86
| Iterable<ReactServerValue>
87
+ | Iterator<ReactServerValue>
88
| Array<ReactServerValue>
89
| Map<ReactServerValue, ReactServerValue>
90
| Set<ReactServerValue>
@@ -157,6 +160,10 @@ function serializeBlobID(id: number): string {
160
return '$B' + id.toString(16);
161
}
162
163
+function serializeIteratorID(id: number): string {
164
+ return '$i' + id.toString(16);
165
+}
166
+
167
function escapeStringValue(value: string): string {
168
if (value[0] === '$') {
169
// We need to escape $ prefixed strings since we use those to encode
@@ -448,7 +455,21 @@ export function processReply(
455
456
const iteratorFn = getIteratorFn(value);
457
if (iteratorFn) {
451
- return Array.from((value: any));
458
+ const iterator = iteratorFn.call(value);
459
+ if (iterator === value) {
460
+ // Iterator, not Iterable
461
+ const partJSON = JSON.stringify(
462
+ Array.from((iterator: any)),
463
+ resolveToJSON,
464
+ );
465
+ if (formData === null) {
466
+ formData = new FormData();
467
+ }
468
+ const iteratorId = nextPartId++;
469
+ formData.append(formFieldPrefix + iteratorId, partJSON);
470
+ return serializeIteratorID(iteratorId);
471
+ }
472
+ return Array.from((iterator: any));
473
}
474
475
// Verify that this is a simple plain object.
packages/react-client/src/__tests__/ReactFlight-test.js
+18
@@ -277,6 +277,24 @@ describe('ReactFlight', () => {
277
expect(ReactNoop).toMatchRenderedOutput(<span>ABC</span>);
278
});
279
280
+ it('can render an iterator as a single shot iterator', async () => {
281
+ const iterator = (function* () {
282
+ yield 'A';
283
+ yield 'B';
284
+ yield 'C';
285
+ })();
286
+
287
+ const transport = ReactNoopFlightServer.render(iterator);
288
+ const result = await ReactNoopFlightClient.read(transport);
289
+
290
+ // The iterator should be the same as itself.
291
+ expect(result[Symbol.iterator]()).toBe(result);
292
+
293
+ expect(Array.from(result)).toEqual(['A', 'B', 'C']);
294
+ // We've already consumed this iterator.
295
+ expect(Array.from(result)).toEqual([]);
296
+ });
297
+
298
it('can render undefined', async () => {
299
function Undefined() {
300
return undefined;
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js
+29
@@ -97,6 +97,35 @@ describe('ReactFlightDOMReply', () => {
97
items.push(item);
98
}
99
expect(items).toEqual(['A', 'B', 'C']);
100
+
101
+ // Multipass
102
+ const items2 = [];
103
+ // eslint-disable-next-line no-for-of-loops/no-for-of-loops
104
+ for (const item of iterable) {
105
+ items2.push(item);
106
+ }
107
+ expect(items2).toEqual(['A', 'B', 'C']);
108
+ });
109
+
110
+ it('can pass an iterator as a reply', async () => {
111
+ const iterator = (function* () {
112
+ yield 'A';
113
+ yield 'B';
114
+ yield 'C';
115
+ })();
116
+
117
+ const body = await ReactServerDOMClient.encodeReply(iterator);
118
+ const result = await ReactServerDOMServer.decodeReply(
119
+ body,
120
+ webpackServerMap,
121
+ );
122
+
123
+ // The iterator should be the same as itself.
124
+ expect(result[Symbol.iterator]()).toBe(result);
125
+
126
+ expect(Array.from(result)).toEqual(['A', 'B', 'C']);
127
+ // We've already consumed this iterator.
128
+ expect(Array.from(result)).toEqual([]);
129
});
130
131
it('can pass weird numbers as a reply', async () => {
packages/react-server/src/ReactFlightReplyServer.js
+6
@@ -477,6 +477,12 @@ function parseModelString(
477
});
478
return data;
479
}
480
+ case 'i': {
481
+ // Iterator
482
+ const id = parseInt(value.slice(2), 16);
483
+ const data = getOutlinedModel(response, id);
484
+ return data[Symbol.iterator]();
485
+ }
486
case 'I': {
487
// $Infinity
488
return Infinity;
packages/react-server/src/ReactFlightServer.js
+17
-1
@@ -241,7 +241,9 @@ export type ReactClientValue =
241
| bigint
242
| ReadableStream
243
| $AsyncIterable<ReactClientValue, ReactClientValue, void>
244
+ | $AsyncIterator<ReactClientValue, ReactClientValue, void>
245
| Iterable<ReactClientValue>
246
+ | Iterator<ReactClientValue>
247
| Array<ReactClientValue>
248
| Map<ReactClientValue, ReactClientValue>
249
| Set<ReactClientValue>
@@ -1457,6 +1459,14 @@ function serializeSet(request: Request, set: Set<ReactClientValue>): string {
1459
return '$W' + id.toString(16);
1460
}
1461
1462
+function serializeIterator(
1463
+ request: Request,
1464
+ iterator: Iterator<ReactClientValue>,
1465
+): string {
1466
+ const id = outlineModel(request, Array.from(iterator));
1467
+ return '$i' + id.toString(16);
1468
+}
1469
+
1470
function serializeTypedArray(
1471
request: Request,
1472
tag: string,
@@ -1910,7 +1920,13 @@ function renderModelDestructive(
1920
1921
const iteratorFn = getIteratorFn(value);
1922
if (iteratorFn) {
1913
- return renderFragment(request, task, Array.from((value: any)));
1923
+ // TODO: Should we serialize the return value as well like we do for AsyncIterables?
1924
+ const iterator = iteratorFn.call(value);
1925
+ if (iterator === value) {
1926
+ // Iterator, not Iterable
1927
+ return serializeIterator(request, (iterator: any));
1928
+ }
1929
+ return renderFragment(request, task, Array.from((iterator: any)));
1930
}
1931
1932
if (enableFlightReadableStream) {