@samitouri / QOS-React / commits / dbdddf1c82

Trigger a proper no-op warning for async state changes on server (#7127)

This commit fixes #5473: ReactDOMServer.renderToString: presence of onClick handler causes errors on async update This commit performs the following changes: - Adds a getUpdateQueue method to ReactServerRenderingTransaction, ReactReconcileTransaction, ReactNativeReconcileTransaction and ReactTestReconcileTransaction - Make the ReactCompositeComponent call this getUpdateQueue instead of using ReactUpdateQueue that was unwanted at certain moments on server - On ReactServerRenderingTransaction, dispatch ReactUpdateQueue's methods while rendering and warning methods afterwards. This is done through the new ReactServerUpdateQueue class - Added a series of tests that mimics the case presented in #5473 with setState, forceUpdate and replaceState - Add flow typechecking on concerned files

Robin Ricard committed Jul 4, 2016 at 16:47 UTC dbdddf1c82884339cdef56326b336227ea51457d
8 files changed +260 -15
src/isomorphic/modern/class/ReactNoopUpdateQueue.js
+6 -5
@@ -13,8 +13,9 @@
13
14 var warning = require('warning');
15
16 -function warnTDZ(publicInstance, callerName) {
16 +function warnNoop(publicInstance, callerName) {
17 if (__DEV__) {
18 + var constructor = publicInstance.constructor;
19 warning(
20 false,
21 '%s(...): Can only update a mounted or mounting component. ' +
@@ -22,7 +23,7 @@ function warnTDZ(publicInstance, callerName) {
23 'This is a no-op. Please check the code for the %s component.',
24 callerName,
25 callerName,
25 - publicInstance.constructor && publicInstance.constructor.displayName || ''
26 + constructor && (constructor.displayName || constructor.name) || 'ReactClass'
27 );
28 }
29 }
@@ -67,7 +68,7 @@ var ReactNoopUpdateQueue = {
68 * @internal
69 */
70 enqueueForceUpdate: function(publicInstance) {
70 - warnTDZ(publicInstance, 'forceUpdate');
71 + warnNoop(publicInstance, 'forceUpdate');
72 },
73
74 /**
@@ -82,7 +83,7 @@ var ReactNoopUpdateQueue = {
83 * @internal
84 */
85 enqueueReplaceState: function(publicInstance, completeState) {
85 - warnTDZ(publicInstance, 'replaceState');
86 + warnNoop(publicInstance, 'replaceState');
87 },
88
89 /**
@@ -96,7 +97,7 @@ var ReactNoopUpdateQueue = {
97 * @internal
98 */
99 enqueueSetState: function(publicInstance, partialState) {
99 - warnTDZ(publicInstance, 'setState');
100 + warnNoop(publicInstance, 'setState');
101 },
102 };
103
src/renderers/dom/client/ReactReconcileTransaction.js
+9 -1
@@ -16,6 +16,7 @@ var PooledClass = require('PooledClass');
16 var ReactBrowserEventEmitter = require('ReactBrowserEventEmitter');
17 var ReactInputSelection = require('ReactInputSelection');
18 var Transaction = require('Transaction');
19 +var ReactUpdateQueue = require('ReactUpdateQueue');
20
21
22 /**
@@ -104,7 +105,7 @@ var TRANSACTION_WRAPPERS = [
105 *
106 * @class ReactReconcileTransaction
107 */
107 -function ReactReconcileTransaction(useCreateElement) {
108 +function ReactReconcileTransaction(useCreateElement: boolean) {
109 this.reinitializeTransaction();
110 // Only server-side rendering really needs this option (see
111 // `ReactServerRendering`), but server-side uses
@@ -135,6 +136,13 @@ var Mixin = {
136 return this.reactMountReady;
137 },
138
139 + /**
140 + * @return {object} The queue to collect React async events.
141 + */
142 + getUpdateQueue: function() {
143 + return ReactUpdateQueue;
144 + },
145 +
146 /**
147 * Save current transaction state -- if the return value from this method is
148 * passed to `rollback`, the transaction will be reset to that state.
src/renderers/dom/server/ReactServerRenderingTransaction.js
+9
@@ -13,6 +13,7 @@
13
14 var PooledClass = require('PooledClass');
15 var Transaction = require('Transaction');
16 +var ReactServerUpdateQueue = require('ReactServerUpdateQueue');
17
18
19 /**
@@ -34,6 +35,7 @@ function ReactServerRenderingTransaction(renderToStaticMarkup) {
35 this.reinitializeTransaction();
36 this.renderToStaticMarkup = renderToStaticMarkup;
37 this.useCreateElement = false;
38 + this.updateQueue = new ReactServerUpdateQueue(this);
39 }
40
41 var Mixin = {
@@ -54,6 +56,13 @@ var Mixin = {
56 return noopCallbackQueue;
57 },
58
59 + /**
60 + * @return {object} The queue to collect React async events.
61 + */
62 + getUpdateQueue: function() {
63 + return this.updateQueue;
64 + },
65 +
66 /**
67 * `PooledClass` looks for this, and will invoke this before allowing this
68 * instance to be reused.
src/renderers/dom/server/ReactServerUpdateQueue.js new
+132
@@ -0,0 +1,132 @@
1 +/**
2 + * Copyright 2015-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 + * @providesModule ReactServerUpdateQueue
10 + * @flow
11 + */
12 +
13 +'use strict';
14 +
15 +var ReactUpdateQueue = require('ReactUpdateQueue');
16 +var Transaction = require('Transaction');
17 +var warning = require('warning');
18 +
19 +function warnNoop(publicInstance: ReactComponent<any, any, any>, callerName: string) {
20 + if (__DEV__) {
21 + var constructor = publicInstance.constructor;
22 + warning(
23 + false,
24 + '%s(...): Can only update a mounting component. ' +
25 + 'This usually means you called %s() outside componentWillMount() on the server. ' +
26 + 'This is a no-op. Please check the code for the %s component.',
27 + callerName,
28 + callerName,
29 + constructor && (constructor.displayName || constructor.name) || 'ReactClass'
30 + );
31 + }
32 +}
33 +
34 +/**
35 + * This is the update queue used for server rendering.
36 + * It delegates to ReactUpdateQueue while server rendering is in progress and
37 + * switches to ReactNoopUpdateQueue after the transaction has completed.
38 + * @class ReactServerUpdateQueue
39 + * @param {Transaction} transaction
40 + */
41 +class ReactServerUpdateQueue {
42 + /* :: transaction: Transaction; */
43 +
44 + constructor(transaction: Transaction) {
45 + this.transaction = transaction;
46 + }
47 +
48 + /**
49 + * Checks whether or not this composite component is mounted.
50 + * @param {ReactClass} publicInstance The instance we want to test.
51 + * @return {boolean} True if mounted, false otherwise.
52 + * @protected
53 + * @final
54 + */
55 + isMounted(publicInstance: ReactComponent<any, any, any>): boolean {
56 + return false;
57 + }
58 +
59 + /**
60 + * Enqueue a callback that will be executed after all the pending updates
61 + * have processed.
62 + *
63 + * @param {ReactClass} publicInstance The instance to use as `this` context.
64 + * @param {?function} callback Called after state is updated.
65 + * @internal
66 + */
67 + enqueueCallback(publicInstance: ReactComponent<any, any, any>, callback?: Function, callerName?: string) {
68 + if (this.transaction.isInTransaction()) {
69 + ReactUpdateQueue.enqueueCallback(publicInstance, callback, callerName);
70 + }
71 + }
72 +
73 + /**
74 + * Forces an update. This should only be invoked when it is known with
75 + * certainty that we are **not** in a DOM transaction.
76 + *
77 + * You may want to call this when you know that some deeper aspect of the
78 + * component's state has changed but `setState` was not called.
79 + *
80 + * This will not invoke `shouldComponentUpdate`, but it will invoke
81 + * `componentWillUpdate` and `componentDidUpdate`.
82 + *
83 + * @param {ReactClass} publicInstance The instance that should rerender.
84 + * @internal
85 + */
86 + enqueueForceUpdate(publicInstance: ReactComponent<any, any, any>) {
87 + if (this.transaction.isInTransaction()) {
88 + ReactUpdateQueue.enqueueForceUpdate(publicInstance);
89 + } else {
90 + warnNoop(publicInstance, 'forceUpdate');
91 + }
92 + }
93 +
94 + /**
95 + * Replaces all of the state. Always use this or `setState` to mutate state.
96 + * You should treat `this.state` as immutable.
97 + *
98 + * There is no guarantee that `this.state` will be immediately updated, so
99 + * accessing `this.state` after calling this method may return the old value.
100 + *
101 + * @param {ReactClass} publicInstance The instance that should rerender.
102 + * @param {object|function} completeState Next state.
103 + * @internal
104 + */
105 + enqueueReplaceState(publicInstance: ReactComponent<any, any, any>, completeState: Object|Function) {
106 + if (this.transaction.isInTransaction()) {
107 + ReactUpdateQueue.enqueueReplaceState(publicInstance, completeState);
108 + } else {
109 + warnNoop(publicInstance, 'replaceState');
110 + }
111 + }
112 +
113 + /**
114 + * Sets a subset of the state. This only exists because _pendingState is
115 + * internal. This provides a merging strategy that is not available to deep
116 + * properties which is confusing. TODO: Expose pendingState or don't use it
117 + * during the merge.
118 + *
119 + * @param {ReactClass} publicInstance The instance that should rerender.
120 + * @param {object|function} partialState Next partial state to be merged with state.
121 + * @internal
122 + */
123 + enqueueSetState(publicInstance: ReactComponent<any, any, any>, partialState: Object|Function) {
124 + if (this.transaction.isInTransaction()) {
125 + ReactUpdateQueue.enqueueSetState(publicInstance, partialState);
126 + } else {
127 + warnNoop(publicInstance, 'setState');
128 + }
129 + }
130 +}
131 +
132 +module.exports = ReactServerUpdateQueue;
src/renderers/dom/server/__tests__/ReactServerRendering-test.js
+78
@@ -400,4 +400,82 @@ describe('ReactServerRendering', function() {
400 expect(markup.indexOf('hello, world') >= 0).toBe(true);
401 });
402 });
403 +
404 + it('warns with a no-op when an async setState is triggered', function() {
405 + class Foo extends React.Component {
406 + componentWillMount() {
407 + this.setState({text: 'hello'});
408 + setTimeout(() => {
409 + this.setState({text: 'error'});
410 + });
411 + }
412 + render() {
413 + return <div onClick={() => {}}>{this.state.text}</div>;
414 + }
415 + }
416 +
417 + spyOn(console, 'error');
418 + ReactServerRendering.renderToString(<Foo />);
419 + jest.runOnlyPendingTimers();
420 + expect(console.error.calls.count()).toBe(1);
421 + expect(console.error.calls.mostRecent().args[0]).toBe(
422 + 'Warning: setState(...): Can only update a mounting component.' +
423 + ' This usually means you called setState() outside componentWillMount() on the server.' +
424 + ' This is a no-op. Please check the code for the Foo component.'
425 + );
426 + var markup = ReactServerRendering.renderToStaticMarkup(<Foo />);
427 + expect(markup).toBe('<div>hello</div>');
428 + });
429 +
430 + it('warns with a no-op when an async replaceState is triggered', function() {
431 + var Bar = React.createClass({
432 + componentWillMount: function() {
433 + this.replaceState({text: 'hello'});
434 + setTimeout(() => {
435 + this.replaceState({text: 'error'});
436 + });
437 + },
438 + render: function() {
439 + return <div onClick={() => {}}>{this.state.text}</div>;
440 + },
441 + });
442 +
443 + spyOn(console, 'error');
444 + ReactServerRendering.renderToString(<Bar />);
445 + jest.runOnlyPendingTimers();
446 + expect(console.error.calls.count()).toBe(1);
447 + expect(console.error.calls.mostRecent().args[0]).toBe(
448 + 'Warning: replaceState(...): Can only update a mounting component. ' +
449 + 'This usually means you called replaceState() outside componentWillMount() on the server. ' +
450 + 'This is a no-op. Please check the code for the Bar component.'
451 + );
452 + var markup = ReactServerRendering.renderToStaticMarkup(<Bar />);
453 + expect(markup).toBe('<div>hello</div>');
454 + });
455 +
456 + it('warns with a no-op when an async forceUpdate is triggered', function() {
457 + var Baz = React.createClass({
458 + componentWillMount: function() {
459 + this.forceUpdate();
460 + setTimeout(() => {
461 + this.forceUpdate();
462 + });
463 + },
464 + render: function() {
465 + return <div onClick={() => {}}></div>;
466 + },
467 + });
468 +
469 + spyOn(console, 'error');
470 + ReactServerRendering.renderToString(<Baz />);
471 + jest.runOnlyPendingTimers();
472 + expect(console.error.calls.count()).toBe(1);
473 + expect(console.error.calls.mostRecent().args[0]).toBe(
474 + 'Warning: forceUpdate(...): Can only update a mounting component. ' +
475 + 'This usually means you called forceUpdate() outside componentWillMount() on the server. ' +
476 + 'This is a no-op. Please check the code for the Baz component.'
477 + );
478 + var markup = ReactServerRendering.renderToStaticMarkup(<Baz />);
479 + expect(markup).toBe('<div></div>');
480 + });
481 });
src/renderers/native/ReactNativeReconcileTransaction.js
+8
@@ -14,6 +14,7 @@
14 var CallbackQueue = require('CallbackQueue');
15 var PooledClass = require('PooledClass');
16 var Transaction = require('Transaction');
17 +var ReactUpdateQueue = require('ReactUpdateQueue');
18
19 /**
20 * Provides a `CallbackQueue` queue for collecting `onDOMReady` callbacks during
@@ -81,6 +82,13 @@ var Mixin = {
82 return this.reactMountReady;
83 },
84
85 + /**
86 + * @return {object} The queue to collect React async events.
87 + */
88 + getUpdateQueue: function() {
89 + return ReactUpdateQueue;
90 + },
91 +
92 /**
93 * `PooledClass` looks for this, and will invoke this before allowing this
94 * instance to be reused.
src/renderers/shared/stack/reconciler/ReactCompositeComponent.js
+10 -9
@@ -20,7 +20,6 @@ var ReactInstrumentation = require('ReactInstrumentation');
20 var ReactNodeTypes = require('ReactNodeTypes');
21 var ReactPropTypeLocations = require('ReactPropTypeLocations');
22 var ReactReconciler = require('ReactReconciler');
23 -var ReactUpdateQueue = require('ReactUpdateQueue');
23
24 var checkReactTypeSpec = require('checkReactTypeSpec');
25
@@ -197,8 +196,10 @@ var ReactCompositeComponentMixin = {
196
197 var Component = this._currentElement.type;
198
199 + var updateQueue = transaction.getUpdateQueue();
200 +
201 // Initialize the public class
201 - var inst = this._constructComponent(publicProps, publicContext);
202 + var inst = this._constructComponent(publicProps, publicContext, updateQueue);
203 var renderedElement;
204
205 // Support functional components
@@ -245,7 +246,7 @@ var ReactCompositeComponentMixin = {
246 inst.props = publicProps;
247 inst.context = publicContext;
248 inst.refs = emptyObject;
248 - inst.updater = ReactUpdateQueue;
249 + inst.updater = updateQueue;
250
251 this._instance = inst;
252
@@ -352,20 +353,20 @@ var ReactCompositeComponentMixin = {
353 return markup;
354 },
355
355 - _constructComponent: function(publicProps, publicContext) {
356 + _constructComponent: function(publicProps, publicContext, updateQueue) {
357 if (__DEV__) {
358 ReactCurrentOwner.current = this;
359 try {
359 - return this._constructComponentWithoutOwner(publicProps, publicContext);
360 + return this._constructComponentWithoutOwner(publicProps, publicContext, updateQueue);
361 } finally {
362 ReactCurrentOwner.current = null;
363 }
364 } else {
364 - return this._constructComponentWithoutOwner(publicProps, publicContext);
365 + return this._constructComponentWithoutOwner(publicProps, publicContext, updateQueue);
366 }
367 },
368
368 - _constructComponentWithoutOwner: function(publicProps, publicContext) {
369 + _constructComponentWithoutOwner: function(publicProps, publicContext, updateQueue) {
370 var Component = this._currentElement.type;
371 var instanceOrElement;
372 if (shouldConstruct(Component)) {
@@ -377,7 +378,7 @@ var ReactCompositeComponentMixin = {
378 );
379 }
380 }
380 - instanceOrElement = new Component(publicProps, publicContext, ReactUpdateQueue);
381 + instanceOrElement = new Component(publicProps, publicContext, updateQueue);
382 if (__DEV__) {
383 if (this._debugID !== 0) {
384 ReactInstrumentation.debugTool.onEndLifeCycleTimer(
@@ -397,7 +398,7 @@ var ReactCompositeComponentMixin = {
398 );
399 }
400 }
400 - instanceOrElement = Component(publicProps, publicContext, ReactUpdateQueue);
401 + instanceOrElement = Component(publicProps, publicContext, updateQueue);
402 if (__DEV__) {
403 if (this._debugID !== 0) {
404 ReactInstrumentation.debugTool.onEndLifeCycleTimer(
src/renderers/testing/ReactTestReconcileTransaction.js
+8
@@ -14,6 +14,7 @@
14 var CallbackQueue = require('CallbackQueue');
15 var PooledClass = require('PooledClass');
16 var Transaction = require('Transaction');
17 +var ReactUpdateQueue = require('ReactUpdateQueue');
18
19 /**
20 * Provides a `CallbackQueue` queue for collecting `onDOMReady` callbacks during
@@ -81,6 +82,13 @@ var Mixin = {
82 return this.reactMountReady;
83 },
84
85 + /**
86 + * @return {object} The queue to collect React async events.
87 + */
88 + getUpdateQueue: function() {
89 + return ReactUpdateQueue;
90 + },
91 +
92 /**
93 * `PooledClass` looks for this, and will invoke this before allowing this
94 * instance to be reused.