Skip to content

Commit 72c4508

Browse files
authored
chore(lib): add ModernAutoControlledComponent (#3776)
1 parent b9c9766 commit 72c4508

4 files changed

Lines changed: 533 additions & 1 deletion

File tree

src/lib/AutoControlledComponent.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import _ from 'lodash'
2727
import { Component } from 'react'
2828

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

3131
/**
3232
* Return the auto controlled state value for a give prop. The initial value is chosen in this order:
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/* eslint-disable no-console */
2+
/**
3+
* Why choose inheritance over a HOC? Multiple advantages for this particular use case.
4+
* In short, we need identical functionality to setState(), unless there is a prop defined
5+
* for the state key. Also:
6+
*
7+
* 1. Single Renders
8+
* Calling setState() does not cause two renders. Consumers and tests do not have to wait two
9+
* renders to get state.
10+
* See www.react.run/4kJFdKoxb/27 for an example of this issue.
11+
*
12+
* 2. Simple Testing
13+
* Using a HOC means you must either test the undecorated component or test through the decorator.
14+
* Testing the undecorated component means you must mock the decorator functionality.
15+
* Testing through the HOC means you can not simply shallow render your component.
16+
*
17+
* 3. Statics
18+
* HOC wrap instances, so statics are no longer accessible. They can be hoisted, but this is more
19+
* looping over properties and storing references. We rely heavily on statics for testing and
20+
* sub components.
21+
*
22+
* 4. Instance Methods
23+
* Some instance methods may be exposed to users via refs. Again, these are lost with HOC unless
24+
* hoisted and exposed by the HOC.
25+
*/
26+
import _ from 'lodash'
27+
import { Component } from 'react'
28+
import { getAutoControlledStateValue, getDefaultPropName } from './AutoControlledComponent'
29+
30+
export default class ModernAutoControlledComponent extends Component {
31+
constructor(...args) {
32+
super(...args)
33+
34+
const { autoControlledProps, getAutoControlledStateFromProps } = this.constructor
35+
const state = _.invoke(this, 'getInitialAutoControlledState', this.props) || {}
36+
37+
if (process.env.NODE_ENV !== 'production') {
38+
const { defaultProps, name, propTypes, getDerivedStateFromProps } = this.constructor
39+
40+
// require usage of getAutoControlledStateFromProps()
41+
if (getDerivedStateFromProps !== ModernAutoControlledComponent.getDerivedStateFromProps) {
42+
/* eslint-disable-next-line no-console */
43+
console.error(
44+
`Auto controlled ${name} must specify a static getAutoControlledStateFromProps() instead of getDerivedStateFromProps().`,
45+
)
46+
}
47+
48+
// require propTypes
49+
_.each(autoControlledProps, (prop) => {
50+
const defaultProp = getDefaultPropName(prop)
51+
// regular prop
52+
if (!_.has(propTypes, defaultProp)) {
53+
console.error(
54+
`${name} is missing "${defaultProp}" propTypes validation for auto controlled prop "${prop}".`,
55+
)
56+
}
57+
// its default prop
58+
if (!_.has(propTypes, prop)) {
59+
console.error(
60+
`${name} is missing propTypes validation for auto controlled prop "${prop}".`,
61+
)
62+
}
63+
})
64+
65+
// prevent autoControlledProps in defaultProps
66+
//
67+
// When setting state, auto controlled props values always win (so the parent can manage them).
68+
// It is not reasonable to decipher the difference between props from the parent and defaultProps.
69+
// Allowing defaultProps results in trySetState always deferring to the defaultProp value.
70+
// Auto controlled props also listed in defaultProps can never be updated.
71+
//
72+
// To set defaults for an AutoControlled prop, you can set the initial state in the
73+
// constructor or by using an ES7 property initializer:
74+
// https://babeljs.io/blog/2015/06/07/react-on-es6-plus#property-initializers
75+
const illegalDefaults = _.intersection(autoControlledProps, _.keys(defaultProps))
76+
if (!_.isEmpty(illegalDefaults)) {
77+
console.error(
78+
[
79+
'Do not set defaultProps for autoControlledProps. You can set defaults by',
80+
'setting state in the constructor or using an ES7 property initializer',
81+
'(https://babeljs.io/blog/2015/06/07/react-on-es6-plus#property-initializers)',
82+
`See ${name} props: "${illegalDefaults}".`,
83+
].join(' '),
84+
)
85+
}
86+
87+
// prevent listing defaultProps in autoControlledProps
88+
//
89+
// Default props are automatically handled.
90+
// Listing defaults in autoControlledProps would result in allowing defaultDefaultValue props.
91+
const illegalAutoControlled = _.filter(autoControlledProps, (prop) =>
92+
_.startsWith(prop, 'default'),
93+
)
94+
if (!_.isEmpty(illegalAutoControlled)) {
95+
console.error(
96+
[
97+
'Do not add default props to autoControlledProps.',
98+
'Default props are automatically handled.',
99+
`See ${name} autoControlledProps: "${illegalAutoControlled}".`,
100+
].join(' '),
101+
)
102+
}
103+
}
104+
105+
// Auto controlled props are copied to state.
106+
// Set initial state by copying auto controlled props to state.
107+
// Also look for the default prop for any auto controlled props (foo => defaultFoo)
108+
// so we can set initial values from defaults.
109+
const initialAutoControlledState = autoControlledProps.reduce((acc, prop) => {
110+
acc[prop] = getAutoControlledStateValue(prop, this.props, state, true)
111+
112+
if (process.env.NODE_ENV !== 'production') {
113+
const defaultPropName = getDefaultPropName(prop)
114+
const { name } = this.constructor
115+
// prevent defaultFoo={} along side foo={}
116+
if (!_.isUndefined(this.props[defaultPropName]) && !_.isUndefined(this.props[prop])) {
117+
console.error(
118+
`${name} prop "${prop}" is auto controlled. Specify either ${defaultPropName} or ${prop}, but not both.`,
119+
)
120+
}
121+
}
122+
123+
return acc
124+
}, {})
125+
126+
this.state = {
127+
...state,
128+
...initialAutoControlledState,
129+
autoControlledProps,
130+
getAutoControlledStateFromProps,
131+
}
132+
}
133+
134+
static getDerivedStateFromProps(props, state) {
135+
const { autoControlledProps, getAutoControlledStateFromProps } = state
136+
137+
// Solve the next state for autoControlledProps
138+
const newStateFromProps = autoControlledProps.reduce((acc, prop) => {
139+
const isNextDefined = !_.isUndefined(props[prop])
140+
141+
// if next is defined then use its value
142+
if (isNextDefined) acc[prop] = props[prop]
143+
144+
return acc
145+
}, {})
146+
147+
// Due to the inheritance of the AutoControlledComponent we should call its
148+
// getAutoControlledStateFromProps() and merge it with the existing state
149+
if (getAutoControlledStateFromProps) {
150+
const computedState = getAutoControlledStateFromProps(props, {
151+
...state,
152+
...newStateFromProps,
153+
})
154+
155+
// We should follow the idea of getDerivedStateFromProps() and return only modified state
156+
return { ...newStateFromProps, ...computedState }
157+
}
158+
159+
return newStateFromProps
160+
}
161+
162+
/**
163+
* Override this method to use getDerivedStateFromProps() in child components.
164+
*/
165+
static getAutoControlledStateFromProps() {
166+
return null
167+
}
168+
}

src/lib/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import makeDebugger from './makeDebugger'
22

33
export AutoControlledComponent from './AutoControlledComponent'
4+
export ModernAutoControlledComponent from './ModernAutoControlledComponent'
45
export { getChildMapping, mergeChildMappings } from './childMapping'
56
export * as childrenUtils from './childrenUtils'
67

0 commit comments

Comments
 (0)