Skip to content

Commit b211ce3

Browse files
jeffcarbslevithomason
authored andcommitted
fix(AutoControlledProps): Ignore undefined (#788)
* breaking(AutoControlledProps): Ignore undefined * feat(AutoControlledComponent): reinit state on cwrp * feat(Popup): add debug logs * refactor(Popup): use if/else instead of switch * fix(AutoControlledComponent): properly handle defaultProps * fix(AutoControlledComponent): only set state if changed * perf(AutoControlledComponent): _.transform to reduce()
1 parent 5f1ede2 commit b211ce3

File tree

3 files changed

+244
-69
lines changed

3 files changed

+244
-69
lines changed

src/lib/AutoControlledComponent.js

Lines changed: 80 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -28,46 +28,43 @@ import { Component } from 'react'
2828

2929
const getDefaultPropName = (prop) => `default${prop[0].toUpperCase() + prop.slice(1)}`
3030

31+
/**
32+
* Return the auto controlled state value for a give prop. The initial value is chosen in this order:
33+
* - default props
34+
* - then, regular props
35+
* - then, `checked` defaults to false
36+
* - then, `value` defaults to '' or [] if props.multiple
37+
* - else, undefined
38+
*
39+
* @param {object} props A props object
40+
* @param {string} propName A prop name
41+
* @param {boolean} [includeDefaultProps=false] Whether or not to heed the default prop value
42+
*/
43+
export const getAutoControlledStateValue = (props, propName, includeDefaultProps = false) => {
44+
const defaultPropName = getDefaultPropName(propName)
45+
const prop = props[propName]
46+
const defaultProp = props[defaultPropName]
47+
48+
const hasProp = prop !== undefined
49+
const hasDefaultProp = defaultProp !== undefined
50+
51+
// defaultProps & props
52+
if (includeDefaultProps && !hasProp && hasDefaultProp) return defaultProp
53+
if (hasProp) return prop
54+
55+
// React doesn't allow changing from uncontrolled to controlled components,
56+
// default checked/value if they were not present.
57+
if (propName === 'checked') return false
58+
if (propName === 'value') return props.multiple ? [] : ''
59+
60+
// otherwise, undefined
61+
}
62+
3163
export default class AutoControlledComponent extends Component {
3264
componentWillMount() {
3365
if (super.componentWillMount) super.componentWillMount()
3466
const { autoControlledProps } = this.constructor
3567

36-
// Auto controlled props are copied to state.
37-
// Set initial state by copying auto controlled props to state.
38-
// Also look for the default prop for any auto controlled props (foo => defaultFoo)
39-
// so we can set initial values from defaults.
40-
this.state = _.transform(autoControlledProps, (res, prop) => {
41-
const defaultPropName = getDefaultPropName(prop)
42-
43-
// try to set initial state in this order:
44-
// - default props
45-
// - then, regular props
46-
// - then, `checked` defaults to false
47-
// - then, `value` defaults to null
48-
// React doesn't allow changing from uncontrolled to controlled components
49-
// this is why we default checked/value if they are not present.
50-
if (_.has(this.props, defaultPropName)) {
51-
res[prop] = this.props[defaultPropName]
52-
} else if (_.has(this.props, prop)) {
53-
res[prop] = this.props[prop]
54-
} else if (prop === 'checked') {
55-
res[prop] = false
56-
} else if (prop === 'value') {
57-
res[prop] = this.props.multiple ? [] : '' // eslint-disable-line react/prop-types
58-
}
59-
60-
if (process.env.NODE_ENV !== 'production') {
61-
const { name } = this.constructor
62-
// prevent defaultFoo={} along side foo={}
63-
if (defaultPropName in this.props && prop in this.props) {
64-
console.error(
65-
`${name} prop "${prop}" is auto controlled. Specify either ${defaultPropName} or ${prop}, but not both.`
66-
)
67-
}
68-
}
69-
}, {})
70-
7168
if (process.env.NODE_ENV !== 'production') {
7269
const { defaultProps, name, propTypes } = this.constructor
7370
// require static autoControlledProps
@@ -116,14 +113,48 @@ export default class AutoControlledComponent extends Component {
116113
].join(' '))
117114
}
118115
}
116+
117+
// Auto controlled props are copied to state.
118+
// Set initial state by copying auto controlled props to state.
119+
// Also look for the default prop for any auto controlled props (foo => defaultFoo)
120+
// so we can set initial values from defaults.
121+
this.state = autoControlledProps.reduce((acc, prop) => {
122+
acc[prop] = getAutoControlledStateValue(this.props, prop, true)
123+
124+
if (process.env.NODE_ENV !== 'production') {
125+
const defaultPropName = getDefaultPropName(prop)
126+
const { name } = this.constructor
127+
// prevent defaultFoo={} along side foo={}
128+
if (defaultPropName in this.props && prop in this.props) {
129+
console.error(
130+
`${name} prop "${prop}" is auto controlled. Specify either ${defaultPropName} or ${prop}, but not both.`
131+
)
132+
}
133+
}
134+
135+
return acc
136+
}, {})
119137
}
120138

