Skip to content

Commit

Permalink
Only most recent transition is pending, per queue
Browse files Browse the repository at this point in the history
When multiple transitions update the same queue, only the most recent
one should be considered pending.

Example: If I switch tabs multiple times, only the last tab I click
should display a pending state (e.g. an inline spinner).
  • Loading branch information
acdlite committed Dec 19, 2019
1 parent b83df2b commit d499067
Show file tree
Hide file tree
Showing 5 changed files with 515 additions and 104 deletions.
136 changes: 32 additions & 104 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {Fiber} from './ReactFiber';
import type {ExpirationTime} from './ReactFiberExpirationTime';
import type {HookEffectTag} from './ReactHookEffectTags';
import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
import type {TransitionInstance} from './ReactFiberTransition';
import type {ReactPriorityLevel} from './SchedulerWithReactIntegration';

import ReactSharedInternals from 'shared/ReactSharedInternals';
Expand Down Expand Up @@ -51,11 +52,11 @@ import is from 'shared/objectIs';
import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
import {
UserBlockingPriority,
NormalPriority,
runWithPriority,
getCurrentPriorityLevel,
} from './SchedulerWithReactIntegration';
startTransition,
requestCurrentTransition,
cancelPendingTransition,
} from './ReactFiberTransition';
import {getCurrentPriorityLevel} from './SchedulerWithReactIntegration';

const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;

