Skip to content

Commit 6565795

Browse files
authored
Suspense (#12279)
* 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 <[email protected]> * 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
1 parent 42a1262 commit 6565795

25 files changed

+1561
-76
lines changed

packages/react-reconciler/src/ReactFiber.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
ContextProvider,
3333
ContextConsumer,
3434
Profiler,
35+
TimeoutComponent,
3536
} from 'shared/ReactTypeOfWork';
3637
import getComponentName from 'shared/getComponentName';
3738

@@ -47,6 +48,7 @@ import {
4748
REACT_PROVIDER_TYPE,
4849
REACT_CONTEXT_TYPE,
4950
REACT_ASYNC_MODE_TYPE,
51+
REACT_TIMEOUT_TYPE,
5052
} from 'shared/ReactSymbols';
5153

5254
let hasBadMapPolyfill;
@@ -368,6 +370,12 @@ export function createFiberFromElement(
368370
case REACT_RETURN_TYPE:
369371
fiberTag = ReturnComponent;
370372
break;
373+
case REACT_TIMEOUT_TYPE:
374+
fiberTag = TimeoutComponent;
375+
// Suspense does not require async, but its children should be strict
376+
// mode compatible.
377+
mode |= StrictMode;
378+
break;
371379
default: {
372380
if (typeof type === 'object' && type !== null) {
373381
switch (type.$$typeof) {

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,21 @@ import {
3636
ContextProvider,
3737
ContextConsumer,
3838
Profiler,
39+
TimeoutComponent,
3940
} from 'shared/ReactTypeOfWork';
4041
import {
4142
NoEffect,
4243
PerformedWork,
4344
Placement,
4445
ContentReset,
45-
Ref,
4646
DidCapture,
4747
Update,
48+
Ref,
4849
} from 'shared/ReactTypeOfSideEffect';
4950
import {ReactCurrentOwner} from 'shared/ReactGlobalSharedState';
5051
import {
5152
enableGetDerivedStateFromCatch,
53+
enableSuspense,
5254
debugRenderPhaseSideEffects,
5355
debugRenderPhaseSideEffectsForStrictMode,
5456
enableProfilerTimer,
@@ -91,8 +93,12 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
9193
newContext: NewContext,
9294
hydrationContext: HydrationContext<C, CX>,
9395
scheduleWork: (fiber: Fiber, expirationTime: ExpirationTime) => void,
94-
computeExpirationForFiber: (fiber: Fiber) => ExpirationTime,
96+
computeExpirationForFiber: (
97+
startTime: ExpirationTime,
98+
fiber: Fiber,
99+
) => ExpirationTime,
95100
profilerTimer: ProfilerTimer,
101+
recalculateCurrentTime: () => ExpirationTime,
96102
) {
97103
const {shouldSetTextContent, shouldDeprioritizeSubtree} = config;
98104

@@ -132,6 +138,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
132138
computeExpirationForFiber,
133139
memoizeProps,
134140
memoizeState,
141+
recalculateCurrentTime,
135142
);
136143

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

768+
function updateTimeoutComponent(
769+
current,
770+
workInProgress,
771+
renderExpirationTime,
772+
) {
773+
if (enableSuspense) {
774+
const nextProps = workInProgress.pendingProps;
775+
const prevProps = workInProgress.memoizedProps;
776+
777+
const prevDidTimeout = workInProgress.memoizedState;
778+
779+
// Check if we already attempted to render the normal state. If we did,
780+
// and we timed out, render the placeholder state.
781+
const alreadyCaptured =
782+
(workInProgress.effectTag & DidCapture) === NoEffect;
783+
const nextDidTimeout = !alreadyCaptured;
784+
785+
if (hasLegacyContextChanged()) {
786+
// Normally we can bail out on props equality but if context has changed
787+
// we don't do the bailout and we have to reuse existing props instead.
788+
} else if (nextProps === prevProps && nextDidTimeout === prevDidTimeout) {
789+
return bailoutOnAlreadyFinishedWork(current, workInProgress);
790+
}
791+
792+
const render = nextProps.children;
793+
const nextChildren = render(nextDidTimeout);
794+
workInProgress.memoizedProps = nextProps;
795+
workInProgress.memoizedState = nextDidTimeout;
796+
reconcileChildren(current, workInProgress, nextChildren);
797+
return workInProgress.child;
798+
} else {
799+
return null;
800+
}
801+
}
802+
761803
function updatePortalComponent(
762804
current,
763805
workInProgress,
@@ -1209,6 +1251,12 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
12091251
// A return component is just a placeholder, we can just run through the
12101252
// next one immediately.
12111253
return null;
1254+
case TimeoutComponent:
1255+
return updateTimeoutComponent(
1256+
current,
1257+
workInProgress,
1258+
renderExpirationTime,
1259+
);
12121260
case HostPortal:
12131261
return updatePortalComponent(
12141262
current,

packages/react-reconciler/src/ReactFiberClassComponent.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,13 @@ export function applyDerivedStateFromProps(
152152
export default function(
153153
legacyContext: LegacyContext,
154154
scheduleWork: (fiber: Fiber, expirationTime: ExpirationTime) => void,
155-
computeExpirationForFiber: (fiber: Fiber) => ExpirationTime,
155+
computeExpirationForFiber: (
156+
currentTime: ExpirationTime,
157+
fiber: Fiber,
158+
) => ExpirationTime,
156159
memoizeProps: (workInProgress: Fiber, props: any) => void,
157160
memoizeState: (workInProgress: Fiber, state: any) => void,
161+
recalculateCurrentTime: () => ExpirationTime,
158162
) {
159163
const {
160164
cacheContext,
@@ -168,7 +172,8 @@ export default function(
168172
isMounted,
169173
enqueueSetState(inst, payload, callback) {
170174
const fiber = ReactInstanceMap.get(inst);
171-
const expirationTime = computeExpirationForFiber(fiber);
175+
const currentTime = recalculateCurrentTime();
176+
const expirationTime = computeExpirationForFiber(currentTime, fiber);
172177

173178
const update = createUpdate(expirationTime);
174179
update.payload = payload;
@@ -184,7 +189,8 @@ export default function(
184189
},
185190
enqueueReplaceState(inst, payload, callback) {
186191
const fiber = ReactInstanceMap.get(inst);
187-
const expirationTime = computeExpirationForFiber(fiber);
192+
const currentTime = recalculateCurrentTime();
193+
const expirationTime = computeExpirationForFiber(currentTime, fiber);
188194

189195
const update = createUpdate(expirationTime);
190196
update.tag = ReplaceState;
@@ -202,7 +208,8 @@ export default function(
202208
},
203209
enqueueForceUpdate(inst, callback) {
204210
const fiber = ReactInstanceMap.get(inst);
205-
const expirationTime = computeExpirationForFiber(fiber);
211+
const currentTime = recalculateCurrentTime();
212+
const expirationTime = computeExpirationForFiber(currentTime, fiber);
206213

207214
const update = createUpdate(expirationTime);
208215
update.tag = ForceUpdate;

packages/react-reconciler/src/ReactFiberCommitWork.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
HostPortal,
2828
CallComponent,
2929
Profiler,
30+
TimeoutComponent,
3031
} from 'shared/ReactTypeOfWork';
3132
import ReactErrorUtils from 'shared/ReactErrorUtils';
3233
import {
@@ -314,6 +315,10 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
314315
// We have no life-cycles associated with Profiler.
315316
return;
316317
}
318+
case TimeoutComponent: {
319+
// We have no life-cycles associated with Timeouts.
320+
return;
321+
}
317322
default: {
318323
invariant(
319324
false,
@@ -836,6 +841,9 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
836841
}
837842
return;
838843
}
844+
case TimeoutComponent: {
845+
return;
846+
}
839847
default: {
840848
invariant(
841849
false,

packages/react-reconciler/src/ReactFiberCompleteWork.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
Fragment,
4141
Mode,
4242
Profiler,
43+
TimeoutComponent,
4344
} from 'shared/ReactTypeOfWork';
4445
import {Placement, Ref, Update} from 'shared/ReactTypeOfSideEffect';
4546
import invariant from 'fbjs/lib/invariant';
@@ -593,6 +594,8 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
593594
return null;
594595
case ForwardRef:
595596
return null;
597+
case TimeoutComponent:
598+
return null;
596599
case Fragment:
597600
return null;
598601
case Mode:

packages/react-reconciler/src/ReactFiberExpirationTime.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,11 @@ export function computeExpirationBucket(
3838
expirationInMs: number,
3939
bucketSizeMs: number,
4040
): ExpirationTime {
41-
return ceiling(
42-
currentTime + expirationInMs / UNIT_SIZE,
43-
bucketSizeMs / UNIT_SIZE,
41+
return (
42+
MAGIC_NUMBER_OFFSET +
43+
ceiling(
44+
currentTime - MAGIC_NUMBER_OFFSET + expirationInMs / UNIT_SIZE,
45+
bucketSizeMs / UNIT_SIZE,
46+
)
4447
);
4548
}

0 commit comments

Comments
 (0)