@samitouri / QOS-React-2 / commits / 9d111ffdfb

Serialize Promises through Flight (#26086)

This lets you pass Promises from server components to client components and `use()` them there. We still don't support Promises as children on the client, so we need to support both. This will be a lot simpler when we remove the need to encode children as lazy since we don't need the lazy encoding anymore then. I noticed that this test failed because we don't synchronously resolve instrumented Promises if they're lazy. The second fix calls `.then()` early to ensure that this lazy initialization can happen eagerly. ~It felt silly to do this with an empty function or something, so I just did the attachment of ping listeners early here. It's also a little silly since they will ping the currently running render for no reason if it's synchronously available.~ EDIT: That didn't work because a ping might interrupt the current render. Probably need a bigger refactor. We could add another extension but we've already taken a lot of liberties with the Promise protocol. At least this is one that doesn't need extension of the protocol as much. Any sub-class of promises could do this.

Sebastian Markbåge committed Feb 1, 2023 at 12:56 UTC 9d111ffdfbcfee4b348a3d49c16f02cb718c896f
4 files changed +169 -15
packages/react-client/src/ReactFlightClient.js
+6
@@ -493,6 +493,12 @@ export function parseModelString(
493 // When passed into React, we'll know how to suspend on this.
494 return createLazyChunkWrapper(chunk);
495 }
496 + case '@': {
497 + // Promise
498 + const id = parseInt(value.substring(2), 16);
499 + const chunk = getChunk(response, id);
500 + return chunk;
501 + }
502 case 'S': {
503 return Symbol.for(value.substring(2));
504 }
packages/react-reconciler/src/ReactFiberThenable.js
+13 -10
@@ -88,6 +88,9 @@ export function trackUsedThenable<T>(
88 // Only instrument the thenable if the status if not defined. If
89 // it's defined, but an unknown value, assume it's been instrumented by
90 // some custom userspace implementation. We treat it as "pending".
91 + // Attach a dummy listener, to ensure that any lazy initialization can
92 + // happen. Flight lazily parses JSON when the value is actually awaited.
93 + thenable.then(noop, noop);
94 } else {
95 const pendingThenable: PendingThenable<T> = (thenable: any);
96 pendingThenable.status = 'pending';
@@ -107,17 +110,17 @@ export function trackUsedThenable<T>(
110 }
111 },
112 );
113 + }
114
111 - // Check one more time in case the thenable resolved synchronously
112 - switch (thenable.status) {
113 - case 'fulfilled': {
114 - const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
115 - return fulfilledThenable.value;
116 - }
117 - case 'rejected': {
118 - const rejectedThenable: RejectedThenable<T> = (thenable: any);
119 - throw rejectedThenable.reason;
120 - }
115 + // Check one more time in case the thenable resolved synchronously.
116 + switch (thenable.status) {
117 + case 'fulfilled': {
118 + const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
119 + return fulfilledThenable.value;
120 + }
121 + case 'rejected': {
122 + const rejectedThenable: RejectedThenable<T> = (thenable: any);
123 + throw rejectedThenable.reason;
124 }
125 }
126
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+46
@@ -905,4 +905,50 @@ describe('ReactFlightDOM', () => {
905
906 expect(reportedErrors).toEqual(['bug in the bundler']);
907 });
908 +
909 + // @gate enableUseHook
910 + it('should pass a Promise through props and be able use() it on the client', async () => {
911 + async function getData() {
912 + return 'async hello';
913 + }
914 +
915 + function Component({data}) {
916 + const text = use(data);
917 + return <p>{text}</p>;
918 + }
919 +
920 + const ClientComponent = clientExports(Component);
921 +
922 + function ServerComponent() {
923 + const data = getData(); // no await here
924 + return <ClientComponent data={data} />;
925 + }
926 +
927 + function Print({response}) {
928 + return use(response);
929 + }
930 +
931 + function App({response}) {
932 + return (
933 + <Suspense fallback={<h1>Loading...</h1>}>
934 + <Print response={response} />
935 + </Suspense>
936 + );
937 + }
938 +
939 + const {writable, readable} = getTestStream();
940 + const {pipe} = ReactServerDOMWriter.renderToPipeableStream(
941 + <ServerComponent />,
942 + webpackMap,
943 + );
944 + pipe(writable);
945 + const response = ReactServerDOMReader.createFromReadableStream(readable);
946 +
947 + const container = document.createElement('div');
948 + const root = ReactDOMClient.createRoot(container);
949 + await act(async () => {
950 + root.render(<App response={response} />);
951 + });
952 + expect(container.innerHTML).toBe('<p>async hello</p>');
953 + });
954 });
packages/react-server/src/ReactFlightServer.js
+104 -5
@@ -216,6 +216,82 @@ const POP = {};
216 const jsxPropsParents: WeakMap<any, any> = new WeakMap();
217 const jsxChildrenParents: WeakMap<any, any> = new WeakMap();
218
219 +function serializeThenable(request: Request, thenable: Thenable<any>): number {
220 + request.pendingChunks++;
221 + const newTask = createTask(
222 + request,
223 + null,
224 + getActiveContext(),
225 + request.abortableTasks,
226 + );
227 +
228 + switch (thenable.status) {
229 + case 'fulfilled': {
230 + // We have the resolved value, we can go ahead and schedule it for serialization.
231 + newTask.model = thenable.value;
232 + pingTask(request, newTask);
233 + return newTask.id;
234 + }
235 + case 'rejected': {
236 + const x = thenable.reason;
237 + const digest = logRecoverableError(request, x);
238 + if (__DEV__) {
239 + const {message, stack} = getErrorMessageAndStackDev(x);
240 + emitErrorChunkDev(request, newTask.id, digest, message, stack);
241 + } else {
242 + emitErrorChunkProd(request, newTask.id, digest);
243 + }
244 + return newTask.id;
245 + }
246 + default: {
247 + if (typeof thenable.status === 'string') {
248 + // Only instrument the thenable if the status if not defined. If
249 + // it's defined, but an unknown value, assume it's been instrumented by
250 + // some custom userspace implementation. We treat it as "pending".
251 + break;
252 + }
253 + const pendingThenable: PendingThenable<mixed> = (thenable: any);
254 + pendingThenable.status = 'pending';
255 + pendingThenable.then(
256 + fulfilledValue => {
257 + if (thenable.status === 'pending') {
258 + const fulfilledThenable: FulfilledThenable<mixed> = (thenable: any);
259 + fulfilledThenable.status = 'fulfilled';
260 + fulfilledThenable.value = fulfilledValue;
261 + }
262 + },
263 + (error: mixed) => {
264 + if (thenable.status === 'pending') {
265 + const rejectedThenable: RejectedThenable<mixed> = (thenable: any);
266 + rejectedThenable.status = 'rejected';
267 + rejectedThenable.reason = error;
268 + }
269 + },
270 + );
271 + break;
272 + }
273 + }
274 +
275 + thenable.then(
276 + value => {
277 + newTask.model = value;
278 + pingTask(request, newTask);
279 + },
280 + reason => {
281 + // TODO: Is it safe to directly emit these without being inside a retry?
282 + const digest = logRecoverableError(request, reason);
283 + if (__DEV__) {
284 + const {message, stack} = getErrorMessageAndStackDev(reason);
285 + emitErrorChunkDev(request, newTask.id, digest, message, stack);
286 + } else {
287 + emitErrorChunkProd(request, newTask.id, digest);
288 + }
289 + },
290 + );
291 +
292 + return newTask.id;
293 +}
294 +
295 function readThenable<T>(thenable: Thenable<T>): T {
296 if (thenable.status === 'fulfilled') {
297 return thenable.value;
@@ -270,6 +346,7 @@ function createLazyWrapperAroundWakeable(wakeable: Wakeable) {
346 }
347
348 function attemptResolveElement(
349 + request: Request,
350 type: any,
351 key: null | React$Key,
352 ref: mixed,
@@ -303,6 +380,14 @@ function attemptResolveElement(
380 result !== null &&
381 typeof result.then === 'function'
382 ) {
383 + // When the return value is in children position we can resolve it immediately,
384 + // to its value without a wrapper if it's synchronously available.
385 + const thenable: Thenable<any> = result;
386 + if (thenable.status === 'fulfilled') {
387 + return thenable.value;
388 + }
389 + // TODO: Once we accept Promises as children on the client, we can just return
390 + // the thenable here.
391 return createLazyWrapperAroundWakeable(result);
392 }
393 return result;
@@ -331,6 +416,7 @@ function attemptResolveElement(
416 const init = type._init;
417 const wrappedType = init(payload);
418 return attemptResolveElement(
419 + request,
420 wrappedType,
421 key,
422 ref,
@@ -345,6 +431,7 @@ function attemptResolveElement(
431 }
432 case REACT_MEMO_TYPE: {
433 return attemptResolveElement(
434 + request,
435 type.type,
436 key,
437 ref,
@@ -414,10 +501,14 @@ function serializeByValueID(id: number): string {
501 return '$' + id.toString(16);
502 }
503
417 -function serializeByRefID(id: number): string {
504 +function serializeLazyID(id: number): string {
505 return '$L' + id.toString(16);
506 }
507
508 +function serializePromiseID(id: number): string {
509 + return '$@' + id.toString(16);
510 +}
511 +
512 function serializeSymbolReference(name: string): string {
513 return '$S' + name;
514 }
@@ -442,7 +533,7 @@ function serializeClientReference(
533 // knows how to deal with lazy values. This lets us suspend
534 // on this component rather than its parent until the code has
535 // loaded.
445 - return serializeByRefID(existingId);
536 + return serializeLazyID(existingId);
537 }
538 return serializeByValueID(existingId);
539 }
@@ -461,7 +552,7 @@ function serializeClientReference(
552 // knows how to deal with lazy values. This lets us suspend
553 // on this component rather than its parent until the code has
554 // loaded.
464 - return serializeByRefID(moduleId);
555 + return serializeLazyID(moduleId);
556 }
557 return serializeByValueID(moduleId);
558 } catch (x) {
@@ -835,6 +926,7 @@ export function resolveModelToJSON(
926 const element: React$Element<any> = (value: any);
927 // Attempt to render the Server Component.
928 value = attemptResolveElement(
929 + request,
930 element.type,
931 element.key,
932 element.ref,
@@ -873,7 +965,7 @@ export function resolveModelToJSON(
965 const ping = newTask.ping;
966 x.then(ping, ping);
967 newTask.thenableState = getThenableStateAfterSuspending();
876 - return serializeByRefID(newTask.id);
968 + return serializeLazyID(newTask.id);
969 } else {
970 // Something errored. We'll still send everything we have up until this point.
971 // We'll replace this element with a lazy reference that throws on the client
@@ -887,7 +979,7 @@ export function resolveModelToJSON(
979 } else {
980 emitErrorChunkProd(request, errorId, digest);
981 }
890 - return serializeByRefID(errorId);
982 + return serializeLazyID(errorId);
983 }
984 }
985 }
@@ -899,6 +991,11 @@ export function resolveModelToJSON(
991 if (typeof value === 'object') {
992 if (isClientReference(value)) {
993 return serializeClientReference(request, parent, key, (value: any));
994 + } else if (typeof value.then === 'function') {
995 + // We assume that any object with a .then property is a "Thenable" type,
996 + // or a Promise type. Either of which can be represented by a Promise.
997 + const promiseId = serializeThenable(request, (value: any));
998 + return serializePromiseID(promiseId);
999 } else if ((value: any).$$typeof === REACT_PROVIDER_TYPE) {
1000 const providerKey = ((value: any): ReactProviderType<any>)._context
1001 ._globalName;
@@ -1157,6 +1254,7 @@ function retryTask(request: Request, task: Task): void {
1254 // also suspends.
1255 task.model = value;
1256 value = attemptResolveElement(
1257 + request,
1258 element.type,
1259 element.key,
1260 element.ref,
@@ -1180,6 +1278,7 @@ function retryTask(request: Request, task: Task): void {
1278 const nextElement: React$Element<any> = (value: any);
1279 task.model = value;
1280 value = attemptResolveElement(
1281 + request,
1282 nextElement.type,
1283 nextElement.key,
1284 nextElement.ref,