Skip to content

Commit

Permalink
Store interleaved updates on separate queue until end of render (#20615)
Browse files Browse the repository at this point in the history
## Motivation

An *interleaved* update is one that is scheduled while a render is
already in progress, typically from a concurrent user input event.

We have to take care not to process these updates during the current
render, because a multiple interleaved updates may have been scheduled
across many components; to avoid tearing, we cannot render some of
those updates without rendering all of them.

## Old approach

What we currently do when we detect an interleaved update is assign a
lane that is not part of the current render.

This has some unfortunate drawbacks. For example, we will eventually run
out of lanes at a given priority level. When this happens, our last
resort is to interrupt the current render and start over from scratch.
If this happens enough, it can lead to starvation.

More concerning, there are a suprising number of places that must
separately account for this case, often in subtle ways. The maintenance
complexity has led to a number of tearing bugs.

## New approach

I added a new field to the update queue, `interleaved`. It's a linked
list, just like the `pending` field. When an interleaved update is
scheduled, we add it to the `interleaved` list instead of `pending`.

Then we push the entire queue object onto a global array. When the
current render exits, we iterate through the array of interleaved queues
and transfer the `interleaved` list to the `pending` list.

So, until the current render has exited (whether due to a commit or an
interruption), it's impossible to process an interleaved update, because
they have not yet been enqueued.

In this new approach, we don't need to resort to clever lanes tricks to
avoid inconsistencies. This should allow us to simplify a lot of the
logic that's currently in ReactFiberWorkLoop and ReactFiberLane,
especially `findUpdateLane` and `getNextLanes`. All the logic for
interleaved updates is isolated to one place.
  • Loading branch information
acdlite committed Jan 22, 2021
1 parent 0fd6805 commit f15f8f6
Show file tree
Hide file tree
Showing 9 changed files with 339 additions and 36 deletions.
6 changes: 3 additions & 3 deletions packages/react-reconciler/src/ReactFiberClassComponent.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ const classComponentUpdater = {
update.callback = callback;
}

enqueueUpdate(fiber, update);
enqueueUpdate(fiber, update, lane);
scheduleUpdateOnFiber(fiber, lane, eventTime);

if (__DEV__) {
Expand Down Expand Up @@ -245,7 +245,7 @@ const classComponentUpdater = {
update.callback = callback;
}

enqueueUpdate(fiber, update);
enqueueUpdate(fiber, update, lane);
scheduleUpdateOnFiber(fiber, lane, eventTime);

if (__DEV__) {
Expand Down Expand Up @@ -276,7 +276,7 @@ const classComponentUpdater = {
update.callback = callback;
}

enqueueUpdate(fiber, update);
enqueueUpdate(fiber, update, lane);
scheduleUpdateOnFiber(fiber, lane, eventTime);

if (__DEV__) {
Expand Down
72 changes: 59 additions & 13 deletions packages/react-reconciler/src/ReactFiberHooks.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
warnIfNotCurrentlyActingUpdatesInDev,
warnIfNotScopedWithMatchingAct,
markSkippedUpdateLanes,
isInterleavedUpdate,
} from './ReactFiberWorkLoop.new';

import invariant from 'shared/invariant';
Expand Down Expand Up @@ -104,6 +105,7 @@ import {logStateUpdateScheduled} from './DebugTracing';
import {markStateUpdateScheduled} from './SchedulingProfiler';
import {CacheContext} from './ReactFiberCacheComponent.new';
import {createUpdate, enqueueUpdate} from './ReactUpdateQueue.new';
import {pushInterleavedQueue} from './ReactFiberInterleavedUpdates.new';

const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;

Expand All @@ -116,8 +118,9 @@ type Update<S, A> = {|
priority?: ReactPriorityLevel,
|};

type UpdateQueue<S, A> = {|
export type UpdateQueue<S, A> = {|
pending: Update<S, A> | null,
interleaved: Update<S, A> | null,
dispatch: (A => mixed) | null,
lastRenderedReducer: ((S, A) => S) | null,
lastRenderedState: S | null,
Expand Down Expand Up @@ -650,6 +653,7 @@ function mountReducer<S, I, A>(
hook.memoizedState = hook.baseState = initialState;
const queue = (hook.queue = {
pending: null,
interleaved: null,
dispatch: null,
lastRenderedReducer: reducer,
lastRenderedState: (initialState: any),
Expand Down Expand Up @@ -792,6 +796,23 @@ function updateReducer<S, I, A>(
queue.lastRenderedState = newState;
}

// Interleaved updates are stored on a separate queue. We aren't going to
// process them during this render, but we do need to track which lanes
// are remaining.
const lastInterleaved = queue.interleaved;
if (lastInterleaved !== null) {
let interleaved = lastInterleaved;
do {
const interleavedLane = interleaved.lane;
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
interleavedLane,
);
markSkippedUpdateLanes(interleavedLane);
interleaved = ((interleaved: any).next: Update<S, A>);
} while (interleaved !== lastInterleaved);
}

const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
}
Expand Down Expand Up @@ -1080,6 +1101,7 @@ function useMutableSource<Source, Snapshot>(
// including any interleaving updates that occur.
const newQueue = {
pending: null,
interleaved: null,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: snapshot,
Expand Down Expand Up @@ -1135,6 +1157,7 @@ function mountState<S>(
hook.memoizedState = hook.baseState = initialState;
const queue = (hook.queue = {
pending: null,
interleaved: null,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
Expand Down Expand Up @@ -1812,7 +1835,7 @@ function refreshCache<T>(fiber: Fiber, seedKey: ?() => T, seedValue: T) {
cache: seededCache,
};
refreshUpdate.payload = payload;
enqueueUpdate(provider, refreshUpdate);
enqueueUpdate(provider, refreshUpdate, lane);
return;
}
}
Expand Down Expand Up @@ -1847,17 +1870,6 @@ function dispatchAction<S, A>(
next: (null: any),
};

// Append the update to the end of the list.
const pending = queue.pending;
if (pending === null) {
// This is the first update. Create a circular list.
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
queue.pending = update;

const alternate = fiber.alternate;
if (
fiber === currentlyRenderingFiber ||
Expand All @@ -1867,7 +1879,41 @@ function dispatchAction<S, A>(
// queue -> linked list of updates. After this render pass, we'll restart
// and apply the stashed updates on top of the work-in-progress hook.
didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
const pending = queue.pending;
if (pending === null) {
// This is the first update. Create a circular list.
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
queue.pending = update;
} else {
if (isInterleavedUpdate(fiber, lane)) {
const interleaved = queue.interleaved;
if (interleaved === null) {
// This is the first update. Create a circular list.
update.next = update;
// At the end of the current render, this queue's interleaved updates will
// be transfered to the pending queue.
pushInterleavedQueue(queue);
} else {
update.next = interleaved.next;
interleaved.next = update;
}
queue.interleaved = update;
} else {
const pending = queue.pending;
if (pending === null) {
// This is the first update. Create a circular list.
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
queue.pending = update;
}

if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
Expand Down
55 changes: 55 additions & 0 deletions packages/react-reconciler/src/ReactFiberInterleavedUpdates.new.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* 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 {UpdateQueue as HookQueue} from './ReactFiberHooks.new';
import type {SharedQueue as ClassQueue} from './ReactUpdateQueue.new';

// An array of all update queues that received updates during the current
// render. When this render exits, either because it finishes or because it is
// interrupted, the interleaved updates will be transfered onto the main part
// of the queue.
let interleavedQueues: Array<
HookQueue<any, any> | ClassQueue<any>,
> | null = null;

export function pushInterleavedQueue(
queue: HookQueue<any, any> | ClassQueue<any>,
) {
if (interleavedQueues === null) {
interleavedQueues = [queue];
} else {
interleavedQueues.push(queue);
}
}

export function enqueueInterleavedUpdates() {
// Transfer the interleaved updates onto the main queue. Each queue has a
// `pending` field and an `interleaved` field. When they are not null, they
// point to the last node in a circular linked list. We need to append the
// interleaved list to the end of the pending list by joining them into a
// single, circular list.
if (interleavedQueues !== null) {
for (let i = 0; i < interleavedQueues.length; i++) {
const queue = interleavedQueues[i];
const lastInterleavedUpdate = queue.interleaved;
if (lastInterleavedUpdate !== null) {
queue.interleaved = null;
const firstInterleavedUpdate = lastInterleavedUpdate.next;
const lastPendingUpdate = queue.pending;
if (lastPendingUpdate !== null) {
const firstPendingUpdate = lastPendingUpdate.next;
lastPendingUpdate.next = (firstInterleavedUpdate: any);
lastInterleavedUpdate.next = (firstPendingUpdate: any);
}
queue.pending = (lastInterleavedUpdate: any);
}
}
interleavedQueues = null;
}
}
27 changes: 21 additions & 6 deletions packages/react-reconciler/src/ReactFiberNewContext.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {ReactContext} from 'shared/ReactTypes';
import type {Fiber, ContextDependency} from './ReactInternalTypes';
import type {StackCursor} from './ReactFiberStack.new';
import type {Lanes} from './ReactFiberLane.new';
import type {SharedQueue} from './ReactUpdateQueue.new';

import {isPrimaryRenderer} from './ReactFiberHostConfig';
import {createCursor, push, pop} from './ReactFiberStack.new';
Expand All @@ -31,7 +32,7 @@ import {

import invariant from 'shared/invariant';
import is from 'shared/objectIs';
import {createUpdate, enqueueUpdate, ForceUpdate} from './ReactUpdateQueue.new';
import {createUpdate, ForceUpdate} from './ReactUpdateQueue.new';
import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork.new';
import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';

Expand Down Expand Up @@ -211,16 +212,30 @@ export function propagateContextChange<T>(

if (fiber.tag === ClassComponent) {
// Schedule a force update on the work-in-progress.
const update = createUpdate(
NoTimestamp,
pickArbitraryLane(renderLanes),
);
const lane = pickArbitraryLane(renderLanes);
const update = createUpdate(NoTimestamp, lane);
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
// this render is thrown away. Since it's a race condition, not sure it's
// worth fixing.
enqueueUpdate(fiber, update);

// Inlined `enqueueUpdate` to remove interleaved update check
const updateQueue = fiber.updateQueue;
if (updateQueue === null) {
// Only occurs if the fiber has been unmounted.
} else {
const sharedQueue: SharedQueue<any> = (updateQueue: any).shared;
const pending = sharedQueue.pending;
if (pending === null) {
// This is the first update. Create a circular list.
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
sharedQueue.pending = update;
}
}
fiber.lanes = mergeLanes(fiber.lanes, renderLanes);
const alternate = fiber.alternate;
Expand Down
2 changes: 1 addition & 1 deletion packages/react-reconciler/src/ReactFiberReconciler.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ export function updateContainer(
update.callback = callback;
}

enqueueUpdate(current, update);
enqueueUpdate(current, update, lane);
scheduleUpdateOnFiber(current, lane, eventTime);

return lane;
Expand Down
2 changes: 1 addition & 1 deletion packages/react-reconciler/src/ReactFiberThrow.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ function throwException(
// prevent a bail out.
const update = createUpdate(NoTimestamp, SyncLane);
update.tag = ForceUpdate;
enqueueUpdate(sourceFiber, update);
enqueueUpdate(sourceFiber, update, SyncLane);
}
}

Expand Down
24 changes: 22 additions & 2 deletions packages/react-reconciler/src/ReactFiberWorkLoop.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ import {
pop as popFromStack,
createCursor,
} from './ReactFiberStack.new';
import {enqueueInterleavedUpdates} from './ReactFiberInterleavedUpdates.new';

import {
markNestedUpdateScheduled,
Expand Down Expand Up @@ -534,6 +535,7 @@ export function scheduleUpdateOnFiber(
}
}

// TODO: Consolidate with `isInterleavedUpdate` check
if (root === workInProgressRoot) {
// Received an update to a tree that's in the middle of rendering. Mark
// that there was an interleaved update work on this root. Unless the
Expand Down Expand Up @@ -671,6 +673,22 @@ function markUpdateLaneFromFiberToRoot(
}
}

export function isInterleavedUpdate(fiber: Fiber, lane: Lane) {
return (
// TODO: Optimize slightly by comparing to root that fiber belongs to.
// Requires some refactoring. Not a big deal though since it's rare for
// concurrent apps to have more than a single root.
workInProgressRoot !== null &&
(fiber.mode & BlockingMode) !== NoMode &&
// If this is a render phase update (i.e. UNSAFE_componentWillReceiveProps),
// then don't treat this as an interleaved update. This pattern is
// accompanied by a warning but we haven't fully deprecated it yet. We can
// remove once the deferRenderPhaseUpdateToNextBatch flag is enabled.
(deferRenderPhaseUpdateToNextBatch ||
(executionContext & RenderContext) === NoContext)
);
}

// Use this function to schedule a task for a root. There's only one task per
// root; if a task was already scheduled, we'll check to make sure the priority
// of the existing task is the same as the priority of the next level that the
Expand Down Expand Up @@ -1352,6 +1370,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) {
workInProgressRootUpdatedLanes = NoLanes;
workInProgressRootPingedLanes = NoLanes;

enqueueInterleavedUpdates();

if (enableSchedulerTracing) {
spawnedWorkDuringRender = null;
}
Expand Down Expand Up @@ -2282,7 +2302,7 @@ function captureCommitPhaseErrorOnRoot(
) {
const errorInfo = createCapturedValue(error, sourceFiber);
const update = createRootErrorUpdate(rootFiber, errorInfo, (SyncLane: Lane));
enqueueUpdate(rootFiber, update);
enqueueUpdate(rootFiber, update, (SyncLane: Lane));
const eventTime = requestEventTime();
const root = markUpdateLaneFromFiberToRoot(rootFiber, (SyncLane: Lane));
if (root !== null) {
Expand Down Expand Up @@ -2319,7 +2339,7 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
errorInfo,
(SyncLane: Lane),
);
enqueueUpdate(fiber, update);
enqueueUpdate(fiber, update, (SyncLane: Lane));
const eventTime = requestEventTime();
const root = markUpdateLaneFromFiberToRoot(fiber, (SyncLane: Lane));
if (root !== null) {
Expand Down
Loading

0 comments on commit f15f8f6

Please sign in to comment.