@samitouri / QOS-React / commits / e0981b8bc5

Handle setState inside an updater function

The update is scheduled as if the current processing update has already been processed; if it has the same or higher priority, it will be flushed in the same batch. We also print a warning.

Andrew Clark committed Dec 13, 2016 at 13:57 UTC e0981b8bc5c30ecbb56bb116161993b79667e607
3 files changed +137 -13
scripts/fiber/tests-passing.txt
+1
@@ -1237,6 +1237,7 @@ src/renderers/shared/fiber/__tests__/ReactIncrementalUpdates-test.js
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 +* enqueues setState inside an updater function as if the in-progress update is progressed (and warns)
1241
1242 src/renderers/shared/fiber/__tests__/ReactTopLevelFragment-test.js
1243 * should render a simple fragment at the top of a component
src/renderers/shared/fiber/ReactFiberUpdateQueue.js
+73 -13
@@ -55,6 +55,9 @@ type Update = {
55 export type UpdateQueue = {
56 first: Update | null,
57 last: Update | null,
58 +
59 + // Dev only
60 + isProcessing?: boolean,
61 };
62
63 function comparePriority(a : PriorityLevel, b : PriorityLevel) : number {
@@ -90,10 +93,21 @@ function ensureUpdateQueue(fiber : Fiber) : UpdateQueue {
93 // We already have an update queue.
94 return fiber.updateQueue;
95 }
93 - const queue = {
94 - first: null,
95 - last: null,
96 - };
96 +
97 + let queue;
98 + if (__DEV__) {
99 + queue = {
100 + first: null,
101 + last: null,
102 + isProcessing: false,
103 + };
104 + } else {
105 + queue = {
106 + first: null,
107 + last: null,
108 + };
109 + }
110 +
111 fiber.updateQueue = queue;
112 return queue;
113 }
@@ -170,10 +184,30 @@ function insertUpdateIntoQueue(queue, update, insertAfter, insertBefore) {
184 //
185 // However, if incoming update is inserted into the same position of both lists,
186 // we shouldn't make a copy.
173 -function insertUpdate(fiber : Fiber, update : Update) : void {
187 +
188 +function insertUpdate(fiber : Fiber, update : Update, methodName : ?string) : void {
189 const queue1 = ensureUpdateQueue(fiber);
190 const queue2 = fiber.alternate ? ensureUpdateQueue(fiber.alternate) : null;
191
192 + // Warn if an update is scheduled from inside an updater function.
193 + if (__DEV__ && typeof methodName === 'string' && (queue1.isProcessing || (queue2 && queue2.isProcessing))) {
194 + if (methodName === 'setState') {
195 + console.error(
196 + 'setState was called from inside the updater function of another' +
197 + 'setState. A function passed as the first argument of setState ' +
198 + 'should not contain any side-effects. Return a partial state object ' +
199 + 'instead of calling setState again. Example: ' +
200 + 'this.setState(function(state) { return { count: state.count + 1 }; })'
201 + );
202 + } else {
203 + console.error(
204 + `${methodName} was called from inside the updater function of ` +
205 + 'setState. A function passed as the first argument of setState ' +
206 + 'should not contain any side-effects.'
207 + );
208 + }
209 + }
210 +
211 const priorityLevel = update.priorityLevel;
212
213 let queue = queue1;
@@ -238,7 +272,11 @@ function addUpdate(
272 isForced: false,
273 next: null,
274 };
241 - insertUpdate(fiber, update);
275 + if (__DEV__) {
276 + insertUpdate(fiber, update, 'setState');
277 + } else {
278 + insertUpdate(fiber, update);
279 + }
280 }
281 exports.addUpdate = addUpdate;
282
@@ -286,8 +324,11 @@ function addReplaceUpdate(
324 queue = null;
325 }
326 }
289 -
290 - insertUpdate(fiber, update);
327 + if (__DEV__) {
328 + insertUpdate(fiber, update, 'replaceState');
329 + } else {
330 + insertUpdate(fiber, update);
331 + }
332 }
333 exports.addReplaceUpdate = addReplaceUpdate;
334
@@ -300,7 +341,11 @@ function addForceUpdate(fiber : Fiber, priorityLevel : PriorityLevel) : void {
341 isForced: true,
342 next: null,
343 };
303 - insertUpdate(fiber, update);
344 + if (__DEV__) {
345 + insertUpdate(fiber, update, 'forceUpdate');
346 + } else {
347 + insertUpdate(fiber, update);
348 + }
349 }
350 exports.addForceUpdate = addForceUpdate;
351
@@ -341,15 +386,28 @@ function beginUpdateQueue(
386 props : any,
387 priorityLevel : PriorityLevel
388 ) : any {
389 + if (__DEV__) {
390 + // Set this flag so we can warn if setState is called inside the update
391 + // function of another setState.
392 + queue.isProcessing = true;
393 + }
394 +
395 // Applies updates with matching priority to the previous state to create
396 // a new state object.
346 -
397 let state = prevState;
398 let dontMutatePrevState = true;
399 let isEmpty = true;
400 let callbackList = null;
401 let update = queue.first;
402 while (update && comparePriority(update.priorityLevel, priorityLevel) <= 0) {
403 + // Remove each update from the queue right before it is processed. That way
404 + // if setState is called from inside an updater function, the new update
405 + // will be inserted in the correct position.
406 + queue.first = update.next;
407 + if (!queue.first) {
408 + queue.last = null;
409 + }
410 +
411 let partialState;
412 if (update.isReplace) {
413 // A replace should drop all previous updates in the queue, so
@@ -392,9 +450,7 @@ function beginUpdateQueue(
450 state = prevState;
451 }
452
395 - if (update) {
396 - queue.first = update;
397 - } else {
453 + if (!queue.first) {
454 // Queue is now empty
455 workInProgress.updateQueue = null;
456 }
@@ -402,6 +458,10 @@ function beginUpdateQueue(
458 workInProgress.callbackList = callbackList;
459 workInProgress.memoizedState = state;
460
461 + if (__DEV__) {
462 + queue.isProcessing = false;
463 + }
464 +
465 return state;
466 }
467 exports.beginUpdateQueue = beginUpdateQueue;
src/renderers/shared/fiber/__tests__/ReactIncrementalUpdates-test.js
+63
@@ -245,4 +245,67 @@ describe('ReactIncrementalUpdates', () => {
245 'callback b',
246 ]);
247 });
248 +
249 + it('enqueues setState inside an updater function as if the in-progress update is progressed (and warns)', () => {
250 + spyOn(console, 'error');
251 + let instance;
252 + let ops = [];
253 + class Foo extends React.Component {
254 + state = {};
255 + componentDidMount() {
256 + ops.push('componentDidMount');
257 + this.setState(function a() {
258 + // Force update b to have Task priority
259 + ReactNoop.syncUpdates(() => {
260 + this.setState({ b: 'b' });
261 + });
262 + return { a: 'a' };
263 + });
264 + }
265 + render() {
266 + ops.push('render');
267 + instance = this;
268 + return <span prop={Object.keys(this.state).join('')} />;
269 + }
270 + }
271 +
272 + ReactNoop.render(<Foo />);
273 + ReactNoop.flush();
274 + expectDev(console.error.calls.count()).toBe(1);
275 + expect(ReactNoop.getChildren()).toEqual([span('ab')]);
276 + expect(ops).toEqual([
277 + // Initial render
278 + 'render',
279 + 'componentDidMount',
280 + // Updates a and b both have Task priority. Update b is enqueued while
281 + // update a is being processed, but it should be inserted into the queue
282 + // as if update a is already processed. Then processing continues. Because
283 + // they have the same priority, update b is processed in the same batch.
284 + // So there should only be a single render below.
285 + 'render',
286 + ]);
287 +
288 + ops = [];
289 +
290 + ReactNoop.performAnimationWork(() => {
291 + instance.setState(function c() {
292 + // Update d happens during the begin phase, so it has low priority.
293 + this.setState({ d: 'd' });
294 + return { c: 'c' };
295 + });
296 + });
297 +
298 + ReactNoop.flush();
299 + expect(ReactNoop.getChildren()).toEqual([span('abcd')]);
300 + expect(ops).toEqual([
301 + // Update c has animation priority. Update d is enqueued while c is being
302 + // processed with animation priority. Because d is low priority, it is not
303 + // processed until the next render. So there should be two renders below.
304 + 'render',
305 + 'render',
306 + ]);
307 +
308 + expectDev(console.error.calls.count()).toBe(2);
309 + console.error.calls.reset();
310 + });
311 });