Skip to content

Commit 9080248

Browse files
committed
[Flight] Encode ReadableStream and AsyncIterables (#28847)
This adds support in Flight for serializing four kinds of streams: - `ReadableStream` with objects as a model. This is a single shot iterator so you can read it only once. It can contain any value including Server Components. Chunks are encoded as is so if you send in 10 typed arrays, you get the same typed arrays out on the other side. - Binary `ReadableStream` with `type: 'bytes'` option. This supports the BYOB protocol. In this mode, the receiving side just gets `Uint8Array`s and they can be split across any single byte boundary into arbitrary chunks. - `AsyncIterable` where the `AsyncIterator` function is different than the `AsyncIterable` itself. In this case we assume that this might be a multi-shot iterable and so we buffer its value and you can iterate it multiple times on the other side. We support the `return` value as a value in the single completion slot, but you can't pass values in `next()`. If you want single-shot, return the AsyncIterator instead. - `AsyncIterator`. These gets serialized as a single-shot as it's just an iterator. `AsyncIterable`/`AsyncIterator` yield Promises that are instrumented with our `.status`/`.value` convention so that they can be synchronously looped over if available. They are also lazily parsed upon read. We can't do this with `ReadableStream` because we use the native implementation of `ReadableStream` which owns the promises. The format is a leading row that indicates which type of stream it is. Then a new row with the same ID is emitted for every chunk. Followed by either an error or close row. `AsyncIterable`s can also be returned as children of Server Components and then they're conceptually the same as fragment arrays/iterables. They can't actually be used as children in Fizz/Fiber but there's a separate plan for that. Only `AsyncIterable` not `AsyncIterator` will be valid as children - just like sync `Iterable` is already supported but single-shot `Iterator` is not. Notably, neither of these streams represent updates over time to a value. They represent multiple values in a list. When the server stream is aborted we also close the underlying stream. However, closing a stream on the client, doesn't close the underlying stream. A couple of possible follow ups I'm not planning on doing right now: - [ ] Free memory by releasing the buffer if an Iterator has been exhausted. Single shots could be optimized further to release individual items as you go. - [ ] We could clean up the underlying stream if the only pending data that's still flowing is from streams and all the streams have cleaned up. It's not very reliable though. It's better to do cancellation for the whole stream - e.g. at the framework level. - [ ] Implement smarter Binary Stream chunk handling. Currently we wait until we've received a whole row for binary chunks and copy them into consecutive memory. We need this to preserve semantics when passing typed arrays. However, for binary streams we don't need that. We can just send whatever pieces we have so far. DiffTrain build for [7909d8e](7909d8e)
1 parent 34d4293 commit 9080248

11 files changed

Lines changed: 256 additions & 167 deletions

compiled/facebook-www/REVISION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
9defcd56bc3cd53ac2901ed93f29218007010434
1+
7909d8eabb7a702618f51e16a351df41aa8da88e

compiled/facebook-www/ReactDOMServer-dev.classic.js

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ if (__DEV__) {
1919
var React = require("react");
2020
var ReactDOM = require("react-dom");
2121

22-
var ReactVersion = "19.0.0-www-classic-bf1258d7";
22+
var ReactVersion = "19.0.0-www-classic-9835bfc2";
2323

2424
// This refers to a WWW module.
2525
var warningWWW = require("warning");
@@ -9940,8 +9940,14 @@ if (__DEV__) {
99409940
}
99419941

99429942
default: {
9943-
if (typeof thenable.status === "string");
9944-
else {
9943+
if (typeof thenable.status === "string") {
9944+
// Only instrument the thenable if the status if not defined. If
9945+
// it's defined, but an unknown value, assume it's been instrumented by
9946+
// some custom userspace implementation. We treat it as "pending".
9947+
// Attach a dummy listener, to ensure that any lazy initialization can
9948+
// happen. Flight lazily parses JSON when the value is actually awaited.
9949+
thenable.then(noop$2, noop$2);
9950+
} else {
99459951
var pendingThenable = thenable;
99469952
pendingThenable.status = "pending";
99479953
pendingThenable.then(
@@ -9959,18 +9965,18 @@ if (__DEV__) {
99599965
rejectedThenable.reason = error;
99609966
}
99619967
}
9962-
); // Check one more time in case the thenable resolved synchronously
9968+
);
9969+
} // Check one more time in case the thenable resolved synchronously
99639970

9964-
switch (thenable.status) {
9965-
case "fulfilled": {
9966-
var fulfilledThenable = thenable;
9967-
return fulfilledThenable.value;
9968-
}
9971+
switch (thenable.status) {
9972+
case "fulfilled": {
9973+
var fulfilledThenable = thenable;
9974+
return fulfilledThenable.value;
9975+
}
99699976

9970-
case "rejected": {
9971-
var rejectedThenable = thenable;
9972-
throw rejectedThenable.reason;
9973-
}
9977+
case "rejected": {
9978+
var rejectedThenable = thenable;
9979+
throw rejectedThenable.reason;
99749980
}
99759981
} // Suspend.
99769982
//

compiled/facebook-www/ReactDOMServer-dev.modern.js

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ if (__DEV__) {
1919
var React = require("react");
2020
var ReactDOM = require("react-dom");
2121

22-
var ReactVersion = "19.0.0-www-modern-d9b30156";
22+
var ReactVersion = "19.0.0-www-modern-72ca4dea";
2323

2424
// This refers to a WWW module.
2525
var warningWWW = require("warning");
@@ -9861,8 +9861,14 @@ if (__DEV__) {
98619861
}
98629862

98639863
default: {
9864-
if (typeof thenable.status === "string");
9865-
else {
9864+
if (typeof thenable.status === "string") {
9865+
// Only instrument the thenable if the status if not defined. If
9866+
// it's defined, but an unknown value, assume it's been instrumented by
9867+
// some custom userspace implementation. We treat it as "pending".
9868+
// Attach a dummy listener, to ensure that any lazy initialization can
9869+
// happen. Flight lazily parses JSON when the value is actually awaited.
9870+
thenable.then(noop$2, noop$2);
9871+
} else {
98669872
var pendingThenable = thenable;
98679873
pendingThenable.status = "pending";
98689874
pendingThenable.then(
@@ -9880,18 +9886,18 @@ if (__DEV__) {
98809886
rejectedThenable.reason = error;
98819887
}
98829888
}
9883-
); // Check one more time in case the thenable resolved synchronously
9889+
);
9890+
} // Check one more time in case the thenable resolved synchronously
98849891

9885-
switch (thenable.status) {
9886-
case "fulfilled": {
9887-
var fulfilledThenable = thenable;
9888-
return fulfilledThenable.value;
9889-
}
9892+
switch (thenable.status) {
9893+
case "fulfilled": {
9894+
var fulfilledThenable = thenable;
9895+
return fulfilledThenable.value;
9896+
}
98909897

9891-
case "rejected": {
9892-
var rejectedThenable = thenable;
9893-
throw rejectedThenable.reason;
9894-
}
9898+
case "rejected": {
9899+
var rejectedThenable = thenable;
9900+
throw rejectedThenable.reason;
98959901
}
98969902
} // Suspend.
98979903
//

compiled/facebook-www/ReactDOMServer-prod.classic.js

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2952,9 +2952,9 @@ function trackUsedThenable(thenableState, thenable, index) {
29522952
case "rejected":
29532953
throw thenable.reason;
29542954
default:
2955-
if ("string" !== typeof thenable.status)
2956-
switch (
2957-
((thenableState = thenable),
2955+
"string" === typeof thenable.status
2956+
? thenable.then(noop$2, noop$2)
2957+
: ((thenableState = thenable),
29582958
(thenableState.status = "pending"),
29592959
thenableState.then(
29602960
function (fulfilledValue) {
@@ -2971,14 +2971,13 @@ function trackUsedThenable(thenableState, thenable, index) {
29712971
rejectedThenable.reason = error;
29722972
}
29732973
}
2974-
),
2975-
thenable.status)
2976-
) {
2977-
case "fulfilled":
2978-
return thenable.value;
2979-
case "rejected":
2980-
throw thenable.reason;
2981-
}
2974+
));
2975+
switch (thenable.status) {
2976+
case "fulfilled":
2977+
return thenable.value;
2978+
case "rejected":
2979+
throw thenable.reason;
2980+
}
29822981
suspendedThenable = thenable;
29832982
throw SuspenseException;
29842983
}
@@ -5681,4 +5680,4 @@ exports.renderToString = function (children, options) {
56815680
'The server used "renderToString" which does not support Suspense. If you intended for this Suspense boundary to render the fallback content on the server consider throwing an Error somewhere within the Suspense boundary. If you intended to have the server wait for the suspended component please switch to "renderToReadableStream" which supports Suspense on the server'
56825681
);
56835682
};
5684-
exports.version = "19.0.0-www-classic-c54d680a";
5683+
exports.version = "19.0.0-www-classic-fef33f20";

compiled/facebook-www/ReactDOMServer-prod.modern.js

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2944,9 +2944,9 @@ function trackUsedThenable(thenableState, thenable, index) {
29442944
case "rejected":
29452945
throw thenable.reason;
29462946
default:
2947-
if ("string" !== typeof thenable.status)
2948-
switch (
2949-
((thenableState = thenable),
2947+
"string" === typeof thenable.status
2948+
? thenable.then(noop$2, noop$2)
2949+
: ((thenableState = thenable),
29502950
(thenableState.status = "pending"),
29512951
thenableState.then(
29522952
function (fulfilledValue) {
@@ -2963,14 +2963,13 @@ function trackUsedThenable(thenableState, thenable, index) {
29632963
rejectedThenable.reason = error;
29642964
}
29652965
}
2966-
),
2967-
thenable.status)
2968-
) {
2969-
case "fulfilled":
2970-
return thenable.value;
2971-
case "rejected":
2972-
throw thenable.reason;
2973-
}
2966+
));
2967+
switch (thenable.status) {
2968+
case "fulfilled":
2969+
return thenable.value;
2970+
case "rejected":
2971+
throw thenable.reason;
2972+
}
29742973
suspendedThenable = thenable;
29752974
throw SuspenseException;
29762975
}
@@ -5659,4 +5658,4 @@ exports.renderToString = function (children, options) {
56595658
'The server used "renderToString" which does not support Suspense. If you intended for this Suspense boundary to render the fallback content on the server consider throwing an Error somewhere within the Suspense boundary. If you intended to have the server wait for the suspended component please switch to "renderToReadableStream" which supports Suspense on the server'
56605659
);
56615660
};
5662-
exports.version = "19.0.0-www-modern-4944636f";
5661+
exports.version = "19.0.0-www-modern-39d2e934";

compiled/facebook-www/ReactDOMServerStreaming-dev.modern.js

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9743,8 +9743,14 @@ if (__DEV__) {
97439743
}
97449744

97459745
default: {
9746-
if (typeof thenable.status === "string");
9747-
else {
9746+
if (typeof thenable.status === "string") {
9747+
// Only instrument the thenable if the status if not defined. If
9748+
// it's defined, but an unknown value, assume it's been instrumented by
9749+
// some custom userspace implementation. We treat it as "pending".
9750+
// Attach a dummy listener, to ensure that any lazy initialization can
9751+
// happen. Flight lazily parses JSON when the value is actually awaited.
9752+
thenable.then(noop$2, noop$2);
9753+
} else {
97489754
var pendingThenable = thenable;
97499755
pendingThenable.status = "pending";
97509756
pendingThenable.then(
@@ -9762,18 +9768,18 @@ if (__DEV__) {
97629768
rejectedThenable.reason = error;
97639769
}
97649770
}
9765-
); // Check one more time in case the thenable resolved synchronously
9771+
);
9772+
} // Check one more time in case the thenable resolved synchronously
97669773

9767-
switch (thenable.status) {
9768-
case "fulfilled": {
9769-
var fulfilledThenable = thenable;
9770-
return fulfilledThenable.value;
9771-
}
9774+
switch (thenable.status) {
9775+
case "fulfilled": {
9776+
var fulfilledThenable = thenable;
9777+
return fulfilledThenable.value;
9778+
}
97729779

9773-
case "rejected": {
9774-
var rejectedThenable = thenable;
9775-
throw rejectedThenable.reason;
9776-
}
9780+
case "rejected": {
9781+
var rejectedThenable = thenable;
9782+
throw rejectedThenable.reason;
97779783
}
97789784
} // Suspend.
97799785
//

compiled/facebook-www/ReactDOMServerStreaming-prod.modern.js

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2818,9 +2818,9 @@ function trackUsedThenable(thenableState, thenable, index) {
28182818
case "rejected":
28192819
throw thenable.reason;
28202820
default:
2821-
if ("string" !== typeof thenable.status)
2822-
switch (
2823-
((thenableState = thenable),
2821+
"string" === typeof thenable.status
2822+
? thenable.then(noop$2, noop$2)
2823+
: ((thenableState = thenable),
28242824
(thenableState.status = "pending"),
28252825
thenableState.then(
28262826
function (fulfilledValue) {
@@ -2837,14 +2837,13 @@ function trackUsedThenable(thenableState, thenable, index) {
28372837
rejectedThenable.reason = error;
28382838
}
28392839
}
2840-
),
2841-
thenable.status)
2842-
) {
2843-
case "fulfilled":
2844-
return thenable.value;
2845-
case "rejected":
2846-
throw thenable.reason;
2847-
}
2840+
));
2841+
switch (thenable.status) {
2842+
case "fulfilled":
2843+
return thenable.value;
2844+
case "rejected":
2845+
throw thenable.reason;
2846+
}
28482847
suspendedThenable = thenable;
28492848
throw SuspenseException;
28502849
}

compiled/facebook-www/ReactFlightDOMClient-dev.modern.js

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,10 @@ if (__DEV__) {
388388
break;
389389

390390
default:
391-
reject(chunk.reason);
391+
if (reject) {
392+
reject(chunk.reason);
393+
}
394+
392395
break;
393396
}
394397
};
@@ -472,7 +475,6 @@ if (__DEV__) {
472475

473476
function triggerErrorOnChunk(chunk, error) {
474477
if (chunk.status !== PENDING && chunk.status !== BLOCKED) {
475-
// We already resolved. We didn't expect to see this.
476478
return;
477479
}
478480

@@ -503,7 +505,6 @@ if (__DEV__) {
503505

504506
function resolveModelChunk(chunk, value) {
505507
if (chunk.status !== PENDING) {
506-
// We already resolved. We didn't expect to see this.
507508
return;
508509
}
509510

@@ -816,6 +817,7 @@ if (__DEV__) {
816817
typeof chunkValue === "object" &&
817818
chunkValue !== null &&
818819
(Array.isArray(chunkValue) ||
820+
typeof chunkValue[ASYNC_ITERATOR] === "function" ||
819821
chunkValue.$$typeof === REACT_ELEMENT_TYPE) &&
820822
!chunkValue._debugInfo
821823
) {
@@ -1118,8 +1120,7 @@ if (__DEV__) {
11181120
}
11191121

11201122
function resolveText(response, id, text) {
1121-
var chunks = response._chunks; // We assume that we always reference large strings after they've been
1122-
// emitted.
1123+
var chunks = response._chunks;
11231124

11241125
chunks.set(id, createInitializedTextChunk(response, text));
11251126
}
@@ -1171,6 +1172,8 @@ if (__DEV__) {
11711172
}
11721173
}
11731174

1175+
var ASYNC_ITERATOR = Symbol.asyncIterator;
1176+
11741177
function resolveErrorDev(response, id, digest, message, stack) {
11751178
var error = new Error(
11761179
message ||
@@ -1275,6 +1278,26 @@ if (__DEV__) {
12751278
}
12761279
}
12771280

1281+
case 82:
1282+
/* "R" */
1283+
// Fallthrough
1284+
1285+
case 114:
1286+
/* "r" */
1287+
// Fallthrough
1288+
1289+
case 88:
1290+
/* "X" */
1291+
// Fallthrough
1292+
1293+
case 120:
1294+
/* "x" */
1295+
// Fallthrough
1296+
1297+
case 67:
1298+
/* "C" */
1299+
// Fallthrough
1300+
12781301
case 80:
12791302
/* "P" */
12801303
// Fallthrough
@@ -1330,9 +1353,12 @@ if (__DEV__) {
13301353
rowState = ROW_LENGTH;
13311354
i++;
13321355
} else if (
1333-
resolvedRowTag > 64 &&
1334-
resolvedRowTag < 91
1356+
(resolvedRowTag > 64 && resolvedRowTag < 91) ||
13351357
/* "A"-"Z" */
1358+
resolvedRowTag === 114 ||
1359+
/* "r" */
1360+
resolvedRowTag === 120
1361+
/* "x" */
13361362
) {
13371363
rowTag = resolvedRowTag;
13381364
rowState = ROW_CHUNK_BY_NEWLINE;

compiled/facebook-www/ReactFlightDOMClient-prod.modern.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Chunk.prototype.then = function (resolve, reject) {
7373
(null === this.reason && (this.reason = []), this.reason.push(reject));
7474
break;
7575
default:
76-
reject(this.reason);
76+
reject && reject(this.reason);
7777
}
7878
};
7979
function readChunk(chunk) {
@@ -438,7 +438,9 @@ function startReadingFromStream(response, stream) {
438438
rowState = value[i];
439439
84 === rowState
440440
? ((rowTag = rowState), (rowState = 2), i++)
441-
: 64 < rowState && 91 > rowState
441+
: (64 < rowState && 91 > rowState) ||
442+
114 === rowState ||
443+
120 === rowState
442444
? ((rowTag = rowState), (rowState = 3), i++)
443445
: ((rowTag = 0), (rowState = 3));
444446
continue;

0 commit comments

Comments
 (0)