Skip to content

Commit 3a5683e

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 07313db commit 3a5683e

8 files changed

+349
-17
lines changed

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

+94-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';
@@ -121,6 +122,10 @@ import {
121122
} from './ReactFiberConcurrentUpdates.new';
122123
import {getTreeId} from './ReactFiberTreeContext.new';
123124
import {now} from './Scheduler';
125+
import {
126+
trackSuspendedThenable,
127+
getSuspendedThenable,
128+
} from './ReactFiberWakeable.new';
124129

125130
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
126131

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

405413
// didScheduleRenderPhaseUpdate = false;
406414
// localIdCounter = 0;
415+
// thenableIndexCounter = 0;
407416

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

446456
if (numberOfReRenders >= RE_RENDER_LIMIT) {
447457
throw new Error(
@@ -525,6 +535,7 @@ export function renderWithHooks<Props, SecondArg>(
525535
didScheduleRenderPhaseUpdate = false;
526536
// This is reset by checkDidRenderIdHook
527537
// localIdCounter = 0;
538+
thenableIndexCounter = 0;
528539

529540
if (didRenderTooFewHooks) {
530541
throw new Error(
@@ -632,6 +643,7 @@ export function resetHooksAfterThrow(): void {
632643

633644
didScheduleRenderPhaseUpdateDuringThisPass = false;
634645
localIdCounter = 0;
646+
thenableIndexCounter = 0;
635647
}
636648

637649
function mountWorkInProgressHook(): Hook {
@@ -724,7 +736,88 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue {
724736
}
725737

726738
function use<T>(usable: Usable<T>): T {
727-
throw new Error('Not implemented.');
739+
if (
740+
usable !== null &&
741+
typeof usable === 'object' &&
742+
typeof usable.then === 'function'
743+
) {
744+
// This is a thenable.
745+
const thenable: Thenable<T> = (usable: any);
746+
747+
// Track the position of the thenable within this fiber.
748+
const index = thenableIndexCounter;
749+
thenableIndexCounter += 1;
750+
751+
switch (thenable.status) {
752+
case 'fulfilled': {
753+
const fulfilledValue: T = (thenable.value: any);
754+
return fulfilledValue;
755+
}
756+
case 'rejected': {
757+
const rejectedError: mixed = thenable.value;
758+
throw rejectedError;
759+
}
760+
default: {
761+
const prevThenableAtIndex = getSuspendedThenable(index);
762+
if (prevThenableAtIndex !== null) {
763+
switch (prevThenableAtIndex.status) {
764+
case 'fulfilled': {
765+
const fulfilledValue: T = (prevThenableAtIndex.value: any);
766+
return fulfilledValue;
767+
}
768+
case 'rejected': {
769+
const rejectedError: mixed = prevThenableAtIndex.value;
770+
throw rejectedError;
771+
}
772+
default: {
773+
// The thenable still hasn't resolved. Suspend with the same
774+
// thenable as last time to avoid redundant listeners.
775+
throw prevThenableAtIndex;
776+
}
777+
}
778+
} else {
779+
// This is the first time something has been used at this index.
780+
// Stash the thenable at the current index so we can reuse it during
781+
// the next attempt.
782+
trackSuspendedThenable(thenable, index);
783+
784+
// Assume that if the status is `pending`, there's already a listener
785+
// that will set it to `fulfilled` or `rejected`.
786+
// TODO: We could do this in `pingSuspendedRoot` instead.
787+
if (thenable.status !== 'pending') {
788+
thenable.status = 'pending';
789+
thenable.then(
790+
fulfilledValue => {
791+
if (thenable.status === 'pending') {
792+
thenable.status = 'fulfilled';
793+
thenable.value = fulfilledValue;
794+
}
795+
},
796+
(error: mixed) => {
797+
if (thenable.status === 'pending') {
798+
thenable.status = 'rejected';
799+
thenable.value = error;
800+
}
801+
},
802+
);
803+
}
804+
throw thenable;
805+
}
806+
}
807+
}
808+
}
809+
810+
// TODO: Add support for Context
811+
812+
if (__DEV__) {
813+
console.error(
814+
'An unsupported type was passed to use(). The currently supported ' +
815+
'types are promises and thenables. Instead received: %s',
816+
usable,
817+
);
818+
}
819+
820+
throw new Error('An unsupported type was passed to use().');
728821
}
729822

730823
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {

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

+94-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';
@@ -121,6 +122,10 @@ import {
121122
} from './ReactFiberConcurrentUpdates.old';
122123
import {getTreeId} from './ReactFiberTreeContext.old';
123124
import {now} from './Scheduler';
125+
import {
126+
trackSuspendedThenable,
127+
getSuspendedThenable,
128+
} from './ReactFiberWakeable.old';
124129

125130
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
126131

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

405413
// didScheduleRenderPhaseUpdate = false;
406414
// localIdCounter = 0;
415+
// thenableIndexCounter = 0;
407416

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

446456
if (numberOfReRenders >= RE_RENDER_LIMIT) {
447457
throw new Error(
@@ -525,6 +535,7 @@ export function renderWithHooks<Props, SecondArg>(
525535
didScheduleRenderPhaseUpdate = false;
526536
// This is reset by checkDidRenderIdHook
527537
// localIdCounter = 0;
538+
thenableIndexCounter = 0;
528539

529540
if (didRenderTooFewHooks) {
530541
throw new Error(
@@ -632,6 +643,7 @@ export function resetHooksAfterThrow(): void {
632643

633644
didScheduleRenderPhaseUpdateDuringThisPass = false;
634645
localIdCounter = 0;
646+
thenableIndexCounter = 0;
635647
}
636648

637649
function mountWorkInProgressHook(): Hook {
@@ -724,7 +736,88 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue {
724736
}
725737

726738
function use<T>(usable: Usable<T>): T {
727-
throw new Error('Not implemented.');
739+
if (
740+
usable !== null &&
741+
typeof usable === 'object' &&
742+
typeof usable.then === 'function'
743+
) {
744+
// This is a thenable.
745+
const thenable: Thenable<T> = (usable: any);
746+
747+
// Track the position of the thenable within this fiber.
748+
const index = thenableIndexCounter;
749+
thenableIndexCounter += 1;
750+
751+
switch (thenable.status) {
752+
case 'fulfilled': {
753+
const fulfilledValue: T = (thenable.value: any);
754+
return fulfilledValue;
755+
}
756+
case 'rejected': {
757+
const rejectedError: mixed = thenable.value;
758+
throw rejectedError;
759+
}
760+
default: {
761+
const prevThenableAtIndex = getSuspendedThenable(index);
762+
if (prevThenableAtIndex !== null) {
763+
switch (prevThenableAtIndex.status) {
764+
case 'fulfilled': {
765+
const fulfilledValue: T = (prevThenableAtIndex.value: any);
766+
return fulfilledValue;
767+
}
768+
case 'rejected': {
769+
const rejectedError: mixed = prevThenableAtIndex.value;
770+
throw rejectedError;
771+
}
772+
default: {
773+
// The thenable still hasn't resolved. Suspend with the same
774+
// thenable as last time to avoid redundant listeners.
775+
throw prevThenableAtIndex;
776+
}
777+
}
778+
} else {
779+
// This is the first time something has been used at this index.
780+
// Stash the thenable at the current index so we can reuse it during
781+
// the next attempt.
782+
trackSuspendedThenable(thenable, index);
783+
784+
// Assume that if the status is `pending`, there's already a listener
785+
// that will set it to `fulfilled` or `rejected`.
786+
// TODO: We could do this in `pingSuspendedRoot` instead.
787+
if (thenable.status !== 'pending') {
788+
thenable.status = 'pending';
789+
thenable.then(
790+
fulfilledValue => {
791+
if (thenable.status === 'pending') {
792+
thenable.status = 'fulfilled';
793+
thenable.value = fulfilledValue;
794+
}
795+
},
796+
(error: mixed) => {
797+
if (thenable.status === 'pending') {
798+
thenable.status = 'rejected';
799+
thenable.value = error;
800+
}
801+
},
802+
);
803+
}
804+
throw thenable;
805+
}
806+
}
807+
}
808+
}
809+
810+
// TODO: Add support for Context
811+
812+
if (__DEV__) {
813+
console.error(
814+
'An unsupported type was passed to use(). The currently supported ' +
815+
'types are promises and thenables. Instead received: %s',
816+
usable,
817+
);
818+
}
819+
820+
throw new Error('An unsupported type was passed to use().');
728821
}
729822

730823
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {

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

+37-3
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,28 @@
77
* @flow
88
*/
99

10-
import type {Wakeable} from 'shared/ReactTypes';
10+
import type {Wakeable, Thenable} from 'shared/ReactTypes';
1111

1212
let suspendedWakeable: Wakeable | null = null;
1313
let wasPinged = false;
1414
let adHocSuspendCount: number = 0;
1515

16+
let suspendedThenables: Array<Thenable<any> | void> | null = null;
17+
let lastUsedThenable: Thenable<any> | null = null;
18+
1619
const MAX_AD_HOC_SUSPEND_COUNT = 50;
1720

1821
export function suspendedWakeableWasPinged() {
1922
return wasPinged;
2023
}
2124

2225
export function trackSuspendedWakeable(wakeable: Wakeable) {
23-
adHocSuspendCount++;
26+
if (wakeable !== lastUsedThenable) {
27+
// If this wakeable was not just `use`-d, it must be an ad hoc wakeable
28+
// that was thrown by an older Suspense implementation. Keep a count of
29+
// these so that we can detect an infinite ping loop.
30+
adHocSuspendCount++;
31+
}
2432
suspendedWakeable = wakeable;
2533
}
2634

@@ -35,10 +43,15 @@ export function attemptToPingSuspendedWakeable(wakeable: Wakeable) {
3543
return false;
3644
}
3745

38-
export function resetWakeableState() {
46+
export function resetWakeableStateAfterEachAttempt() {
3947
suspendedWakeable = null;
4048
wasPinged = false;
4149
adHocSuspendCount = 0;
50+
lastUsedThenable = null;
51+
}
52+
53+
export function resetThenableStateOnCompletion() {
54+
suspendedThenables = null;
4255
}
4356

4457
export function throwIfInfinitePingLoopDetected() {
@@ -48,3 +61,24 @@ export function throwIfInfinitePingLoopDetected() {
4861
// the render phase so that it gets the component stack.
4962
}
5063
}
64+
65+
export function trackSuspendedThenable<T>(
66+
thenable: Thenable<T>,
67+
index: number,
68+
) {
69+
if (suspendedThenables === null) {
70+
suspendedThenables = [];
71+
}
72+
suspendedThenables[index] = thenable;
73+
lastUsedThenable = thenable;
74+
}
75+
76+
export function getSuspendedThenable<T>(index: number): Thenable<T> | null {
77+
if (suspendedThenables !== null) {
78+
const thenable = suspendedThenables[index];
79+
if (thenable !== undefined) {
80+
return thenable;
81+
}
82+
}
83+
return null;
84+
}

0 commit comments

Comments
 (0)