Files
react/packages/react-reconciler/src/ReactUpdateQueue.js
T
Andrew Clark b548b3cd64 Decouple update queue from Fiber type (#12600)
* Decouple update queue from Fiber type

The update queue is in need of a refactor. Recent bugfixes (#12528) have
exposed some flaws in how it's modeled. Upcoming features like Suspense
and [redacted] also rely on the update queue in ways that weren't
anticipated in the original design.

Major changes:

- Instead of boolean flags for `isReplace` and `isForceUpdate`, updates
have a `tag` field (like Fiber). This lowers the cost for adding new
types of updates.
- Render phase updates are special cased. Updates scheduled during
the render phase are dropped if the work-in-progress does not commit.
This is used for `getDerivedStateFrom{Props,Catch}`.
- `callbackList` has been replaced with a generic effect list. Aside
from callbacks, this is also used for `componentDidCatch`.

* Remove first class UpdateQueue types and use closures instead

I tried to avoid this at first, since we avoid it everywhere else in the Fiber
codebase, but since updates are not in a hot path, the trade off with file size
seems worth it.

* Store captured errors on a separate part of the update queue

This way they can be reused independently of updates like
getDerivedStateFromProps. This will be important for resuming.

* Revert back to storing hasForceUpdate on the update queue

Instead of using the effect tag. Ideally, this would be part of the
return type of processUpdateQueue.

* Rename UpdateQueue effect type back to Callback

I don't love this name either, but it's less confusing than UpdateQueue
I suppose. Conceptually, this is usually a callback: setState callbacks,
componentDidCatch. The only case that feels a bit weird is Timeouts,
which use this effect to attach a promise listener. I guess that kinda
fits, too.

* Call getDerivedStateFromProps every render, even if props did not change

Rather than enqueue a new setState updater for every props change, we
can skip the update queue entirely and merge the result into state at
the end. This makes more sense, since "receiving props" is not an event
that should be observed. It's still a bit weird, since eventually we do
persist the derived state (in other words, it accumulates).

* Store captured effects on separate list from "own" effects (callbacks)

For resuming, we need the ability to discard the "own" effects while
reusing the captured effects.

* Optimize for class components

Change `process` and `callback` to match the expected payload types
for class components. I had intended for the update queue to be reusable
for both class components and a future React API, but we'll likely have
to fork anyway.

* Only double-invoke render phase lifecycles functions in DEV

* Use global state to track currently processing queue in DEV
2018-04-22 23:05:28 -07:00

641 lines
20 KiB
JavaScript

/**
* Copyright (c) 2013-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
// UpdateQueue is a linked list of prioritized updates.
//
// Like fibers, update queues come in pairs: a current queue, which represents
// the visible state of the screen, and a work-in-progress queue, which is
// can be mutated and processed asynchronously before it is committed — a form
// of double buffering. If a work-in-progress render is discarded before
// finishing, we create a new work-in-progress by cloning the current queue.
//
// Both queues share a persistent, singly-linked list structure. To schedule an
// update, we append it to the end of both queues. Each queue maintains a
// pointer to first update in the persistent list that hasn't been processed.
// The work-in-progress pointer always has a position equal to or greater than
// the current queue, since we always work on that one. The current queue's
// pointer is only updated during the commit phase, when we swap in the
// work-in-progress.
//
// For example:
//
// Current pointer: A - B - C - D - E - F
// Work-in-progress pointer: D - E - F
// ^
// The work-in-progress queue has
// processed more updates than current.
//
// The reason we append to both queues is because otherwise we might drop
// updates without ever processing them. For example, if we only add updates to
// the work-in-progress queue, some updates could be lost whenever a work-in
// -progress render restarts by cloning from current. Similarly, if we only add
// updates to the current queue, the updates will be lost whenever an already
// in-progress queue commits and swaps with the current queue. However, by
// adding to both queues, we guarantee that the update will be part of the next
// work-in-progress. (And because the work-in-progress queue becomes the
// current queue once it commits, there's no danger of applying the same
// update twice.)
//
// Prioritization
// --------------
//
// Updates are not sorted by priority, but by insertion; new updates are always
// appended to the end of the list.
//
// The priority is still important, though. When processing the update queue
// during the render phase, only the updates with sufficient priority are
// included in the result. If we skip an update because it has insufficient
// priority, it remains in the queue to be processed later, during a lower
// priority render. Crucially, all updates subsequent to a skipped update also
// remain in the queue *regardless of their priority*. That means high priority
// updates are sometimes processed twice, at two separate priorities. We also
// keep track of a base state, that represents the state before the first
// update in the queue is applied.
//
// For example:
//
// Given a base state of '', and the following queue of updates
//
// A1 - B2 - C1 - D2
//
// where the number indicates the priority, and the update is applied to the
// previous state by appending a letter, React will process these updates as
// two separate renders, one per distinct priority level:
//
// First render, at priority 1:
// Base state: ''
// Updates: [A1, C1]
// Result state: 'AC'
//
// Second render, at priority 2:
// Base state: 'A' <- The base state does not include C1,
// because B2 was skipped.
// Updates: [B2, C1, D2] <- C1 was rebased on top of B2
// Result state: 'ABCD'
//
// Because we process updates in insertion order, and rebase high priority
// updates when preceding updates are skipped, the final result is deterministic
// regardless of priority. Intermediate state may vary according to system
// resources, but the final state is always the same.
import type {Fiber} from './ReactFiber';
import type {ExpirationTime} from './ReactFiberExpirationTime';
import {NoWork} from './ReactFiberExpirationTime';
import {
Callback,
ShouldCapture,
DidCapture,
} from 'shared/ReactTypeOfSideEffect';
import {ClassComponent} from 'shared/ReactTypeOfWork';
import {
debugRenderPhaseSideEffects,
debugRenderPhaseSideEffectsForStrictMode,
} from 'shared/ReactFeatureFlags';
import {StrictMode} from './ReactTypeOfMode';
import invariant from 'fbjs/lib/invariant';
import warning from 'fbjs/lib/warning';
export type Update<State> = {
expirationTime: ExpirationTime,
tag: 0 | 1 | 2 | 3,
payload: any,
callback: (() => mixed) | null,
next: Update<State> | null,
nextEffect: Update<State> | null,
};
export type UpdateQueue<State> = {
expirationTime: ExpirationTime,
baseState: State,
firstUpdate: Update<State> | null,
lastUpdate: Update<State> | null,
firstCapturedUpdate: Update<State> | null,
lastCapturedUpdate: Update<State> | null,
firstEffect: Update<State> | null,
lastEffect: Update<State> | null,
firstCapturedEffect: Update<State> | null,
lastCapturedEffect: Update<State> | null,
// TODO: Workaround for lack of tuples. Could use global state instead.
hasForceUpdate: boolean,
};
export const UpdateState = 0;
export const ReplaceState = 1;
export const ForceUpdate = 2;
export const CaptureUpdate = 3;
let didWarnUpdateInsideUpdate;
let currentlyProcessingQueue;
export let resetCurrentlyProcessingQueue;
if (__DEV__) {
didWarnUpdateInsideUpdate = false;
currentlyProcessingQueue = null;
resetCurrentlyProcessingQueue = () => {
currentlyProcessingQueue = null;
};
}
export function createUpdateQueue<State>(baseState: State): UpdateQueue<State> {
const queue: UpdateQueue<State> = {
expirationTime: NoWork,
baseState,
firstUpdate: null,
lastUpdate: null,
firstCapturedUpdate: null,
lastCapturedUpdate: null,
firstEffect: null,
lastEffect: null,
firstCapturedEffect: null,
lastCapturedEffect: null,
hasForceUpdate: false,
};
return queue;
}
function cloneUpdateQueue<State>(
currentQueue: UpdateQueue<State>,
): UpdateQueue<State> {
const queue: UpdateQueue<State> = {
expirationTime: currentQueue.expirationTime,
baseState: currentQueue.baseState,
firstUpdate: currentQueue.firstUpdate,
lastUpdate: currentQueue.lastUpdate,
// TODO: With resuming, if we bail out and resuse the child tree, we should
// keep these effects.
firstCapturedUpdate: null,
lastCapturedUpdate: null,
hasForceUpdate: false,
firstEffect: null,
lastEffect: null,
firstCapturedEffect: null,
lastCapturedEffect: null,
};
return queue;
}
export function createUpdate(expirationTime: ExpirationTime): Update<*> {
return {
expirationTime: expirationTime,
tag: UpdateState,
payload: null,
callback: null,
next: null,
nextEffect: null,
};
}
function appendUpdateToQueue<State>(
queue: UpdateQueue<State>,
update: Update<State>,
expirationTime: ExpirationTime,
) {
// Append the update to the end of the list.
if (queue.lastUpdate === null) {
// Queue is empty
queue.firstUpdate = queue.lastUpdate = update;
} else {
queue.lastUpdate.next = update;
queue.lastUpdate = update;
}
if (
queue.expirationTime === NoWork ||
queue.expirationTime > expirationTime
) {
// The incoming update has the earliest expiration of any update in the
// queue. Update the queue's expiration time.
queue.expirationTime = expirationTime;
}
}
export function enqueueUpdate<State>(
fiber: Fiber,
update: Update<State>,
expirationTime: ExpirationTime,
) {
// Update queues are created lazily.
const alternate = fiber.alternate;
let queue1;
let queue2;
if (alternate === null) {
// There's only one fiber.
queue1 = fiber.updateQueue;
queue2 = null;
if (queue1 === null) {
queue1 = fiber.updateQueue = createUpdateQueue(fiber.memoizedState);
}
} else {
// There are two owners.
queue1 = fiber.updateQueue;
queue2 = alternate.updateQueue;
if (queue1 === null) {
if (queue2 === null) {
// Neither fiber has an update queue. Create new ones.
queue1 = fiber.updateQueue = createUpdateQueue(fiber.memoizedState);
queue2 = alternate.updateQueue = createUpdateQueue(
alternate.memoizedState,
);
} else {
// Only one fiber has an update queue. Clone to create a new one.
queue1 = fiber.updateQueue = cloneUpdateQueue(queue2);
}
} else {
if (queue2 === null) {
// Only one fiber has an update queue. Clone to create a new one.
queue2 = alternate.updateQueue = cloneUpdateQueue(queue1);
} else {
// Both owners have an update queue.
}
}
}
if (queue2 === null || queue1 === queue2) {
// There's only a single queue.
appendUpdateToQueue(queue1, update, expirationTime);
} else {
// There are two queues. We need to append the update to both queues,
// while accounting for the persistent structure of the list — we don't
// want the same update to be added multiple times.
if (queue1.lastUpdate === null || queue2.lastUpdate === null) {
// One of the queues is not empty. We must add the update to both queues.
appendUpdateToQueue(queue1, update, expirationTime);
appendUpdateToQueue(queue2, update, expirationTime);
} else {
// Both queues are non-empty. The last update is the same in both lists,
// because of structural sharing. So, only append to one of the lists.
appendUpdateToQueue(queue1, update, expirationTime);
// But we still need to update the `lastUpdate` pointer of queue2.
queue2.lastUpdate = update;
}
}
if (__DEV__) {
if (
fiber.tag === ClassComponent &&
(currentlyProcessingQueue === queue1 ||
(queue2 !== null && currentlyProcessingQueue === queue2)) &&
!didWarnUpdateInsideUpdate
) {
warning(
false,
'An update (setState, replaceState, or forceUpdate) was scheduled ' +
'from inside an update function. Update functions should be pure, ' +
'with zero side-effects. Consider using componentDidUpdate or a ' +
'callback.',
);
didWarnUpdateInsideUpdate = true;
}
}
}
export function enqueueCapturedUpdate<State>(
workInProgress: Fiber,
update: Update<State>,
renderExpirationTime: ExpirationTime,
) {
// Captured updates go into a separate list, and only on the work-in-
// progress queue.
let workInProgressQueue = workInProgress.updateQueue;
if (workInProgressQueue === null) {
workInProgressQueue = workInProgress.updateQueue = createUpdateQueue(
workInProgress.memoizedState,
);
} else {
// TODO: I put this here rather than createWorkInProgress so that we don't
// clone the queue unnecessarily. There's probably a better way to
// structure this.
workInProgressQueue = ensureWorkInProgressQueueIsAClone(
workInProgress,
workInProgressQueue,
);
}
// Append the update to the end of the list.
if (workInProgressQueue.lastCapturedUpdate === null) {
// This is the first render phase update
workInProgressQueue.firstCapturedUpdate = workInProgressQueue.lastCapturedUpdate = update;
} else {
workInProgressQueue.lastCapturedUpdate.next = update;
workInProgressQueue.lastCapturedUpdate = update;
}
if (
workInProgressQueue.expirationTime === NoWork ||
workInProgressQueue.expirationTime > renderExpirationTime
) {
// The incoming update has the earliest expiration of any update in the
// queue. Update the queue's expiration time.
workInProgressQueue.expirationTime = renderExpirationTime;
}
}
function ensureWorkInProgressQueueIsAClone<State>(
workInProgress: Fiber,
queue: UpdateQueue<State>,
): UpdateQueue<State> {
const current = workInProgress.alternate;
if (current !== null) {
// If the work-in-progress queue is equal to the current queue,
// we need to clone it first.
if (queue === current.updateQueue) {
queue = workInProgress.updateQueue = cloneUpdateQueue(queue);
}
}
return queue;
}
function getStateFromUpdate<State>(
workInProgress: Fiber,
queue: UpdateQueue<State>,
update: Update<State>,
prevState: State,
nextProps: any,
instance: any,
): any {
switch (update.tag) {
case ReplaceState: {
const payload = update.payload;
if (typeof payload === 'function') {
// Updater function
if (__DEV__) {
if (
debugRenderPhaseSideEffects ||
(debugRenderPhaseSideEffectsForStrictMode &&
workInProgress.mode & StrictMode)
) {
payload.call(instance, prevState, nextProps);
}
}
return payload.call(instance, prevState, nextProps);
}
// State object
return payload;
}
case CaptureUpdate: {
workInProgress.effectTag =
(workInProgress.effectTag & ~ShouldCapture) | DidCapture;
}
// Intentional fallthrough
case UpdateState: {
const payload = update.payload;
let partialState;
if (typeof payload === 'function') {
// Updater function
if (__DEV__) {
if (
debugRenderPhaseSideEffects ||
(debugRenderPhaseSideEffectsForStrictMode &&
workInProgress.mode & StrictMode)
) {
payload.call(instance, prevState, nextProps);
}
}
partialState = payload.call(instance, prevState, nextProps);
} else {
// Partial state object
partialState = payload;
}
if (partialState === null || partialState === undefined) {
// Null and undefined are treated as no-ops.
return prevState;
}
// Merge the partial state and the previous state.
return Object.assign({}, prevState, partialState);
}
case ForceUpdate: {
queue.hasForceUpdate = true;
return prevState;
}
}
return prevState;
}
export function processUpdateQueue<State>(
workInProgress: Fiber,
queue: UpdateQueue<State>,
props: any,
instance: any,
renderExpirationTime: ExpirationTime,
): void {
if (
queue.expirationTime === NoWork ||
queue.expirationTime > renderExpirationTime
) {
// Insufficient priority. Bailout.
return;
}
queue = ensureWorkInProgressQueueIsAClone(workInProgress, queue);
if (__DEV__) {
currentlyProcessingQueue = queue;
}
// These values may change as we process the queue.
let newBaseState = queue.baseState;
let newFirstUpdate = null;
let newExpirationTime = NoWork;
// Iterate through the list of updates to compute the result.
let update = queue.firstUpdate;
let resultState = newBaseState;
while (update !== null) {
const updateExpirationTime = update.expirationTime;
if (updateExpirationTime > renderExpirationTime) {
// This update does not have sufficient priority. Skip it.
if (newFirstUpdate === null) {
// This is the first skipped update. It will be the first update in
// the new list.
newFirstUpdate = update;
// Since this is the first update that was skipped, the current result
// is the new base state.
newBaseState = resultState;
}
// Since this update will remain in the list, update the remaining
// expiration time.
if (
newExpirationTime === NoWork ||
newExpirationTime > updateExpirationTime
) {
newExpirationTime = updateExpirationTime;
}
} else {
// This update does have sufficient priority. Process it and compute
// a new result.
resultState = getStateFromUpdate(
workInProgress,
queue,
update,
resultState,
props,
instance,
);
const callback = update.callback;
if (callback !== null) {
workInProgress.effectTag |= Callback;
// Set this to null, in case it was mutated during an aborted render.
update.nextEffect = null;
if (queue.lastEffect === null) {
queue.firstEffect = queue.lastEffect = update;
} else {
queue.lastEffect.nextEffect = update;
queue.lastEffect = update;
}
}
}
// Continue to the next update.
update = update.next;
}
// Separately, iterate though the list of captured updates.
let newFirstCapturedUpdate = null;
update = queue.firstCapturedUpdate;
while (update !== null) {
const updateExpirationTime = update.expirationTime;
if (updateExpirationTime > renderExpirationTime) {
// This update does not have sufficient priority. Skip it.
if (newFirstCapturedUpdate === null) {
// This is the first skipped captured update. It will be the first
// update in the new list.
newFirstCapturedUpdate = update;
// If this is the first update that was skipped, the current result is
// the new base state.
if (newFirstUpdate === null) {
newBaseState = resultState;
}
}
// Since this update will remain in the list, update the remaining
// expiration time.
if (
newExpirationTime === NoWork ||
newExpirationTime > updateExpirationTime
) {
newExpirationTime = updateExpirationTime;
}
} else {
// This update does have sufficient priority. Process it and compute
// a new result.
resultState = getStateFromUpdate(
workInProgress,
queue,
update,
resultState,
props,
instance,
);
const callback = update.callback;
if (callback !== null) {
workInProgress.effectTag |= Callback;
// Set this to null, in case it was mutated during an aborted render.
update.nextEffect = null;
if (queue.lastCapturedEffect === null) {
queue.firstCapturedEffect = queue.lastCapturedEffect = update;
} else {
queue.lastCapturedEffect.nextEffect = update;
queue.lastCapturedEffect = update;
}
}
}
update = update.next;
}
if (newFirstUpdate === null) {
queue.lastUpdate = null;
}
if (newFirstCapturedUpdate === null) {
queue.lastCapturedUpdate = null;
} else {
workInProgress.effectTag |= Callback;
}
if (newFirstUpdate === null && newFirstCapturedUpdate === null) {
// We processed every update, without skipping. That means the new base
// state is the same as the result state.
newBaseState = resultState;
}
queue.baseState = newBaseState;
queue.firstUpdate = newFirstUpdate;
queue.firstCapturedUpdate = newFirstCapturedUpdate;
queue.expirationTime = newExpirationTime;
workInProgress.memoizedState = resultState;
if (__DEV__) {
currentlyProcessingQueue = null;
}
}
function callCallback(callback, context) {
invariant(
typeof callback === 'function',
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: %s',
callback,
);
callback.call(context);
}
export function commitUpdateQueue<State>(
finishedWork: Fiber,
finishedQueue: UpdateQueue<State>,
instance: any,
renderExpirationTime: ExpirationTime,
): void {
// If the finished render included captured updates, and there are still
// lower priority updates left over, we need to keep the captured updates
// in the queue so that they are rebased and not dropped once we process the
// queue again at the lower priority.
if (finishedQueue.firstCapturedUpdate !== null) {
// Join the captured update list to the end of the normal list.
if (finishedQueue.lastUpdate !== null) {
finishedQueue.lastUpdate.next = finishedQueue.firstCapturedUpdate;
finishedQueue.lastUpdate = finishedQueue.lastCapturedUpdate;
}
// Clear the list of captured updates.
finishedQueue.firstCapturedUpdate = finishedQueue.lastCapturedUpdate = null;
}
// Commit the effects
let effect = finishedQueue.firstEffect;
finishedQueue.firstEffect = finishedQueue.lastEffect = null;
while (effect !== null) {
const callback = effect.callback;
if (callback !== null) {
effect.callback = null;
callCallback(callback, instance);
}
effect = effect.nextEffect;
}
effect = finishedQueue.firstCapturedEffect;
finishedQueue.firstCapturedEffect = finishedQueue.lastCapturedEffect = null;
while (effect !== null) {
const callback = effect.callback;
if (callback !== null) {
effect.callback = null;
callCallback(callback, instance);
}
effect = effect.nextEffect;
}
}