-
Notifications
You must be signed in to change notification settings - Fork 47k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Trigger a proper no-op warning for async state changes on server #7127
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
/** | ||
* Copyright 2015-present, Facebook, Inc. | ||
* All rights reserved. | ||
* | ||
* This source code is licensed under the BSD-style license found in the | ||
* LICENSE file in the root directory of this source tree. An additional grant | ||
* of patent rights can be found in the PATENTS file in the same directory. | ||
* | ||
* @providesModule ReactServerUpdateQueue | ||
* @flow | ||
*/ | ||
|
||
'use strict'; | ||
|
||
var ReactUpdateQueue = require('ReactUpdateQueue'); | ||
var Transaction = require('Transaction'); | ||
var warning = require('warning'); | ||
|
||
function warnNoop(publicInstance: ReactComponent<any, any, any>, callerName: string) { | ||
if (__DEV__) { | ||
var constructor = publicInstance.constructor; | ||
warning( | ||
false, | ||
'%s(...): Can only update a mounting component. ' + | ||
'This usually means you called %s() outside componentWillMount() on the server. ' + | ||
'This is a no-op. Please check the code for the %s component.', | ||
callerName, | ||
callerName, | ||
constructor && (constructor.displayName || constructor.name) || 'ReactClass' | ||
); | ||
} | ||
} | ||
|
||
/** | ||
* This is the update queue used for server rendering. | ||
* It delegates to ReactUpdateQueue while server rendering is in progress and | ||
* switches to ReactNoopUpdateQueue after the transaction has completed. | ||
* @class ReactServerUpdateQueue | ||
* @param {Transaction} transaction | ||
*/ | ||
class ReactServerUpdateQueue { | ||
/* :: transaction: Transaction; */ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don’t use this convention anywhere so let’s remove it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's actually needed by flow to be able to assign There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scratch that, the transform needed there is an ES7 transform: syntax-class-properties |
||
|
||
constructor(transaction: Transaction) { | ||
this.transaction = transaction; | ||
} | ||
|
||
/** | ||
* Checks whether or not this composite component is mounted. | ||
* @param {ReactClass} publicInstance The instance we want to test. | ||
* @return {boolean} True if mounted, false otherwise. | ||
* @protected | ||
* @final | ||
*/ | ||
isMounted(publicInstance: ReactComponent<any, any, any>): boolean { | ||
return false; | ||
} | ||
|
||
/** | ||
* Enqueue a callback that will be executed after all the pending updates | ||
* have processed. | ||
* | ||
* @param {ReactClass} publicInstance The instance to use as `this` context. | ||
* @param {?function} callback Called after state is updated. | ||
* @internal | ||
*/ | ||
enqueueCallback(publicInstance: ReactComponent<any, any, any>, callback?: Function, callerName?: string) { | ||
if (this.transaction.isInTransaction()) { | ||
ReactUpdateQueue.enqueueCallback(publicInstance, callback, callerName); | ||
} | ||
} | ||
|
||
/** | ||
* Forces an update. This should only be invoked when it is known with | ||
* certainty that we are **not** in a DOM transaction. | ||
* | ||
* You may want to call this when you know that some deeper aspect of the | ||
* component's state has changed but `setState` was not called. | ||
* | ||
* This will not invoke `shouldComponentUpdate`, but it will invoke | ||
* `componentWillUpdate` and `componentDidUpdate`. | ||
* | ||
* @param {ReactClass} publicInstance The instance that should rerender. | ||
* @internal | ||
*/ | ||
enqueueForceUpdate(publicInstance: ReactComponent<any, any, any>) { | ||
if (this.transaction.isInTransaction()) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, scratch that, maybe your |
||
ReactUpdateQueue.enqueueForceUpdate(publicInstance); | ||
} else { | ||
warnNoop(publicInstance, 'forceUpdate'); | ||
} | ||
} | ||
|
||
/** | ||
* Replaces all of the state. Always use this or `setState` to mutate state. | ||
* You should treat `this.state` as immutable. | ||
* | ||
* There is no guarantee that `this.state` will be immediately updated, so | ||
* accessing `this.state` after calling this method may return the old value. | ||
* | ||
* @param {ReactClass} publicInstance The instance that should rerender. | ||
* @param {object|function} completeState Next state. | ||
* @internal | ||
*/ | ||
enqueueReplaceState(publicInstance: ReactComponent<any, any, any>, completeState: Object|Function) { | ||
if (this.transaction.isInTransaction()) { | ||
ReactUpdateQueue.enqueueReplaceState(publicInstance, completeState); | ||
} else { | ||
warnNoop(publicInstance, 'replaceState'); | ||
} | ||
} | ||
|
||
/** | ||
* Sets a subset of the state. This only exists because _pendingState is | ||
* internal. This provides a merging strategy that is not available to deep | ||
* properties which is confusing. TODO: Expose pendingState or don't use it | ||
* during the merge. | ||
* | ||
* @param {ReactClass} publicInstance The instance that should rerender. | ||
* @param {object|function} partialState Next partial state to be merged with state. | ||
* @internal | ||
*/ | ||
enqueueSetState(publicInstance: ReactComponent<any, any, any>, partialState: Object|Function) { | ||
if (this.transaction.isInTransaction()) { | ||
ReactUpdateQueue.enqueueSetState(publicInstance, partialState); | ||
} else { | ||
warnNoop(publicInstance, 'setState'); | ||
} | ||
} | ||
} | ||
|
||
module.exports = ReactServerUpdateQueue; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How about we just write this as ES class? We use Babel now so I don’t see why not just use the class syntax. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh! Cool! I thought that this part wasn't quite yet babel-ready. I should have checked the gulp pipeline ... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea, I’m not 100% sure (we don’t seem to use classes yet) but this could be the first one 😉 . I think There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, of course! I can change the babelrc if needed... |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -400,4 +400,82 @@ describe('ReactServerRendering', function() { | |
expect(markup.indexOf('hello, world') >= 0).toBe(true); | ||
}); | ||
}); | ||
|
||
it('warns with a no-op when an async setState is triggered', function() { | ||
class Foo extends React.Component { | ||
componentWillMount() { | ||
this.setState({text: 'hello'}); | ||
setTimeout(() => { | ||
this.setState({text: 'error'}); | ||
}); | ||
} | ||
render() { | ||
return <div onClick={() => {}}>{this.state.text}</div>; | ||
} | ||
} | ||
|
||
spyOn(console, 'error'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, I don’t know. That’s what the rest of the codebase does tho. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Historically we ran these tests in regular phantom in the browser, before jest really existed. We did that for a while before turning off last year. We wanted to maintain that option value moving forward and tying too closely to jest would make it harder (unless jest starts supporting that use case). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For now, I'll stick with the jasmine-way of spying on things... |
||
ReactServerRendering.renderToString(<Foo />); | ||
jest.runOnlyPendingTimers(); | ||
expect(console.error.calls.count()).toBe(1); | ||
expect(console.error.calls.mostRecent().args[0]).toBe( | ||
'Warning: setState(...): Can only update a mounting component.' + | ||
' This usually means you called setState() outside componentWillMount() on the server.' + | ||
' This is a no-op. Please check the code for the Foo component.' | ||
); | ||
var markup = ReactServerRendering.renderToStaticMarkup(<Foo />); | ||
expect(markup).toBe('<div>hello</div>'); | ||
}); | ||
|
||
it('warns with a no-op when an async replaceState is triggered', function() { | ||
var Bar = React.createClass({ | ||
componentWillMount: function() { | ||
this.replaceState({text: 'hello'}); | ||
setTimeout(() => { | ||
this.replaceState({text: 'error'}); | ||
}); | ||
}, | ||
render: function() { | ||
return <div onClick={() => {}}>{this.state.text}</div>; | ||
}, | ||
}); | ||
|
||
spyOn(console, 'error'); | ||
ReactServerRendering.renderToString(<Bar />); | ||
jest.runOnlyPendingTimers(); | ||
expect(console.error.calls.count()).toBe(1); | ||
expect(console.error.calls.mostRecent().args[0]).toBe( | ||
'Warning: replaceState(...): Can only update a mounting component. ' + | ||
'This usually means you called replaceState() outside componentWillMount() on the server. ' + | ||
'This is a no-op. Please check the code for the Bar component.' | ||
); | ||
var markup = ReactServerRendering.renderToStaticMarkup(<Bar />); | ||
expect(markup).toBe('<div>hello</div>'); | ||
}); | ||
|
||
it('warns with a no-op when an async forceUpdate is triggered', function() { | ||
var Baz = React.createClass({ | ||
componentWillMount: function() { | ||
this.forceUpdate(); | ||
setTimeout(() => { | ||
this.forceUpdate(); | ||
}); | ||
}, | ||
render: function() { | ||
return <div onClick={() => {}}></div>; | ||
}, | ||
}); | ||
|
||
spyOn(console, 'error'); | ||
ReactServerRendering.renderToString(<Baz />); | ||
jest.runOnlyPendingTimers(); | ||
expect(console.error.calls.count()).toBe(1); | ||
expect(console.error.calls.mostRecent().args[0]).toBe( | ||
'Warning: forceUpdate(...): Can only update a mounting component. ' + | ||
'This usually means you called forceUpdate() outside componentWillMount() on the server. ' + | ||
'This is a no-op. Please check the code for the Baz component.' | ||
); | ||
var markup = ReactServerRendering.renderToStaticMarkup(<Baz />); | ||
expect(markup).toBe('<div></div>'); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: