Skip to content

Commit c4e720a

Browse files
acdliterickhanlonii
authored andcommitted
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).
1 parent 72f5995 commit c4e720a

File tree

7 files changed

+425
-4
lines changed

7 files changed

+425
-4
lines changed

packages/react-reconciler/src/ReactFiberWakeable.new.js

+4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
let suspendedThenable: Thenable<mixed> | null = null;
1919
let adHocSuspendCount: number = 0;
2020

21+
// TODO: Sparse arrays are bad for performance.
2122
let usedThenables: Array<Thenable<any> | void> | null = null;
2223
let lastUsedThenable: Thenable<any> | null = null;
2324

@@ -74,6 +75,9 @@ export function trackSuspendedWakeable(wakeable: Wakeable) {
7475
suspendedThenable = null;
7576
break;
7677
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.
7781
const pendingThenable: PendingThenable<mixed> = (thenable: any);
7882
pendingThenable.status = 'pending';
7983
pendingThenable.then(

packages/react-reconciler/src/ReactFiberWakeable.old.js

+4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
let suspendedThenable: Thenable<mixed> | null = null;
1919
let adHocSuspendCount: number = 0;
2020

21+
// TODO: Sparse arrays are bad for performance.
2122
let usedThenables: Array<Thenable<any> | void> | null = null;
2223
let lastUsedThenable: Thenable<any> | null = null;
2324

@@ -74,6 +75,9 @@ export function trackSuspendedWakeable(wakeable: Wakeable) {
7475
suspendedThenable = null;
7576
break;
7677
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.
7781
const pendingThenable: PendingThenable<mixed> = (thenable: any);
7882
pendingThenable.status = 'pending';
7983
pendingThenable.then(

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

+147
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ let ReactDOMServer;
2424
let ReactServerDOMWriter;
2525
let ReactServerDOMReader;
2626
let Suspense;
27+
let use;
2728

2829
describe('ReactFlightDOMBrowser', () => {
2930
beforeEach(() => {
@@ -39,6 +40,7 @@ describe('ReactFlightDOMBrowser', () => {
3940
ReactServerDOMWriter = require('react-server-dom-webpack/writer.browser.server');
4041
ReactServerDOMReader = require('react-server-dom-webpack');
4142
Suspense = React.Suspense;
43+
use = React.experimental_use;
4244
});
4345

4446
async function waitForSuspense(fn) {
@@ -562,4 +564,149 @@ describe('ReactFlightDOMBrowser', () => {
562564

563565
expect(reportedErrors).toEqual(['for reasons']);
564566
});
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+
});
565712
});

packages/react-server/src/ReactFlightHooks.js

+91-1
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,20 @@
99

1010
import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactInternalTypes';
1111
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';
1314
import {REACT_SERVER_CONTEXT_TYPE} from 'shared/ReactSymbols';
1415
import {readContext as readContextImpl} from './ReactFlightNewContext';
16+
import {enableUseHook} from 'shared/ReactFeatureFlags';
17+
import {
18+
getPreviouslyUsedThenableAtIndex,
19+
createThenableState,
20+
trackUsedThenable,
21+
} from './ReactFlightWakeable';
1522

1623
let currentRequest = null;
24+
let thenableIndexCounter = 0;
25+
let thenableState = null;
1726

1827
export function prepareToUseHooksForRequest(request: Request) {
1928
currentRequest = request;
@@ -23,6 +32,17 @@ export function resetHooksForRequest() {
2332
currentRequest = null;
2433
}
2534

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+
2646
function readContext<T>(context: ReactServerContext<T>): T {
2747
if (__DEV__) {
2848
if (context.$$typeof !== REACT_SERVER_CONTEXT_TYPE) {
@@ -83,6 +103,7 @@ export const Dispatcher: DispatcherType = {
83103
useMemoCache(size: number): Array<any> {
84104
return new Array(size);
85105
},
106+
use: enableUseHook ? use : (unsupportedHook: any),
86107
};
87108

88109
function unsupportedHook(): void {
@@ -116,3 +137,72 @@ function useId(): string {
116137
// use 'S' for Flight components to distinguish from 'R' and 'r' in Fizz/Client
117138
return ':' + currentRequest.identifierPrefix + 'S' + id.toString(32) + ':';
118139
}
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+
}

0 commit comments

Comments
 (0)