Skip to content

Commit d4f58c3

Browse files
authored
Support Promise as a renderable node (#25634)
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.
1 parent f411e89 commit d4f58c3

File tree

6 files changed

+404
-21
lines changed

6 files changed

+404
-21
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

+18
Original file line numberDiff line numberDiff line change
@@ -5402,6 +5402,24 @@ describe('ReactDOMFizzServer', () => {
54025402
});
54035403
expect(getVisibleChildren(container)).toEqual('Hi');
54045404
});
5405+
5406+
it('promise as node', async () => {
5407+
const promise = Promise.resolve('Hi');
5408+
await act(async () => {
5409+
const {pipe} = renderToPipeableStream(promise);
5410+
pipe(writable);
5411+
});
5412+
5413+
// TODO: The `act` implementation in this file doesn't unwrap microtasks
5414+
// automatically. We can't use the same `act` we use for Fiber tests
5415+
// because that relies on the mock Scheduler. Doesn't affect any public
5416+
// API but we might want to fix this for our own internal tests.
5417+
await act(async () => {
5418+
await promise;
5419+
});
5420+
5421+
expect(getVisibleChildren(container)).toEqual('Hi');
5422+
});
54055423
});
54065424

54075425
describe('useEffectEvent', () => {

packages/react-reconciler/src/ReactChildFiber.js

+136-3
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
*/
99

1010
import type {ReactElement} from 'shared/ReactElementType';
11-
import type {ReactPortal} from 'shared/ReactTypes';
11+
import type {ReactPortal, Thenable} from 'shared/ReactTypes';
1212
import type {Fiber} from './ReactInternalTypes';
1313
import type {Lanes} from './ReactFiberLane';
14+
import type {ThenableState} from './ReactFiberThenable';
1415

1516
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
1617
import {
@@ -25,6 +26,8 @@ import {
2526
REACT_FRAGMENT_TYPE,
2627
REACT_PORTAL_TYPE,
2728
REACT_LAZY_TYPE,
29+
REACT_CONTEXT_TYPE,
30+
REACT_SERVER_CONTEXT_TYPE,
2831
} from 'shared/ReactSymbols';
2932
import {ClassComponent, HostText, HostPortal, Fragment} from './ReactWorkTags';
3033
import isArray from 'shared/isArray';
@@ -41,6 +44,11 @@ import {
4144
import {isCompatibleFamilyForHotReloading} from './ReactFiberHotReloading';
4245
import {getIsHydrating} from './ReactFiberHydrationContext';
4346
import {pushTreeFork} from './ReactFiberTreeContext';
47+
import {createThenableState, trackUsedThenable} from './ReactFiberThenable';
48+
49+
// This tracks the thenables that are unwrapped during reconcilation.
50+
let thenableState: ThenableState | null = null;
51+
let thenableIndexCounter: number = 0;
4452

4553
let didWarnAboutMaps;
4654
let didWarnAboutGenerators;
@@ -99,6 +107,15 @@ function isReactClass(type: any) {
99107
return type.prototype && type.prototype.isReactComponent;
100108
}
101109

110+
function unwrapThenable<T>(thenable: Thenable<T>): T {
111+
const index = thenableIndexCounter;
112+
thenableIndexCounter += 1;
113+
if (thenableState === null) {
114+
thenableState = createThenableState();
115+
}
116+
return trackUsedThenable(thenableState, thenable, index);
117+
}
118+
102119
function coerceRef(
103120
returnFiber: Fiber,
104121
current: Fiber | null,
@@ -551,6 +568,21 @@ function createChildReconciler(
551568
return created;
552569
}
553570

571+
// Usable node types
572+
//
573+
// Unwrap the inner value and recursively call this function again.
574+
if (typeof newChild.then === 'function') {
575+
const thenable: Thenable<any> = (newChild: any);
576+
return createChild(returnFiber, unwrapThenable(thenable), lanes);
577+
}
578+
579+
if (
580+
newChild.$$typeof === REACT_CONTEXT_TYPE ||
581+
newChild.$$typeof === REACT_SERVER_CONTEXT_TYPE
582+
) {
583+
// TODO: Implement Context as child type.
584+
}
585+
554586
throwOnInvalidObjectType(returnFiber, newChild);
555587
}
556588

@@ -570,7 +602,6 @@ function createChildReconciler(
570602
lanes: Lanes,
571603
): Fiber | null {
572604
// Update the fiber if the keys match, otherwise return null.
573-
574605
const key = oldFiber !== null ? oldFiber.key : null;
575606

576607
if (
@@ -617,6 +648,26 @@ function createChildReconciler(
617648
return updateFragment(returnFiber, oldFiber, newChild, lanes, null);
618649
}
619650

651+
// Usable node types
652+
//
653+
// Unwrap the inner value and recursively call this function again.
654+
if (typeof newChild.then === 'function') {
655+
const thenable: Thenable<any> = (newChild: any);
656+
return updateSlot(
657+
returnFiber,
658+
oldFiber,
659+
unwrapThenable(thenable),
660+
lanes,
661+
);
662+
}
663+
664+
if (
665+
newChild.$$typeof === REACT_CONTEXT_TYPE ||
666+
newChild.$$typeof === REACT_SERVER_CONTEXT_TYPE
667+
) {
668+
// TODO: Implement Context as child type.
669+
}
670+
620671
throwOnInvalidObjectType(returnFiber, newChild);
621672
}
622673

@@ -679,6 +730,27 @@ function createChildReconciler(
679730
return updateFragment(returnFiber, matchedFiber, newChild, lanes, null);
680731
}
681732

733+
// Usable node types
734+
//
735+
// Unwrap the inner value and recursively call this function again.
736+
if (typeof newChild.then === 'function') {
737+
const thenable: Thenable<any> = (newChild: any);
738+
return updateFromMap(
739+
existingChildren,
740+
returnFiber,
741+
newIdx,
742+
unwrapThenable(thenable),
743+
lanes,
744+
);
745+
}
746+
747+
if (
748+
newChild.$$typeof === REACT_CONTEXT_TYPE ||
749+
newChild.$$typeof === REACT_SERVER_CONTEXT_TYPE
750+
) {
751+
// TODO: Implement Context as child type.
752+
}
753+
682754
throwOnInvalidObjectType(returnFiber, newChild);
683755
}
684756

@@ -1250,7 +1322,7 @@ function createChildReconciler(
12501322
// This API will tag the children with the side-effect of the reconciliation
12511323
// itself. They will be added to the side-effect list as we pass through the
12521324
// children and the parent.
1253-
function reconcileChildFibers(
1325+
function reconcileChildFibersImpl(
12541326
returnFiber: Fiber,
12551327
currentFirstChild: Fiber | null,
12561328
newChild: any,
@@ -1264,6 +1336,7 @@ function createChildReconciler(
12641336
// Handle top level unkeyed fragments as if they were arrays.
12651337
// This leads to an ambiguity between <>{[...]}</> and <>...</>.
12661338
// We treat the ambiguous cases above the same.
1339+
// TODO: Let's use recursion like we do for Usable nodes?
12671340
const isUnkeyedTopLevelFragment =
12681341
typeof newChild === 'object' &&
12691342
newChild !== null &&
@@ -1324,6 +1397,39 @@ function createChildReconciler(
13241397
);
13251398
}
13261399

1400+
// Usables are a valid React node type. When React encounters a Usable in
1401+
// a child position, it unwraps it using the same algorithm as `use`. For
1402+
// example, for promises, React will throw an exception to unwind the
1403+
// stack, then replay the component once the promise resolves.
1404+
//
1405+
// A difference from `use` is that React will keep unwrapping the value
1406+
// until it reaches a non-Usable type.
1407+
//
1408+
// e.g. Usable<Usable<Usable<T>>> should resolve to T
1409+
//
1410+
// The structure is a bit unfortunate. Ideally, we shouldn't need to
1411+
// replay the entire begin phase of the parent fiber in order to reconcile
1412+
// the children again. This would require a somewhat significant refactor,
1413+
// because reconcilation happens deep within the begin phase, and
1414+
// depending on the type of work, not always at the end. We should
1415+
// consider as an future improvement.
1416+
if (typeof newChild.then === 'function') {
1417+
const thenable: Thenable<any> = (newChild: any);
1418+
return reconcileChildFibersImpl(
1419+
returnFiber,
1420+
currentFirstChild,
1421+
unwrapThenable(thenable),
1422+
lanes,
1423+
);
1424+
}
1425+
1426+
if (
1427+
newChild.$$typeof === REACT_CONTEXT_TYPE ||
1428+
newChild.$$typeof === REACT_SERVER_CONTEXT_TYPE
1429+
) {
1430+
// TODO: Implement Context as child type.
1431+
}
1432+
13271433
throwOnInvalidObjectType(returnFiber, newChild);
13281434
}
13291435

@@ -1351,13 +1457,40 @@ function createChildReconciler(
13511457
return deleteRemainingChildren(returnFiber, currentFirstChild);
13521458
}
13531459

1460+
function reconcileChildFibers(
1461+
returnFiber: Fiber,
1462+
currentFirstChild: Fiber | null,
1463+
newChild: any,
1464+
lanes: Lanes,
1465+
): Fiber | null {
1466+
// This indirection only exists so we can reset `thenableState` at the end.
1467+
// It should get inlined by Closure.
1468+
thenableIndexCounter = 0;
1469+
const firstChildFiber = reconcileChildFibersImpl(
1470+
returnFiber,
1471+
currentFirstChild,
1472+
newChild,
1473+
lanes,
1474+
);
1475+
thenableState = null;
1476+
// Don't bother to reset `thenableIndexCounter` to 0 because it always gets
1477+
// set at the beginning.
1478+
return firstChildFiber;
1479+
}
1480+
13541481
return reconcileChildFibers;
13551482
}
13561483

13571484
export const reconcileChildFibers: ChildReconciler =
13581485
createChildReconciler(true);
13591486
export const mountChildFibers: ChildReconciler = createChildReconciler(false);
13601487

1488+
export function resetChildReconcilerOnUnwind(): void {
1489+
// On unwind, clear any pending thenables that were used.
1490+
thenableState = null;
1491+
thenableIndexCounter = 0;
1492+
}
1493+
13611494
export function cloneChildFibers(
13621495
current: Fiber | null,
13631496
workInProgress: Fiber,

packages/react-reconciler/src/ReactFiberWorkLoop.js

+10-8
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ import {
278278
getShellBoundary,
279279
} from './ReactFiberSuspenseContext';
280280
import {resolveDefaultProps} from './ReactFiberLazyComponent';
281+
import {resetChildReconcilerOnUnwind} from './ReactChildFiber';
281282

282283
const ceil = Math.ceil;
283284

@@ -1766,6 +1767,7 @@ function resetSuspendedWorkLoopOnUnwind() {
17661767
// Reset module-level state that was set during the render phase.
17671768
resetContextDependencies();
17681769
resetHooksOnUnwind();
1770+
resetChildReconcilerOnUnwind();
17691771
}
17701772

17711773
function handleThrow(root: FiberRoot, thrownValue: any): void {
@@ -2423,14 +2425,14 @@ function replaySuspendedUnitOfWork(unitOfWork: Fiber): void {
24232425
break;
24242426
}
24252427
default: {
2426-
if (__DEV__) {
2427-
console.error(
2428-
'Unexpected type of work: %s, Currently only function ' +
2429-
'components are replayed after suspending. This is a bug in React.',
2430-
unitOfWork.tag,
2431-
);
2432-
}
2433-
resetSuspendedWorkLoopOnUnwind();
2428+
// Other types besides function components are reset completely before
2429+
// being replayed. Currently this only happens when a Usable type is
2430+
// reconciled — the reconciler will suspend.
2431+
//
2432+
// We reset the fiber back to its original state; however, this isn't
2433+
// a full "unwind" because we're going to reuse the promises that were
2434+
// reconciled previously. So it's intentional that we don't call
2435+
// resetSuspendedWorkLoopOnUnwind here.
24342436
unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes);
24352437
unitOfWork = workInProgress = resetWorkInProgress(
24362438
unitOfWork,

0 commit comments

Comments
 (0)