121139
componentWillReceiveProps(nextProps) {
122140
if (super.componentWillReceiveProps) super.componentWillReceiveProps(nextProps)
141+
const { autoControlledProps } = this.constructor
142+
143+
// Solve the next state for autoControlledProps
144+
const newState = autoControlledProps.reduce((acc, prop) => {
145+
const isNextUndefined = _.isUndefined(nextProps[prop])
146+
const propWasRemoved = !_.isUndefined(this.props[prop]) && isNextUndefined
147+
148+
// if next is defined then use its value
149+
if (!isNextUndefined) acc[prop] = nextProps[prop]
123150

124-
// props always win, update state with all auto controlled prop
125-
const newState = _.pick(nextProps, this.constructor.autoControlledProps)
126-
if (!_.isEmpty(newState)) this.setState(newState)
151+
// reinitialize state for props just removed / set undefined
152+
else if (propWasRemoved) acc[prop] = getAutoControlledStateValue(nextProps, prop)
153+
154+
return acc
155+
}, {})
156+
157+
if (Object.keys(newState).length > 0) this.setState(newState)
127158
}
128159

129160
/**
@@ -147,12 +178,19 @@ export default class AutoControlledComponent extends Component {
147178
}
148179
}
149180

150-
// pick auto controlled props
151-
// omit props from parent
152-
let newState = _.omit(_.pick(maybeState, autoControlledProps), _.keys(this.props))
181+
let newState = Object.keys(maybeState).reduce((acc, prop) => {
182+
// ignore props defined by the parent
183+
if (this.props[prop] !== undefined) return acc
184+
185+
// ignore props not listed in auto controlled props
186+
if (autoControlledProps.indexOf(prop) === -1) return acc
187+
188+
acc[prop] = maybeState[prop]
189+
return acc
190+
}, {})
153191

154192
if (state) newState = { ...newState, ...state }
155193

156-
if (!_.isEmpty(newState)) this.setState(newState)
194+
if (Object.keys(newState).length > 0) this.setState(newState)
157195
}
158196
}

src/modules/Popup/Popup.js

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
getElementType,
66
getUnhandledProps,
77
isBrowser,
8+
makeDebugger,
89
META,
910
SUI,
1011
useKeyOnly,
@@ -14,6 +15,8 @@ import Portal from '../../addons/Portal'
1415
import PopupContent from './PopupContent'
1516
import PopupHeader from './PopupHeader'
1617

18+
const debug = makeDebugger('popup')
19+
1720
const _meta = {
1821
name: 'Popup',
1922
type: META.TYPES.MODULE,
@@ -222,32 +225,26 @@ export default class Popup extends Component {
222225

223226
const { on, hoverable } = this.props
224227

225-
switch (on) {
226-
case 'click':
227-
portalProps.openOnTriggerClick = true
228-
portalProps.closeOnTriggerClick = true
229-
portalProps.closeOnDocumentClick = true
230-
break
231-
232-
case 'focus':
233-
portalProps.openOnTriggerFocus = true
234-
portalProps.closeOnTriggerBlur = true
235-
break
236-
237-
default: // default to hover
238-
portalProps.openOnTriggerMouseOver = true
239-
portalProps.closeOnTriggerMouseLeave = true
240-
// Taken from SUI: https://git.io/vPmCm
241-
portalProps.mouseLeaveDelay = 70
242-
portalProps.mouseOverDelay = 50
243-
break
244-
}
245-
246228
if (hoverable) {
247229
portalProps.closeOnPortalMouseLeave = true
248230
portalProps.mouseLeaveDelay = 300
249231
}
250232

233+
if (on === 'click') {
234+
portalProps.openOnTriggerClick = true
235+
portalProps.closeOnTriggerClick = true
236+
portalProps.closeOnDocumentClick = true
237+
} else if (on === 'focus') {
238+
portalProps.openOnTriggerFocus = true
239+
portalProps.closeOnTriggerBlur = true
240+
} else if (on === 'hover') {
241+
portalProps.openOnTriggerMouseOver = true
242+
portalProps.closeOnTriggerMouseLeave = true
243+
// Taken from SUI: https://git.io/vPmCm
244+
portalProps.mouseLeaveDelay = 70
245+
portalProps.mouseOverDelay = 50
246+
}
247+
251248
return portalProps
252249
}
253250

@@ -258,18 +255,21 @@ export default class Popup extends Component {
258255
}
259256

260257
handleClose = (e) => {
258+
debug('handleClose()')
261259
const { onClose } = this.props
262260
if (onClose) onClose(e, this.props)
263261
}
264262

265263
handleOpen = (e) => {
264+
debug('handleOpen()')
266265
this.coords = e.currentTarget.getBoundingClientRect()
267266

268267
const { onOpen } = this.props
269268
if (onOpen) onOpen(e, this.props)
270269
}
271270

272271
handlePortalMount = (e) => {
272+
debug('handlePortalMount()')
273273
if (this.props.hideOnScroll) {
274274
window.addEventListener('scroll', this.hideOnScroll)
275275
}
@@ -279,11 +279,13 @@ export default class Popup extends Component {
279279
}
280280

281281
handlePortalUnmount = (e) => {
282+
debug('handlePortalUnmount()')
282283
const { onUnmount } = this.props
283284
if (onUnmount) onUnmount(e, this.props)
284285
}
285286

286287
popupMounted = (ref) => {
288+
debug('popupMounted()')
287289
this.popupCoords = ref ? ref.getBoundingClientRect() : null
288290
this.setPopupStyle()
289291
}
@@ -333,10 +335,12 @@ export default class Popup extends Component {
333335
</ElementType>
334336
)
335337

338+
const mergedPortalProps = { ...this.getPortalProps(), ...portalProps }
339+
debug('portal props:', mergedPortalProps)
340+
336341
return (
337342
<Portal
338-
{...this.getPortalProps()}
339-
{...portalProps}
343+
{...mergedPortalProps}
340344
trigger={trigger}
341345
onClose={this.handleClose}
342346
onMount={this.handlePortalMount}

0 commit comments

Comments
 (0)