Skip to content

Commit

Permalink
Support Promise as a renderable node (#25634)
Browse files Browse the repository at this point in the history
Implements Promise as a valid React node types. The idea is that any
type that can be unwrapped with `use` should also be renderable.

When the reconciler encounters a Usable in a child position, it will
transparently unwrap the value before reconciling it. The value of the
inner value will determine the identity of the child during
reconciliation, not the Usable object that wraps around it.

Unlike `use`, the reconciler will recursively unwrap the value until it
reaches a non-Usable type, e.g. `Usable<Usable<Usable<T>>>` will resolve
to T.

In this initial commit, I've added support for Promises. I will do
Context in the [next
step](#25641).

Being able to render a promise as a child has several interesting
implications. The Server Components response format can use this feature
in its implementation — instead of wrapping references to client
components in `React.lazy`, it can just use a promise.

This also fulfills one of the requirements for async components on the
client, because an async component always returns a promise for a React
node. However, we will likely warn and/or lint against this for the time
being because there are major caveats if you re-render an async
component in response to user input. (Note: async components already
work in a Server Components environment — the caveats only apply to
running them in the browser.)

To suspend, React uses the same algorithm as `use`: by throwing an
exception to unwind the stack, then replaying the begin phase once the
promise resolves. It's a little weird to suspend during reconciliation,
however, `lazy` already does this so if there were any obvious bugs
related to that we likely would have already found them.

Still, 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 reconciliation happens deep within the begin phase, and
depending on the type of work, not always at the end. We should consider
as a future improvement.
  • Loading branch information
acdlite committed Mar 11, 2023
1 parent f411e89 commit d4f58c3
Show file tree
Hide file tree
Showing 6 changed files with 404 additions and 21 deletions.
18 changes: 18 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
139 changes: 136 additions & 3 deletions packages/react-reconciler/src/ReactChildFiber.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -99,6 +107,15 @@ function isReactClass(type: any) {
return type.prototype && type.prototype.isReactComponent;
}

function unwrapThenable<T>(thenable: Thenable<T>): T {
const index = thenableIndexCounter;
thenableIndexCounter += 1;
if (thenableState === null) {
thenableState = createThenableState();
}
return trackUsedThenable(thenableState, thenable, index);
}

function coerceRef(
returnFiber: Fiber,
current: Fiber | null,
Expand Down Expand Up @@ -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<any> = (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);
}

Expand All @@ -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 (
Expand Down Expand Up @@ -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<any> = (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);
}

Expand Down Expand Up @@ -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<any> = (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);
}

Expand Down Expand Up @@ -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,
Expand All @@ -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 &&
Expand Down Expand Up @@ -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<Usable<Usable<T>>> 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<any> = (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);
}

Expand Down Expand Up @@ -1351,13 +1457,40 @@ 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;
}

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,
Expand Down
18 changes: 10 additions & 8 deletions packages/react-reconciler/src/ReactFiberWorkLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ import {
getShellBoundary,
} from './ReactFiberSuspenseContext';
import {resolveDefaultProps} from './ReactFiberLazyComponent';
import {resetChildReconcilerOnUnwind} from './ReactChildFiber';

const ceil = Math.ceil;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit d4f58c3

Please sign in to comment.