Skip to content

Commit

Permalink
Suspense (#12279)
Browse files Browse the repository at this point in the history
* Timeout component

Adds Timeout component. If a promise is thrown from inside a Timeout component,
React will suspend the in-progress render from committing. When the promise
resolves, React will retry. If the render is suspended for longer than the
maximum threshold, the Timeout switches to a placeholder state.

The timeout threshold is defined as the minimum of:
- The expiration time of the current render
- The `ms` prop given to each Timeout component in the ancestor path of the
thrown promise.

* Add a test for nested fallbacks

Co-authored-by: Andrew Clark <acdlite@fb.com>

* Resume on promise rejection

React should resume rendering regardless of whether it resolves
or rejects.

* Wrap Suspense code in feature flag

* Children of a Timeout must be strict mode compatible

Async is not required for Suspense, but strict mode is.

* Simplify list of pending work

Some of this was added with "soft expiration" in mind, but now with our revised
model for how soft expiration will work, this isn't necessary.

It would be nice to remove more of this, but I think the list itself is inherent
because we need a way to track the start times, for <Timeout ms={ms} />.

* Only use the Timeout update queue to store promises, not for state

It already worked this way in practice.

* Wrap more Suspense-only paths in the feature flag

* Attach promise listener immediately on suspend

Instead of waiting for commit phase.

* Infer approximate start time using expiration time

* Remove list of pending priority levels

We can replicate almost all the functionality by tracking just five
separate levels: the highest/lowest priority pending levels, the
highest/lowest priority suspended levels, and the lowest pinged level.

We lose a bit of granularity, in that if there are multiple levels of
pending updates, only the first and last ones are known. But in practice
this likely isn't a big deal.

These heuristics are almost entirely isolated to a single module and
can be adjusted later, without API changes, if necessary.

Non-IO-bound work is not affected at all.

* ReactFiberPendingWork -> ReactFiberPendingPriority

* Renaming method names from "pending work" to "pending priority"

* Get rid of SuspenseThenable module

Idk why I thought this was neccessary

* Nits based on Sebastian's feedback

* More naming nits + comments

* Add test for hiding a suspended tree to unblock

* Revert change to expiration time rounding

This means you have to account for the start time approximation
heuristic when writing Suspense tests, but that's going to be
true regardless.

When updating the tests, I also made a fix related to offscreen
priority. We should never timeout inside a hidden tree.

* palceholder -> placeholder
  • Loading branch information
acdlite committed May 11, 2018
1 parent 42a1262 commit 6565795
Show file tree
Hide file tree
Showing 25 changed files with 1,561 additions and 76 deletions.
8 changes: 8 additions & 0 deletions packages/react-reconciler/src/ReactFiber.js
Expand Up @@ -32,6 +32,7 @@ import {
ContextProvider, ContextProvider,
ContextConsumer, ContextConsumer,
Profiler, Profiler,
TimeoutComponent,
} from 'shared/ReactTypeOfWork'; } from 'shared/ReactTypeOfWork';
import getComponentName from 'shared/getComponentName'; import getComponentName from 'shared/getComponentName';


Expand All @@ -47,6 +48,7 @@ import {
REACT_PROVIDER_TYPE, REACT_PROVIDER_TYPE,
REACT_CONTEXT_TYPE, REACT_CONTEXT_TYPE,
REACT_ASYNC_MODE_TYPE, REACT_ASYNC_MODE_TYPE,
REACT_TIMEOUT_TYPE,
} from 'shared/ReactSymbols'; } from 'shared/ReactSymbols';