Expand Down Expand Up @@ -115,14 +116,11 @@ type Update<S, A> = {
type UpdateQueue<S, A> = {
pending: Update<S, A> | null,
dispatch: (A => mixed) | null,
pendingTransition: TransitionInstance | null,
lastRenderedReducer: ((S, A) => S) | null,
lastRenderedState: S | null,
};

type TransitionInstance = {|
pendingExpirationTime: ExpirationTime,
|};

export type HookType =
| 'useState'
| 'useReducer'
Expand Down Expand Up @@ -651,6 +649,7 @@ function mountReducer<S, I, A>(
const queue = (hook.queue = {
pending: null,
dispatch: null,
pendingTransition: null,
lastRenderedReducer: reducer,
lastRenderedState: (initialState: any),
});
Expand Down Expand Up @@ -858,6 +857,7 @@ function mountState<S>(
const queue = (hook.queue = {
pending: null,
dispatch: null,
pendingTransition: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
});
Expand Down Expand Up @@ -1222,89 +1222,14 @@ function rerenderDeferredValue<T>(
return prevValue;
}

function startTransition(fiber, instance, config, callback) {
let resolvedConfig: SuspenseConfig | null =
config === undefined ? null : config;

// TODO: runWithPriority shouldn't be necessary here. React should manage its
// own concept of priority, and only consult Scheduler for updates that are
// scheduled from outside a React context.
const priorityLevel = getCurrentPriorityLevel();
runWithPriority(
priorityLevel < UserBlockingPriority ? UserBlockingPriority : priorityLevel,
() => {
const currentTime = requestCurrentTimeForUpdate();
const expirationTime = computeExpirationForFiber(
currentTime,
fiber,
null,
);
scheduleWork(fiber, expirationTime);
},
);
runWithPriority(
priorityLevel > NormalPriority ? NormalPriority : priorityLevel,
() => {
const currentTime = requestCurrentTimeForUpdate();
let expirationTime = computeExpirationForFiber(
currentTime,
fiber,
resolvedConfig,
);
// Set the expiration time at which the pending transition will finish.
// Because there's only a single transition per useTransition hook, we
// don't need a queue here; we can cheat by only tracking the most
// recently scheduled transition.
// TODO: This trick depends on transition expiration times being
// monotonically decreasing in priority, but since expiration times
// currently correspond to `timeoutMs`, that might not be true if
// `timeoutMs` changes to something smaller before the previous transition
// resolves. But this is a temporary edge case, since we're about to
// remove the correspondence between `timeoutMs` and the expiration time.
const oldPendingExpirationTime = instance.pendingExpirationTime;
while (
oldPendingExpirationTime !== NoWork &&
oldPendingExpirationTime <= expirationTime
) {
// Temporary hack to make pendingExpirationTime monotonically decreasing
if (resolvedConfig === null) {
resolvedConfig = {
timeoutMs: 5250,
};
} else {
resolvedConfig = {
timeoutMs: (resolvedConfig.timeoutMs | 0 || 5000) + 250,
busyDelayMs: resolvedConfig.busyDelayMs,
busyMinDurationMs: resolvedConfig.busyMinDurationMs,
};
}
expirationTime = computeExpirationForFiber(
currentTime,
fiber,
resolvedConfig,
);
}
instance.pendingExpirationTime = expirationTime;

scheduleWork(fiber, expirationTime);
const previousConfig = ReactCurrentBatchConfig.suspense;
ReactCurrentBatchConfig.suspense = resolvedConfig;
try {
callback();
} finally {
ReactCurrentBatchConfig.suspense = previousConfig;
}
},
);
}

function mountTransition(
config: SuspenseConfig | void | null,
): [(() => void) => void, boolean] {
const hook = mountWorkInProgressHook();

const fiber = ((currentlyRenderingFiber: any): Fiber);
const instance: TransitionInstance = {
pendingExpirationTime: NoWork,
fiber,
};
// TODO: Intentionally storing this on the queue field to avoid adding a new/
// one; `queue` should be a union.
Expand All @@ -1316,15 +1241,9 @@ function mountTransition(
// Then we don't have to recompute the callback whenever it changes. However,
// if we don't end up changing the API, we should at least optimize this
// to use the same hook instead of a separate hook just for the callback.
const start = mountCallback(
startTransition.bind(
null,
((currentlyRenderingFiber: any): Fiber),
instance,
config,
),
[config],
);
const start = mountCallback(startTransition.bind(null, instance, config), [
config,
]);

const resolvedExpirationTime = NoWork;
hook.memoizedState = {
Expand Down Expand Up @@ -1420,15 +1339,9 @@ function updateTransition(
resolvedExpirationTime: newResolvedExpirationTime,
};

const start = updateCallback(
startTransition.bind(
null,
((currentlyRenderingFiber: any): Fiber),
instance,
config,
),
[config],
);
const start = updateCallback(startTransition.bind(null, instance, config), [
config,
]);

return [start, newIsPending];
}
Expand All @@ -1450,6 +1363,7 @@ function dispatchAction<S, A>(

const currentTime = requestCurrentTimeForUpdate();
const suspenseConfig = requestCurrentSuspenseConfig();
const transition = requestCurrentTransition();
const expirationTime = computeExpirationForFiber(
currentTime,
fiber,
Expand Down Expand Up @@ -1480,6 +1394,19 @@ function dispatchAction<S, A>(
}
queue.pending = update;

if (transition !== null) {
const prevPendingTransition = queue.pendingTransition;
if (transition !== prevPendingTransition) {
queue.pendingTransition = transition;
if (prevPendingTransition !== null) {
// There's already a pending transition on this queue. The new
// transition supersedes the old one. Turn of the `isPending` state
// of the previous transition.
cancelPendingTransition(prevPendingTransition);
}
}
}

const alternate = fiber.alternate;
if (
fiber === currentlyRenderingFiber ||
Expand Down Expand Up @@ -1538,6 +1465,7 @@ function dispatchAction<S, A>(
warnIfNotCurrentlyActingUpdatesInDev(fiber);
}
}

scheduleWork(fiber, expirationTime);
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/react-reconciler/src/ReactFiberSuspenseConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import ReactSharedInternals from 'shared/ReactSharedInternals';

// TODO: Remove React.unstable_withSuspenseConfig and move this to the renderer
const {ReactCurrentBatchConfig} = ReactSharedInternals;

export type SuspenseConfig = {|
Expand Down
140 changes: 140 additions & 0 deletions packages/react-reconciler/src/ReactFiberTransition.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

import type {Fiber} from './ReactFiber';
import type {ExpirationTime} from './ReactFiberExpirationTime';
import type {SuspenseConfig} from './ReactFiberSuspenseConfig';

import ReactSharedInternals from 'shared/ReactSharedInternals';

import {
UserBlockingPriority,
NormalPriority,
runWithPriority,
getCurrentPriorityLevel,
} from './SchedulerWithReactIntegration';
import {
scheduleUpdateOnFiber,
computeExpirationForFiber,
requestCurrentTimeForUpdate,
} from './ReactFiberWorkLoop';
import {NoWork} from './ReactFiberExpirationTime';

const {ReactCurrentBatchConfig} = ReactSharedInternals;

export type TransitionInstance = {|
pendingExpirationTime: ExpirationTime,
fiber: Fiber,
|};

// Inside `startTransition`, this is the transition instance that corresponds to
// the `useTransition` hook.
let currentTransition: TransitionInstance | null = null;

// Inside `startTransition`, this is the expiration time of the update that
// turns on `isPending`. We also use it to turn off the `isPending` of previous
// transitions, if they exists.
let userBlockingExpirationTime = NoWork;

export function requestCurrentTransition(): TransitionInstance | null {
return currentTransition;
}

export function startTransition(
transitionInstance: TransitionInstance,
config: SuspenseConfig | null | void,
callback: () => void,
) {
const fiber = transitionInstance.fiber;

let resolvedConfig: SuspenseConfig | null =
config === undefined ? null : config;

// TODO: runWithPriority shouldn't be necessary here. React should manage its
// own concept of priority, and only consult Scheduler for updates that are
// scheduled from outside a React context.
const priorityLevel = getCurrentPriorityLevel();
runWithPriority(
priorityLevel < UserBlockingPriority ? UserBlockingPriority : priorityLevel,
() => {
const currentTime = requestCurrentTimeForUpdate();
userBlockingExpirationTime = computeExpirationForFiber(
currentTime,
fiber,
null,
);
scheduleUpdateOnFiber(fiber, userBlockingExpirationTime);
},
);
runWithPriority(
priorityLevel > NormalPriority ? NormalPriority : priorityLevel,
() => {
const currentTime = requestCurrentTimeForUpdate();
let expirationTime = computeExpirationForFiber(
currentTime,
fiber,
resolvedConfig,
);
// Set the expiration time at which the pending transition will finish.
// Because there's only a single transition per useTransition hook, we
// don't need a queue here; we can cheat by only tracking the most
// recently scheduled transition.
// TODO: This trick depends on transition expiration times being
// monotonically decreasing in priority, but since expiration times
// currently correspond to `timeoutMs`, that might not be true if
// `timeoutMs` changes to something smaller before the previous transition
// resolves. But this is a temporary edge case, since we're about to
// remove the correspondence between `timeoutMs` and the expiration time.
const oldPendingExpirationTime = transitionInstance.pendingExpirationTime;
while (
oldPendingExpirationTime !== NoWork &&
oldPendingExpirationTime <= expirationTime
) {
// Temporary hack to make pendingExpirationTime monotonically decreasing
if (resolvedConfig === null) {
resolvedConfig = {
timeoutMs: 5250,
};
} else {
resolvedConfig = {
timeoutMs: (resolvedConfig.timeoutMs | 0 || 5000) + 250,
busyDelayMs: resolvedConfig.busyDelayMs,
busyMinDurationMs: resolvedConfig.busyMinDurationMs,
};
}
expirationTime = computeExpirationForFiber(
currentTime,
fiber,
resolvedConfig,
);
}
transitionInstance.pendingExpirationTime = expirationTime;

scheduleUpdateOnFiber(fiber, expirationTime);
const previousConfig = ReactCurrentBatchConfig.suspense;
const previousTransition = currentTransition;
ReactCurrentBatchConfig.suspense = resolvedConfig;
currentTransition = transitionInstance;
try {
callback();
} finally {
ReactCurrentBatchConfig.suspense = previousConfig;
currentTransition = previousTransition;
userBlockingExpirationTime = NoWork;
}
},
);
}

export function cancelPendingTransition(prevTransition: TransitionInstance) {
// Turn off the `isPending` state of the previous transition, at the same
// priority we use to turn on the `isPending` state of the current transition.
prevTransition.pendingExpirationTime = NoWork;
scheduleUpdateOnFiber(prevTransition.fiber, userBlockingExpirationTime);
}
Loading

0 comments on commit d499067

Please sign in to comment.