Skip to content

Commit 9d111ff

Browse files
authored
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.
1 parent 0ba4698 commit 9d111ff

File tree

4 files changed

+169
-15
lines changed

4 files changed

+169
-15
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,12 @@ export function parseModelString(
493493
// When passed into React, we'll know how to suspend on this.
494494
return createLazyChunkWrapper(chunk);
495495
}
496+
case '@': {
497+
// Promise
498+
const id = parseInt(value.substring(2), 16);
499+
const chunk = getChunk(response, id);
500+
return chunk;
501+
}
496502
case 'S': {
497503
return Symbol.for(value.substring(2));
498504
}

packages/react-reconciler/src/ReactFiberThenable.js

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ export function trackUsedThenable<T>(
8888
// Only instrument the thenable if the status if not defined. If
8989
// it's defined, but an unknown value, assume it's been instrumented by
9090
// 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);
9194
} else {
9295
const pendingThenable: PendingThenable<T> = (thenable: any);
9396
pendingThenable.status = 'pending';
@@ -107,17 +110,17 @@ export function trackUsedThenable<T>(
107110
}
108111
},
109112
);
113+
}
110114

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;
121124
}
122125
}
123126

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,4 +905,50 @@ describe('ReactFlightDOM', () => {
905905

906906
expect(reportedErrors).toEqual(['bug in the bundler']);
907907
});
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+
});
908954
});

packages/react-server/src/ReactFlightServer.js

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,82 @@ const POP = {};
216216
const jsxPropsParents: WeakMap<any, any> = new WeakMap();
217217
const jsxChildrenParents: WeakMap<any, any> = new WeakMap();
218218

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+
219295
function readThenable<T>(thenable: Thenable<T>): T {
220296
if (thenable.status === 'fulfilled') {
221297
return thenable.value;
@@ -270,6 +346,7 @@ function createLazyWrapperAroundWakeable(wakeable: Wakeable) {
270346
}
271347

272348
function attemptResolveElement(
349+
request: Request,
273350
type: any,
274351
key: null | React$Key,
275352
ref: mixed,
@@ -303,6 +380,14 @@ function attemptResolveElement(
303380
result !== null &&
304381
typeof result.then === 'function'
305382
) {
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.
306391
return createLazyWrapperAroundWakeable(result);
307392
}
308393
return result;
@@ -331,6 +416,7 @@ function attemptResolveElement(
331416
const init = type._init;
332417
const wrappedType = init(payload);
333418
return attemptResolveElement(
419+
request,
334420
wrappedType,
335421
key,
336422
ref,
@@ -345,6 +431,7 @@ function attemptResolveElement(
345431
}
346432
case REACT_MEMO_TYPE: {
347433
return attemptResolveElement(
434+
request,
348435
type.type,
349436
key,
350437
ref,
@@ -414,10 +501,14 @@ function serializeByValueID(id: number): string {
414501
return '$' + id.toString(16);
415502
}
416503

417-
function serializeByRefID(id: number): string {
504+
function serializeLazyID(id: number): string {
418505
return '$L' + id.toString(16);
419506
}
420507

508+
function serializePromiseID(id: number): string {
509+
return '$@' + id.toString(16);
510+
}
511+
421512
function serializeSymbolReference(name: string): string {
422513
return '$S' + name;
423514
}
@@ -442,7 +533,7 @@ function serializeClientReference(
442533
// knows how to deal with lazy values. This lets us suspend
443534
// on this component rather than its parent until the code has
444535
// loaded.
445-
return serializeByRefID(existingId);
536+
return serializeLazyID(existingId);
446537
}
447538
return serializeByValueID(existingId);
448539
}
@@ -461,7 +552,7 @@ function serializeClientReference(
461552
// knows how to deal with lazy values. This lets us suspend
462553
// on this component rather than its parent until the code has
463554
// loaded.
464-
return serializeByRefID(moduleId);
555+
return serializeLazyID(moduleId);
465556
}
466557
return serializeByValueID(moduleId);
467558
} catch (x) {
@@ -835,6 +926,7 @@ export function resolveModelToJSON(
835926
const element: React$Element<any> = (value: any);
836927
// Attempt to render the Server Component.
837928
value = attemptResolveElement(
929+
request,
838930
element.type,
839931
element.key,
840932
element.ref,
@@ -873,7 +965,7 @@ export function resolveModelToJSON(
873965
const ping = newTask.ping;
874966
x.then(ping, ping);
875967
newTask.thenableState = getThenableStateAfterSuspending();
876-
return serializeByRefID(newTask.id);
968+
return serializeLazyID(newTask.id);
877969
} else {
878970
// Something errored. We'll still send everything we have up until this point.
879971
// We'll replace this element with a lazy reference that throws on the client
@@ -887,7 +979,7 @@ export function resolveModelToJSON(
887979
} else {
888980
emitErrorChunkProd(request, errorId, digest);
889981
}
890-
return serializeByRefID(errorId);
982+
return serializeLazyID(errorId);
891983
}
892984
}
893985
}
@@ -899,6 +991,11 @@ export function resolveModelToJSON(
899991
if (typeof value === 'object') {
900992
if (isClientReference(value)) {
901993
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);
902999
} else if ((value: any).$$typeof === REACT_PROVIDER_TYPE) {
9031000
const providerKey = ((value: any): ReactProviderType<any>)._context
9041001
._globalName;
@@ -1157,6 +1254,7 @@ function retryTask(request: Request, task: Task): void {
11571254
// also suspends.
11581255
task.model = value;
11591256
value = attemptResolveElement(
1257+
request,
11601258
element.type,
11611259
element.key,
11621260
element.ref,
@@ -1180,6 +1278,7 @@ function retryTask(request: Request, task: Task): void {
11801278
const nextElement: React$Element<any> = (value: any);
11811279
task.model = value;
11821280
value = attemptResolveElement(
1281+
request,
11831282
nextElement.type,
11841283
nextElement.key,
11851284
nextElement.ref,

0 commit comments

Comments
 (0)