From 3fa1a60c7672b137fe027d06f6cd335da103954f Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 7 Sep 2022 17:31:59 -0400 Subject: [PATCH] experimental_use(promise) for Server Components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../src/ReactFiberWakeable.new.js | 4 + .../src/ReactFiberWakeable.old.js | 4 + .../__tests__/ReactFlightDOMBrowser-test.js | 147 ++++++++++++++++++ packages/react-server/src/ReactFlightHooks.js | 92 ++++++++++- .../react-server/src/ReactFlightServer.js | 70 ++++++++- .../react-server/src/ReactFlightWakeable.js | 111 +++++++++++++ .../src/ReactSharedSubset.experimental.js | 1 + 7 files changed, 425 insertions(+), 4 deletions(-) create mode 100644 packages/react-server/src/ReactFlightWakeable.js diff --git a/packages/react-reconciler/src/ReactFiberWakeable.new.js b/packages/react-reconciler/src/ReactFiberWakeable.new.js index 83bfad32c5cf1..e494aa34fc1f2 100644 --- a/packages/react-reconciler/src/ReactFiberWakeable.new.js +++ b/packages/react-reconciler/src/ReactFiberWakeable.new.js @@ -18,6 +18,7 @@ import type { let suspendedThenable: Thenable | null = null; let adHocSuspendCount: number = 0; +// TODO: Sparse arrays are bad for performance. let usedThenables: Array | void> | null = null; let lastUsedThenable: Thenable | null = null; @@ -74,6 +75,9 @@ export function trackSuspendedWakeable(wakeable: Wakeable) { suspendedThenable = null; break; default: { + // TODO: Only instrument the thenable if the status if not defined. If + // it's defined, but an unknown value, assume it's been instrumented by + // some custom userspace implementation. const pendingThenable: PendingThenable = (thenable: any); pendingThenable.status = 'pending'; pendingThenable.then( diff --git a/packages/react-reconciler/src/ReactFiberWakeable.old.js b/packages/react-reconciler/src/ReactFiberWakeable.old.js index 83bfad32c5cf1..e494aa34fc1f2 100644 --- a/packages/react-reconciler/src/ReactFiberWakeable.old.js +++ b/packages/react-reconciler/src/ReactFiberWakeable.old.js @@ -18,6 +18,7 @@ import type { let suspendedThenable: Thenable | null = null; let adHocSuspendCount: number = 0; +// TODO: Sparse arrays are bad for performance. let usedThenables: Array | void> | null = null; let lastUsedThenable: Thenable | null = null; @@ -74,6 +75,9 @@ export function trackSuspendedWakeable(wakeable: Wakeable) { suspendedThenable = null; break; default: { + // TODO: Only instrument the thenable if the status if not defined. If + // it's defined, but an unknown value, assume it's been instrumented by + // some custom userspace implementation. const pendingThenable: PendingThenable = (thenable: any); pendingThenable.status = 'pending'; pendingThenable.then( diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 43573af1df141..1b59c72ab3e48 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -24,6 +24,7 @@ let ReactDOMServer; let ReactServerDOMWriter; let ReactServerDOMReader; let Suspense; +let use; describe('ReactFlightDOMBrowser', () => { beforeEach(() => { @@ -39,6 +40,7 @@ describe('ReactFlightDOMBrowser', () => { ReactServerDOMWriter = require('react-server-dom-webpack/writer.browser.server'); ReactServerDOMReader = require('react-server-dom-webpack'); Suspense = React.Suspense; + use = React.experimental_use; }); async function waitForSuspense(fn) { @@ -562,4 +564,149 @@ describe('ReactFlightDOMBrowser', () => { expect(reportedErrors).toEqual(['for reasons']); }); + + // @gate enableUseHook + it('basic use(promise)', async () => { + function Server() { + return ( + use(Promise.resolve('A')) + + use(Promise.resolve('B')) + + use(Promise.resolve('C')) + ); + } + + const stream = ReactServerDOMWriter.renderToReadableStream(); + const response = ReactServerDOMReader.createFromReadableStream(stream); + + function Client() { + return response.readRoot(); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + expect(container.innerHTML).toBe('ABC'); + }); + + // @gate enableUseHook + it('use(promise) in multiple components', async () => { + function Child({prefix}) { + return prefix + use(Promise.resolve('C')) + use(Promise.resolve('D')); + } + + function Parent() { + return ( + + ); + } + + const stream = ReactServerDOMWriter.renderToReadableStream(); + const response = ReactServerDOMReader.createFromReadableStream(stream); + + function Client() { + return response.readRoot(); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + expect(container.innerHTML).toBe('ABCD'); + }); + + // @gate enableUseHook + it('using a rejected promise will throw', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.reject(new Error('Oops!')); + const promiseC = Promise.resolve('C'); + + // Jest/Node will raise an unhandled rejected error unless we await this. It + // works fine in the browser, though. + await expect(promiseB).rejects.toThrow('Oops!'); + + function Server() { + return use(promiseA) + use(promiseB) + use(promiseC); + } + + const reportedErrors = []; + const stream = ReactServerDOMWriter.renderToReadableStream( + , + webpackMap, + { + onError(x) { + reportedErrors.push(x); + }, + }, + ); + const response = ReactServerDOMReader.createFromReadableStream(stream); + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return this.state.error.message; + } + return this.props.children; + } + } + + function Client() { + return response.readRoot(); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + expect(container.innerHTML).toBe('Oops!'); + expect(reportedErrors.length).toBe(1); + expect(reportedErrors[0].message).toBe('Oops!'); + }); + + // @gate enableUseHook + it("use a promise that's already been instrumented and resolved", async () => { + const thenable = { + status: 'fulfilled', + value: 'Hi', + then() {}, + }; + + // This will never suspend because the thenable already resolved + function Server() { + return use(thenable); + } + + const stream = ReactServerDOMWriter.renderToReadableStream(); + const response = ReactServerDOMReader.createFromReadableStream(stream); + + function Client() { + return response.readRoot(); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.innerHTML).toBe('Hi'); + }); }); diff --git a/packages/react-server/src/ReactFlightHooks.js b/packages/react-server/src/ReactFlightHooks.js index 137e41cb25b00..2644898f1cf5b 100644 --- a/packages/react-server/src/ReactFlightHooks.js +++ b/packages/react-server/src/ReactFlightHooks.js @@ -9,11 +9,20 @@ import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactInternalTypes'; import type {Request} from './ReactFlightServer'; -import type {ReactServerContext} from 'shared/ReactTypes'; +import type {ReactServerContext, Thenable, Usable} from 'shared/ReactTypes'; +import type {ThenableState} from './ReactFlightWakeable'; import {REACT_SERVER_CONTEXT_TYPE} from 'shared/ReactSymbols'; import {readContext as readContextImpl} from './ReactFlightNewContext'; +import {enableUseHook} from 'shared/ReactFeatureFlags'; +import { + getPreviouslyUsedThenableAtIndex, + createThenableState, + trackUsedThenable, +} from './ReactFlightWakeable'; let currentRequest = null; +let thenableIndexCounter = 0; +let thenableState = null; export function prepareToUseHooksForRequest(request: Request) { currentRequest = request; @@ -23,6 +32,17 @@ export function resetHooksForRequest() { currentRequest = null; } +export function prepareToUseHooksForComponent( + prevThenableState: ThenableState | null, +) { + thenableIndexCounter = 0; + thenableState = prevThenableState; +} + +export function getThenableStateAfterSuspending() { + return thenableState; +} + function readContext(context: ReactServerContext): T { if (__DEV__) { if (context.$$typeof !== REACT_SERVER_CONTEXT_TYPE) { @@ -83,6 +103,7 @@ export const Dispatcher: DispatcherType = { useMemoCache(size: number): Array { return new Array(size); }, + use: enableUseHook ? use : (unsupportedHook: any), }; function unsupportedHook(): void { @@ -116,3 +137,72 @@ function useId(): string { // use 'S' for Flight components to distinguish from 'R' and 'r' in Fizz/Client return ':' + currentRequest.identifierPrefix + 'S' + id.toString(32) + ':'; } + +function use(usable: Usable): T { + if (usable !== null && typeof usable === 'object') { + if (typeof usable.then === 'function') { + // This is a thenable. + const thenable: Thenable = (usable: any); + + // Track the position of the thenable within this fiber. + const index = thenableIndexCounter; + thenableIndexCounter += 1; + + switch (thenable.status) { + case 'fulfilled': { + const fulfilledValue: T = thenable.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError = thenable.reason; + throw rejectedError; + } + default: { + const prevThenableAtIndex: Thenable | null = getPreviouslyUsedThenableAtIndex( + thenableState, + index, + ); + if (prevThenableAtIndex !== null) { + switch (prevThenableAtIndex.status) { + case 'fulfilled': { + const fulfilledValue: T = prevThenableAtIndex.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError: mixed = prevThenableAtIndex.reason; + throw rejectedError; + } + default: { + // The thenable still hasn't resolved. Suspend with the same + // thenable as last time to avoid redundant listeners. + throw prevThenableAtIndex; + } + } + } else { + // This is the first time something has been used at this index. + // Stash the thenable at the current index so we can reuse it during + // the next attempt. + if (thenableState === null) { + thenableState = createThenableState(); + } + trackUsedThenable(thenableState, thenable, index); + + // Suspend. + // TODO: Throwing here is an implementation detail that allows us to + // unwind the call stack. But we shouldn't allow it to leak into + // userspace. Throw an opaque placeholder value instead of the + // actual thenable. If it doesn't get captured by the work loop, log + // a warning, because that means something in userspace must have + // caught it. + throw thenable; + } + } + } + } else { + // TODO: Add support for Context + } + } + + // eslint-disable-next-line react-internal/safe-string-coercion + throw new Error('An unsupported type was passed to use(): ' + String(usable)); +} diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 2a994fc8f32fd..7c6801e378754 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -16,9 +16,11 @@ import type { ModuleKey, } from './ReactFlightServerConfig'; import type {ContextSnapshot} from './ReactFlightNewContext'; +import type {ThenableState} from './ReactFlightWakeable'; import type { ReactProviderType, ServerContextJSONValue, + Wakeable, } from 'shared/ReactTypes'; import { @@ -44,6 +46,8 @@ import { Dispatcher, getCurrentCache, prepareToUseHooksForRequest, + prepareToUseHooksForComponent, + getThenableStateAfterSuspending, resetHooksForRequest, setCurrentCache, } from './ReactFlightHooks'; @@ -54,6 +58,7 @@ import { getActiveContext, rootContextSnapshot, } from './ReactFlightNewContext'; +import {trackSuspendedWakeable} from './ReactFlightWakeable'; import { REACT_ELEMENT_TYPE, @@ -98,6 +103,7 @@ type Task = { model: ReactModel, ping: () => void, context: ContextSnapshot, + thenableState: ThenableState | null, }; export type Request = { @@ -185,6 +191,7 @@ function attemptResolveElement( key: null | React$Key, ref: mixed, props: any, + prevThenableState: ThenableState | null, ): ReactModel { if (ref !== null && ref !== undefined) { // When the ref moves to the regular props object this will implicitly @@ -200,6 +207,7 @@ function attemptResolveElement( return [REACT_ELEMENT_TYPE, type, key, props]; } // This is a server-side component. + prepareToUseHooksForComponent(prevThenableState); return type(props); } else if (typeof type === 'string') { // This is a host element. E.g. HTML. @@ -225,14 +233,27 @@ function attemptResolveElement( const payload = type._payload; const init = type._init; const wrappedType = init(payload); - return attemptResolveElement(wrappedType, key, ref, props); + return attemptResolveElement( + wrappedType, + key, + ref, + props, + prevThenableState, + ); } case REACT_FORWARD_REF_TYPE: { const render = type.render; + prepareToUseHooksForComponent(prevThenableState); return render(props, undefined); } case REACT_MEMO_TYPE: { - return attemptResolveElement(type.type, key, ref, props); + return attemptResolveElement( + type.type, + key, + ref, + props, + prevThenableState, + ); } case REACT_PROVIDER_TYPE: { pushProvider(type._context, props.value); @@ -286,6 +307,7 @@ function createTask( model, context, ping: () => pingTask(request, task), + thenableState: null, }; abortSet.add(task); return task; @@ -569,6 +591,7 @@ export function resolveModelToJSON( element.key, element.ref, element.props, + null, ); break; } @@ -591,6 +614,11 @@ export function resolveModelToJSON( ); const ping = newTask.ping; x.then(ping, ping); + + const wakeable: Wakeable = x; + trackSuspendedWakeable(wakeable); + newTask.thenableState = getThenableStateAfterSuspending(); + return serializeByRefID(newTask.id); } else { logRecoverableError(request, x); @@ -828,16 +856,22 @@ function retryTask(request: Request, task: Task): void { // We completed this by other means before we had a chance to retry it. return; } + switchContext(task.context); try { let value = task.model; - while ( + if ( typeof value === 'object' && value !== null && (value: any).$$typeof === REACT_ELEMENT_TYPE ) { // TODO: Concatenate keys of parents onto children. const element: React$Element = (value: any); + + // When retrying a component, reuse the thenableState from the + // previous attempt. + const prevThenableState = task.thenableState; + // Attempt to render the server component. // Doing this here lets us reuse this same task if the next component // also suspends. @@ -847,8 +881,34 @@ function retryTask(request: Request, task: Task): void { element.key, element.ref, element.props, + prevThenableState, ); + + // Successfully finished this component. We're going to keep rendering + // using the same task, but we reset its thenable state before continuing. + task.thenableState = null; + + // Keep rendering and reuse the same task. This inner loop is separate + // from the render above because we don't need to reset the thenable state + // until the next time something suspends and retries. + while ( + typeof value === 'object' && + value !== null && + (value: any).$$typeof === REACT_ELEMENT_TYPE + ) { + // TODO: Concatenate keys of parents onto children. + const nextElement: React$Element = (value: any); + task.model = value; + value = attemptResolveElement( + nextElement.type, + nextElement.key, + nextElement.ref, + nextElement.props, + null, + ); + } } + const processedChunk = processModelChunk(request, task.id, value); request.completedJSONChunks.push(processedChunk); request.abortableTasks.delete(task); @@ -858,6 +918,10 @@ function retryTask(request: Request, task: Task): void { // Something suspended again, let's pick it back up later. const ping = task.ping; x.then(ping, ping); + + const wakeable: Wakeable = x; + trackSuspendedWakeable(wakeable); + task.thenableState = getThenableStateAfterSuspending(); return; } else { request.abortableTasks.delete(task); diff --git a/packages/react-server/src/ReactFlightWakeable.js b/packages/react-server/src/ReactFlightWakeable.js new file mode 100644 index 0000000000000..c4d2e67d863bf --- /dev/null +++ b/packages/react-server/src/ReactFlightWakeable.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// Corresponds to ReactFiberWakeable module. Generally, changes to one module +// should be reflected in the other. + +// TODO: Rename this module and the corresponding Fiber one to "Thenable" +// instead of "Wakeable". Or some other more appropriate name. + +import type { + Wakeable, + Thenable, + PendingThenable, + FulfilledThenable, + RejectedThenable, +} from 'shared/ReactTypes'; + +// TODO: Sparse arrays are bad for performance. +export opaque type ThenableState = Array | void>; + +export function createThenableState(): ThenableState { + // The ThenableState is created the first time a component suspends. If it + // suspends again, we'll reuse the same state. + return []; +} + +export function trackSuspendedWakeable(wakeable: Wakeable) { + // If this wakeable isn't already a thenable, turn it into one now. Then, + // when we resume the work loop, we can check if its status is + // still pending. + // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable. + const thenable: Thenable = (wakeable: any); + + // We use an expando to track the status and result of a thenable so that we + // can synchronously unwrap the value. Think of this as an extension of the + // Promise API, or a custom interface that is a superset of Thenable. + // + // If the thenable doesn't have a status, set it to "pending" and attach + // a listener that will update its status and result when it resolves. + switch (thenable.status) { + case 'pending': + // Since the status is already "pending", we can assume it will be updated + // when it resolves, either by React or something in userspace. + break; + case 'fulfilled': + case 'rejected': + // A thenable that already resolved shouldn't have been thrown, so this is + // unexpected. Suggests a mistake in a userspace data library. Don't track + // this thenable, because if we keep trying it will likely infinite loop + // without ever resolving. + // TODO: Log a warning? + break; + default: { + // TODO: Only instrument the thenable if the status if not defined. If + // it's defined, but an unknown value, assume it's been instrumented by + // some custom userspace implementation. + const pendingThenable: PendingThenable = (thenable: any); + pendingThenable.status = 'pending'; + pendingThenable.then( + fulfilledValue => { + if (thenable.status === 'pending') { + const fulfilledThenable: FulfilledThenable = (thenable: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = fulfilledValue; + } + }, + (error: mixed) => { + if (thenable.status === 'pending') { + const rejectedThenable: RejectedThenable = (thenable: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = error; + } + }, + ); + break; + } + } +} + +export function trackUsedThenable( + thenableState: ThenableState, + thenable: Thenable, + index: number, +) { + // This is only a separate function from trackSuspendedWakeable for symmetry + // with Fiber. + // TODO: Disallow throwing a thenable directly. It must go through `use` (or + // some equivalent for internal Suspense implementations). We can't do this in + // Fiber yet because it's a breaking change but we can do it in Server + // Components because Server Components aren't released yet. + thenableState[index] = thenable; +} + +export function getPreviouslyUsedThenableAtIndex( + thenableState: ThenableState | null, + index: number, +): Thenable | null { + if (thenableState !== null) { + const thenable = thenableState[index]; + if (thenable !== undefined) { + return thenable; + } + } + return null; +} diff --git a/packages/react/src/ReactSharedSubset.experimental.js b/packages/react/src/ReactSharedSubset.experimental.js index 4a9ad873c1dc8..fe096ba1dba9e 100644 --- a/packages/react/src/ReactSharedSubset.experimental.js +++ b/packages/react/src/ReactSharedSubset.experimental.js @@ -20,6 +20,7 @@ export { createMutableSource as unstable_createMutableSource, createRef, createServerContext, + experimental_use, forwardRef, isValidElement, lazy,