Skip to content

Commit

Permalink
Support Promise as a renderable node
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.

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 Nov 6, 2022
1 parent d01bba2 commit 324cf1f
Show file tree
Hide file tree
Showing 8 changed files with 522 additions and 26 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 @@ -5327,6 +5327,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('useEvent', () => {
Expand Down
118 changes: 113 additions & 5 deletions packages/react-reconciler/src/ReactChildFiber.new.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.new';
import type {ThenableState} from './ReactFiberThenable.new';

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 @@ -44,6 +47,11 @@ import {isCompatibleFamilyForHotReloading} from './ReactFiberHotReloading.new';
import {StrictLegacyMode} from './ReactTypeOfMode';
import {getIsHydrating} from './ReactFiberHydrationContext.new';
import {pushTreeFork} from './ReactFiberTreeContext.new';
import {createThenableState, trackUsedThenable} from './ReactFiberThenable.new';

// 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 @@ -98,6 +106,49 @@ if (__DEV__) {
};
}

function transparentlyUnwrapPossiblyUsableValue(maybeUsable: Object): any {
// 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.
while (maybeUsable !== null && maybeUsable !== undefined) {
if (typeof maybeUsable.then === 'function') {
// This is a thenable
const thenable: Thenable<any> = (maybeUsable: any);
const index = thenableIndexCounter;
thenableIndexCounter += 1;

if (thenableState === null) {
thenableState = createThenableState();
}
maybeUsable = trackUsedThenable(thenableState, thenable, index);
continue;
} else if (
maybeUsable.$$typeof === REACT_CONTEXT_TYPE ||
maybeUsable.$$typeof === REACT_SERVER_CONTEXT_TYPE
) {
// TODO: Implement Context as child type.
// const context: ReactContext<mixed> = (maybeUsable: any);
// maybeUsable = readContext(context);
// continue;
}
break;
}
return maybeUsable;
}

function coerceRef(
returnFiber: Fiber,
current: Fiber | null,
Expand Down Expand Up @@ -502,6 +553,8 @@ function createChildReconciler(shouldTrackSideEffects): ChildReconciler {
newChild: any,
lanes: Lanes,
): Fiber | null {
newChild = transparentlyUnwrapPossiblyUsableValue(newChild);

if (
(typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number'
Expand Down Expand Up @@ -576,6 +629,7 @@ function createChildReconciler(shouldTrackSideEffects): ChildReconciler {
lanes: Lanes,
): Fiber | null {
// Update the fiber if the keys match, otherwise return null.
newChild = transparentlyUnwrapPossiblyUsableValue(newChild);

const key = oldFiber !== null ? oldFiber.key : null;

Expand Down Expand Up @@ -642,6 +696,8 @@ function createChildReconciler(shouldTrackSideEffects): ChildReconciler {
newChild: any,
lanes: Lanes,
): Fiber | null {
newChild = transparentlyUnwrapPossiblyUsableValue(newChild);

if (
(typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number'
Expand Down Expand Up @@ -1256,12 +1312,14 @@ function createChildReconciler(shouldTrackSideEffects): ChildReconciler {
// 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,
lanes: Lanes,
): Fiber | null {
newChild = transparentlyUnwrapPossiblyUsableValue(newChild);

// This function is not recursive.
// If the top level item is an array, we treat it as a set of children,
// not as a fragment. Nested arrays on the other hand will be treated as
Expand Down Expand Up @@ -1357,13 +1415,63 @@ function createChildReconciler(shouldTrackSideEffects): ChildReconciler {
return deleteRemainingChildren(returnFiber, currentFirstChild);
}

return reconcileChildFibers;
return reconcileChildFibersImpl;
}

export const reconcileChildFibers: ChildReconciler = createChildReconciler(
export const reconcileChildFibersImpl: ChildReconciler = createChildReconciler(
true,
);
export const mountChildFibers: ChildReconciler = createChildReconciler(false);
export const mountChildFibersImpl: ChildReconciler = createChildReconciler(
false,
);

export 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;
}

export function mountChildFibers(
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 = mountChildFibersImpl(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
thenableState = null;
// Don't bother to reset `thenableIndexCounter` to 0 because it always gets
// set at the beginning.
return firstChildFiber;
}

export function resetChildReconcilerOnUnwind(): void {
// On unwind, clear any pending thenables that were used.
thenableState = null;
thenableIndexCounter = 0;
}

export function cloneChildFibers(
current: Fiber | null,
Expand Down
Loading

0 comments on commit 324cf1f

Please sign in to comment.