diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index e745835c1601a..dd7affb27384b 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -5402,6 +5402,24 @@ describe('ReactDOMFizzServer', () => { }); expect(getVisibleChildren(container)).toEqual('Hi'); }); + + it('promise as node', async () => { + const promise = Promise.resolve('Hi'); + await act(async () => { + const {pipe} = renderToPipeableStream(promise); + pipe(writable); + }); + + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + await act(async () => { + await promise; + }); + + expect(getVisibleChildren(container)).toEqual('Hi'); + }); }); describe('useEffectEvent', () => { diff --git a/packages/react-reconciler/src/ReactChildFiber.js b/packages/react-reconciler/src/ReactChildFiber.js index b73d2668295f1..426d81da01f78 100644 --- a/packages/react-reconciler/src/ReactChildFiber.js +++ b/packages/react-reconciler/src/ReactChildFiber.js @@ -8,9 +8,10 @@ */ import type {ReactElement} from 'shared/ReactElementType'; -import type {ReactPortal} from 'shared/ReactTypes'; +import type {ReactPortal, Thenable} from 'shared/ReactTypes'; import type {Fiber} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane'; +import type {ThenableState} from './ReactFiberThenable'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; import { @@ -25,6 +26,8 @@ import { REACT_FRAGMENT_TYPE, REACT_PORTAL_TYPE, REACT_LAZY_TYPE, + REACT_CONTEXT_TYPE, + REACT_SERVER_CONTEXT_TYPE, } from 'shared/ReactSymbols'; import {ClassComponent, HostText, HostPortal, Fragment} from './ReactWorkTags'; import isArray from 'shared/isArray'; @@ -41,6 +44,11 @@ import { import {isCompatibleFamilyForHotReloading} from './ReactFiberHotReloading'; import {getIsHydrating} from './ReactFiberHydrationContext'; import {pushTreeFork} from './ReactFiberTreeContext'; +import {createThenableState, trackUsedThenable} from './ReactFiberThenable'; + +// This tracks the thenables that are unwrapped during reconcilation. +let thenableState: ThenableState | null = null; +let thenableIndexCounter: number = 0; let didWarnAboutMaps; let didWarnAboutGenerators; @@ -99,6 +107,15 @@ function isReactClass(type: any) { return type.prototype && type.prototype.isReactComponent; } +function unwrapThenable(thenable: Thenable): T { + const index = thenableIndexCounter; + thenableIndexCounter += 1; + if (thenableState === null) { + thenableState = createThenableState(); + } + return trackUsedThenable(thenableState, thenable, index); +} + function coerceRef( returnFiber: Fiber, current: Fiber | null, @@ -551,6 +568,21 @@ function createChildReconciler( return created; } + // Usable node types + // + // Unwrap the inner value and recursively call this function again. + if (typeof newChild.then === 'function') { + const thenable: Thenable = (newChild: any); + return createChild(returnFiber, unwrapThenable(thenable), lanes); + } + + if ( + newChild.$$typeof === REACT_CONTEXT_TYPE || + newChild.$$typeof === REACT_SERVER_CONTEXT_TYPE + ) { + // TODO: Implement Context as child type. + } + throwOnInvalidObjectType(returnFiber, newChild); } @@ -570,7 +602,6 @@ function createChildReconciler( lanes: Lanes, ): Fiber | null { // Update the fiber if the keys match, otherwise return null. - const key = oldFiber !== null ? oldFiber.key : null; if ( @@ -617,6 +648,26 @@ function createChildReconciler( return updateFragment(returnFiber, oldFiber, newChild, lanes, null); } + // Usable node types + // + // Unwrap the inner value and recursively call this function again. + if (typeof newChild.then === 'function') { + const thenable: Thenable = (newChild: any); + return updateSlot( + returnFiber, + oldFiber, + unwrapThenable(thenable), + lanes, + ); + } + + if ( + newChild.$$typeof === REACT_CONTEXT_TYPE || + newChild.$$typeof === REACT_SERVER_CONTEXT_TYPE + ) { + // TODO: Implement Context as child type. + } + throwOnInvalidObjectType(returnFiber, newChild); } @@ -679,6 +730,27 @@ function createChildReconciler( return updateFragment(returnFiber, matchedFiber, newChild, lanes, null); } + // Usable node types + // + // Unwrap the inner value and recursively call this function again. + if (typeof newChild.then === 'function') { + const thenable: Thenable = (newChild: any); + return updateFromMap( + existingChildren, + returnFiber, + newIdx, + unwrapThenable(thenable), + lanes, + ); + } + + if ( + newChild.$$typeof === REACT_CONTEXT_TYPE || + newChild.$$typeof === REACT_SERVER_CONTEXT_TYPE + ) { + // TODO: Implement Context as child type. + } + throwOnInvalidObjectType(returnFiber, newChild); } @@ -1250,7 +1322,7 @@ function createChildReconciler( // This API will tag the children with the side-effect of the reconciliation // itself. They will be added to the side-effect list as we pass through the // children and the parent. - function reconcileChildFibers( + function reconcileChildFibersImpl( returnFiber: Fiber, currentFirstChild: Fiber | null, newChild: any, @@ -1264,6 +1336,7 @@ function createChildReconciler( // Handle top level unkeyed fragments as if they were arrays. // This leads to an ambiguity between <>{[...]} and <>.... // We treat the ambiguous cases above the same. + // TODO: Let's use recursion like we do for Usable nodes? const isUnkeyedTopLevelFragment = typeof newChild === 'object' && newChild !== null && @@ -1324,6 +1397,39 @@ function createChildReconciler( ); } + // Usables are a valid React node type. When React encounters a Usable in + // a child position, it unwraps it using the same algorithm as `use`. For + // example, for promises, React will throw an exception to unwind the + // stack, then replay the component once the promise resolves. + // + // A difference from `use` is that React will keep unwrapping the value + // until it reaches a non-Usable type. + // + // e.g. Usable>> should resolve to T + // + // The structure is a bit unfortunate. Ideally, we shouldn't need to + // replay the entire begin phase of the parent fiber in order to reconcile + // the children again. This would require a somewhat significant refactor, + // because reconcilation happens deep within the begin phase, and + // depending on the type of work, not always at the end. We should + // consider as an future improvement. + if (typeof newChild.then === 'function') { + const thenable: Thenable = (newChild: any); + return reconcileChildFibersImpl( + returnFiber, + currentFirstChild, + unwrapThenable(thenable), + lanes, + ); + } + + if ( + newChild.$$typeof === REACT_CONTEXT_TYPE || + newChild.$$typeof === REACT_SERVER_CONTEXT_TYPE + ) { + // TODO: Implement Context as child type. + } + throwOnInvalidObjectType(returnFiber, newChild); } @@ -1351,6 +1457,27 @@ function createChildReconciler( return deleteRemainingChildren(returnFiber, currentFirstChild); } + function reconcileChildFibers( + returnFiber: Fiber, + currentFirstChild: Fiber | null, + newChild: any, + lanes: Lanes, + ): Fiber | null { + // This indirection only exists so we can reset `thenableState` at the end. + // It should get inlined by Closure. + thenableIndexCounter = 0; + const firstChildFiber = reconcileChildFibersImpl( + returnFiber, + currentFirstChild, + newChild, + lanes, + ); + thenableState = null; + // Don't bother to reset `thenableIndexCounter` to 0 because it always gets + // set at the beginning. + return firstChildFiber; + } + return reconcileChildFibers; } @@ -1358,6 +1485,12 @@ export const reconcileChildFibers: ChildReconciler = createChildReconciler(true); export const mountChildFibers: ChildReconciler = createChildReconciler(false); +export function resetChildReconcilerOnUnwind(): void { + // On unwind, clear any pending thenables that were used. + thenableState = null; + thenableIndexCounter = 0; +} + export function cloneChildFibers( current: Fiber | null, workInProgress: Fiber, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index ee05b674bb8bc..0bad69e81aa23 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -278,6 +278,7 @@ import { getShellBoundary, } from './ReactFiberSuspenseContext'; import {resolveDefaultProps} from './ReactFiberLazyComponent'; +import {resetChildReconcilerOnUnwind} from './ReactChildFiber'; const ceil = Math.ceil; @@ -1766,6 +1767,7 @@ function resetSuspendedWorkLoopOnUnwind() { // Reset module-level state that was set during the render phase. resetContextDependencies(); resetHooksOnUnwind(); + resetChildReconcilerOnUnwind(); } function handleThrow(root: FiberRoot, thrownValue: any): void { @@ -2423,14 +2425,14 @@ function replaySuspendedUnitOfWork(unitOfWork: Fiber): void { break; } default: { - if (__DEV__) { - console.error( - 'Unexpected type of work: %s, Currently only function ' + - 'components are replayed after suspending. This is a bug in React.', - unitOfWork.tag, - ); - } - resetSuspendedWorkLoopOnUnwind(); + // Other types besides function components are reset completely before + // being replayed. Currently this only happens when a Usable type is + // reconciled — the reconciler will suspend. + // + // We reset the fiber back to its original state; however, this isn't + // a full "unwind" because we're going to reuse the promises that were + // reconciled previously. So it's intentional that we don't call + // resetSuspendedWorkLoopOnUnwind here. unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); unitOfWork = workInProgress = resetWorkInProgress( unitOfWork, diff --git a/packages/react-reconciler/src/__tests__/ReactThenable-test.js b/packages/react-reconciler/src/__tests__/ReactUse-test.js similarity index 85% rename from packages/react-reconciler/src/__tests__/ReactThenable-test.js rename to packages/react-reconciler/src/__tests__/ReactUse-test.js index 5677396ae1370..be219dbcf0aa2 100644 --- a/packages/react-reconciler/src/__tests__/ReactThenable-test.js +++ b/packages/react-reconciler/src/__tests__/ReactUse-test.js @@ -8,6 +8,7 @@ let use; let useDebugValue; let useState; let useMemo; +let useEffect; let Suspense; let startTransition; let cache; @@ -17,7 +18,7 @@ let waitForPaint; let assertLog; let waitForAll; -describe('ReactThenable', () => { +describe('ReactUse', () => { beforeEach(() => { jest.resetModules(); @@ -29,6 +30,7 @@ describe('ReactThenable', () => { useDebugValue = React.useDebugValue; useState = React.useState; useMemo = React.useMemo; + useEffect = React.useEffect; Suspense = React.Suspense; startTransition = React.startTransition; cache = React.cache; @@ -1182,4 +1184,201 @@ describe('ReactThenable', () => { assertLog(['A1']); expect(root).toMatchRenderedOutput('A1'); }); + + test('basic promise as child', async () => { + const promise = Promise.resolve(); + const root = ReactNoop.createRoot(); + await act(() => { + startTransition(() => { + root.render(promise); + }); + }); + assertLog(['Hi']); + expect(root).toMatchRenderedOutput('Hi'); + }); + + test('basic async component', async () => { + async function App() { + await getAsyncText('Hi'); + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + assertLog(['Async text requested [Hi]']); + + await act(() => resolveTextRequests('Hi')); + assertLog([ + // TODO: We shouldn't have to replay the function body again. Skip + // straight to reconciliation. + 'Async text requested [Hi]', + 'Hi', + ]); + expect(root).toMatchRenderedOutput('Hi'); + }); + + test('async child of a non-function component (e.g. a class)', async () => { + class App extends React.Component { + async render() { + const text = await getAsyncText('Hi'); + return ; + } + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + assertLog(['Async text requested [Hi]']); + + await act(async () => resolveTextRequests('Hi')); + assertLog([ + // TODO: We shouldn't have to replay the render function again. We could + // skip straight to reconciliation. However, it's not as urgent to fix + // this for fiber types that aren't function components, so we can special + // case those in the meantime. + 'Async text requested [Hi]', + 'Hi', + ]); + expect(root).toMatchRenderedOutput('Hi'); + }); + + test('async children are recursively unwrapped', async () => { + // This is a Usable of a Usable. `use` would only unwrap a single level, but + // when passed as a child, the reconciler recurisvely unwraps until it + // resolves to a non-Usable value. + const thenable = { + then() {}, + status: 'fulfilled', + value: { + then() {}, + status: 'fulfilled', + value: , + }, + }; + const root = ReactNoop.createRoot(); + await act(() => { + root.render(thenable); + }); + assertLog(['Hi']); + expect(root).toMatchRenderedOutput('Hi'); + }); + + test('async children are transparently unwrapped before being reconciled (top level)', async () => { + function Child({text}) { + useEffect(() => { + Scheduler.log(`Mount: ${text}`); + }, [text]); + return ; + } + + async function App({text}) { + // The child returned by this component is always a promise (async + // functions always return promises). React should unwrap it and reconcile + // the result, not the promise itself. + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + assertLog(['A', 'Mount: A']); + expect(root).toMatchRenderedOutput('A'); + + // Update the child's props. It should not remount. + await act(() => { + startTransition(() => { + root.render(); + }); + }); + assertLog(['B', 'Mount: B']); + expect(root).toMatchRenderedOutput('B'); + }); + + test('async children are transparently unwrapped before being reconciled (siblings)', async () => { + function Child({text}) { + useEffect(() => { + Scheduler.log(`Mount: ${text}`); + }, [text]); + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render( + <> + {Promise.resolve()} + {Promise.resolve()} + {Promise.resolve()} + , + ); + }); + }); + assertLog(['A', 'B', 'C', 'Mount: A', 'Mount: B', 'Mount: C']); + expect(root).toMatchRenderedOutput('ABC'); + + await act(() => { + startTransition(() => { + root.render( + <> + {Promise.resolve()} + {Promise.resolve()} + {Promise.resolve()} + , + ); + }); + }); + // Nothing should have remounted + assertLog(['A', 'B', 'C']); + expect(root).toMatchRenderedOutput('ABC'); + }); + + test('async children are transparently unwrapped before being reconciled (siblings, reordered)', async () => { + function Child({text}) { + useEffect(() => { + Scheduler.log(`Mount: ${text}`); + }, [text]); + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + startTransition(() => { + root.render( + <> + {Promise.resolve()} + {Promise.resolve()} + {Promise.resolve()} + , + ); + }); + }); + assertLog(['A', 'B', 'C', 'Mount: A', 'Mount: B', 'Mount: C']); + expect(root).toMatchRenderedOutput('ABC'); + + await act(() => { + startTransition(() => { + root.render( + <> + {Promise.resolve()} + {Promise.resolve()} + {Promise.resolve()} + , + ); + }); + }); + // Nothing should have remounted + assertLog(['B', 'A', 'C']); + expect(root).toMatchRenderedOutput('BAC'); + }); }); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 2517e5ef02425..bf236793bcee1 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -584,15 +584,7 @@ function use(usable: Usable): T { 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; - - if (thenableState === null) { - thenableState = createThenableState(); - } - return trackUsedThenable(thenableState, thenable, index); + return unwrapThenable(thenable); } else if ( usable.$$typeof === REACT_CONTEXT_TYPE || usable.$$typeof === REACT_SERVER_CONTEXT_TYPE @@ -606,6 +598,15 @@ function use(usable: Usable): T { throw new Error('An unsupported type was passed to use(): ' + String(usable)); } +export function unwrapThenable(thenable: Thenable): T { + const index = thenableIndexCounter; + thenableIndexCounter += 1; + if (thenableState === null) { + thenableState = createThenableState(); + } + return trackUsedThenable(thenableState, thenable, index); +} + function unsupportedRefresh() { throw new Error('Cache cannot be refreshed during server rendering.'); } diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 6f362a4b4db00..5fe894a50f44a 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -18,6 +18,7 @@ import type { ReactProviderType, OffscreenMode, Wakeable, + Thenable, } from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type { @@ -102,6 +103,7 @@ import { currentResponseState, setCurrentResponseState, getThenableStateAfterSuspending, + unwrapThenable, } from './ReactFizzHooks'; import {DefaultCacheDispatcher} from './ReactFizzCache'; import {getStackByComponentStackNode} from './ReactFizzComponentStack'; @@ -123,6 +125,7 @@ import { REACT_MEMO_TYPE, REACT_PROVIDER_TYPE, REACT_CONTEXT_TYPE, + REACT_SERVER_CONTEXT_TYPE, REACT_SCOPE_TYPE, REACT_OFFSCREEN_TYPE, } from 'shared/ReactSymbols'; @@ -1440,6 +1443,33 @@ function renderNodeDestructiveImpl( } } + // Usables are a valid React node type. When React encounters a Usable in + // a child position, it unwraps it using the same algorithm as `use`. For + // example, for promises, React will throw an exception to unwind the + // stack, then replay the component once the promise resolves. + // + // A difference from `use` is that React will keep unwrapping the value + // until it reaches a non-Usable type. + // + // e.g. Usable>> should resolve to T + const maybeUsable: Object = node; + if (typeof maybeUsable.then === 'function') { + const thenable: Thenable = (maybeUsable: any); + return renderNodeDestructiveImpl( + request, + task, + null, + unwrapThenable(thenable), + ); + } + + if ( + maybeUsable.$$typeof === REACT_CONTEXT_TYPE || + maybeUsable.$$typeof === REACT_SERVER_CONTEXT_TYPE + ) { + // TODO: Implement Context as child type. + } + // $FlowFixMe[method-unbinding] const childString = Object.prototype.toString.call(node);