experimental_use(promise) for Server Components (#25207)
Follow up to #25084. Implements experimental_use(promise) API in the Server Components runtime (Flight). The implementation is much simpler than in Fiber because there is no state. Even the "state" added in this PR — to track the result of each promise across attempts — is reset as soon as a component successfully renders without suspending. There are also fewer caveats around neglecting to cache a promise because the state of the promises is preserved even if we switch to a different task. Server Components is the primary runtime where this API is intended to be used. The last runtime where we need to implement this is the server renderer (Fizz).
Andrew Clark committed
Sep 7, 2022 at 22:27 UTC
7028ce74561b1585de401f8a70ce79b98d63849d
7 files changed
+425
-4
packages/react-reconciler/src/ReactFiberWakeable.new.js
+4
@@ -18,6 +18,7 @@ import type {
18
let suspendedThenable: Thenable<mixed> | null = null;
19
let adHocSuspendCount: number = 0;
20
21
+// TODO: Sparse arrays are bad for performance.
22
let usedThenables: Array<Thenable<any> | void> | null = null;
23
let lastUsedThenable: Thenable<any> | null = null;
24
@@ -74,6 +75,9 @@ export function trackSuspendedWakeable(wakeable: Wakeable) {
75
suspendedThenable = null;
76
break;
77
default: {
78
+ // TODO: Only instrument the thenable if the status if not defined. If
79
+ // it's defined, but an unknown value, assume it's been instrumented by
80
+ // some custom userspace implementation.
81
const pendingThenable: PendingThenable<mixed> = (thenable: any);
82
pendingThenable.status = 'pending';
83
pendingThenable.then(
packages/react-reconciler/src/ReactFiberWakeable.old.js
+4
@@ -18,6 +18,7 @@ import type {
18
let suspendedThenable: Thenable<mixed> | null = null;
19
let adHocSuspendCount: number = 0;
20
21
+// TODO: Sparse arrays are bad for performance.
22
let usedThenables: Array<Thenable<any> | void> | null = null;
23
let lastUsedThenable: Thenable<any> | null = null;
24
@@ -74,6 +75,9 @@ export function trackSuspendedWakeable(wakeable: Wakeable) {
75
suspendedThenable = null;
76
break;
77
default: {
78
+ // TODO: Only instrument the thenable if the status if not defined. If
79
+ // it's defined, but an unknown value, assume it's been instrumented by
80
+ // some custom userspace implementation.
81
const pendingThenable: PendingThenable<mixed> = (thenable: any);
82
pendingThenable.status = 'pending';
83
pendingThenable.then(
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+147
@@ -24,6 +24,7 @@ let ReactDOMServer;
24
let ReactServerDOMWriter;
25
let ReactServerDOMReader;
26
let Suspense;
27
+let use;
28
29
describe('ReactFlightDOMBrowser', () => {
30
beforeEach(() => {
@@ -39,6 +40,7 @@ describe('ReactFlightDOMBrowser', () => {
40
ReactServerDOMWriter = require('react-server-dom-webpack/writer.browser.server');
41
ReactServerDOMReader = require('react-server-dom-webpack');
42
Suspense = React.Suspense;
43
+ use = React.experimental_use;
44
});
45
46
async function waitForSuspense(fn) {
@@ -562,4 +564,149 @@ describe('ReactFlightDOMBrowser', () => {
564
565
expect(reportedErrors).toEqual(['for reasons']);
566
});
567
+
568
+ // @gate enableUseHook
569
+ it('basic use(promise)', async () => {
570
+ function Server() {
571
+ return (
572
+ use(Promise.resolve('A')) +
573
+ use(Promise.resolve('B')) +
574
+ use(Promise.resolve('C'))
575
+ );
576
+ }
577
+
578
+ const stream = ReactServerDOMWriter.renderToReadableStream(<Server />);
579
+ const response = ReactServerDOMReader.createFromReadableStream(stream);
580
+
581
+ function Client() {
582
+ return response.readRoot();
583
+ }
584
+
585
+ const container = document.createElement('div');
586
+ const root = ReactDOMClient.createRoot(container);
587
+ await act(async () => {
588
+ root.render(
589
+ <Suspense fallback="Loading...">
590
+ <Client />
591
+ </Suspense>,
592
+ );
593
+ });
594
+ expect(container.innerHTML).toBe('ABC');
595
+ });
596
+
597
+ // @gate enableUseHook
598
+ it('use(promise) in multiple components', async () => {
599
+ function Child({prefix}) {
600
+ return prefix + use(Promise.resolve('C')) + use(Promise.resolve('D'));
601
+ }
602
+
603
+ function Parent() {
604
+ return (
605
+ <Child prefix={use(Promise.resolve('A')) + use(Promise.resolve('B'))} />
606
+ );
607
+ }
608
+
609
+ const stream = ReactServerDOMWriter.renderToReadableStream(<Parent />);
610
+ const response = ReactServerDOMReader.createFromReadableStream(stream);
611
+
612
+ function Client() {
613
+ return response.readRoot();
614
+ }
615
+
616
+ const container = document.createElement('div');
617
+ const root = ReactDOMClient.createRoot(container);
618
+ await act(async () => {
619
+ root.render(
620
+ <Suspense fallback="Loading...">
621
+ <Client />
622
+ </Suspense>,
623
+ );
624
+ });
625
+ expect(container.innerHTML).toBe('ABCD');
626
+ });
627
+
628
+ // @gate enableUseHook
629
+ it('using a rejected promise will throw', async () => {
630
+ const promiseA = Promise.resolve('A');
631
+ const promiseB = Promise.reject(new Error('Oops!'));
632
+ const promiseC = Promise.resolve('C');
633
+
634
+ // Jest/Node will raise an unhandled rejected error unless we await this. It
635
+ // works fine in the browser, though.
636
+ await expect(promiseB).rejects.toThrow('Oops!');
637
+
638
+ function Server() {
639
+ return use(promiseA) + use(promiseB) + use(promiseC);
640
+ }
641
+
642
+ const reportedErrors = [];
643
+ const stream = ReactServerDOMWriter.renderToReadableStream(
644
+ <Server />,
645
+ webpackMap,
646
+ {
647
+ onError(x) {
648
+ reportedErrors.push(x);
649
+ },
650
+ },
651
+ );
652
+ const response = ReactServerDOMReader.createFromReadableStream(stream);
653
+
654
+ class ErrorBoundary extends React.Component {
655
+ state = {error: null};
656
+ static getDerivedStateFromError(error) {
657
+ return {error};
658
+ }
659
+ render() {
660
+ if (this.state.error) {
661
+ return this.state.error.message;
662
+ }
663
+ return this.props.children;
664
+ }
665
+ }
666
+
667
+ function Client() {
668
+ return response.readRoot();
669
+ }
670
+
671
+ const container = document.createElement('div');
672
+ const root = ReactDOMClient.createRoot(container);
673
+ await act(async () => {
674
+ root.render(
675
+ <ErrorBoundary>
676
+ <Client />
677
+ </ErrorBoundary>,
678
+ );
679
+ });
680
+ expect(container.innerHTML).toBe('Oops!');
681
+ expect(reportedErrors.length).toBe(1);
682
+ expect(reportedErrors[0].message).toBe('Oops!');
683
+ });
684
+
685
+ // @gate enableUseHook
686
+ it("use a promise that's already been instrumented and resolved", async () => {
687
+ const thenable = {
688
+ status: 'fulfilled',
689
+ value: 'Hi',
690
+ then() {},
691
+ };
692
+
693
+ // This will never suspend because the thenable already resolved
694
+ function Server() {
695
+ return use(thenable);
696
+ }
697
+
698
+ const stream = ReactServerDOMWriter.renderToReadableStream(<Server />);
699
+ const response = ReactServerDOMReader.createFromReadableStream(stream);
700
+
701
+ function Client() {
702
+ return response.readRoot();
703
+ }
704
+
705
+ const container = document.createElement('div');
706
+ const root = ReactDOMClient.createRoot(container);
707
+ await act(async () => {
708
+ root.render(<Client />);
709
+ });
710
+ expect(container.innerHTML).toBe('Hi');
711
+ });
712
});
packages/react-server/src/ReactFlightHooks.js
+91
-1
@@ -9,11 +9,20 @@
9
10
import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactInternalTypes';
11
import type {Request} from './ReactFlightServer';
12
-import type {ReactServerContext} from 'shared/ReactTypes';
12
+import type {ReactServerContext, Thenable, Usable} from 'shared/ReactTypes';
13
+import type {ThenableState} from './ReactFlightWakeable';
14
import {REACT_SERVER_CONTEXT_TYPE} from 'shared/ReactSymbols';
15
import {readContext as readContextImpl} from './ReactFlightNewContext';
16
+import {enableUseHook} from 'shared/ReactFeatureFlags';
17
+import {
18
+ getPreviouslyUsedThenableAtIndex,
19
+ createThenableState,
20
+ trackUsedThenable,
21
+} from './ReactFlightWakeable';
22
23
let currentRequest = null;
24
+let thenableIndexCounter = 0;
25
+let thenableState = null;
26
27
export function prepareToUseHooksForRequest(request: Request) {
28
currentRequest = request;
@@ -23,6 +32,17 @@ export function resetHooksForRequest() {
32
currentRequest = null;
33
}
34
35
+export function prepareToUseHooksForComponent(
36
+ prevThenableState: ThenableState | null,
37
+) {
38
+ thenableIndexCounter = 0;
39
+ thenableState = prevThenableState;
40
+}
41
+
42
+export function getThenableStateAfterSuspending() {
43
+ return thenableState;
44
+}
45
+
46
function readContext<T>(context: ReactServerContext<T>): T {
47
if (__DEV__) {
48
if (context.$$typeof !== REACT_SERVER_CONTEXT_TYPE) {
@@ -83,6 +103,7 @@ export const Dispatcher: DispatcherType = {
103
useMemoCache(size: number): Array<any> {
104
return new Array(size);
105
},
106
+ use: enableUseHook ? use : (unsupportedHook: any),
107
};
108
109
function unsupportedHook(): void {
@@ -116,3 +137,72 @@ function useId(): string {
137
// use 'S' for Flight components to distinguish from 'R' and 'r' in Fizz/Client
138
return ':' + currentRequest.identifierPrefix + 'S' + id.toString(32) + ':';
139
}
140
+
141
+function use<T>(usable: Usable<T>): T {
142
+ if (usable !== null && typeof usable === 'object') {
143
+ if (typeof usable.then === 'function') {
144
+ // This is a thenable.
145
+ const thenable: Thenable<T> = (usable: any);
146
+
147
+ // Track the position of the thenable within this fiber.
148
+ const index = thenableIndexCounter;
149
+ thenableIndexCounter += 1;
150
+
151
+ switch (thenable.status) {
152
+ case 'fulfilled': {
153
+ const fulfilledValue: T = thenable.value;
154
+ return fulfilledValue;
155
+ }
156
+ case 'rejected': {
157
+ const rejectedError = thenable.reason;
158
+ throw rejectedError;
159
+ }
160
+ default: {
161
+ const prevThenableAtIndex: Thenable<T> | null = getPreviouslyUsedThenableAtIndex(
162
+ thenableState,
163
+ index,
164
+ );
165
+ if (prevThenableAtIndex !== null) {
166
+ switch (prevThenableAtIndex.status) {
167
+ case 'fulfilled': {
168
+ const fulfilledValue: T = prevThenableAtIndex.value;
169
+ return fulfilledValue;
170
+ }
171
+ case 'rejected': {
172
+ const rejectedError: mixed = prevThenableAtIndex.reason;
173
+ throw rejectedError;
174
+ }
175
+ default: {
176
+ // The thenable still hasn't resolved. Suspend with the same
177
+ // thenable as last time to avoid redundant listeners.
178
+ throw prevThenableAtIndex;
179
+ }
180
+ }
181
+ } else {
182
+ // This is the first time something has been used at this index.
183
+ // Stash the thenable at the current index so we can reuse it during
184
+ // the next attempt.
185
+ if (thenableState === null) {
186
+ thenableState = createThenableState();
187
+ }
188
+ trackUsedThenable(thenableState, thenable, index);
189
+
190
+ // Suspend.
191
+ // TODO: Throwing here is an implementation detail that allows us to
192
+ // unwind the call stack. But we shouldn't allow it to leak into
193
+ // userspace. Throw an opaque placeholder value instead of the
194
+ // actual thenable. If it doesn't get captured by the work loop, log
195
+ // a warning, because that means something in userspace must have
196
+ // caught it.
197
+ throw thenable;
198
+ }
199
+ }
200
+ }
201
+ } else {
202
+ // TODO: Add support for Context
203
+ }
204
+ }
205
+
206
+ // eslint-disable-next-line react-internal/safe-string-coercion
207
+ throw new Error('An unsupported type was passed to use(): ' + String(usable));
208
+}
packages/react-server/src/ReactFlightServer.js
+67
-3
@@ -16,9 +16,11 @@ import type {
16
ModuleKey,
17
} from './ReactFlightServerConfig';
18
import type {ContextSnapshot} from './ReactFlightNewContext';
19
+import type {ThenableState} from './ReactFlightWakeable';
20
import type {
21
ReactProviderType,
22
ServerContextJSONValue,
23
+ Wakeable,
24
} from 'shared/ReactTypes';
25
26
import {
@@ -44,6 +46,8 @@ import {
46
Dispatcher,
47
getCurrentCache,
48
prepareToUseHooksForRequest,
49
+ prepareToUseHooksForComponent,
50
+ getThenableStateAfterSuspending,
51
resetHooksForRequest,
52
setCurrentCache,
53
} from './ReactFlightHooks';
@@ -54,6 +58,7 @@ import {
58
getActiveContext,
59
rootContextSnapshot,
60
} from './ReactFlightNewContext';
61
+import {trackSuspendedWakeable} from './ReactFlightWakeable';
62
63
import {
64
REACT_ELEMENT_TYPE,
@@ -98,6 +103,7 @@ type Task = {
103
model: ReactModel,
104
ping: () => void,
105
context: ContextSnapshot,
106
+ thenableState: ThenableState | null,
107
};
108
109
export type Request = {
@@ -185,6 +191,7 @@ function attemptResolveElement(
191
key: null | React$Key,
192
ref: mixed,
193
props: any,
194
+ prevThenableState: ThenableState | null,
195
): ReactModel {
196
if (ref !== null && ref !== undefined) {
197
// When the ref moves to the regular props object this will implicitly
@@ -200,6 +207,7 @@ function attemptResolveElement(
207
return [REACT_ELEMENT_TYPE, type, key, props];
208
}
209
// This is a server-side component.
210
+ prepareToUseHooksForComponent(prevThenableState);
211
return type(props);
212
} else if (typeof type === 'string') {
213
// This is a host element. E.g. HTML.
@@ -225,14 +233,27 @@ function attemptResolveElement(
233
const payload = type._payload;
234
const init = type._init;
235
const wrappedType = init(payload);
228
- return attemptResolveElement(wrappedType, key, ref, props);
236
+ return attemptResolveElement(
237
+ wrappedType,
238
+ key,
239
+ ref,
240
+ props,
241
+ prevThenableState,
242
+ );
243
}
244
case REACT_FORWARD_REF_TYPE: {
245
const render = type.render;
246
+ prepareToUseHooksForComponent(prevThenableState);
247
return render(props, undefined);
248
}
249
case REACT_MEMO_TYPE: {
235
- return attemptResolveElement(type.type, key, ref, props);
250
+ return attemptResolveElement(
251
+ type.type,
252
+ key,
253
+ ref,
254
+ props,
255
+ prevThenableState,
256
+ );
257
}
258
case REACT_PROVIDER_TYPE: {
259
pushProvider(type._context, props.value);
@@ -286,6 +307,7 @@ function createTask(
307
model,
308
context,
309
ping: () => pingTask(request, task),
310
+ thenableState: null,
311
};
312
abortSet.add(task);
313
return task;
@@ -569,6 +591,7 @@ export function resolveModelToJSON(
591
element.key,
592
element.ref,
593
element.props,
594
+ null,
595
);
596
break;
597
}
@@ -591,6 +614,11 @@ export function resolveModelToJSON(
614
);
615
const ping = newTask.ping;
616
x.then(ping, ping);
617
+
618
+ const wakeable: Wakeable = x;
619
+ trackSuspendedWakeable(wakeable);
620
+ newTask.thenableState = getThenableStateAfterSuspending();
621
+
622
return serializeByRefID(newTask.id);
623
} else {
624
logRecoverableError(request, x);
@@ -828,16 +856,22 @@ function retryTask(request: Request, task: Task): void {
856
// We completed this by other means before we had a chance to retry it.
857
return;
858
}
859
+
860
switchContext(task.context);
861
try {
862
let value = task.model;
834
- while (
863
+ if (
864
typeof value === 'object' &&
865
value !== null &&
866
(value: any).$$typeof === REACT_ELEMENT_TYPE
867
) {
868
// TODO: Concatenate keys of parents onto children.
869
const element: React$Element<any> = (value: any);
870
+
871
+ // When retrying a component, reuse the thenableState from the
872
+ // previous attempt.
873
+ const prevThenableState = task.thenableState;
874
+
875
// Attempt to render the server component.
876
// Doing this here lets us reuse this same task if the next component
877
// also suspends.
@@ -847,8 +881,34 @@ function retryTask(request: Request, task: Task): void {
881
element.key,
882
element.ref,
883
element.props,
884
+ prevThenableState,
885
);
886
+
887
+ // Successfully finished this component. We're going to keep rendering
888
+ // using the same task, but we reset its thenable state before continuing.
889
+ task.thenableState = null;
890
+
891
+ // Keep rendering and reuse the same task. This inner loop is separate
892
+ // from the render above because we don't need to reset the thenable state
893
+ // until the next time something suspends and retries.
894
+ while (
895
+ typeof value === 'object' &&
896
+ value !== null &&
897
+ (value: any).$$typeof === REACT_ELEMENT_TYPE
898
+ ) {
899
+ // TODO: Concatenate keys of parents onto children.
900
+ const nextElement: React$Element<any> = (value: any);
901
+ task.model = value;
902
+ value = attemptResolveElement(
903
+ nextElement.type,
904
+ nextElement.key,
905
+ nextElement.ref,
906
+ nextElement.props,
907
+ null,
908
+ );
909
+ }
910
}
911
+
912
const processedChunk = processModelChunk(request, task.id, value);
913
request.completedJSONChunks.push(processedChunk);
914
request.abortableTasks.delete(task);
@@ -858,6 +918,10 @@ function retryTask(request: Request, task: Task): void {
918
// Something suspended again, let's pick it back up later.
919
const ping = task.ping;
920
x.then(ping, ping);
921
+
922
+ const wakeable: Wakeable = x;
923
+ trackSuspendedWakeable(wakeable);
924
+ task.thenableState = getThenableStateAfterSuspending();
925
return;
926
} else {
927
request.abortableTasks.delete(task);
packages/react-server/src/ReactFlightWakeable.js
new
+111
@@ -0,0 +1,111 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+// Corresponds to ReactFiberWakeable module. Generally, changes to one module
11
+// should be reflected in the other.
12
+
13
+// TODO: Rename this module and the corresponding Fiber one to "Thenable"
14
+// instead of "Wakeable". Or some other more appropriate name.
15
+
16
+import type {
17
+ Wakeable,
18
+ Thenable,
19
+ PendingThenable,
20
+ FulfilledThenable,
21
+ RejectedThenable,
22
+} from 'shared/ReactTypes';
23
+
24
+// TODO: Sparse arrays are bad for performance.
25
+export opaque type ThenableState = Array<Thenable<any> | void>;
26
+
27
+export function createThenableState(): ThenableState {
28
+ // The ThenableState is created the first time a component suspends. If it
29
+ // suspends again, we'll reuse the same state.
30
+ return [];
31
+}
32
+
33
+export function trackSuspendedWakeable(wakeable: Wakeable) {
34
+ // If this wakeable isn't already a thenable, turn it into one now. Then,
35
+ // when we resume the work loop, we can check if its status is
36
+ // still pending.
37
+ // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable.
38
+ const thenable: Thenable<mixed> = (wakeable: any);
39
+
40
+ // We use an expando to track the status and result of a thenable so that we
41
+ // can synchronously unwrap the value. Think of this as an extension of the
42
+ // Promise API, or a custom interface that is a superset of Thenable.
43
+ //
44
+ // If the thenable doesn't have a status, set it to "pending" and attach
45
+ // a listener that will update its status and result when it resolves.
46
+ switch (thenable.status) {
47
+ case 'pending':
48
+ // Since the status is already "pending", we can assume it will be updated
49
+ // when it resolves, either by React or something in userspace.
50
+ break;
51
+ case 'fulfilled':
52
+ case 'rejected':
53
+ // A thenable that already resolved shouldn't have been thrown, so this is
54
+ // unexpected. Suggests a mistake in a userspace data library. Don't track
55
+ // this thenable, because if we keep trying it will likely infinite loop
56
+ // without ever resolving.
57
+ // TODO: Log a warning?
58
+ break;
59
+ default: {
60
+ // TODO: Only instrument the thenable if the status if not defined. If
61
+ // it's defined, but an unknown value, assume it's been instrumented by
62
+ // some custom userspace implementation.
63
+ const pendingThenable: PendingThenable<mixed> = (thenable: any);
64
+ pendingThenable.status = 'pending';
65
+ pendingThenable.then(
66
+ fulfilledValue => {
67
+ if (thenable.status === 'pending') {
68
+ const fulfilledThenable: FulfilledThenable<mixed> = (thenable: any);
69
+ fulfilledThenable.status = 'fulfilled';
70
+ fulfilledThenable.value = fulfilledValue;
71
+ }
72
+ },
73
+ (error: mixed) => {
74
+ if (thenable.status === 'pending') {
75
+ const rejectedThenable: RejectedThenable<mixed> = (thenable: any);
76
+ rejectedThenable.status = 'rejected';
77
+ rejectedThenable.reason = error;
78
+ }
79
+ },
80
+ );
81
+ break;
82
+ }
83
+ }
84
+}
85
+
86
+export function trackUsedThenable<T>(
87
+ thenableState: ThenableState,
88
+ thenable: Thenable<T>,
89
+ index: number,
90
+) {
91
+ // This is only a separate function from trackSuspendedWakeable for symmetry
92
+ // with Fiber.
93
+ // TODO: Disallow throwing a thenable directly. It must go through `use` (or
94
+ // some equivalent for internal Suspense implementations). We can't do this in
95
+ // Fiber yet because it's a breaking change but we can do it in Server
96
+ // Components because Server Components aren't released yet.
97
+ thenableState[index] = thenable;
98
+}
99
+
100
+export function getPreviouslyUsedThenableAtIndex<T>(
101
+ thenableState: ThenableState | null,
102
+ index: number,
103
+): Thenable<T> | null {
104
+ if (thenableState !== null) {
105
+ const thenable = thenableState[index];
106
+ if (thenable !== undefined) {
107
+ return thenable;
108
+ }
109
+ }
110
+ return null;
111
+}
packages/react/src/ReactSharedSubset.experimental.js
+1
@@ -20,6 +20,7 @@ export {
20
createMutableSource as unstable_createMutableSource,
21
createRef,
22
createServerContext,
23
+ experimental_use,
24
forwardRef,
25
isValidElement,
26
lazy,