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
});