Skip to content

Commit

Permalink
use(promise)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
acdlite committed Aug 25, 2022
1 parent 9cf575e commit cf0c5ae
Show file tree
Hide file tree
Showing 15 changed files with 701 additions and 127 deletions.
77 changes: 76 additions & 1 deletion packages/react-reconciler/src/ReactFiberHooks.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
ReactContext,
StartTransitionOptions,
Usable,
Thenable,
} from 'shared/ReactTypes';
import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes';
import type {Lanes, Lane} from './ReactFiberLane.new';
Expand Down Expand Up @@ -122,6 +123,10 @@ import {
} from './ReactFiberConcurrentUpdates.new';
import {getTreeId} from './ReactFiberTreeContext.new';
import {now} from './Scheduler';
import {
trackUsedThenable,
getPreviouslyUsedThenableAtIndex,
} from './ReactFiberWakeable.new';

const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;

Expand Down Expand Up @@ -207,6 +212,9 @@ let didScheduleRenderPhaseUpdate: boolean = false;
let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false;
// Counts the number of useId hooks in this component.
let localIdCounter: number = 0;
// Counts number of `use`-d thenables
let thenableIndexCounter: number = 0;

// Used for ids that are generated completely client-side (i.e. not during
// hydration). This counter is global, so client ids are not stable across
// render attempts.
Expand Down Expand Up @@ -405,6 +413,7 @@ export function renderWithHooks<Props, SecondArg>(

// didScheduleRenderPhaseUpdate = false;
// localIdCounter = 0;
// thenableIndexCounter = 0;

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

if (numberOfReRenders >= RE_RENDER_LIMIT) {
throw new Error(
Expand Down Expand Up @@ -526,6 +536,7 @@ export function renderWithHooks<Props, SecondArg>(
didScheduleRenderPhaseUpdate = false;
// This is reset by checkDidRenderIdHook
// localIdCounter = 0;
thenableIndexCounter = 0;

if (didRenderTooFewHooks) {
throw new Error(
Expand Down Expand Up @@ -633,6 +644,7 @@ export function resetHooksAfterThrow(): void {

didScheduleRenderPhaseUpdateDuringThisPass = false;
localIdCounter = 0;
thenableIndexCounter = 0;
}

function mountWorkInProgressHook(): Hook {
Expand Down Expand Up @@ -725,7 +737,70 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue {
}

function use<T>(usable: Usable<T>): T {
throw new Error('Not implemented.');
if (
usable !== null &&
typeof usable === 'object' &&
typeof usable.then === 'function'
) {
// This is a thenable.
const thenable: Thenable<T> = (usable: any);

// Track the position of the thenable within this fiber.
const index = thenableIndexCounter;
thenableIndexCounter += 1;

switch (thenable.status) {
case 'fulfilled': {
const fulfilledValue: T = thenable.value;
return fulfilledValue;
}
case 'rejected': {
const rejectedError = thenable.reason;
throw rejectedError;
}
default: {
const prevThenableAtIndex: Thenable<T> | null = getPreviouslyUsedThenableAtIndex(
index,
);
if (prevThenableAtIndex !== null) {
switch (prevThenableAtIndex.status) {
case 'fulfilled': {
const fulfilledValue: T = prevThenableAtIndex.value;
return fulfilledValue;
}
case 'rejected': {
const rejectedError: mixed = prevThenableAtIndex.reason;
throw rejectedError;
}
default: {
// The thenable still hasn't resolved. Suspend with the same
// thenable as last time to avoid redundant listeners.
throw prevThenableAtIndex;
}
}
} else {
// This is the first time something has been used at this index.
// Stash the thenable at the current index so we can reuse it during
// the next attempt.
trackUsedThenable(thenable, index);

// Suspend.
// TODO: Throwing here is an implementation detail that allows us to
// unwind the call stack. But we shouldn't allow it to leak into
// userspace. Throw an opaque placeholder value instead of the
// actual thenable. If it doesn't get captured by the work loop, log
// a warning, because that means something in userspace must have
// caught it.
throw thenable;
}
}
}
}

// TODO: Add support for Context

// eslint-disable-next-line react-internal/safe-string-coercion
throw new Error('An unsupported type was passed to use(): ' + String(usable));
}

function useMemoCache(size: number): Array<any> {
Expand Down
77 changes: 76 additions & 1 deletion packages/react-reconciler/src/ReactFiberHooks.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
ReactContext,
StartTransitionOptions,
Usable,
Thenable,
} from 'shared/ReactTypes';
import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes';
import type {Lanes, Lane} from './ReactFiberLane.old';
Expand Down Expand Up @@ -122,6 +123,10 @@ import {
} from './ReactFiberConcurrentUpdates.old';
import {getTreeId} from './ReactFiberTreeContext.old';
import {now} from './Scheduler';
import {
trackUsedThenable,
getPreviouslyUsedThenableAtIndex,
} from './ReactFiberWakeable.old';

const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;

Expand Down Expand Up @@ -207,6 +212,9 @@ let didScheduleRenderPhaseUpdate: boolean = false;
let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false;
// Counts the number of useId hooks in this component.
let localIdCounter: number = 0;
// Counts number of `use`-d thenables
let thenableIndexCounter: number = 0;

// Used for ids that are generated completely client-side (i.e. not during
// hydration). This counter is global, so client ids are not stable across
// render attempts.
Expand Down Expand Up @@ -405,6 +413,7 @@ export function renderWithHooks<Props, SecondArg>(

// didScheduleRenderPhaseUpdate = false;
// localIdCounter = 0;
// thenableIndexCounter = 0;

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

if (numberOfReRenders >= RE_RENDER_LIMIT) {
throw new Error(
Expand Down Expand Up @@ -526,6 +536,7 @@ export function renderWithHooks<Props, SecondArg>(
didScheduleRenderPhaseUpdate = false;
// This is reset by checkDidRenderIdHook
// localIdCounter = 0;
thenableIndexCounter = 0;

if (didRenderTooFewHooks) {
throw new Error(
Expand Down Expand Up @@ -633,6 +644,7 @@ export function resetHooksAfterThrow(): void {

didScheduleRenderPhaseUpdateDuringThisPass = false;
localIdCounter = 0;
thenableIndexCounter = 0;
}

function mountWorkInProgressHook(): Hook {
Expand Down Expand Up @@ -725,7 +737,70 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue {
}

function use<T>(usable: Usable<T>): T {
throw new Error('Not implemented.');
if (
usable !== null &&
typeof usable === 'object' &&
typeof usable.then === 'function'
) {
// This is a thenable.
const thenable: Thenable<T> = (usable: any);

// Track the position of the thenable within this fiber.
const index = thenableIndexCounter;
thenableIndexCounter += 1;

switch (thenable.status) {
case 'fulfilled': {
const fulfilledValue: T = thenable.value;
return fulfilledValue;
}
case 'rejected': {
const rejectedError = thenable.reason;
throw rejectedError;
}
default: {
const prevThenableAtIndex: Thenable<T> | null = getPreviouslyUsedThenableAtIndex(
index,
);
if (prevThenableAtIndex !== null) {
switch (prevThenableAtIndex.status) {
case 'fulfilled': {
const fulfilledValue: T = prevThenableAtIndex.value;
return fulfilledValue;
}
case 'rejected': {
const rejectedError: mixed = prevThenableAtIndex.reason;
throw rejectedError;
}
default: {
// The thenable still hasn't resolved. Suspend with the same
// thenable as last time to avoid redundant listeners.
throw prevThenableAtIndex;
}
}
} else {
// This is the first time something has been used at this index.
// Stash the thenable at the current index so we can reuse it during
// the next attempt.
trackUsedThenable(thenable, index);

// Suspend.
// TODO: Throwing here is an implementation detail that allows us to
// unwind the call stack. But we shouldn't allow it to leak into
// userspace. Throw an opaque placeholder value instead of the
// actual thenable. If it doesn't get captured by the work loop, log
// a warning, because that means something in userspace must have
// caught it.
throw thenable;
}
}
}
}

// TODO: Add support for Context

// eslint-disable-next-line react-internal/safe-string-coercion
throw new Error('An unsupported type was passed to use(): ' + String(usable));
}

function useMemoCache(size: number): Array<any> {
Expand Down
18 changes: 16 additions & 2 deletions packages/react-reconciler/src/ReactFiberLane.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,11 @@ export function markStarvedLanesAsExpired(
// Iterate through the pending lanes and check if we've reached their
// expiration time. If so, we'll assume the update is being starved and mark
// it as expired to force it to finish.
let lanes = pendingLanes;
//
// We exclude retry lanes because those must always be time sliced, in order
// to unwrap uncached promises.
// TODO: Write a test for this
let lanes = pendingLanes & ~RetryLanes;
while (lanes > 0) {
const index = pickArbitraryLaneIndex(lanes);
const lane = 1 << index;
Expand Down Expand Up @@ -435,7 +439,15 @@ export function getHighestPriorityPendingLanes(root: FiberRoot) {
return getHighestPriorityLanes(root.pendingLanes);
}

export function getLanesToRetrySynchronouslyOnError(root: FiberRoot): Lanes {
export function getLanesToRetrySynchronouslyOnError(
root: FiberRoot,
originallyAttemptedLanes: Lanes,
): Lanes {
if (root.errorRecoveryDisabledLanes & originallyAttemptedLanes) {
// The error recovery mechanism is disabled until these lanes are cleared.
return NoLanes;
}

const everythingButOffscreen = root.pendingLanes & ~OffscreenLane;
if (everythingButOffscreen !== NoLanes) {
return everythingButOffscreen;
Expand Down Expand Up @@ -646,6 +658,8 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {

root.entangledLanes &= remainingLanes;

root.errorRecoveryDisabledLanes &= remainingLanes;

const entanglements = root.entanglements;
const eventTimes = root.eventTimes;
const expirationTimes = root.expirationTimes;
Expand Down
18 changes: 16 additions & 2 deletions packages/react-reconciler/src/ReactFiberLane.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,11 @@ export function markStarvedLanesAsExpired(
// Iterate through the pending lanes and check if we've reached their
// expiration time. If so, we'll assume the update is being starved and mark
// it as expired to force it to finish.
let lanes = pendingLanes;
//
// We exclude retry lanes because those must always be time sliced, in order
// to unwrap uncached promises.
// TODO: Write a test for this
let lanes = pendingLanes & ~RetryLanes;
while (lanes > 0) {
const index = pickArbitraryLaneIndex(lanes);
const lane = 1 << index;
Expand Down Expand Up @@ -435,7 +439,15 @@ export function getHighestPriorityPendingLanes(root: FiberRoot) {
return getHighestPriorityLanes(root.pendingLanes);
}

export function getLanesToRetrySynchronouslyOnError(root: FiberRoot): Lanes {
export function getLanesToRetrySynchronouslyOnError(
root: FiberRoot,
originallyAttemptedLanes: Lanes,
): Lanes {
if (root.errorRecoveryDisabledLanes & originallyAttemptedLanes) {
// The error recovery mechanism is disabled until these lanes are cleared.
return NoLanes;
}

const everythingButOffscreen = root.pendingLanes & ~OffscreenLane;
if (everythingButOffscreen !== NoLanes) {
return everythingButOffscreen;
Expand Down Expand Up @@ -646,6 +658,8 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {

root.entangledLanes &= remainingLanes;

root.errorRecoveryDisabledLanes &= remainingLanes;

const entanglements = root.entanglements;
const eventTimes = root.eventTimes;
const expirationTimes = root.expirationTimes;
Expand Down
1 change: 1 addition & 0 deletions packages/react-reconciler/src/ReactFiberRoot.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function FiberRootNode(
this.expiredLanes = NoLanes;
this.mutableReadLanes = NoLanes;
this.finishedLanes = NoLanes;
this.errorRecoveryDisabledLanes = NoLanes;

this.entangledLanes = NoLanes;
this.entanglements = createLaneMap(NoLanes);
Expand Down
1 change: 1 addition & 0 deletions packages/react-reconciler/src/ReactFiberRoot.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function FiberRootNode(
this.expiredLanes = NoLanes;
this.mutableReadLanes = NoLanes;
this.finishedLanes = NoLanes;
this.errorRecoveryDisabledLanes = NoLanes;

this.entangledLanes = NoLanes;
this.entanglements = createLaneMap(NoLanes);
Expand Down
Loading

0 comments on commit cf0c5ae

Please sign in to comment.