let hasBadMapPolyfill; let hasBadMapPolyfill;
Expand Down Expand Up @@ -368,6 +370,12 @@ export function createFiberFromElement(
case REACT_RETURN_TYPE: case REACT_RETURN_TYPE:
fiberTag = ReturnComponent; fiberTag = ReturnComponent;
break; break;
case REACT_TIMEOUT_TYPE:
fiberTag = TimeoutComponent;
// Suspense does not require async, but its children should be strict
// mode compatible.
mode |= StrictMode;
break;
default: { default: {
if (typeof type === 'object' && type !== null) { if (typeof type === 'object' && type !== null) {
switch (type.$$typeof) { switch (type.$$typeof) {
Expand Down
52 changes: 50 additions & 2 deletions packages/react-reconciler/src/ReactFiberBeginWork.js
Expand Up @@ -36,19 +36,21 @@ import {
ContextProvider, ContextProvider,
ContextConsumer, ContextConsumer,
Profiler, Profiler,
TimeoutComponent,
} from 'shared/ReactTypeOfWork'; } from 'shared/ReactTypeOfWork';
import { import {
NoEffect, NoEffect,
PerformedWork, PerformedWork,
Placement, Placement,
ContentReset, ContentReset,
Ref,
DidCapture, DidCapture,
Update, Update,
Ref,
} from 'shared/ReactTypeOfSideEffect'; } from 'shared/ReactTypeOfSideEffect';
import {ReactCurrentOwner} from 'shared/ReactGlobalSharedState'; import {ReactCurrentOwner} from 'shared/ReactGlobalSharedState';
import { import {
enableGetDerivedStateFromCatch, enableGetDerivedStateFromCatch,
enableSuspense,
debugRenderPhaseSideEffects, debugRenderPhaseSideEffects,
debugRenderPhaseSideEffectsForStrictMode, debugRenderPhaseSideEffectsForStrictMode,
enableProfilerTimer, enableProfilerTimer,
Expand Down Expand Up @@ -91,8 +93,12 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
newContext: NewContext, newContext: NewContext,
hydrationContext: HydrationContext<C, CX>, hydrationContext: HydrationContext<C, CX>,
scheduleWork: (fiber: Fiber, expirationTime: ExpirationTime) => void, scheduleWork: (fiber: Fiber, expirationTime: ExpirationTime) => void,
computeExpirationForFiber: (fiber: Fiber) => ExpirationTime, computeExpirationForFiber: (
startTime: ExpirationTime,
fiber: Fiber,
) => ExpirationTime,
profilerTimer: ProfilerTimer, profilerTimer: ProfilerTimer,
recalculateCurrentTime: () => ExpirationTime,
) { ) {
const {shouldSetTextContent, shouldDeprioritizeSubtree} = config; const {shouldSetTextContent, shouldDeprioritizeSubtree} = config;


Expand Down Expand Up @@ -132,6 +138,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
computeExpirationForFiber, computeExpirationForFiber,
memoizeProps, memoizeProps,
memoizeState, memoizeState,
recalculateCurrentTime,
); );


// TODO: Remove this and use reconcileChildrenAtExpirationTime directly. // TODO: Remove this and use reconcileChildrenAtExpirationTime directly.
Expand Down Expand Up @@ -758,6 +765,41 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
return workInProgress.stateNode; return workInProgress.stateNode;
} }


function updateTimeoutComponent(
current,
workInProgress,
renderExpirationTime,
) {
if (enableSuspense) {
const nextProps = workInProgress.pendingProps;
const prevProps = workInProgress.memoizedProps;

const prevDidTimeout = workInProgress.memoizedState;

// Check if we already attempted to render the normal state. If we did,
// and we timed out, render the placeholder state.
const alreadyCaptured =
(workInProgress.effectTag & DidCapture) === NoEffect;
const nextDidTimeout = !alreadyCaptured;

if (hasLegacyContextChanged()) {
// Normally we can bail out on props equality but if context has changed
// we don't do the bailout and we have to reuse existing props instead.
} else if (nextProps === prevProps && nextDidTimeout === prevDidTimeout) {
return bailoutOnAlreadyFinishedWork(current, workInProgress);
}

const render = nextProps.children;
const nextChildren = render(nextDidTimeout);
workInProgress.memoizedProps = nextProps;
workInProgress.memoizedState = nextDidTimeout;
reconcileChildren(current, workInProgress, nextChildren);
return workInProgress.child;
} else {
return null;
}
}

function updatePortalComponent( function updatePortalComponent(
current, current,
workInProgress, workInProgress,
Expand Down Expand Up @@ -1209,6 +1251,12 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
// A return component is just a placeholder, we can just run through the // A return component is just a placeholder, we can just run through the
// next one immediately. // next one immediately.
return null; return null;
case TimeoutComponent:
return updateTimeoutComponent(
current,
workInProgress,
renderExpirationTime,
);
case HostPortal: case HostPortal:
return updatePortalComponent( return updatePortalComponent(
current, current,
Expand Down
15 changes: 11 additions & 4 deletions packages/react-reconciler/src/ReactFiberClassComponent.js
Expand Up @@ -152,9 +152,13 @@ export function applyDerivedStateFromProps(
export default function( export default function(
legacyContext: LegacyContext, legacyContext: LegacyContext,
scheduleWork: (fiber: Fiber, expirationTime: ExpirationTime) => void, scheduleWork: (fiber: Fiber, expirationTime: ExpirationTime) => void,
computeExpirationForFiber: (fiber: Fiber) => ExpirationTime, computeExpirationForFiber: (
currentTime: ExpirationTime,
fiber: Fiber,
) => ExpirationTime,
memoizeProps: (workInProgress: Fiber, props: any) => void, memoizeProps: (workInProgress: Fiber, props: any) => void,
memoizeState: (workInProgress: Fiber, state: any) => void, memoizeState: (workInProgress: Fiber, state: any) => void,
recalculateCurrentTime: () => ExpirationTime,
) { ) {
const { const {
cacheContext, cacheContext,
Expand All @@ -168,7 +172,8 @@ export default function(
isMounted, isMounted,
enqueueSetState(inst, payload, callback) { enqueueSetState(inst, payload, callback) {
const fiber = ReactInstanceMap.get(inst); const fiber = ReactInstanceMap.get(inst);
const expirationTime = computeExpirationForFiber(fiber); const currentTime = recalculateCurrentTime();
const expirationTime = computeExpirationForFiber(currentTime, fiber);


const update = createUpdate(expirationTime); const update = createUpdate(expirationTime);
update.payload = payload; update.payload = payload;
Expand All @@ -184,7 +189,8 @@ export default function(
}, },
enqueueReplaceState(inst, payload, callback) { enqueueReplaceState(inst, payload, callback) {
const fiber = ReactInstanceMap.get(inst); const fiber = ReactInstanceMap.get(inst);
const expirationTime = computeExpirationForFiber(fiber); const currentTime = recalculateCurrentTime();
const expirationTime = computeExpirationForFiber(currentTime, fiber);


const update = createUpdate(expirationTime); const update = createUpdate(expirationTime);
update.tag = ReplaceState; update.tag = ReplaceState;
Expand All @@ -202,7 +208,8 @@ export default function(
}, },
enqueueForceUpdate(inst, callback) { enqueueForceUpdate(inst, callback) {
const fiber = ReactInstanceMap.get(inst); const fiber = ReactInstanceMap.get(inst);
const expirationTime = computeExpirationForFiber(fiber); const currentTime = recalculateCurrentTime();
const expirationTime = computeExpirationForFiber(currentTime, fiber);


const update = createUpdate(expirationTime); const update = createUpdate(expirationTime);
update.tag = ForceUpdate; update.tag = ForceUpdate;
Expand Down
8 changes: 8 additions & 0 deletions packages/react-reconciler/src/ReactFiberCommitWork.js
Expand Up @@ -27,6 +27,7 @@ import {
HostPortal, HostPortal,
CallComponent, CallComponent,
Profiler, Profiler,
TimeoutComponent,
} from 'shared/ReactTypeOfWork'; } from 'shared/ReactTypeOfWork';
import ReactErrorUtils from 'shared/ReactErrorUtils'; import ReactErrorUtils from 'shared/ReactErrorUtils';
import { import {
Expand Down Expand Up @@ -314,6 +315,10 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
// We have no life-cycles associated with Profiler. // We have no life-cycles associated with Profiler.
return; return;
} }
case TimeoutComponent: {
// We have no life-cycles associated with Timeouts.
return;
}
default: { default: {
invariant( invariant(
false, false,
Expand Down Expand Up @@ -836,6 +841,9 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
} }
return; return;
} }
case TimeoutComponent: {
return;
}
default: { default: {
invariant( invariant(
false, false,
Expand Down
3 changes: 3 additions & 0 deletions packages/react-reconciler/src/ReactFiberCompleteWork.js
Expand Up @@ -40,6 +40,7 @@ import {
Fragment, Fragment,
Mode, Mode,
Profiler, Profiler,
TimeoutComponent,
} from 'shared/ReactTypeOfWork'; } from 'shared/ReactTypeOfWork';
import {Placement, Ref, Update} from 'shared/ReactTypeOfSideEffect'; import {Placement, Ref, Update} from 'shared/ReactTypeOfSideEffect';
import invariant from 'fbjs/lib/invariant'; import invariant from 'fbjs/lib/invariant';
Expand Down Expand Up @@ -593,6 +594,8 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
return null; return null;
case ForwardRef: case ForwardRef:
return null; return null;
case TimeoutComponent:
return null;
case Fragment: case Fragment:
return null; return null;
case Mode: case Mode:
Expand Down
9 changes: 6 additions & 3 deletions packages/react-reconciler/src/ReactFiberExpirationTime.js
Expand Up @@ -38,8 +38,11 @@ export function computeExpirationBucket(
expirationInMs: number, expirationInMs: number,
bucketSizeMs: number, bucketSizeMs: number,
): ExpirationTime { ): ExpirationTime {
return ceiling( return (
currentTime + expirationInMs / UNIT_SIZE, MAGIC_NUMBER_OFFSET +
bucketSizeMs / UNIT_SIZE, ceiling(
currentTime - MAGIC_NUMBER_OFFSET + expirationInMs / UNIT_SIZE,
bucketSizeMs / UNIT_SIZE,
)
); );
} }

0 comments on commit 6565795

Please sign in to comment.