-
Notifications
You must be signed in to change notification settings - Fork 4.1k
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
breaking(AutoControlledProps): Ignore undefined #788
Changes from 4 commits
c3248be
6aea3f0
09c6b4b
0791cb9
d23db73
5b3e939
d9a9e66
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 |
---|---|---|
|
@@ -28,6 +28,33 @@ import { Component } from 'react' | |
|
||
const getDefaultPropName = (prop) => `default${prop[0].toUpperCase() + prop.slice(1)}` | ||
|
||
/** | ||
* Return the auto controlled state value for a give prop. The initial value is chosen in this order: | ||
* - default props | ||
* - then, regular props | ||
* - then, `checked` defaults to false | ||
* - then, `value` defaults to '' or [] if props.multiple | ||
* - else, undefined | ||
* | ||
* @param {object} props A props object | ||
* @param {string} propName A prop name | ||
* @param {boolean} [includeDefaultProps=false] Whether or not to heed the default prop value | ||
*/ | ||
export const getAutoControlledStateValue = (props, propName, includeDefaultProps = false) => { | ||
const defaultPropName = getDefaultPropName(propName) | ||
|
||
// defaultProps & props | ||
if (includeDefaultProps && _.has(props, defaultPropName)) return props[defaultPropName] | ||
if (props[propName] !== undefined) return props[propName] | ||
|
||
// React doesn't allow changing from uncontrolled to controlled components, | ||
// default checked/value if they were not present. | ||
if (propName === 'checked') return false | ||
if (propName === 'value') return props.multiple ? [] : '' | ||
|
||
// otherwise, undefined | ||
} | ||
|
||
export default class AutoControlledComponent extends Component { | ||
componentWillMount() { | ||
if (super.componentWillMount) super.componentWillMount() | ||
|
@@ -38,26 +65,10 @@ export default class AutoControlledComponent extends Component { | |
// Also look for the default prop for any auto controlled props (foo => defaultFoo) | ||
// so we can set initial values from defaults. | ||
this.state = _.transform(autoControlledProps, (res, prop) => { | ||
const defaultPropName = getDefaultPropName(prop) | ||
|
||
// try to set initial state in this order: | ||
// - default props | ||
// - then, regular props | ||
// - then, `checked` defaults to false | ||
// - then, `value` defaults to null | ||
// React doesn't allow changing from uncontrolled to controlled components | ||
// this is why we default checked/value if they are not present. | ||
if (_.has(this.props, defaultPropName)) { | ||
res[prop] = this.props[defaultPropName] | ||
} else if (_.has(this.props, prop)) { | ||
res[prop] = this.props[prop] | ||
} else if (prop === 'checked') { | ||
res[prop] = false | ||
} else if (prop === 'value') { | ||
res[prop] = this.props.multiple ? [] : '' // eslint-disable-line react/prop-types | ||
} | ||
res[prop] = getAutoControlledStateValue(this.props, prop, true) | ||
|
||
if (process.env.NODE_ENV !== 'production') { | ||
const defaultPropName = getDefaultPropName(prop) | ||
const { name } = this.constructor | ||
// prevent defaultFoo={} along side foo={} | ||
if (defaultPropName in this.props && prop in this.props) { | ||
|
@@ -120,10 +131,24 @@ export default class AutoControlledComponent extends Component { | |
|
||
componentWillReceiveProps(nextProps) { | ||
if (super.componentWillReceiveProps) super.componentWillReceiveProps(nextProps) | ||
const { autoControlledProps } = this.constructor | ||
|
||
// props always win, update state with all auto controlled prop | ||
const newState = _.pick(nextProps, this.constructor.autoControlledProps) | ||
if (!_.isEmpty(newState)) this.setState(newState) | ||
// Solve the next state for autoControlledProps | ||
const newState = _.transform(autoControlledProps, (acc, prop) => { | ||
const isNextUndefined = _.isUndefined(nextProps[prop]) | ||
const propWasRemoved = _.has(this.props, prop) && isNextUndefined | ||
|
||
// if next is defined then use its value | ||
if (!isNextUndefined) acc[prop] = nextProps[prop] | ||
|
||
// reinitialize state for props just removed / set undefined | ||
else if (propWasRemoved) acc[prop] = getAutoControlledStateValue(nextProps, prop) | ||
|
||
// otherwise, continue persisting the auto controlled state value | ||
else acc[prop] = this.state[prop] | ||
}, {}) | ||
|
||
this.setState(newState) | ||
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. Might be worth checking if state has actually changed so we can avoid a re-render if possible. 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. The final I've removed the unnecessary state copying and added a check to only set state if newState is not empty. |
||
} | ||
|
||
/** | ||
|
@@ -147,9 +172,10 @@ export default class AutoControlledComponent extends Component { | |
} | ||
} | ||
|
||
// pick auto controlled props | ||
// omit props from parent | ||
let newState = _.omit(_.pick(maybeState, autoControlledProps), _.keys(this.props)) | ||
const parentDefinedProps = _.omitBy(this.props, _.isUndefined) | ||
|
||
// Pick auto controlled props, omitting props defined by the parent. | ||
let newState = _.omit(_.pick(maybeState, autoControlledProps), _.keys(parentDefinedProps)) | ||
|
||
if (state) newState = { ...newState, ...state } | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -95,6 +95,55 @@ describe('extending AutoControlledComponent', () => { | |
wrapper | ||
.should.have.state(randomProp, props[randomProp]) | ||
}) | ||
|
||
it('sets state for props passed as undefined by the parent', () => { | ||
consoleUtil.disableOnce() | ||
|
||
const props = makeProps() | ||
const autoControlledProps = _.keys(props) | ||
|
||
const randomProp = _.sample(autoControlledProps) | ||
const randomValue = faker.hacker.phrase() | ||
|
||
props[randomProp] = undefined | ||
|
||
TestClass = createTestClass({ autoControlledProps, state: {} }) | ||
const wrapper = shallow(<TestClass {...props } />) | ||
|
||
wrapper | ||
.instance() | ||
.trySetState({ [randomProp]: randomValue }) | ||
|
||
wrapper | ||
.should.have.state(randomProp, randomValue) | ||
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. Comment here says |
||
}) | ||
|
||
it('does not set state for props passed as null by the parent', () => { | ||
consoleUtil.disableOnce() | ||
|
||
const props = makeProps() | ||
const autoControlledProps = _.keys(props) | ||
|
||
const randomProp = _.sample(autoControlledProps) | ||
const randomValue = faker.hacker.phrase() | ||
|
||
props[randomProp] = null | ||
|
||
TestClass = createTestClass({ autoControlledProps, state: {} }) | ||
const wrapper = shallow(<TestClass {...props } />) | ||
|
||
wrapper | ||
.instance() | ||
.trySetState({ [randomProp]: randomValue }) | ||
|
||
// not updated | ||
wrapper | ||
.should.not.have.state(randomProp, randomValue) | ||
|
||
// is original value | ||
wrapper | ||
.should.have.state(randomProp, props[randomProp]) | ||
}) | ||
}) | ||
|
||
describe('initial state', () => { | ||
|
@@ -244,5 +293,45 @@ describe('extending AutoControlledComponent', () => { | |
wrapper | ||
.should.not.have.state(randomDefaultProp, randomValue) | ||
}) | ||
|
||
it('sets state to undefined for props passed as undefined by the parent', () => { | ||
consoleUtil.disableOnce() | ||
const props = makeProps() | ||
const autoControlledProps = _.keys(props) | ||
|
||
const randomProp = _.sample(autoControlledProps) | ||
|
||
TestClass = createTestClass({ autoControlledProps, state: {} }) | ||
const wrapper = shallow(<TestClass {...props} />) | ||
|
||
// state exists initially | ||
wrapper | ||
.should.have.state(randomProp) | ||
|
||
wrapper | ||
.setProps({ [randomProp]: undefined }) | ||
|
||
// use "should not have" to assert undefined state | ||
wrapper | ||
.should.not.have.state(randomProp) | ||
}) | ||
|
||
it('does not set state for props passed as null by the parent', () => { | ||
consoleUtil.disableOnce() | ||
|
||
const props = makeProps() | ||
const autoControlledProps = _.keys(props) | ||
|
||
const randomProp = _.sample(autoControlledProps) | ||
|
||
TestClass = createTestClass({ autoControlledProps, state: {} }) | ||
const wrapper = shallow(<TestClass {...props} />) | ||
|
||
wrapper | ||
.setProps({ [randomProp]: null }) | ||
|
||
wrapper | ||
.should.have.state(randomProp, null) | ||
}) | ||
}) | ||
}) |
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.
I think this needs to be:
If you explicitly pass
undefined
then_.has(this.props, prop)
will always return true because_.has
just checks if the path exists, not that the path has a defined value. This means we'd continually reinitialize to the default value.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.
Indeed, I've updated this and similar issues in
getAutoControlledStateValue()
. I've also added several tests to ensure defaults are properly handled in both instances: