Skip to content

Commit

Permalink
experimental_use(promise) (#25084)
Browse files Browse the repository at this point in the history
* Internal `act`: Unwrapping resolved promises

This update our internal implementation of `act` to support React's new
behavior for unwrapping promises. Like we did with Scheduler, when 
something suspends, it will yield to the main thread so the microtasks
can run, then continue in a new task.

I need to implement the same behavior in the public version of `act`,
but there are some additional considerations so I'll do that in a
separate commit.

* Move throwException to after work loop resumes

throwException is the function that finds the nearest boundary and
schedules it for a second render pass. We should only call it right 
before we unwind the stack — not if we receive an immediate ping and
render the fiber again.

This was an oversight in 8ef3a7c that I didn't notice because it happens
to mostly work, anyway. What made me notice the mistake is that
throwException also marks the entire render phase as suspended
(RootDidSuspend or RootDidSuspendWithDelay), which is only supposed to
be happen if we show a fallback. One consequence was that, in the 
RootDidSuspendWithDelay case, the entire commit phase was blocked,
because that's the exit status we use to block a bad fallback
from appearing.

* Use expando to check whether promise has resolved

Add a `status` expando to a thrown thenable to track when its value has
resolved.

In a later step, we'll also use `value` and `reason` expandos to track
the resolved value.

This is not part of the official JavaScript spec — think of
it as an extension of the Promise API, or a custom interface that is a
superset of Thenable. However, it's inspired by the terminology used
by `Promise.allSettled`.

The intent is that this will be a public API — Suspense implementations
can set these expandos to allow React to unwrap the value synchronously
without waiting a microtask.

* Scaffolding for `experimental_use` hook

Sets up a new experimental hook behind a feature flag, but does not
implement it yet.

* 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.
  • Loading branch information
acdlite committed Aug 25, 2022
1 parent 11ed701 commit b6978bc
Show file tree
Hide file tree
Showing 33 changed files with 1,398 additions and 439 deletions.
36 changes: 24 additions & 12 deletions packages/jest-react/src/internalAct.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import enqueueTask from 'shared/enqueueTask';
let actingUpdatesScopeDepth = 0;

export function act<T>(scope: () => Thenable<T> | T): Thenable<T> {
if (Scheduler.unstable_flushAllWithoutAsserting === undefined) {
if (Scheduler.unstable_flushUntilNextPaint === undefined) {
throw Error(
'This version of `act` requires a special mock build of Scheduler.',
);
Expand Down Expand Up @@ -120,19 +120,31 @@ export function act<T>(scope: () => Thenable<T> | T): Thenable<T> {
}

function flushActWork(resolve, reject) {
// Flush suspended fallbacks
// $FlowFixMe: Flow doesn't know about global Jest object
jest.runOnlyPendingTimers();
enqueueTask(() => {
if (Scheduler.unstable_hasPendingWork()) {
try {
const didFlushWork = Scheduler.unstable_flushAllWithoutAsserting();
if (didFlushWork) {
flushActWork(resolve, reject);
} else {
resolve();
}
Scheduler.unstable_flushUntilNextPaint();
} catch (error) {
reject(error);
}
});

// If Scheduler yields while there's still work, it's so that we can
// unblock the main thread (e.g. for paint or for microtasks). Yield to
// the main thread and continue in a new task.
enqueueTask(() => flushActWork(resolve, reject));
return;
}

// Once the scheduler queue is empty, run all the timers. The purpose of this
// is to force any pending fallbacks to commit. The public version of act does
// this with dev-only React runtime logic, but since our internal act needs to
// work work production builds of React, we have to cheat.
// $FlowFixMe: Flow doesn't know about global Jest object
jest.runOnlyPendingTimers();
if (Scheduler.unstable_hasPendingWork()) {
// Committing a fallback scheduled additional work. Continue flushing.
flushActWork(resolve, reject);
return;
}

resolve();
}
129 changes: 129 additions & 0 deletions packages/react-reconciler/src/ReactFiberHooks.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import type {
MutableSourceSubscribeFn,
ReactContext,
StartTransitionOptions,
Usable,
Thenable,
} from 'shared/ReactTypes';
import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes';
import type {Lanes, Lane} from './ReactFiberLane.new';
Expand All @@ -32,6 +34,7 @@ import {
enableLazyContextPropagation,
enableUseMutableSource,
enableTransitionTracing,
enableUseHook,
enableUseMemoCacheHook,
} from 'shared/ReactFeatureFlags';

Expand Down Expand Up @@ -120,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 @@ -205,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 @@ -403,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 @@ -441,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 @@ -524,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 @@ -631,6 +644,7 @@ export function resetHooksAfterThrow(): void {

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

function mountWorkInProgressHook(): Hook {
Expand Down Expand Up @@ -722,6 +736,73 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue {
};
}

function use<T>(usable: Usable<T>): T {
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> {
throw new Error('Not implemented.');
}
Expand Down Expand Up @@ -2421,6 +2502,9 @@ if (enableCache) {
(ContextOnlyDispatcher: Dispatcher).getCacheForType = getCacheForType;
(ContextOnlyDispatcher: Dispatcher).useCacheRefresh = throwInvalidHookError;
}
if (enableUseHook) {
(ContextOnlyDispatcher: Dispatcher).use = throwInvalidHookError;
}
if (enableUseMemoCacheHook) {
(ContextOnlyDispatcher: Dispatcher).useMemoCache = throwInvalidHookError;
}
Expand Down Expand Up @@ -2452,6 +2536,9 @@ if (enableCache) {
(HooksDispatcherOnMount: Dispatcher).getCacheForType = getCacheForType;
(HooksDispatcherOnMount: Dispatcher).useCacheRefresh = mountRefresh;
}
if (enableUseHook) {
(HooksDispatcherOnMount: Dispatcher).use = use;
}
if (enableUseMemoCacheHook) {
(HooksDispatcherOnMount: Dispatcher).useMemoCache = useMemoCache;
}
Expand Down Expand Up @@ -2485,6 +2572,9 @@ if (enableCache) {
if (enableUseMemoCacheHook) {
(HooksDispatcherOnUpdate: Dispatcher).useMemoCache = useMemoCache;
}
if (enableUseHook) {
(HooksDispatcherOnUpdate: Dispatcher).use = use;
}

const HooksDispatcherOnRerender: Dispatcher = {
readContext,
Expand Down Expand Up @@ -2513,6 +2603,9 @@ if (enableCache) {
(HooksDispatcherOnRerender: Dispatcher).getCacheForType = getCacheForType;
(HooksDispatcherOnRerender: Dispatcher).useCacheRefresh = updateRefresh;
}
if (enableUseHook) {
(HooksDispatcherOnRerender: Dispatcher).use = use;
}
if (enableUseMemoCacheHook) {
(HooksDispatcherOnRerender: Dispatcher).useMemoCache = useMemoCache;
}
Expand Down Expand Up @@ -2691,6 +2784,9 @@ if (__DEV__) {
return mountRefresh();
};
}
if (enableUseHook) {
(HooksDispatcherOnMountInDEV: Dispatcher).use = use;
}
if (enableUseMemoCacheHook) {
(HooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = useMemoCache;
}
Expand Down Expand Up @@ -2836,6 +2932,9 @@ if (__DEV__) {
return mountRefresh();
};
}
if (enableUseHook) {
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).use = use;
}
if (enableUseMemoCacheHook) {
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useMemoCache = useMemoCache;
}
Expand Down Expand Up @@ -2981,6 +3080,9 @@ if (__DEV__) {
return updateRefresh();
};
}
if (enableUseHook) {
(HooksDispatcherOnUpdateInDEV: Dispatcher).use = use;
}
if (enableUseMemoCacheHook) {
(HooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = useMemoCache;
}
Expand Down Expand Up @@ -3127,6 +3229,9 @@ if (__DEV__) {
return updateRefresh();
};
}
if (enableUseHook) {
(HooksDispatcherOnRerenderInDEV: Dispatcher).use = use;
}
if (enableUseMemoCacheHook) {
(HooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = useMemoCache;
}
Expand Down Expand Up @@ -3289,6 +3394,14 @@ if (__DEV__) {
return mountRefresh();
};
}
if (enableUseHook) {
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).use = function<T>(
usable: Usable<T>,
): T {
warnInvalidHookAccess();
return use(usable);
};
}
if (enableUseMemoCacheHook) {
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = function(
size: number,
Expand Down Expand Up @@ -3456,6 +3569,14 @@ if (__DEV__) {
return updateRefresh();
};
}
if (enableUseHook) {
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).use = function<T>(
usable: Usable<T>,
): T {
warnInvalidHookAccess();
return use(usable);
};
}
if (enableUseMemoCacheHook) {
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = function(
size: number,
Expand Down Expand Up @@ -3624,6 +3745,14 @@ if (__DEV__) {
return updateRefresh();
};
}
if (enableUseHook) {
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).use = function<T>(
usable: Usable<T>,
): T {
warnInvalidHookAccess();
return use(usable);
};
}
if (enableUseMemoCacheHook) {
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = function(
size: number,
Expand Down

0 comments on commit b6978bc

Please sign in to comment.