connect.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. import _extends from "@babel/runtime/helpers/esm/extends";
  2. import _objectWithoutPropertiesLoose from "@babel/runtime/helpers/esm/objectWithoutPropertiesLoose";
  3. const _excluded = ["reactReduxForwardedRef"];
  4. /* eslint-disable valid-jsdoc, @typescript-eslint/no-unused-vars */
  5. import hoistStatics from 'hoist-non-react-statics';
  6. import * as React from 'react';
  7. import { isValidElementType, isContextConsumer } from 'react-is';
  8. import defaultSelectorFactory from '../connect/selectorFactory';
  9. import { mapDispatchToPropsFactory } from '../connect/mapDispatchToProps';
  10. import { mapStateToPropsFactory } from '../connect/mapStateToProps';
  11. import { mergePropsFactory } from '../connect/mergeProps';
  12. import { createSubscription } from '../utils/Subscription';
  13. import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect';
  14. import shallowEqual from '../utils/shallowEqual';
  15. import warning from '../utils/warning';
  16. import { ReactReduxContext } from './Context';
  17. import { notInitialized } from '../utils/useSyncExternalStore';
  18. let useSyncExternalStore = notInitialized;
  19. export const initializeConnect = fn => {
  20. useSyncExternalStore = fn;
  21. }; // Define some constant arrays just to avoid re-creating these
  22. const EMPTY_ARRAY = [null, 0];
  23. const NO_SUBSCRIPTION_ARRAY = [null, null]; // Attempts to stringify whatever not-really-a-component value we were given
  24. // for logging in an error message
  25. const stringifyComponent = Comp => {
  26. try {
  27. return JSON.stringify(Comp);
  28. } catch (err) {
  29. return String(Comp);
  30. }
  31. };
  32. // This is "just" a `useLayoutEffect`, but with two modifications:
  33. // - we need to fall back to `useEffect` in SSR to avoid annoying warnings
  34. // - we extract this to a separate function to avoid closing over values
  35. // and causing memory leaks
  36. function useIsomorphicLayoutEffectWithArgs(effectFunc, effectArgs, dependencies) {
  37. useIsomorphicLayoutEffect(() => effectFunc(...effectArgs), dependencies);
  38. } // Effect callback, extracted: assign the latest props values to refs for later usage
  39. function captureWrapperProps(lastWrapperProps, lastChildProps, renderIsScheduled, wrapperProps, // actualChildProps: unknown,
  40. childPropsFromStoreUpdate, notifyNestedSubs) {
  41. // We want to capture the wrapper props and child props we used for later comparisons
  42. lastWrapperProps.current = wrapperProps;
  43. renderIsScheduled.current = false; // If the render was from a store update, clear out that reference and cascade the subscriber update
  44. if (childPropsFromStoreUpdate.current) {
  45. childPropsFromStoreUpdate.current = null;
  46. notifyNestedSubs();
  47. }
  48. } // Effect callback, extracted: subscribe to the Redux store or nearest connected ancestor,
  49. // check for updates after dispatched actions, and trigger re-renders.
  50. function subscribeUpdates(shouldHandleStateChanges, store, subscription, childPropsSelector, lastWrapperProps, lastChildProps, renderIsScheduled, isMounted, childPropsFromStoreUpdate, notifyNestedSubs, // forceComponentUpdateDispatch: React.Dispatch<any>,
  51. additionalSubscribeListener) {
  52. // If we're not subscribed to the store, nothing to do here
  53. if (!shouldHandleStateChanges) return () => {}; // Capture values for checking if and when this component unmounts
  54. let didUnsubscribe = false;
  55. let lastThrownError = null; // We'll run this callback every time a store subscription update propagates to this component
  56. const checkForUpdates = () => {
  57. if (didUnsubscribe || !isMounted.current) {
  58. // Don't run stale listeners.
  59. // Redux doesn't guarantee unsubscriptions happen until next dispatch.
  60. return;
  61. } // TODO We're currently calling getState ourselves here, rather than letting `uSES` do it
  62. const latestStoreState = store.getState();
  63. let newChildProps, error;
  64. try {
  65. // Actually run the selector with the most recent store state and wrapper props
  66. // to determine what the child props should be
  67. newChildProps = childPropsSelector(latestStoreState, lastWrapperProps.current);
  68. } catch (e) {
  69. error = e;
  70. lastThrownError = e;
  71. }
  72. if (!error) {
  73. lastThrownError = null;
  74. } // If the child props haven't changed, nothing to do here - cascade the subscription update
  75. if (newChildProps === lastChildProps.current) {
  76. if (!renderIsScheduled.current) {
  77. notifyNestedSubs();
  78. }
  79. } else {
  80. // Save references to the new child props. Note that we track the "child props from store update"
  81. // as a ref instead of a useState/useReducer because we need a way to determine if that value has
  82. // been processed. If this went into useState/useReducer, we couldn't clear out the value without
  83. // forcing another re-render, which we don't want.
  84. lastChildProps.current = newChildProps;
  85. childPropsFromStoreUpdate.current = newChildProps;
  86. renderIsScheduled.current = true; // TODO This is hacky and not how `uSES` is meant to be used
  87. // Trigger the React `useSyncExternalStore` subscriber
  88. additionalSubscribeListener();
  89. }
  90. }; // Actually subscribe to the nearest connected ancestor (or store)
  91. subscription.onStateChange = checkForUpdates;
  92. subscription.trySubscribe(); // Pull data from the store after first render in case the store has
  93. // changed since we began.
  94. checkForUpdates();
  95. const unsubscribeWrapper = () => {
  96. didUnsubscribe = true;
  97. subscription.tryUnsubscribe();
  98. subscription.onStateChange = null;
  99. if (lastThrownError) {
  100. // It's possible that we caught an error due to a bad mapState function, but the
  101. // parent re-rendered without this component and we're about to unmount.
  102. // This shouldn't happen as long as we do top-down subscriptions correctly, but
  103. // if we ever do those wrong, this throw will surface the error in our tests.
  104. // In that case, throw the error from here so it doesn't get lost.
  105. throw lastThrownError;
  106. }
  107. };
  108. return unsubscribeWrapper;
  109. } // Reducer initial state creation for our update reducer
  110. const initStateUpdates = () => EMPTY_ARRAY;
  111. function strictEqual(a, b) {
  112. return a === b;
  113. }
  114. /**
  115. * Infers the type of props that a connector will inject into a component.
  116. */
  117. let hasWarnedAboutDeprecatedPureOption = false;
  118. /**
  119. * Connects a React component to a Redux store.
  120. *
  121. * - Without arguments, just wraps the component, without changing the behavior / props
  122. *
  123. * - If 2 params are passed (3rd param, mergeProps, is skipped), default behavior
  124. * is to override ownProps (as stated in the docs), so what remains is everything that's
  125. * not a state or dispatch prop
  126. *
  127. * - When 3rd param is passed, we don't know if ownProps propagate and whether they
  128. * should be valid component props, because it depends on mergeProps implementation.
  129. * As such, it is the user's responsibility to extend ownProps interface from state or
  130. * dispatch props or both when applicable
  131. *
  132. * @param mapStateToProps A function that extracts values from state
  133. * @param mapDispatchToProps Setup for dispatching actions
  134. * @param mergeProps Optional callback to merge state and dispatch props together
  135. * @param options Options for configuring the connection
  136. *
  137. */
  138. function connect(mapStateToProps, mapDispatchToProps, mergeProps, {
  139. // The `pure` option has been removed, so TS doesn't like us destructuring this to check its existence.
  140. // @ts-ignore
  141. pure,
  142. areStatesEqual = strictEqual,
  143. areOwnPropsEqual = shallowEqual,
  144. areStatePropsEqual = shallowEqual,
  145. areMergedPropsEqual = shallowEqual,
  146. // use React's forwardRef to expose a ref of the wrapped component
  147. forwardRef = false,
  148. // the context consumer to use
  149. context = ReactReduxContext
  150. } = {}) {
  151. if (process.env.NODE_ENV !== 'production') {
  152. if (pure !== undefined && !hasWarnedAboutDeprecatedPureOption) {
  153. hasWarnedAboutDeprecatedPureOption = true;
  154. warning('The `pure` option has been removed. `connect` is now always a "pure/memoized" component');
  155. }
  156. }
  157. const Context = context;
  158. const initMapStateToProps = mapStateToPropsFactory(mapStateToProps);
  159. const initMapDispatchToProps = mapDispatchToPropsFactory(mapDispatchToProps);
  160. const initMergeProps = mergePropsFactory(mergeProps);
  161. const shouldHandleStateChanges = Boolean(mapStateToProps);
  162. const wrapWithConnect = WrappedComponent => {
  163. if (process.env.NODE_ENV !== 'production' && !isValidElementType(WrappedComponent)) {
  164. throw new Error(`You must pass a component to the function returned by connect. Instead received ${stringifyComponent(WrappedComponent)}`);
  165. }
  166. const wrappedComponentName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
  167. const displayName = `Connect(${wrappedComponentName})`;
  168. const selectorFactoryOptions = {
  169. shouldHandleStateChanges,
  170. displayName,
  171. wrappedComponentName,
  172. WrappedComponent,
  173. // @ts-ignore
  174. initMapStateToProps,
  175. // @ts-ignore
  176. initMapDispatchToProps,
  177. initMergeProps,
  178. areStatesEqual,
  179. areStatePropsEqual,
  180. areOwnPropsEqual,
  181. areMergedPropsEqual
  182. };
  183. function ConnectFunction(props) {
  184. const [propsContext, reactReduxForwardedRef, wrapperProps] = React.useMemo(() => {
  185. // Distinguish between actual "data" props that were passed to the wrapper component,
  186. // and values needed to control behavior (forwarded refs, alternate context instances).
  187. // To maintain the wrapperProps object reference, memoize this destructuring.
  188. const {
  189. reactReduxForwardedRef
  190. } = props,
  191. wrapperProps = _objectWithoutPropertiesLoose(props, _excluded);
  192. return [props.context, reactReduxForwardedRef, wrapperProps];
  193. }, [props]);
  194. const ContextToUse = React.useMemo(() => {
  195. // Users may optionally pass in a custom context instance to use instead of our ReactReduxContext.
  196. // Memoize the check that determines which context instance we should use.
  197. return propsContext && propsContext.Consumer && // @ts-ignore
  198. isContextConsumer( /*#__PURE__*/React.createElement(propsContext.Consumer, null)) ? propsContext : Context;
  199. }, [propsContext, Context]); // Retrieve the store and ancestor subscription via context, if available
  200. const contextValue = React.useContext(ContextToUse); // The store _must_ exist as either a prop or in context.
  201. // We'll check to see if it _looks_ like a Redux store first.
  202. // This allows us to pass through a `store` prop that is just a plain value.
  203. const didStoreComeFromProps = Boolean(props.store) && Boolean(props.store.getState) && Boolean(props.store.dispatch);
  204. const didStoreComeFromContext = Boolean(contextValue) && Boolean(contextValue.store);
  205. if (process.env.NODE_ENV !== 'production' && !didStoreComeFromProps && !didStoreComeFromContext) {
  206. throw new Error(`Could not find "store" in the context of ` + `"${displayName}". Either wrap the root component in a <Provider>, ` + `or pass a custom React context provider to <Provider> and the corresponding ` + `React context consumer to ${displayName} in connect options.`);
  207. } // Based on the previous check, one of these must be true
  208. const store = didStoreComeFromProps ? props.store : contextValue.store;
  209. const getServerState = didStoreComeFromContext ? contextValue.getServerState : store.getState;
  210. const childPropsSelector = React.useMemo(() => {
  211. // The child props selector needs the store reference as an input.
  212. // Re-create this selector whenever the store changes.
  213. return defaultSelectorFactory(store.dispatch, selectorFactoryOptions);
  214. }, [store]);
  215. const [subscription, notifyNestedSubs] = React.useMemo(() => {
  216. if (!shouldHandleStateChanges) return NO_SUBSCRIPTION_ARRAY; // This Subscription's source should match where store came from: props vs. context. A component
  217. // connected to the store via props shouldn't use subscription from context, or vice versa.
  218. const subscription = createSubscription(store, didStoreComeFromProps ? undefined : contextValue.subscription); // `notifyNestedSubs` is duplicated to handle the case where the component is unmounted in
  219. // the middle of the notification loop, where `subscription` will then be null. This can
  220. // probably be avoided if Subscription's listeners logic is changed to not call listeners
  221. // that have been unsubscribed in the middle of the notification loop.
  222. const notifyNestedSubs = subscription.notifyNestedSubs.bind(subscription);
  223. return [subscription, notifyNestedSubs];
  224. }, [store, didStoreComeFromProps, contextValue]); // Determine what {store, subscription} value should be put into nested context, if necessary,
  225. // and memoize that value to avoid unnecessary context updates.
  226. const overriddenContextValue = React.useMemo(() => {
  227. if (didStoreComeFromProps) {
  228. // This component is directly subscribed to a store from props.
  229. // We don't want descendants reading from this store - pass down whatever
  230. // the existing context value is from the nearest connected ancestor.
  231. return contextValue;
  232. } // Otherwise, put this component's subscription instance into context, so that
  233. // connected descendants won't update until after this component is done
  234. return _extends({}, contextValue, {
  235. subscription
  236. });
  237. }, [didStoreComeFromProps, contextValue, subscription]); // Set up refs to coordinate values between the subscription effect and the render logic
  238. const lastChildProps = React.useRef();
  239. const lastWrapperProps = React.useRef(wrapperProps);
  240. const childPropsFromStoreUpdate = React.useRef();
  241. const renderIsScheduled = React.useRef(false);
  242. const isProcessingDispatch = React.useRef(false);
  243. const isMounted = React.useRef(false);
  244. const latestSubscriptionCallbackError = React.useRef();
  245. useIsomorphicLayoutEffect(() => {
  246. isMounted.current = true;
  247. return () => {
  248. isMounted.current = false;
  249. };
  250. }, []);
  251. const actualChildPropsSelector = React.useMemo(() => {
  252. const selector = () => {
  253. // Tricky logic here:
  254. // - This render may have been triggered by a Redux store update that produced new child props
  255. // - However, we may have gotten new wrapper props after that
  256. // If we have new child props, and the same wrapper props, we know we should use the new child props as-is.
  257. // But, if we have new wrapper props, those might change the child props, so we have to recalculate things.
  258. // So, we'll use the child props from store update only if the wrapper props are the same as last time.
  259. if (childPropsFromStoreUpdate.current && wrapperProps === lastWrapperProps.current) {
  260. return childPropsFromStoreUpdate.current;
  261. } // TODO We're reading the store directly in render() here. Bad idea?
  262. // This will likely cause Bad Things (TM) to happen in Concurrent Mode.
  263. // Note that we do this because on renders _not_ caused by store updates, we need the latest store state
  264. // to determine what the child props should be.
  265. return childPropsSelector(store.getState(), wrapperProps);
  266. };
  267. return selector;
  268. }, [store, wrapperProps]); // We need this to execute synchronously every time we re-render. However, React warns
  269. // about useLayoutEffect in SSR, so we try to detect environment and fall back to
  270. // just useEffect instead to avoid the warning, since neither will run anyway.
  271. const subscribeForReact = React.useMemo(() => {
  272. const subscribe = reactListener => {
  273. if (!subscription) {
  274. return () => {};
  275. }
  276. return subscribeUpdates(shouldHandleStateChanges, store, subscription, // @ts-ignore
  277. childPropsSelector, lastWrapperProps, lastChildProps, renderIsScheduled, isMounted, childPropsFromStoreUpdate, notifyNestedSubs, reactListener);
  278. };
  279. return subscribe;
  280. }, [subscription]);
  281. useIsomorphicLayoutEffectWithArgs(captureWrapperProps, [lastWrapperProps, lastChildProps, renderIsScheduled, wrapperProps, childPropsFromStoreUpdate, notifyNestedSubs]);
  282. let actualChildProps;
  283. try {
  284. actualChildProps = useSyncExternalStore( // TODO We're passing through a big wrapper that does a bunch of extra side effects besides subscribing
  285. subscribeForReact, // TODO This is incredibly hacky. We've already processed the store update and calculated new child props,
  286. // TODO and we're just passing that through so it triggers a re-render for us rather than relying on `uSES`.
  287. actualChildPropsSelector, getServerState ? () => childPropsSelector(getServerState(), wrapperProps) : actualChildPropsSelector);
  288. } catch (err) {
  289. if (latestSubscriptionCallbackError.current) {
  290. ;
  291. err.message += `\nThe error may be correlated with this previous error:\n${latestSubscriptionCallbackError.current.stack}\n\n`;
  292. }
  293. throw err;
  294. }
  295. useIsomorphicLayoutEffect(() => {
  296. latestSubscriptionCallbackError.current = undefined;
  297. childPropsFromStoreUpdate.current = undefined;
  298. lastChildProps.current = actualChildProps;
  299. }); // Now that all that's done, we can finally try to actually render the child component.
  300. // We memoize the elements for the rendered child component as an optimization.
  301. const renderedWrappedComponent = React.useMemo(() => {
  302. return (
  303. /*#__PURE__*/
  304. // @ts-ignore
  305. React.createElement(WrappedComponent, _extends({}, actualChildProps, {
  306. ref: reactReduxForwardedRef
  307. }))
  308. );
  309. }, [reactReduxForwardedRef, WrappedComponent, actualChildProps]); // If React sees the exact same element reference as last time, it bails out of re-rendering
  310. // that child, same as if it was wrapped in React.memo() or returned false from shouldComponentUpdate.
  311. const renderedChild = React.useMemo(() => {
  312. if (shouldHandleStateChanges) {
  313. // If this component is subscribed to store updates, we need to pass its own
  314. // subscription instance down to our descendants. That means rendering the same
  315. // Context instance, and putting a different value into the context.
  316. return /*#__PURE__*/React.createElement(ContextToUse.Provider, {
  317. value: overriddenContextValue
  318. }, renderedWrappedComponent);
  319. }
  320. return renderedWrappedComponent;
  321. }, [ContextToUse, renderedWrappedComponent, overriddenContextValue]);
  322. return renderedChild;
  323. }
  324. const _Connect = React.memo(ConnectFunction);
  325. // Add a hacky cast to get the right output type
  326. const Connect = _Connect;
  327. Connect.WrappedComponent = WrappedComponent;
  328. Connect.displayName = ConnectFunction.displayName = displayName;
  329. if (forwardRef) {
  330. const _forwarded = React.forwardRef(function forwardConnectRef(props, ref) {
  331. // @ts-ignore
  332. return /*#__PURE__*/React.createElement(Connect, _extends({}, props, {
  333. reactReduxForwardedRef: ref
  334. }));
  335. });
  336. const forwarded = _forwarded;
  337. forwarded.displayName = displayName;
  338. forwarded.WrappedComponent = WrappedComponent;
  339. return hoistStatics(forwarded, WrappedComponent);
  340. }
  341. return hoistStatics(Connect, WrappedComponent);
  342. };
  343. return wrapWithConnect;
  344. }
  345. export default connect;