297 lines
11 KiB
JavaScript
297 lines
11 KiB
JavaScript
import hoistStatics from 'hoist-non-react-statics'
|
|
import invariant from 'invariant'
|
|
import { Component, createElement } from 'react'
|
|
|
|
import Subscription from '../utils/Subscription'
|
|
import { storeShape, subscriptionShape } from '../utils/PropTypes'
|
|
|
|
let hotReloadingVersion = 0
|
|
const dummyState = {}
|
|
function noop() {}
|
|
function makeSelectorStateful(sourceSelector, store) {
|
|
// wrap the selector in an object that tracks its results between runs.
|
|
const selector = {
|
|
run: function runComponentSelector(props) {
|
|
try {
|
|
const nextProps = sourceSelector(store.getState(), props)
|
|
if (nextProps !== selector.props || selector.error) {
|
|
selector.shouldComponentUpdate = true
|
|
selector.props = nextProps
|
|
selector.error = null
|
|
}
|
|
} catch (error) {
|
|
selector.shouldComponentUpdate = true
|
|
selector.error = error
|
|
}
|
|
}
|
|
}
|
|
|
|
return selector
|
|
}
|
|
|
|
export default function connectAdvanced(
|
|
/*
|
|
selectorFactory is a func that is responsible for returning the selector function used to
|
|
compute new props from state, props, and dispatch. For example:
|
|
|
|
export default connectAdvanced((dispatch, options) => (state, props) => ({
|
|
thing: state.things[props.thingId],
|
|
saveThing: fields => dispatch(actionCreators.saveThing(props.thingId, fields)),
|
|
}))(YourComponent)
|
|
|
|
Access to dispatch is provided to the factory so selectorFactories can bind actionCreators
|
|
outside of their selector as an optimization. Options passed to connectAdvanced are passed to
|
|
the selectorFactory, along with displayName and WrappedComponent, as the second argument.
|
|
|
|
Note that selectorFactory is responsible for all caching/memoization of inbound and outbound
|
|
props. Do not use connectAdvanced directly without memoizing results between calls to your
|
|
selector, otherwise the Connect component will re-render on every state or props change.
|
|
*/
|
|
selectorFactory,
|
|
// options object:
|
|
{
|
|
// the func used to compute this HOC's displayName from the wrapped component's displayName.
|
|
// probably overridden by wrapper functions such as connect()
|
|
getDisplayName = name => `ConnectAdvanced(${name})`,
|
|
|
|
// shown in error messages
|
|
// probably overridden by wrapper functions such as connect()
|
|
methodName = 'connectAdvanced',
|
|
|
|
// if defined, the name of the property passed to the wrapped element indicating the number of
|
|
// calls to render. useful for watching in react devtools for unnecessary re-renders.
|
|
renderCountProp = undefined,
|
|
|
|
// determines whether this HOC subscribes to store changes
|
|
shouldHandleStateChanges = true,
|
|
|
|
// the key of props/context to get the store
|
|
storeKey = 'store',
|
|
|
|
// if true, the wrapped element is exposed by this HOC via the getWrappedInstance() function.
|
|
withRef = false,
|
|
|
|
// additional options are passed through to the selectorFactory
|
|
...connectOptions
|
|
} = {}
|
|
) {
|
|
const subscriptionKey = storeKey + 'Subscription'
|
|
const version = hotReloadingVersion++
|
|
|
|
const contextTypes = {
|
|
[storeKey]: storeShape,
|
|
[subscriptionKey]: subscriptionShape,
|
|
}
|
|
const childContextTypes = {
|
|
[subscriptionKey]: subscriptionShape,
|
|
}
|
|
|
|
return function wrapWithConnect(WrappedComponent) {
|
|
invariant(
|
|
typeof WrappedComponent == 'function',
|
|
`You must pass a component to the function returned by ` +
|
|
`${methodName}. Instead received ${JSON.stringify(WrappedComponent)}`
|
|
)
|
|
|
|
const wrappedComponentName = WrappedComponent.displayName
|
|
|| WrappedComponent.name
|
|
|| 'Component'
|
|
|
|
const displayName = getDisplayName(wrappedComponentName)
|
|
|
|
const selectorFactoryOptions = {
|
|
...connectOptions,
|
|
getDisplayName,
|
|
methodName,
|
|
renderCountProp,
|
|
shouldHandleStateChanges,
|
|
storeKey,
|
|
withRef,
|
|
displayName,
|
|
wrappedComponentName,
|
|
WrappedComponent
|
|
}
|
|
|
|
class Connect extends Component {
|
|
constructor(props, context) {
|
|
super(props, context)
|
|
|
|
this.version = version
|
|
this.state = {}
|
|
this.renderCount = 0
|
|
this.store = props[storeKey] || context[storeKey]
|
|
this.propsMode = Boolean(props[storeKey])
|
|
this.setWrappedInstance = this.setWrappedInstance.bind(this)
|
|
|
|
invariant(this.store,
|
|
`Could not find "${storeKey}" in either the context or props of ` +
|
|
`"${displayName}". Either wrap the root component in a <Provider>, ` +
|
|
`or explicitly pass "${storeKey}" as a prop to "${displayName}".`
|
|
)
|
|
|
|
this.initSelector()
|
|
this.initSubscription()
|
|
}
|
|
|
|
getChildContext() {
|
|
// If this component received store from props, its subscription should be transparent
|
|
// to any descendants receiving store+subscription from context; it passes along
|
|
// subscription passed to it. Otherwise, it shadows the parent subscription, which allows
|
|
// Connect to control ordering of notifications to flow top-down.
|
|
const subscription = this.propsMode ? null : this.subscription
|
|
return { [subscriptionKey]: subscription || this.context[subscriptionKey] }
|
|
}
|
|
|
|
componentDidMount() {
|
|
if (!shouldHandleStateChanges) return
|
|
|
|
// componentWillMount fires during server side rendering, but componentDidMount and
|
|
// componentWillUnmount do not. Because of this, trySubscribe happens during ...didMount.
|
|
// Otherwise, unsubscription would never take place during SSR, causing a memory leak.
|
|
// To handle the case where a child component may have triggered a state change by
|
|
// dispatching an action in its componentWillMount, we have to re-run the select and maybe
|
|
// re-render.
|
|
this.subscription.trySubscribe()
|
|
this.selector.run(this.props)
|
|
if (this.selector.shouldComponentUpdate) this.forceUpdate()
|
|
}
|
|
|
|
componentWillReceiveProps(nextProps) {
|
|
this.selector.run(nextProps)
|
|
}
|
|
|
|
shouldComponentUpdate() {
|
|
return this.selector.shouldComponentUpdate
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
if (this.subscription) this.subscription.tryUnsubscribe()
|
|
this.subscription = null
|
|
this.notifyNestedSubs = noop
|
|
this.store = null
|
|
this.selector.run = noop
|
|
this.selector.shouldComponentUpdate = false
|
|
}
|
|
|
|
getWrappedInstance() {
|
|
invariant(withRef,
|
|
`To access the wrapped instance, you need to specify ` +
|
|
`{ withRef: true } in the options argument of the ${methodName}() call.`
|
|
)
|
|
return this.wrappedInstance
|
|
}
|
|
|
|
setWrappedInstance(ref) {
|
|
this.wrappedInstance = ref
|
|
}
|
|
|
|
initSelector() {
|
|
const sourceSelector = selectorFactory(this.store.dispatch, selectorFactoryOptions)
|
|
this.selector = makeSelectorStateful(sourceSelector, this.store)
|
|
this.selector.run(this.props)
|
|
}
|
|
|
|
initSubscription() {
|
|
if (!shouldHandleStateChanges) return
|
|
|
|
// parentSub's source should match where store came from: props vs. context. A component
|
|
// connected to the store via props shouldn't use subscription from context, or vice versa.
|
|
const parentSub = (this.propsMode ? this.props : this.context)[subscriptionKey]
|
|
this.subscription = new Subscription(this.store, parentSub, this.onStateChange.bind(this))
|
|
|
|
// `notifyNestedSubs` is duplicated to handle the case where the component is unmounted in
|
|
// the middle of the notification loop, where `this.subscription` will then be null. An
|
|
// extra null check every change can be avoided by copying the method onto `this` and then
|
|
// replacing it with a no-op on unmount. This can probably be avoided if Subscription's
|
|
// listeners logic is changed to not call listeners that have been unsubscribed in the
|
|
// middle of the notification loop.
|
|
this.notifyNestedSubs = this.subscription.notifyNestedSubs.bind(this.subscription)
|
|
}
|
|
|
|
onStateChange() {
|
|
this.selector.run(this.props)
|
|
|
|
if (!this.selector.shouldComponentUpdate) {
|
|
this.notifyNestedSubs()
|
|
} else {
|
|
this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
|
|
this.setState(dummyState)
|
|
}
|
|
}
|
|
|
|
notifyNestedSubsOnComponentDidUpdate() {
|
|
// `componentDidUpdate` is conditionally implemented when `onStateChange` determines it
|
|
// needs to notify nested subs. Once called, it unimplements itself until further state
|
|
// changes occur. Doing it this way vs having a permanent `componentDidUpdate` that does
|
|
// a boolean check every time avoids an extra method call most of the time, resulting
|
|
// in some perf boost.
|
|
this.componentDidUpdate = undefined
|
|
this.notifyNestedSubs()
|
|
}
|
|
|
|
isSubscribed() {
|
|
return Boolean(this.subscription) && this.subscription.isSubscribed()
|
|
}
|
|
|
|
addExtraProps(props) {
|
|
if (!withRef && !renderCountProp && !(this.propsMode && this.subscription)) return props
|
|
// make a shallow copy so that fields added don't leak to the original selector.
|
|
// this is especially important for 'ref' since that's a reference back to the component
|
|
// instance. a singleton memoized selector would then be holding a reference to the
|
|
// instance, preventing the instance from being garbage collected, and that would be bad
|
|
const withExtras = { ...props }
|
|
if (withRef) withExtras.ref = this.setWrappedInstance
|
|
if (renderCountProp) withExtras[renderCountProp] = this.renderCount++
|
|
if (this.propsMode && this.subscription) withExtras[subscriptionKey] = this.subscription
|
|
return withExtras
|
|
}
|
|
|
|
render() {
|
|
const selector = this.selector
|
|
selector.shouldComponentUpdate = false
|
|
|
|
if (selector.error) {
|
|
throw selector.error
|
|
} else {
|
|
return createElement(WrappedComponent, this.addExtraProps(selector.props))
|
|
}
|
|
}
|
|
}
|
|
|
|
Connect.WrappedComponent = WrappedComponent
|
|
Connect.displayName = displayName
|
|
Connect.childContextTypes = childContextTypes
|
|
Connect.contextTypes = contextTypes
|
|
Connect.propTypes = contextTypes
|
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
Connect.prototype.componentWillUpdate = function componentWillUpdate() {
|
|
// We are hot reloading!
|
|
if (this.version !== version) {
|
|
this.version = version
|
|
this.initSelector()
|
|
|
|
// If any connected descendants don't hot reload (and resubscribe in the process), their
|
|
// listeners will be lost when we unsubscribe. Unfortunately, by copying over all
|
|
// listeners, this does mean that the old versions of connected descendants will still be
|
|
// notified of state changes; however, their onStateChange function is a no-op so this
|
|
// isn't a huge deal.
|
|
let oldListeners = [];
|
|
|
|
if (this.subscription) {
|
|
oldListeners = this.subscription.listeners.get()
|
|
this.subscription.tryUnsubscribe()
|
|
}
|
|
this.initSubscription()
|
|
if (shouldHandleStateChanges) {
|
|
this.subscription.trySubscribe()
|
|
oldListeners.forEach(listener => this.subscription.listeners.subscribe(listener))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return hoistStatics(Connect, WrappedComponent)
|
|
}
|
|
}
|