Skip to content

Commit 7cc1620

Browse files
committed
use(promise)
Adds experimental support to Fiber for unwrapping the value of a promise inside a component. It is not yet implemented for Server Components, but that is planned. If promise has already resolved, the value can be unwrapped "immediately" without showing a fallback. The trick we use to implement this is to yield to the main thread (literally suspending the work loop), wait for the microtask queue to drain, then check if the promise resolved in the meantime. If so, we can resume the last attempted fiber without unwinding the stack. This functionality was implemented in previous commits. Another feature is that the promises do not need to be cached between attempts. Because we assume idempotent execution of components, React will track the promises that were used during the previous attempt and reuse the result. You shouldn't rely on this property, but during initial render it mostly just works. Updates are trickier, though, because if you used an uncached promise, we have no way of knowing whether the underlying data has changed, so we have to unwrap the promise every time. It will still work, but it's inefficient and can lead to unnecessary fallbacks if it happens during a discrete update. When we implement this for Server Components, this will be less of an issue because there are no updates in that environment. However, it's still better for performance to cache data requests, so the same principles largely apply. The intention is that this will eventually be the only supported way to suspend on arbitrary promises. Throwing a promise directly will be deprecated.
1 parent 9cf575e commit 7cc1620

15 files changed

+705
-127
lines changed

packages/react-reconciler/src/ReactFiberHooks.new.js

+76-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
ReactContext,
1515
StartTransitionOptions,
1616
Usable,
17+
Thenable,
1718
} from 'shared/ReactTypes';
1819
import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes';
1920
import type {Lanes, Lane} from './ReactFiberLane.new';
@@ -122,6 +123,10 @@ import {
122123
} from './ReactFiberConcurrentUpdates.new';
123124
import {getTreeId} from './ReactFiberTreeContext.new';
124125
import {now} from './Scheduler';
126+
import {
127+
trackUsedThenable,
128+
getPreviouslyUsedThenableAtIndex,
129+
} from './ReactFiberWakeable.new';
125130

126131
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
127132

