diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.new.js b/packages/react-reconciler/src/ReactFiberClassComponent.new.js index 89dda1d076..68570bd0e7 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.new.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.new.js @@ -196,7 +196,7 @@ const classComponentUpdater = { suspenseConfig, ); - const update = createUpdate(expirationTime, suspenseConfig); + const update = createUpdate(currentTime, expirationTime, suspenseConfig); update.payload = payload; if (callback !== undefined && callback !== null) { if (__DEV__) { @@ -218,7 +218,7 @@ const classComponentUpdater = { suspenseConfig, ); - const update = createUpdate(expirationTime, suspenseConfig); + const update = createUpdate(currentTime, expirationTime, suspenseConfig); update.tag = ReplaceState; update.payload = payload; @@ -242,7 +242,7 @@ const classComponentUpdater = { suspenseConfig, ); - const update = createUpdate(expirationTime, suspenseConfig); + const update = createUpdate(currentTime, expirationTime, suspenseConfig); update.tag = ForceUpdate; if (callback !== undefined && callback !== null) { diff --git a/packages/react-reconciler/src/ReactFiberExpirationTime.new.js b/packages/react-reconciler/src/ReactFiberExpirationTime.new.js index d4ef2f8264..c83285b78f 100644 --- a/packages/react-reconciler/src/ReactFiberExpirationTime.new.js +++ b/packages/react-reconciler/src/ReactFiberExpirationTime.new.js @@ -35,6 +35,8 @@ export const Idle = 2; // Continuous Hydration is slightly higher than Idle and is used to increase // priority of hover targets. export const ContinuousHydration = 3; +export const LongTransition = 49999; +export const ShortTransition = 99999; export const Sync = MAX_SIGNED_31_BIT_INT; export const Batched = Sync - 1; @@ -84,16 +86,13 @@ export function computeAsyncExpiration( ); } -export function computeSuspenseExpiration( +export function computeSuspenseTimeout( currentTime: ExpirationTime, timeoutMs: number, ): ExpirationTime { - // TODO: Should we warn if timeoutMs is lower than the normal pri expiration time? - return computeExpirationBucket( - currentTime, - timeoutMs, - LOW_PRIORITY_BATCH_SIZE, - ); + const currentTimeMs = expirationTimeToMs(currentTime); + const deadlineMs = currentTimeMs + timeoutMs; + return msToExpirationTime(deadlineMs); } // We intentionally set a higher expiration time for interactive updates in diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 2e31b9ac40..32d2cd2eb7 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -96,6 +96,9 @@ import {getIsRendering} from './ReactCurrentFiber'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; type Update = {| + // TODO: Temporary field. Will remove this by storing a map of + // transition -> start time on the root. + eventTime: ExpirationTime, expirationTime: ExpirationTime, suspenseConfig: null | SuspenseConfig, action: A, @@ -715,14 +718,17 @@ function updateReducer( let newBaseQueueLast = null; let update = first; do { + const suspenseConfig = update.suspenseConfig; const updateExpirationTime = update.expirationTime; + const updateEventTime = update.eventTime; if (updateExpirationTime < renderExpirationTime) { // Priority is insufficient. Skip this update. If this is the first // skipped update, the previous update/state is the new base // update/state. const clone: Update = { - expirationTime: update.expirationTime, - suspenseConfig: update.suspenseConfig, + eventTime: updateEventTime, + expirationTime: updateExpirationTime, + suspenseConfig: suspenseConfig, action: update.action, eagerReducer: update.eagerReducer, eagerState: update.eagerState, @@ -744,6 +750,7 @@ function updateReducer( if (newBaseQueueLast !== null) { const clone: Update = { + eventTime: updateEventTime, expirationTime: Sync, // This update is going to be committed so we never want uncommit it. suspenseConfig: update.suspenseConfig, action: update.action, @@ -760,10 +767,7 @@ function updateReducer( // TODO: We should skip this update if it was already committed but currently // we have no way of detecting the difference between a committed and suspended // update here. - markRenderEventTimeAndConfig( - updateExpirationTime, - update.suspenseConfig, - ); + markRenderEventTimeAndConfig(updateEventTime, suspenseConfig); // Process this update. if (update.eagerReducer === reducer) { @@ -1651,6 +1655,7 @@ function dispatchAction( ); const update: Update = { + eventTime: currentTime, expirationTime, suspenseConfig, action, diff --git a/packages/react-reconciler/src/ReactFiberNewContext.new.js b/packages/react-reconciler/src/ReactFiberNewContext.new.js index c6273bb9fa..f0a386c85c 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.new.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.new.js @@ -206,7 +206,7 @@ export function propagateContextChange( if (fiber.tag === ClassComponent) { // Schedule a force update on the work-in-progress. - const update = createUpdate(renderExpirationTime, null); + const update = createUpdate(NoWork, renderExpirationTime, null); update.tag = ForceUpdate; // TODO: Because we don't have a work-in-progress, this will add the // update to the current fiber, too, which means it will persist even if diff --git a/packages/react-reconciler/src/ReactFiberReconciler.new.js b/packages/react-reconciler/src/ReactFiberReconciler.new.js index 7206599b5b..16de01ee73 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.new.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.new.js @@ -271,7 +271,7 @@ export function updateContainer( } } - const update = createUpdate(expirationTime, suspenseConfig); + const update = createUpdate(currentTime, expirationTime, suspenseConfig); // Caution: React DevTools currently depends on this property // being called "element". update.payload = {element}; diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index bb87a4b004..a0da2e5e86 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -56,7 +56,7 @@ import { } from './ReactFiberWorkLoop.new'; import {logCapturedError} from './ReactFiberErrorLogger'; -import {Sync} from './ReactFiberExpirationTime.new'; +import {Sync, NoWork} from './ReactFiberExpirationTime.new'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; @@ -65,7 +65,7 @@ function createRootErrorUpdate( errorInfo: CapturedValue, expirationTime: ExpirationTime, ): Update { - const update = createUpdate(expirationTime, null); + const update = createUpdate(NoWork, expirationTime, null); // Unmount the root by rendering null. update.tag = CaptureUpdate; // Caution: React DevTools currently depends on this property @@ -84,7 +84,7 @@ function createClassErrorUpdate( errorInfo: CapturedValue, expirationTime: ExpirationTime, ): Update { - const update = createUpdate(expirationTime, null); + const update = createUpdate(NoWork, expirationTime, null); update.tag = CaptureUpdate; const getDerivedStateFromError = fiber.type.getDerivedStateFromError; if (typeof getDerivedStateFromError === 'function') { @@ -260,7 +260,7 @@ function throwException( // When we try rendering again, we should not reuse the current fiber, // since it's known to be in an inconsistent state. Use a force update to // prevent a bail out. - const update = createUpdate(Sync, null); + const update = createUpdate(NoWork, Sync, null); update.tag = ForceUpdate; enqueueUpdate(sourceFiber, update); } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 86eb5a6b36..a329e2e223 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -119,11 +119,13 @@ import { expirationTimeToMs, computeInteractiveExpiration, computeAsyncExpiration, - computeSuspenseExpiration, - inferPriorityFromExpirationTime, + computeSuspenseTimeout, LOW_PRIORITY_EXPIRATION, + inferPriorityFromExpirationTime, Batched, Idle, + ShortTransition, + LongTransition, } from './ReactFiberExpirationTime.new'; import {beginWork as originalBeginWork} from './ReactFiberBeginWork.new'; import {completeWork} from './ReactFiberCompleteWork.new'; @@ -220,7 +222,7 @@ let workInProgressRootFatalError: mixed = null; // This is conceptually a time stamp but expressed in terms of an ExpirationTime // because we deal mostly with expiration times in the hot path, so this avoids // the conversion happening in the hot path. -let workInProgressRootLatestProcessedExpirationTime: ExpirationTime = Sync; +let workInProgressRootLatestProcessedEventTime: ExpirationTime = Sync; let workInProgressRootLatestSuspenseTimeout: ExpirationTime = Sync; let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null; // The work left over by components that were visited during this render. Only @@ -328,11 +330,16 @@ export function computeExpirationForFiber( let expirationTime; if (suspenseConfig !== null) { - // Compute an expiration time based on the Suspense timeout. - expirationTime = computeSuspenseExpiration( - currentTime, - suspenseConfig.timeoutMs | 0 || LOW_PRIORITY_EXPIRATION, - ); + // If there's a SuspenseConfig, choose an expiration time that's lower + // priority than a normal concurrent update (regardless of the current + // Scheduler priority.) Timeouts larger than 10 seconds move one level lower + // than that. + const timeoutMs = suspenseConfig.timeoutMs; + expirationTime = + // TODO: This will coerce numbers larger than 31 bits to 0. + timeoutMs === undefined || (timeoutMs | 0) < 10000 + ? ShortTransition + : LongTransition; } else { // Compute an expiration time based on the Scheduler priority. switch (priorityLevel) { @@ -379,7 +386,7 @@ export function scheduleUpdateOnFiber( const root = markUpdateTimeFromFiberToRoot(fiber, expirationTime); if (root === null) { warnAboutUpdateOnUnmountedFiberInDEV(fiber); - return; + return null; } // TODO: computeExpirationForFiber also reads the priority. Pass the @@ -728,7 +735,7 @@ function finishConcurrentRender( // have a new loading state ready. We want to ensure that we commit // that as soon as possible. const hasNotProcessedNewUpdates = - workInProgressRootLatestProcessedExpirationTime === Sync; + workInProgressRootLatestProcessedEventTime === Sync; if ( hasNotProcessedNewUpdates && // do not delay if we're inside an act() scope @@ -836,34 +843,19 @@ function finishConcurrentRender( // can use as the timeout. msUntilTimeout = expirationTimeToMs(workInProgressRootLatestSuspenseTimeout) - now(); - } else if (workInProgressRootLatestProcessedExpirationTime === Sync) { + } else if (workInProgressRootLatestProcessedEventTime === Sync) { // This should never normally happen because only new updates // cause delayed states, so we should have processed something. // However, this could also happen in an offscreen tree. msUntilTimeout = 0; } else { - // If we don't have a suspense config, we're going to use a - // heuristic to determine how long we can suspend. - const eventTimeMs: number = inferTimeFromExpirationTime( - workInProgressRootLatestProcessedExpirationTime, + // If we didn't process a suspense config, compute a JND based on + // the amount of time elapsed since the most recent event time. + const eventTimeMs = expirationTimeToMs( + workInProgressRootLatestProcessedEventTime, ); - const currentTimeMs = now(); - const timeUntilExpirationMs = - expirationTimeToMs(expirationTime) - currentTimeMs; - let timeElapsed = currentTimeMs - eventTimeMs; - if (timeElapsed < 0) { - // We get this wrong some time since we estimate the time. - timeElapsed = 0; - } - - msUntilTimeout = jnd(timeElapsed) - timeElapsed; - - // Clamp the timeout to the expiration time. TODO: Once the - // event time is exact instead of inferred from expiration time - // we don't need this. - if (timeUntilExpirationMs < msUntilTimeout) { - msUntilTimeout = timeUntilExpirationMs; - } + const timeElapsedMs = now() - eventTimeMs; + msUntilTimeout = jnd(timeElapsedMs) - timeElapsedMs; } // Don't bother with a very short suspense time. @@ -891,7 +883,7 @@ function finishConcurrentRender( flushSuspenseFallbacksInTests && IsThisRendererActing.current ) && - workInProgressRootLatestProcessedExpirationTime !== Sync && + workInProgressRootLatestProcessedEventTime !== Sync && workInProgressRootCanSuspendUsingConfig !== null ) { // If we have exceeded the minimum loading delay, which probably @@ -899,7 +891,7 @@ function finishConcurrentRender( // a bit longer to ensure that the spinner is shown for // enough time. const msUntilTimeout = computeMsUntilSuspenseLoadingDelay( - workInProgressRootLatestProcessedExpirationTime, + workInProgressRootLatestProcessedEventTime, expirationTime, workInProgressRootCanSuspendUsingConfig, ); @@ -1193,7 +1185,7 @@ function prepareFreshStack(root, expirationTime) { renderExpirationTime = expirationTime; workInProgressRootExitStatus = RootIncomplete; workInProgressRootFatalError = null; - workInProgressRootLatestProcessedExpirationTime = Sync; + workInProgressRootLatestProcessedEventTime = Sync; workInProgressRootLatestSuspenseTimeout = Sync; workInProgressRootCanSuspendUsingConfig = null; workInProgressRootNextUnprocessedUpdateTime = NoWork; @@ -1298,23 +1290,33 @@ export function markCommitTimeOfFallback() { } export function markRenderEventTimeAndConfig( - expirationTime: ExpirationTime, + eventTime: ExpirationTime, suspenseConfig: null | SuspenseConfig, ): void { - if ( - expirationTime < workInProgressRootLatestProcessedExpirationTime && - expirationTime > Idle - ) { - workInProgressRootLatestProcessedExpirationTime = expirationTime; - } - if (suspenseConfig !== null) { - if ( - expirationTime < workInProgressRootLatestSuspenseTimeout && - expirationTime > Idle - ) { - workInProgressRootLatestSuspenseTimeout = expirationTime; - // Most of the time we only have one config and getting wrong is not bad. - workInProgressRootCanSuspendUsingConfig = suspenseConfig; + // Anything lower pri than Idle is not an update, so we should skip it. + if (eventTime > Idle) { + // Track the most recent event time of all updates processed in this batch. + if (workInProgressRootLatestProcessedEventTime > eventTime) { + workInProgressRootLatestProcessedEventTime = eventTime; + } + + // Track the largest/latest timeout deadline in this batch. + // TODO: If there are two transitions in the same batch, shouldn't we + // choose the smaller one? Maybe this is because when an intermediate + // transition is superseded, we should ignore its suspense config, but + // we don't currently. + if (suspenseConfig !== null) { + // If `timeoutMs` is not specified, we default to 5 seconds. We have to + // resolve this default here because `suspenseConfig` is owned + // by userspace. + // TODO: Store this on the root instead (transition -> timeoutMs) + // TODO: Should this default to a JND instead? + const timeoutMs = suspenseConfig.timeoutMs | 0 || LOW_PRIORITY_EXPIRATION; + const timeoutTime = computeSuspenseTimeout(eventTime, timeoutMs); + if (timeoutTime < workInProgressRootLatestSuspenseTimeout) { + workInProgressRootLatestSuspenseTimeout = timeoutTime; + workInProgressRootCanSuspendUsingConfig = suspenseConfig; + } } } } @@ -1372,27 +1374,6 @@ export function renderHasNotSuspendedYet(): boolean { return workInProgressRootExitStatus === RootIncomplete; } -function inferTimeFromExpirationTime(expirationTime: ExpirationTime): number { - // We don't know exactly when the update was scheduled, but we can infer an - // approximate start time from the expiration time. - const earliestExpirationTimeMs = expirationTimeToMs(expirationTime); - return earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION; -} - -function inferTimeFromExpirationTimeWithSuspenseConfig( - expirationTime: ExpirationTime, - suspenseConfig: SuspenseConfig, -): number { - // We don't know exactly when the update was scheduled, but we can infer an - // approximate start time from the expiration time by subtracting the timeout - // that was added to the event time. - const earliestExpirationTimeMs = expirationTimeToMs(expirationTime); - return ( - earliestExpirationTimeMs - - (suspenseConfig.timeoutMs | 0 || LOW_PRIORITY_EXPIRATION) - ); -} - function renderRootSync(root, expirationTime) { const prevExecutionContext = executionContext; executionContext |= RenderContext; @@ -2540,7 +2521,7 @@ export function pingSuspendedRoot( if ( workInProgressRootExitStatus === RootSuspendedWithDelay || (workInProgressRootExitStatus === RootSuspended && - workInProgressRootLatestProcessedExpirationTime === Sync && + workInProgressRootLatestProcessedEventTime === Sync && now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) ) { // Restart from the root. Don't need to schedule a ping because @@ -2679,10 +2660,7 @@ function computeMsUntilSuspenseLoadingDelay( // Compute the time until this render pass would expire. const currentTimeMs: number = now(); - const eventTimeMs: number = inferTimeFromExpirationTimeWithSuspenseConfig( - mostRecentEventTime, - suspenseConfig, - ); + const eventTimeMs: number = expirationTimeToMs(mostRecentEventTime); const timeElapsed = currentTimeMs - eventTimeMs; if (timeElapsed <= busyDelayMs) { // If we haven't yet waited longer than the initial delay, we don't diff --git a/packages/react-reconciler/src/ReactUpdateQueue.new.js b/packages/react-reconciler/src/ReactUpdateQueue.new.js index 19195fea45..82c35615a3 100644 --- a/packages/react-reconciler/src/ReactUpdateQueue.new.js +++ b/packages/react-reconciler/src/ReactUpdateQueue.new.js @@ -110,6 +110,9 @@ import {getCurrentPriorityLevel} from './SchedulerWithReactIntegration.new'; import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev'; export type Update = {| + // TODO: Temporary field. Will remove this by storing a map of + // transition -> event time on the root. + eventTime: ExpirationTime, expirationTime: ExpirationTime, suspenseConfig: null | SuspenseConfig, @@ -123,7 +126,9 @@ export type Update = {| priority?: ReactPriorityLevel, |}; -type SharedQueue = {|pending: Update | null|}; +type SharedQueue = {| + pending: Update | null, +|}; export type UpdateQueue = {| baseState: State, @@ -187,10 +192,12 @@ export function cloneUpdateQueue( } export function createUpdate( + eventTime: ExpirationTime, expirationTime: ExpirationTime, suspenseConfig: null | SuspenseConfig, ): Update<*> { const update: Update<*> = { + eventTime, expirationTime, suspenseConfig, @@ -268,6 +275,7 @@ export function enqueueCapturedUpdate( let update = firstBaseUpdate; do { const clone: Update = { + eventTime: update.eventTime, expirationTime: update.expirationTime, suspenseConfig: update.suspenseConfig, @@ -471,13 +479,15 @@ export function processUpdateQueue( let update = firstBaseUpdate; do { + const updateEventTime = update.eventTime; const updateExpirationTime = update.expirationTime; if (updateExpirationTime < renderExpirationTime) { // Priority is insufficient. Skip this update. If this is the first // skipped update, the previous update/state is the new base // update/state. const clone: Update = { - expirationTime: update.expirationTime, + eventTime: updateEventTime, + expirationTime: updateExpirationTime, suspenseConfig: update.suspenseConfig, tag: update.tag, @@ -501,6 +511,7 @@ export function processUpdateQueue( if (newLastBaseUpdate !== null) { const clone: Update = { + eventTime: updateEventTime, expirationTime: Sync, // This update is going to be committed so we never want uncommit it. suspenseConfig: update.suspenseConfig, @@ -519,10 +530,7 @@ export function processUpdateQueue( // TODO: We should skip this update if it was already committed but currently // we have no way of detecting the difference between a committed and suspended // update here. - markRenderEventTimeAndConfig( - updateExpirationTime, - update.suspenseConfig, - ); + markRenderEventTimeAndConfig(updateEventTime, update.suspenseConfig); // Process this update. newState = getStateFromUpdate( diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js index 0f116f3645..5a06229412 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js @@ -2843,6 +2843,9 @@ describe('ReactHooksWithNoopRenderer', () => { span('Before... Pending: true'), ]); + // Resolve the promise. The whole tree has now completed. However, + // because we exceeded the busy threshold, we won't commit the + // result yet. Scheduler.unstable_advanceTime(1000); await advanceTimers(1000); expect(Scheduler).toHaveYielded([ @@ -2853,13 +2856,16 @@ describe('ReactHooksWithNoopRenderer', () => { span('Before... Pending: true'), ]); - Scheduler.unstable_advanceTime(1000); - await advanceTimers(1000); + // Advance time until just before the `busyMinDuration` threshold. + Scheduler.unstable_advanceTime(999); + await advanceTimers(999); expect(ReactNoop.getChildren()).toEqual([ span('Before... Pending: true'), ]); - Scheduler.unstable_advanceTime(250); - await advanceTimers(250); + + // Advance time just a bit more. Now we complete the transition. + Scheduler.unstable_advanceTime(300); + await advanceTimers(300); expect(ReactNoop.getChildren()).toEqual([ span('After... Pending: false'), ]); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js index 5b121c06ba..e60860b624 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js @@ -498,6 +498,13 @@ describe('ReactSuspensePlaceholder', () => { , ); + + // TODO: This is here only to shift us into the next JND bucket. A + // consequence of AsyncText relying on the same timer queue as React's + // internal Suspense timer. We should decouple our AsyncText helpers + // from timers. + Scheduler.unstable_advanceTime(100); + expect(Scheduler).toFlushAndYield([ 'App', 'Suspending', diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js index e6005f514f..503b473f47 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js @@ -2580,22 +2580,17 @@ describe('ReactSuspenseWithNoopRenderer', () => { // but at this scope we should suspend for longer. Scheduler.unstable_next(() => ReactNoop.render()); }, - {timeoutMs: 2000}, + {timeoutMs: 60000}, ); - expect(Scheduler).toFlushAndYield([ - 'Suspend! [C]', - 'Loading...', - 'Suspend! [C]', - 'Loading...', - ]); + expect(Scheduler).toFlushAndYield(['B', 'Suspend! [C]', 'Loading...']); expect(ReactNoop.getChildren()).toEqual([span('B')]); Scheduler.unstable_advanceTime(1200); await advanceTimers(1200); // Even after a second, we have still not yet flushed the loading state. expect(ReactNoop.getChildren()).toEqual([span('B')]); - Scheduler.unstable_advanceTime(1200); - await advanceTimers(1200); - // After the two second timeout we show the loading state. + Scheduler.unstable_advanceTime(60000); + await advanceTimers(60000); + // After the timeout we show the loading state. expect(ReactNoop.getChildren()).toEqual([ hiddenSpan('B'), span('Loading...'), @@ -2698,23 +2693,6 @@ describe('ReactSuspenseWithNoopRenderer', () => { }); it('supports delaying a busy spinner from disappearing', async () => { - function useLoadingIndicator(config) { - const [isLoading, setLoading] = React.useState(false); - const start = React.useCallback( - cb => { - setLoading(true); - Scheduler.unstable_next(() => - React.unstable_withSuspenseConfig(() => { - setLoading(false); - cb(); - }, config), - ); - }, - [setLoading, config], - ); - return [isLoading, start]; - } - const SUSPENSE_CONFIG = { timeoutMs: 10000, busyDelayMs: 500, @@ -2725,7 +2703,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { function App() { const [page, setPage] = React.useState('A'); - const [isLoading, startLoading] = useLoadingIndicator(SUSPENSE_CONFIG); + const [startLoading, isLoading] = React.useTransition(SUSPENSE_CONFIG); transitionToPage = nextPage => startLoading(() => setPage(nextPage)); return ( @@ -2907,7 +2885,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { () => { ReactNoop.render(); }, - {timeoutMs: 2000}, + {timeoutMs: 2500}, ); expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); @@ -3763,12 +3741,17 @@ describe('ReactSuspenseWithNoopRenderer', () => { it.experimental( 'does not get stuck in pending state with render phase updates', async () => { - let setTextWithTransition; + let setTextWithShortTransition; + let setTextWithLongTransition; function App() { - const [startTransition, isPending] = React.useTransition({ + const [startShortTransition, isPending1] = React.useTransition({ + timeoutMs: 5000, + }); + const [startLongTransition, isPending2] = React.useTransition({ timeoutMs: 30000, }); + const isPending = isPending1 || isPending2; const [text, setText] = React.useState(''); const [mirror, setMirror] = React.useState(''); @@ -3777,8 +3760,13 @@ describe('ReactSuspenseWithNoopRenderer', () => { setMirror(text); } - setTextWithTransition = value => { - startTransition(() => { + setTextWithShortTransition = value => { + startShortTransition(() => { + setText(value); + }); + }; + setTextWithLongTransition = value => { + startLongTransition(() => { setText(value); }); }; @@ -3808,9 +3796,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { // Update to "a". That will suspend. await ReactNoop.act(async () => { - setTextWithTransition('a'); - // Let it expire. This is important for the repro. - Scheduler.unstable_advanceTime(1000); + setTextWithShortTransition('a'); expect(Scheduler).toFlushAndYield([ 'Pending...', '', @@ -3828,10 +3814,12 @@ describe('ReactSuspenseWithNoopRenderer', () => { // Update to "b". That will suspend, too. await ReactNoop.act(async () => { - setTextWithTransition('b'); + setTextWithLongTransition('b'); expect(Scheduler).toFlushAndYield([ // Neither is resolved yet. 'Pending...', + '', + 'Pending...', 'Suspend! [a]', 'Loading...', 'Suspend! [b]',