@samitouri / QOS-React / commits / 20f00045d3

Apply pending updates in order of priority

The queue maintains a pointer to the last progressed update in the list. Updates that come after that pointer are pending. The pointer is set to the end of the list during reconciliation. Pending updates are sorted by priority then insertion. Progressed updates are sorted by the order in which they were applied during reconciliation, which may not be by priority: if a component bails out before the updates are committed, in the next render, the progressed updates are applied in the same order that they were previously, even if a higher priority update comes in. Once a progressed update is flushed/committed, it's removed from the queue.

Andrew Clark committed Dec 11, 2016 at 13:13 UTC 20f00045d323475251fec8d9f581d9ce6dc3fe91
3 files changed +426 -59
scripts/fiber/tests-passing.txt
+8
@@ -1230,6 +1230,14 @@ src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js
1230 * invokes ref callbacks after insertion/update/unmount
1231 * supports string refs
1232
1233 +src/renderers/shared/fiber/__tests__/ReactIncrementalUpdates-test.js
1234 +* applies updates in order of priority
1235 +* applies updates with equal priority in insertion order
1236 +* only drops updates with equal or lesser priority when replaceState is called
1237 +* can abort an update, schedule additional updates, and resume
1238 +* can abort an update, schedule a replaceState, and resume
1239 +* does not call callbacks that are scheduled by another callback until a later commit
1240 +
1241 src/renderers/shared/fiber/__tests__/ReactTopLevelFragment-test.js
1242 * should render a simple fragment at the top of a component
1243 * should preserve state when switching from a single child
src/renderers/shared/fiber/ReactFiberUpdateQueue.js
+170 -59
@@ -20,7 +20,11 @@ const {
20 Callback: CallbackEffect,
21 } = require('ReactTypeOfSideEffect');
22
23 -const { NoWork } = require('ReactPriorityLevel');
23 +const {
24 + NoWork,
25 + SynchronousPriority,
26 + TaskPriority,
27 +} = require('ReactPriorityLevel');
28
29 type PartialState<State, Props> =
30 $Subtype<State> |
@@ -37,22 +41,57 @@ type Update = {
41 next: Update | null,
42 };
43
44 +// Linked-list of updates
45 +//
46 +// - A "pending" update is one that has been scheduled but not yet used
47 +// for reconciliation.
48 +// - A "progressed" update is an update that was used for reconciliation, but
49 +// has not yet been flushed.
50 +//
51 +// The queue maintains a pointer to the last progressed update in the list.
52 +// Updates that come after that pointer are pending. The pointer is set to the
53 +// end of the list during reconciliation.
54 +//
55 +// Pending updates are sorted by priority then insertion. Progressed updates
56 +// are sorted by the order in which they were applied during reconciliation,
57 +// which may not be by priority: if a component bails out before the updates are
58 +// committed, in the next render, the progressed updates are applied in the same
59 +// order that they were previously, even if a higher priority update comes in.
60 +//
61 +// Once a progressed update is flushed/committed, it's removed from the queue.
62 export type UpdateQueue = {
41 - // Points to the first update.
63 first: Update | null,
64 + // A pointer to the last progressed update in the queue. This may be null
65 + // even in a non-empty queue, if all the updates are pending.
66 lastProgressedUpdate: Update | null,
44 - // Points to the last update.
67 last: Update | null,
68 };
69
48 -function getFirstPendingUpdate(queue : UpdateQueue) {
70 +function comparePriority(a : PriorityLevel, b : PriorityLevel) : number {
71 + // When comparing update priorities, treat sync and Task work as equal.
72 + // TODO: Could we avoid the need for this by always coercing sync priority
73 + // to Task when scheduling an update?
74 + if ((a === TaskPriority || a === SynchronousPriority) &&
75 + (b === TaskPriority || b === SynchronousPriority)) {
76 + return 0;
77 + }
78 + if (a === NoWork && b !== NoWork) {
79 + return -Infinity;
80 + }
81 + if (a !== NoWork && b === NoWork) {
82 + return Infinity;
83 + }
84 + return a - b;
85 +}
86 +
87 +function getFirstPendingUpdate(queue : UpdateQueue) : Update | null {
88 if (queue.lastProgressedUpdate) {
89 return queue.lastProgressedUpdate.next;
90 }
91 return queue.first;
92 }
93
55 -function getFirstProgressedUpdate(queue : UpdateQueue) {
94 +function getFirstProgressedUpdate(queue : UpdateQueue) : Update | null {
95 if (queue.lastProgressedUpdate) {
96 return queue.first;
97 }
@@ -60,8 +99,12 @@ function getFirstProgressedUpdate(queue : UpdateQueue) {
99 }
100
101 function hasPendingUpdate(queue : UpdateQueue, priorityLevel : PriorityLevel) : boolean {
63 - // TODO: Check priority level
64 - return Boolean(getFirstPendingUpdate(queue));
102 + const firstPendingUpdate = getFirstPendingUpdate(queue);
103 + if (!firstPendingUpdate) {
104 + return false;
105 + }
106 + // Return true if the first pending update has greater or equal priority.
107 + return comparePriority(firstPendingUpdate.priorityLevel, priorityLevel) <= 0;
108 }
109 exports.hasPendingUpdate = hasPendingUpdate;
110
@@ -72,7 +115,7 @@ function ensureUpdateQueue(fiber : Fiber) : UpdateQueue {
115 // We already have an update queue.
116 return fiber.updateQueue;
117 }
75 - const queue = {
118 + const queue : UpdateQueue = {
119 first: null,
120 lastProgressedUpdate: null,
121 last: null,
@@ -87,20 +130,61 @@ function ensureUpdateQueue(fiber : Fiber) : UpdateQueue {
130 }
131 exports.ensureUpdateQueue = ensureUpdateQueue;
132
90 -function insertUpdateIntoQueue(queue : UpdateQueue, update : Update, priorityLevel : PriorityLevel) : void {
133 +function insertUpdateIntoQueue(queue : UpdateQueue, update : Update) : void {
134 // Add a pending update to the end of the queue.
92 - // TODO: Insert updates in order of priority.
135 if (!queue.last) {
136 // The queue is empty.
137 queue.first = queue.last = update;
96 - } else {
97 - // The queue is not empty. Append the update to the end.
138 + return;
139 + }
140 + // The queue is not empty. Insert the new update into the queue, sorted by
141 + // priority then insertion order.
142 + const firstPendingUpdate = getFirstPendingUpdate(queue);
143 + if (!firstPendingUpdate && queue.last) {
144 + // This is the first pending update. Add it to the end of the queue.
145 queue.last.next = update;
146 queue.last = update;
100 - if (queue.lastProgressedUpdate && !queue.lastProgressedUpdate.next) {
101 - queue.lastProgressedUpdate.next = update;
147 + return;
148 + }
149 +
150 + const priorityLevel = update.priorityLevel;
151 + const lastPendingUpdate = queue.last;
152 +
153 + let insertAfter;
154 + let insertBefore;
155 + if (queue.last && comparePriority(queue.last.priorityLevel, priorityLevel) <= 0) {
156 + // Fast path where the incoming update has equal or lower priority than the
157 + // last pending update. We can just append it to the end of the queue.
158 + insertAfter = lastPendingUpdate;
159 + insertBefore = null;
160 + } else {
161 + // Loop through the pending updates to find the first one with lower
162 + // priority than the incoming update. Insert the incoming update before
163 + // that one.
164 + insertAfter = queue.lastProgressedUpdate;
165 + insertBefore = firstPendingUpdate;
166 + while (
167 + insertBefore &&
168 + comparePriority(insertBefore.priorityLevel, priorityLevel) <= 0
169 + ) {
170 + insertAfter = insertBefore;
171 + insertBefore = insertBefore.next;
172 }
173 }
174 +
175 + if (insertAfter) {
176 + insertAfter.next = update;
177 + } else {
178 + // This is the first item in the queue.
179 + queue.first = update;
180 + }
181 +
182 + if (insertBefore) {
183 + update.next = insertBefore;
184 + } else {
185 + // This is the last item in the queue.
186 + queue.last = update;
187 + }
188 }
189
190 function addUpdate(
@@ -108,7 +192,7 @@ function addUpdate(
192 partialState : PartialState<any, any> | null,
193 priorityLevel : PriorityLevel
194 ) : void {
111 - const update = {
195 + const update : Update = {
196 priorityLevel,
197 partialState,
198 callback: null,
@@ -116,7 +200,7 @@ function addUpdate(
200 isForced: false,
201 next: null,
202 };
119 - insertUpdateIntoQueue(queue, update, priorityLevel);
203 + insertUpdateIntoQueue(queue, update);
204 }
205 exports.addUpdate = addUpdate;
206
@@ -125,7 +209,7 @@ function addReplaceUpdate(
209 state : any | null,
210 priorityLevel : PriorityLevel
211 ) : void {
128 - const replaceUpdate = {
212 + const update : Update = {
213 priorityLevel,
214 partialState: state,
215 callback: null,
@@ -133,29 +217,57 @@ function addReplaceUpdate(
217 isForced: false,
218 next: null,
219 };
136 -
220 + // Add a pending update to the end of the queue.
221 if (!queue.last) {
222 // The queue is empty.
139 - queue.first = queue.last = replaceUpdate;
140 - } else {
141 - // The queue is not empty.
223 + queue.first = queue.last = update;
224 + return;
225 + }
226 + // The queue is not empty. Insert the new update into the queue, sorted by
227 + // priority then insertion order. Since this is a replace, drop all pending
228 + // updates with equal priority. We can't drop updates with higher priority,
229 + // because they might be flushed in an earlier commit. We'll drop them during
230 + // the commit phase if necessary.
231 + const firstPendingUpdate = getFirstPendingUpdate(queue);
232 + if (!firstPendingUpdate && queue.last) {
233 + // This is the first pending update. Add it to the end of the queue.
234 + queue.last.next = update;
235 + queue.last = update;
236 + return;
237 + }
238
143 - // Drop all existing pending updates.
144 - // TODO: Only drop updates with matching priority.
145 - if (queue.lastProgressedUpdate) {
146 - queue.lastProgressedUpdate.next = replaceUpdate;
147 - queue.last = replaceUpdate;
148 - } else {
149 - // Drop everything
150 - queue.first = queue.last = replaceUpdate;
239 + // Find the last pending update with equal priority.
240 + let replaceAfter = queue.lastProgressedUpdate;
241 + let replaceBefore = firstPendingUpdate;
242 + if (replaceBefore) {
243 + let comparison = Infinity;
244 + while (replaceBefore &&
245 + (comparison = comparePriority(replaceBefore.priorityLevel, priorityLevel)) <= 0) {
246 + if (comparison < 0) {
247 + replaceAfter = replaceBefore;
248 + }
249 + replaceBefore = replaceBefore.next;
250 }
251 }
252
253 + if (replaceAfter) {
254 + replaceAfter.next = update;
255 + } else {
256 + // This is the first item in the queue.
257 + queue.first = update;
258 + }
259 +
260 + if (replaceBefore) {
261 + update.next = replaceBefore;
262 + } else {
263 + // This is the last item in the queue.
264 + queue.last = update;
265 + }
266 }
267 exports.addReplaceUpdate = addReplaceUpdate;
268
269 function addForceUpdate(queue : UpdateQueue, priorityLevel : PriorityLevel) : void {
158 - const update = {
270 + const update : Update = {
271 priorityLevel,
272 partialState: null,
273 callback: null,
@@ -163,21 +275,24 @@ function addForceUpdate(queue : UpdateQueue, priorityLevel : PriorityLevel) : vo
275 isForced: true,
276 next: null,
277 };
166 - insertUpdateIntoQueue(queue, update, priorityLevel);
278 + insertUpdateIntoQueue(queue, update);
279 }
280 exports.addForceUpdate = addForceUpdate;
281
282
283 function addCallback(queue : UpdateQueue, callback: Callback, priorityLevel : PriorityLevel) : void {
172 - if (getFirstPendingUpdate(queue) && queue.last && !queue.last.callback) {
284 + if (getFirstPendingUpdate(queue) &&
285 + queue.last &&
286 + (queue.last.priorityLevel === priorityLevel) &&
287 + !queue.last.callback) {
288 // If pending updates already exist, and the last pending update does not
174 - // have a callback, we can add the new callback to that update.
175 - // TODO: Add an additional check to ensure the priority matches.
289 + // have a callback, and the priority levels are equal, we can add the
290 + // incoming callback to that update to avoid an extra allocation.
291 queue.last.callback = callback;
292 return;
293 }
294
180 - const update = {
295 + const update : Update = {
296 priorityLevel,
297 partialState: null,
298 callback,
@@ -185,25 +300,13 @@ function addCallback(queue : UpdateQueue, callback: Callback, priorityLevel : Pr
300 isForced: false,
301 next: null,
302 };
188 - insertUpdateIntoQueue(queue, update, priorityLevel);
303 + insertUpdateIntoQueue(queue, update);
304 }
305 exports.addCallback = addCallback;
306
307 function getPendingPriority(queue : UpdateQueue) : PriorityLevel {
193 - // Loop through the pending updates to recompute the pending priority.
194 - // TODO: Once updates are sorted, just read from the first pending update.
195 - let priorityLevel = NoWork;
196 - // Start with first pending update
197 - let update = getFirstPendingUpdate(queue);
198 - while (update) {
199 - if (priorityLevel === NoWork ||
200 - priorityLevel >= update.priorityLevel) {
201 - // Update pending priority
202 - priorityLevel = update.priorityLevel;
203 - }
204 - update = update.next;
205 - }
206 - return priorityLevel;
308 + const firstPendingUpdate = getFirstPendingUpdate(queue);
309 + return firstPendingUpdate ? firstPendingUpdate.priorityLevel : NoWork;
310 }
311 exports.getPendingPriority = getPendingPriority;
312
@@ -225,23 +328,25 @@ function beginUpdateQueue(
328 props : any,
329 priorityLevel : PriorityLevel
330 ) : any {
228 - // This merges the entire update queue into a single object, not just the
229 - // pending updates, because the previous state and props may have changed.
230 - // TODO: Would memoization be worth it?
331 + // Applies updates with matching priority to the previous state to create
332 + // a new state object. If an update was used previously but never flushed
333 + // due to a bail out, it's used again regardless of its priority.
334
335 // Reset these flags. We'll update them while looping through the queue.
336 workInProgress.effectTag &= ~ForceUpdate;
337 workInProgress.effectTag &= ~CallbackEffect;
338
339 + const prevLastProgressedUpdate = queue.lastProgressedUpdate;
340 let state = prevState;
341 let dontMutatePrevState = true;
342 let isEmpty = true;
239 -
240 - // TODO: Stop merging once we reach an update whose priority doesn't match.
241 - // Should this also apply to updates that were previous merged but bailed out?
242 - let update : Update | null = queue.first;
343 + let alreadyProgressedUpdate = Boolean(prevLastProgressedUpdate);
344 let lastProgressedUpdate = null;
244 - while (update) {
345 + let update : Update | null = queue.first;
346 + while (update && (
347 + alreadyProgressedUpdate ||
348 + comparePriority(update.priorityLevel, priorityLevel) <= 0
349 + )) {
350 let partialState;
351 if (update.isReplace) {
352 // A replace should drop all previous updates in the queue, so
@@ -267,10 +372,15 @@ function beginUpdateQueue(
372 if (update.callback) {
373 workInProgress.effectTag |= CallbackEffect;
374 }
375 + if (update === prevLastProgressedUpdate) {
376 + alreadyProgressedUpdate = false;
377 + }
378 lastProgressedUpdate = update;
379 update = update.next;
380 }
381
382 +
383 + // Mark the point in the queue where we stopped applying updates
384 queue.lastProgressedUpdate = lastProgressedUpdate;
385
386 if (isEmpty) {
@@ -296,8 +406,9 @@ function commitUpdateQueue(finishedWork : Fiber, queue : UpdateQueue, context :
406 }
407 }
408
299 - // Drop all completed updates, leaving only the pending updates.
409 + // Drop all comitted updates, leaving only the pending updates.
410 queue.first = getFirstPendingUpdate(queue);
411 + queue.lastProgressedUpdate = null;
412 if (!queue.first) {
413 queue.last = queue.lastProgressedUpdate = null;
414
src/renderers/shared/fiber/__tests__/ReactIncrementalUpdates-test.js new
+248
@@ -0,0 +1,248 @@
1 +/**
2 + * Copyright 2013-present, Facebook, Inc.
3 + * All rights reserved.
4 + *
5 + * This source code is licensed under the BSD-style license found in the
6 + * LICENSE file in the root directory of this source tree. An additional grant
7 + * of patent rights can be found in the PATENTS file in the same directory.
8 + *
9 + * @emails react-core
10 + */
11 +
12 +'use strict';
13 +
14 +var React;
15 +var ReactNoop;
16 +
17 +describe('ReactIncrementalUpdates', () => {
18 + beforeEach(() => {
19 + jest.resetModuleRegistry();
20 + React = require('React');
21 + ReactNoop = require('ReactNoop');
22 + });
23 +
24 + function div(...children) {
25 + children = children.map(c => typeof c === 'string' ? { text: c } : c);
26 + return { type: 'div', children, prop: undefined };
27 + }
28 +
29 + function span(prop) {
30 + return { type: 'span', children: [], prop };
31 + }
32 +
33 + it('applies updates in order of priority', () => {
34 + let state;
35 + class Foo extends React.Component {
36 + state = {};
37 + componentDidMount() {
38 + ReactNoop.performAnimationWork(() => {
39 + // Has Animation priority
40 + this.setState({ b: 'b' });
41 + this.setState({ c: 'c' });
42 + });
43 + // Has Task priority
44 + this.setState({ a: 'a' });
45 + }
46 + render() {
47 + state = this.state;
48 + return <div />;
49 + }
50 + }
51 +
52 + ReactNoop.render(<Foo />);
53 + ReactNoop.flush();
54 + expect(Object.keys(state)).toEqual(['a', 'b', 'c']);
55 + });
56 +
57 + it('applies updates with equal priority in insertion order', () => {
58 + let state;
59 + class Foo extends React.Component {
60 + state = {};
61 + componentDidMount() {
62 + // All have Task priority
63 + this.setState({ a: 'a' });
64 + this.setState({ b: 'b' });
65 + this.setState({ c: 'c' });
66 + }
67 + render() {
68 + state = this.state;
69 + return <div />;
70 + }
71 + }
72 +
73 + ReactNoop.render(<Foo />);
74 + ReactNoop.flush();
75 + expect(Object.keys(state)).toEqual(['a', 'b', 'c']);
76 + });
77 +
78 + it('only drops updates with equal or lesser priority when replaceState is called', () => {
79 + let instance;
80 + const Foo = React.createClass({
81 + getInitialState() {
82 + return {};
83 + },
84 + render() {
85 + instance = this;
86 + return (
87 + <span prop={Object.keys(this.state).join('')} />
88 + );
89 + },
90 + });
91 +
92 + ReactNoop.render(<Foo />);
93 + ReactNoop.flush();
94 +
95 + instance.setState({ x: 'x' });
96 + instance.setState({ y: 'y' });
97 + ReactNoop.performAnimationWork(() => {
98 + instance.setState({ a: 'a' });
99 + instance.setState({ b: 'b' });
100 + });
101 + instance.replaceState({ c: 'c' });
102 + instance.setState({ d: 'd' });
103 +
104 + ReactNoop.flushAnimationPri();
105 + // Even though a replaceState has been already scheduled, it hasn't been
106 + // flushed yet because it has low priority.
107 + expect(ReactNoop.getChildren()).toEqual([span('ab')]);
108 +
109 + ReactNoop.flush();
110 + // Now the rest of the updates are flushed.
111 + expect(ReactNoop.getChildren()).toEqual([span('cd')]);
112 + });
113 +
114 + it('can abort an update, schedule additional updates, and resume', () => {
115 + let instance;
116 + class Foo extends React.Component {
117 + state = {};
118 + render() {
119 + instance = this;
120 + return (
121 + <span prop={Object.keys(this.state).join('')} />
122 + );
123 + }
124 + }
125 +
126 + ReactNoop.render(<Foo />);
127 + ReactNoop.flush();
128 +
129 + let progressedUpdates = [];
130 + function createUpdate(letter) {
131 + return () => {
132 + progressedUpdates.push(letter);
133 + return {
134 + [letter]: letter,
135 + };
136 + };
137 + }
138 +
139 + instance.setState(createUpdate('a'));
140 + instance.setState(createUpdate('b'));
141 + instance.setState(createUpdate('c'));
142 +
143 + // Do just enough work to begin the update but not enough to flush it
144 + ReactNoop.flushDeferredPri(20);
145 + expect(ReactNoop.getChildren()).toEqual([span('')]);
146 + expect(progressedUpdates).toEqual(['a', 'b', 'c']);
147 +
148 + instance.setState(createUpdate('f'));
149 + ReactNoop.performAnimationWork(() => {
150 + instance.setState(createUpdate('d'));
151 + instance.setState(createUpdate('e'));
152 + });
153 + instance.setState(createUpdate('g'));
154 +
155 + // Updates a, b, and c were aborted, so they should be applied first even
156 + // though they have low priority. Update f was scheduled after the render
157 + // was aborted, so it should come after d and e, which have higher priority.
158 + ReactNoop.flush();
159 + expect(ReactNoop.getChildren()).toEqual([span('abcdefg')]);
160 + expect(progressedUpdates).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g']);
161 + });
162 +
163 + it('can abort an update, schedule a replaceState, and resume', () => {
164 + let instance;
165 + const Foo = React.createClass({
166 + getInitialState() {
167 + return {};
168 + },
169 + render() {
170 + instance = this;
171 + return (
172 + <span prop={Object.keys(this.state).join('')} />
173 + );
174 + },
175 + });
176 +
177 + ReactNoop.render(<Foo />);
178 + ReactNoop.flush();
179 +
180 + let progressedUpdates = [];
181 + function createUpdate(letter) {
182 + return () => {
183 + progressedUpdates.push(letter);
184 + return {
185 + [letter]: letter,
186 + };
187 + };
188 + }
189 +
190 + instance.setState(createUpdate('a'));
191 + instance.setState(createUpdate('b'));
192 + instance.setState(createUpdate('c'));
193 +
194 + // Do just enough work to begin the update but not enough to flush it
195 + ReactNoop.flushDeferredPri(20);
196 + expect(ReactNoop.getChildren()).toEqual([span('')]);
197 + expect(progressedUpdates).toEqual(['a', 'b', 'c']);
198 +
199 + progressedUpdates = [];
200 +
201 + instance.setState(createUpdate('f'));
202 + ReactNoop.performAnimationWork(() => {
203 + instance.setState(createUpdate('d'));
204 + instance.replaceState(createUpdate('e'));
205 + });
206 + instance.setState(createUpdate('g'));
207 +
208 + // Updates a, b, and c were aborted, so they should be applied first even
209 + // though they have low priority. Update f was scheduled after the render
210 + // was aborted, so it should come after d and e, which have higher priority.
211 + // Because e is a replaceState, d gets dropped.
212 + ReactNoop.flush();
213 + expect(ReactNoop.getChildren()).toEqual([span('efg')]);
214 + // Ensure that update d is not progressed before being replaced.
215 + expect(progressedUpdates).toEqual(['e', 'f', 'g']);
216 + });
217 +
218 + it('does not call callbacks that are scheduled by another callback until a later commit', () => {
219 + let ops = [];
220 + class Foo extends React.Component {
221 + state = {};
222 + componentDidMount() {
223 + ops.push('did mount');
224 + this.setState({ a: 'a' }, () => {
225 + ops.push('callback a');
226 + this.setState({ b: 'b' }, () => {
227 + ops.push('callback b');
228 + });
229 + });
230 + }
231 + render() {
232 + ops.push('render');
233 + return <div />;
234 + }
235 + }
236 +
237 + ReactNoop.render(<Foo />);
238 + ReactNoop.flush();
239 + expect(ops).toEqual([
240 + 'render',
241 + 'did mount',
242 + 'render',
243 + 'callback a',
244 + 'render',
245 + 'callback b',
246 + ]);
247 + });
248 +});