@@ -207,6 +212,9 @@ let didScheduleRenderPhaseUpdate: boolean = false;
207212
let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false;
208213
// Counts the number of useId hooks in this component.
209214
let localIdCounter: number = 0;
215+
// Counts number of `use`-d thenables
216+
let thenableIndexCounter: number = 0;
217+
210218
// Used for ids that are generated completely client-side (i.e. not during
211219
// hydration). This counter is global, so client ids are not stable across
212220
// render attempts.
@@ -405,6 +413,7 @@ export function renderWithHooks<Props, SecondArg>(
405413

406414
// didScheduleRenderPhaseUpdate = false;
407415
// localIdCounter = 0;
416+
// thenableIndexCounter = 0;
408417

409418
// TODO Warn if no hooks are used at all during mount, then some are used during update.
410419
// Currently we will identify the update render as a mount because memoizedState === null.
@@ -443,6 +452,7 @@ export function renderWithHooks<Props, SecondArg>(
443452
do {
444453
didScheduleRenderPhaseUpdateDuringThisPass = false;
445454
localIdCounter = 0;
455+
thenableIndexCounter = 0;
446456

447457
if (numberOfReRenders >= RE_RENDER_LIMIT) {
448458
throw new Error(
@@ -526,6 +536,7 @@ export function renderWithHooks<Props, SecondArg>(
526536
didScheduleRenderPhaseUpdate = false;
527537
// This is reset by checkDidRenderIdHook
528538
// localIdCounter = 0;
539+
thenableIndexCounter = 0;
529540

530541
if (didRenderTooFewHooks) {
531542
throw new Error(
@@ -633,6 +644,7 @@ export function resetHooksAfterThrow(): void {
633644

634645
didScheduleRenderPhaseUpdateDuringThisPass = false;
635646
localIdCounter = 0;
647+
thenableIndexCounter = 0;
636648
}
637649

638650
function mountWorkInProgressHook(): Hook {
@@ -725,7 +737,70 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue {
725737
}
726738

727739
function use<T>(usable: Usable<T>): T {
728-
throw new Error('Not implemented.');
740+
if (
741+
usable !== null &&
742+
typeof usable === 'object' &&
743+
typeof usable.then === 'function'
744+
) {
745+
// This is a thenable.
746+
const thenable: Thenable<T> = (usable: any);
747+
748+
// Track the position of the thenable within this fiber.
749+
const index = thenableIndexCounter;
750+
thenableIndexCounter += 1;
751+
752+
switch (thenable.status) {
753+
case 'fulfilled': {
754+
const fulfilledValue: T = thenable.value;
755+
return fulfilledValue;
756+
}
757+
case 'rejected': {
758+
const rejectedError = thenable.reason;
759+
throw rejectedError;
760+
}
761+
default: {
762+
const prevThenableAtIndex: Thenable<T> | null = getPreviouslyUsedThenableAtIndex(
763+
index,
764+
);
765+
if (prevThenableAtIndex !== null) {
766+
switch (prevThenableAtIndex.status) {
767+
case 'fulfilled': {
768+
const fulfilledValue: T = prevThenableAtIndex.value;
769+
return fulfilledValue;
770+
}
771+
case 'rejected': {
772+
const rejectedError: mixed = prevThenableAtIndex.reason;
773+
throw rejectedError;
774+
}
775+
default: {
776+
// The thenable still hasn't resolved. Suspend with the same
777+
// thenable as last time to avoid redundant listeners.
778+
throw prevThenableAtIndex;
779+
}
780+
}
781+
} else {
782+
// This is the first time something has been used at this index.
783+
// Stash the thenable at the current index so we can reuse it during
784+
// the next attempt.
785+
trackUsedThenable(thenable, index);
786+
787+
// Suspend.
788+
// TODO: Throwing here is an implementation detail that allows us to
789+
// unwind the call stack. But we shouldn't allow it to leak into
790+
// userspace. Throw an opaque placeholder value instead of the
791+
// actual thenable. If it doesn't get captured by the work loop, log
792+
// a warning, because that means something in userspace must have
793+
// caught it.
794+
throw thenable;
795+
}
796+
}
797+
}
798+
}
799+
800+
// TODO: Add support for Context
801+
802+
// eslint-disable-next-line react-internal/safe-string-coercion
803+
throw new Error('An unsupported type was passed to use(): ' + String(usable));
729804
}
730805

731806
function useMemoCache(size: number): Array<any> {

packages/react-reconciler/src/ReactFiberHooks.old.js

+76-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
ReactContext,
1515
StartTransitionOptions,
1616
Usable,
17+
Thenable,
1718
} from 'shared/ReactTypes';
1819
import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes';
1920
import type {Lanes, Lane} from './ReactFiberLane.old';
@@ -122,6 +123,10 @@ import {
122123
} from './ReactFiberConcurrentUpdates.old';
123124
import {getTreeId} from './ReactFiberTreeContext.old';
124125
import {now} from './Scheduler';
126+
import {
127+
trackUsedThenable,
128+
getPreviouslyUsedThenableAtIndex,
129+
} from './ReactFiberWakeable.old';
125130

126131
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
127132

@@ -207,6 +212,9 @@ let didScheduleRenderPhaseUpdate: boolean = false;
207212
let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false;
208213
// Counts the number of useId hooks in this component.
209214
let localIdCounter: number = 0;
215+
// Counts number of `use`-d thenables
216+
let thenableIndexCounter: number = 0;
217+
210218
// Used for ids that are generated completely client-side (i.e. not during
211219
// hydration). This counter is global, so client ids are not stable across
212220
// render attempts.
@@ -405,6 +413,7 @@ export function renderWithHooks<Props, SecondArg>(
405413

406414
// didScheduleRenderPhaseUpdate = false;
407415
// localIdCounter = 0;
416+
// thenableIndexCounter = 0;
408417

409418
// TODO Warn if no hooks are used at all during mount, then some are used during update.
410419
// Currently we will identify the update render as a mount because memoizedState === null.
@@ -443,6 +452,7 @@ export function renderWithHooks<Props, SecondArg>(
443452
do {
444453
didScheduleRenderPhaseUpdateDuringThisPass = false;
445454
localIdCounter = 0;
455+
thenableIndexCounter = 0;
446456

447457
if (numberOfReRenders >= RE_RENDER_LIMIT) {
448458
throw new Error(
@@ -526,6 +536,7 @@ export function renderWithHooks<Props, SecondArg>(
526536
didScheduleRenderPhaseUpdate = false;
527537
// This is reset by checkDidRenderIdHook
528538
// localIdCounter = 0;
539+
thenableIndexCounter = 0;
529540

530541
if (didRenderTooFewHooks) {
531542
throw new Error(
@@ -633,6 +644,7 @@ export function resetHooksAfterThrow(): void {
633644

634645
didScheduleRenderPhaseUpdateDuringThisPass = false;
635646
localIdCounter = 0;
647+
thenableIndexCounter = 0;
636648
}
637649

638650
function mountWorkInProgressHook(): Hook {
@@ -725,7 +737,70 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue {
725737
}
726738

727739
function use<T>(usable: Usable<T>): T {
728-
throw new Error('Not implemented.');
740+
if (
741+
usable !== null &&
742+
typeof usable === 'object' &&
743+
typeof usable.then === 'function'
744+
) {
745+
// This is a thenable.
746+
const thenable: Thenable<T> = (usable: any);
747+
748+
// Track the position of the thenable within this fiber.
749+
const index = thenableIndexCounter;
750+
thenableIndexCounter += 1;
751+
752+
switch (thenable.status) {
753+
case 'fulfilled': {
754+
const fulfilledValue: T = thenable.value;
755+
return fulfilledValue;
756+
}
757+
case 'rejected': {
758+
const rejectedError = thenable.reason;
759+
throw rejectedError;
760+
}
761+
default: {
762+
const prevThenableAtIndex: Thenable<T> | null = getPreviouslyUsedThenableAtIndex(
763+
index,
764+
);
765+
if (prevThenableAtIndex !== null) {
766+
switch (prevThenableAtIndex.status) {
767+
case 'fulfilled': {
768+
const fulfilledValue: T = prevThenableAtIndex.value;
769+
return fulfilledValue;
770+
}
771+
case 'rejected': {
772+
const rejectedError: mixed = prevThenableAtIndex.reason;
773+
throw rejectedError;
774+
}
775+
default: {
776+
// The thenable still hasn't resolved. Suspend with the same
777+
// thenable as last time to avoid redundant listeners.
778+
throw prevThenableAtIndex;
779+
}
780+
}
781+
} else {
782+
// This is the first time something has been used at this index.
783+
// Stash the thenable at the current index so we can reuse it during
784+
// the next attempt.
785+
trackUsedThenable(thenable, index);
786+
787+
// Suspend.
788+
// TODO: Throwing here is an implementation detail that allows us to
789+
// unwind the call stack. But we shouldn't allow it to leak into
790+
// userspace. Throw an opaque placeholder value instead of the
791+
// actual thenable. If it doesn't get captured by the work loop, log
792+
// a warning, because that means something in userspace must have
793+
// caught it.
794+
throw thenable;
795+
}
796+
}
797+
}
798+
}
799+
800+
// TODO: Add support for Context
801+
802+
// eslint-disable-next-line react-internal/safe-string-coercion
803+
throw new Error('An unsupported type was passed to use(): ' + String(usable));
729804
}
730805

731806
function useMemoCache(size: number): Array<any> {

packages/react-reconciler/src/ReactFiberLane.new.js

+16-2
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,11 @@ export function markStarvedLanesAsExpired(
403403
// Iterate through the pending lanes and check if we've reached their
404404
// expiration time. If so, we'll assume the update is being starved and mark
405405
// it as expired to force it to finish.
406-
let lanes = pendingLanes;
406+
//
407+
// We exclude retry lanes because those must always be time sliced, in order
408+
// to unwrap uncached promises.
409+
// TODO: Write a test for this
410+
let lanes = pendingLanes & ~RetryLanes;
407411
while (lanes > 0) {
408412
const index = pickArbitraryLaneIndex(lanes);
409413
const lane = 1 << index;
@@ -435,7 +439,15 @@ export function getHighestPriorityPendingLanes(root: FiberRoot) {
435439
return getHighestPriorityLanes(root.pendingLanes);
436440
}
437441

438-
export function getLanesToRetrySynchronouslyOnError(root: FiberRoot): Lanes {
442+
export function getLanesToRetrySynchronouslyOnError(
443+
root: FiberRoot,
444+
originallyAttemptedLanes: Lanes,
445+
): Lanes {
446+
if (root.errorRecoveryDisabledLanes & originallyAttemptedLanes) {
447+
// The error recovery mechanism is disabled until these lanes are cleared.
448+
return NoLanes;
449+
}
450+
439451
const everythingButOffscreen = root.pendingLanes & ~OffscreenLane;
440452
if (everythingButOffscreen !== NoLanes) {
441453
return everythingButOffscreen;
@@ -646,6 +658,8 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
646658

647659
root.entangledLanes &= remainingLanes;
648660

661+
root.errorRecoveryDisabledLanes &= remainingLanes;
662+
649663
const entanglements = root.entanglements;
650664
const eventTimes = root.eventTimes;
651665
const expirationTimes = root.expirationTimes;

packages/react-reconciler/src/ReactFiberLane.old.js

+16-2
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,11 @@ export function markStarvedLanesAsExpired(
403403
// Iterate through the pending lanes and check if we've reached their
404404
// expiration time. If so, we'll assume the update is being starved and mark
405405
// it as expired to force it to finish.
406-
let lanes = pendingLanes;
406+
//
407+
// We exclude retry lanes because those must always be time sliced, in order
408+
// to unwrap uncached promises.
409+
// TODO: Write a test for this
410+
let lanes = pendingLanes & ~RetryLanes;
407411
while (lanes > 0) {
408412
const index = pickArbitraryLaneIndex(lanes);
409413
const lane = 1 << index;
@@ -435,7 +439,15 @@ export function getHighestPriorityPendingLanes(root: FiberRoot) {
435439
return getHighestPriorityLanes(root.pendingLanes);
436440
}
437441

438-
export function getLanesToRetrySynchronouslyOnError(root: FiberRoot): Lanes {
442+
export function getLanesToRetrySynchronouslyOnError(
443+
root: FiberRoot,
444+
originallyAttemptedLanes: Lanes,
445+
): Lanes {
446+
if (root.errorRecoveryDisabledLanes & originallyAttemptedLanes) {
447+
// The error recovery mechanism is disabled until these lanes are cleared.
448+
return NoLanes;
449+
}
450+
439451
const everythingButOffscreen = root.pendingLanes & ~OffscreenLane;
440452
if (everythingButOffscreen !== NoLanes) {
441453
return everythingButOffscreen;
@@ -646,6 +658,8 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
646658

647659
root.entangledLanes &= remainingLanes;
648660

661+
root.errorRecoveryDisabledLanes &= remainingLanes;
662+
649663
const entanglements = root.entanglements;
650664
const eventTimes = root.eventTimes;
651665
const expirationTimes = root.expirationTimes;

packages/react-reconciler/src/ReactFiberRoot.new.js

+1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ function FiberRootNode(
7070
this.expiredLanes = NoLanes;
7171
this.mutableReadLanes = NoLanes;
7272
this.finishedLanes = NoLanes;
73+
this.errorRecoveryDisabledLanes = NoLanes;
7374

7475
this.entangledLanes = NoLanes;
7576
this.entanglements = createLaneMap(NoLanes);

packages/react-reconciler/src/ReactFiberRoot.old.js

+1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ function FiberRootNode(
7070
this.expiredLanes = NoLanes;
7171
this.mutableReadLanes = NoLanes;
7272
this.finishedLanes = NoLanes;
73+
this.errorRecoveryDisabledLanes = NoLanes;
7374

7475
this.entangledLanes = NoLanes;
7576
this.entanglements = createLaneMap(NoLanes);

0 commit comments

Comments
 (0)