diff --git a/package.json b/package.json index de228a83c..a2e120895 100644 --- a/package.json +++ b/package.json @@ -77,8 +77,8 @@ "@rollup/plugin-replace": "^2.3.3", "@testing-library/jest-dom": "^5.11.5", "@testing-library/jest-native": "^3.4.3", - "@testing-library/react": "^12.0.0", - "@testing-library/react-hooks": "^3.4.2", + "@testing-library/react": "https://pkg.csb.dev/testing-library/react-testing-library/commit/0e2cf7da/@testing-library/react/_pkg.tgz", + "@testing-library/react-hooks": "^7.0.1", "@testing-library/react-native": "^7.1.0", "@types/create-react-class": "^15.6.3", "@types/object-assign": "^4.0.30", @@ -103,10 +103,10 @@ "glob": "^7.1.6", "jest": "^26.6.1", "prettier": "^2.1.2", - "react": "^16.14.0", - "react-dom": "^16.14.0", + "react": "18.0.0-alpha-b9934d6db-20210805", + "react-dom": "18.0.0-alpha-b9934d6db-20210805", "react-native": "^0.64.1", - "react-test-renderer": "^16.14.0", + "react-test-renderer": "18.0.0-alpha-b9934d6db-20210805", "redux": "^4.0.5", "rimraf": "^3.0.2", "rollup": "^2.32.1", diff --git a/src/alternate-renderers.ts b/src/alternate-renderers.ts deleted file mode 100644 index 456c7a23d..000000000 --- a/src/alternate-renderers.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './exports' - -import { getBatch } from './utils/batch' - -// For other renderers besides ReactDOM and React Native, -// use the default noop batch function -const batch = getBatch() - -export { batch } diff --git a/src/components/Context.ts b/src/components/Context.ts index 1dbf9568a..66729d26e 100644 --- a/src/components/Context.ts +++ b/src/components/Context.ts @@ -1,14 +1,13 @@ -import React from 'react' +import React, { MutableSource } from 'react' import { Action, AnyAction, Store } from 'redux' import type { FixTypeLater } from '../types' -import type { Subscription } from '../utils/Subscription' export interface ReactReduxContextValue< SS = FixTypeLater, A extends Action = AnyAction > { + storeSource: MutableSource> store: Store - subscription: Subscription } export const ReactReduxContext = diff --git a/src/components/Provider.tsx b/src/components/Provider.tsx index ffe9197fa..adfe25135 100644 --- a/src/components/Provider.tsx +++ b/src/components/Provider.tsx @@ -1,7 +1,10 @@ -import React, { Context, ReactNode, useMemo } from 'react' +import React, { + Context, + ReactNode, + unstable_createMutableSource as createMutableSource, + useMemo, +} from 'react' import { ReactReduxContext, ReactReduxContextValue } from './Context' -import { createSubscription } from '../utils/Subscription' -import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' import type { FixTypeLater } from '../types' import { Action, AnyAction, Store } from 'redux' @@ -12,38 +15,25 @@ export interface ProviderProps { store: Store /** * Optional context to be used internally in react-redux. Use React.createContext() to create a context to be used. - * If this is used, generate own connect HOC by using connectAdvanced, supplying the same context provided to the - * Provider. Initial value doesn't matter, as it is overwritten with the internal state of Provider. + * If this is used, you'll need to customize `connect` by supplying the same context provided to the Provider. + * Initial value doesn't matter, as it is overwritten with the internal state of Provider. */ context?: Context children: ReactNode } -function Provider({ store, context, children }: ProviderProps) { - const contextValue = useMemo(() => { - const subscription = createSubscription(store) - subscription.onStateChange = subscription.notifyNestedSubs - return { - store, - subscription, - } - }, [store]) - - const previousState = useMemo(() => store.getState(), [store]) - - useIsomorphicLayoutEffect(() => { - const { subscription } = contextValue - subscription.trySubscribe() - - if (previousState !== store.getState()) { - subscription.notifyNestedSubs() - } - return () => { - subscription.tryUnsubscribe() - subscription.onStateChange = undefined - } - }, [contextValue, previousState]) +export function createReduxContext(store: Store) { + return { + storeSource: createMutableSource(store, () => store.getState()), + store, + } +} +function Provider({ store, context, children }: ProviderProps) { + const contextValue: ReactReduxContextValue = useMemo( + () => createReduxContext(store), + [store] + ) const Context = context || ReactReduxContext return {children} diff --git a/src/components/connect.tsx b/src/components/connect.tsx new file mode 100644 index 000000000..423601cd3 --- /dev/null +++ b/src/components/connect.tsx @@ -0,0 +1,539 @@ +/* eslint-disable valid-jsdoc, @typescript-eslint/no-unused-vars */ +import hoistStatics from 'hoist-non-react-statics' +import React, { useCallback, useContext, useMemo } from 'react' +import { isContextConsumer, isValidElementType } from 'react-is' +import type { Dispatch, Store } from 'redux' + +import type { + AdvancedComponentDecorator, + ConnectedComponent, + DefaultRootState, + InferableComponentEnhancer, + InferableComponentEnhancerWithProps, + ResolveThunks, + DispatchProp, +} from '../types' + +import defaultSelectorFactory, { + MapStateToPropsParam, + MapDispatchToPropsParam, + MergeProps, + MapDispatchToPropsNonObject, + SelectorFactoryOptions, +} from '../connect/selectorFactory' +import defaultMapDispatchToPropsFactories from '../connect/mapDispatchToProps' +import defaultMapStateToPropsFactories from '../connect/mapStateToProps' +import defaultMergePropsFactories from '../connect/mergeProps' + +import shallowEqual from '../utils/shallowEqual' + +import { + ReactReduxContext, + ReactReduxContextValue, + ReactReduxContextInstance, +} from './Context' +import { createReduxContext } from './Provider' +import { useStoreSource } from '../utils/useStoreSource' + +// Define some constant arrays just to avoid re-creating these +const EMPTY_ARRAY: [unknown, number] = [null, 0] +const NO_SUBSCRIPTION_ARRAY = [null, null] + +// Attempts to stringify whatever not-really-a-component value we were given +// for logging in an error message +const stringifyComponent = (Comp: unknown) => { + try { + return JSON.stringify(Comp) + } catch (err) { + return String(Comp) + } +} + +// Reducer for our "forceUpdate" equivalent. +// This primarily stores the current error, if any, +// but also an update counter. +// Since we're returning a new array anyway, in theory the counter isn't needed. +// Or for that matter, since the dispatch gets a new object, we don't even need an array. +function storeStateUpdatesReducer( + state: [unknown, number], + action: { payload: unknown } +) { + const [, updateCount] = state + return [action.payload, updateCount + 1] +} + +export interface ConnectProps { + reactReduxForwardedRef?: React.ForwardedRef + context?: ReactReduxContextInstance + store?: Store +} + +function match( + arg: unknown, + factories: ((value: unknown) => T)[], + name: string +): T { + for (let i = factories.length - 1; i >= 0; i--) { + const result = factories[i](arg) + if (result) return result + } + + return ((dispatch: Dispatch, options: { wrappedComponentName: string }) => { + throw new Error( + `Invalid value of type ${typeof arg} for ${name} argument when connecting component ${ + options.wrappedComponentName + }.` + ) + }) as any +} + +function strictEqual(a: unknown, b: unknown) { + return a === b +} + +/** + * Infers the type of props that a connector will inject into a component. + */ +export type ConnectedProps = + TConnector extends InferableComponentEnhancerWithProps< + infer TInjectedProps, + any + > + ? unknown extends TInjectedProps + ? TConnector extends InferableComponentEnhancer + ? TInjectedProps + : never + : TInjectedProps + : never + +export interface ConnectOptions< + State = DefaultRootState, + TStateProps = {}, + TOwnProps = {}, + TMergedProps = {} +> { + forwardRef?: boolean + context?: typeof ReactReduxContext + pure?: boolean + areStatesEqual?: (nextState: State, prevState: State) => boolean + + areOwnPropsEqual?: ( + nextOwnProps: TOwnProps, + prevOwnProps: TOwnProps + ) => boolean + + areStatePropsEqual?: ( + nextStateProps: TStateProps, + prevStateProps: TStateProps + ) => boolean + areMergedPropsEqual?: ( + nextMergedProps: TMergedProps, + prevMergedProps: TMergedProps + ) => boolean +} + +/* @public */ +function connect(): InferableComponentEnhancer + +/* @public */ +function connect< + TStateProps = {}, + no_dispatch = {}, + TOwnProps = {}, + State = DefaultRootState +>( + mapStateToProps: MapStateToPropsParam +): InferableComponentEnhancerWithProps + +/* @public */ +function connect( + mapStateToProps: null | undefined, + mapDispatchToProps: MapDispatchToPropsNonObject +): InferableComponentEnhancerWithProps + +/* @public */ +function connect( + mapStateToProps: null | undefined, + mapDispatchToProps: MapDispatchToPropsParam +): InferableComponentEnhancerWithProps, TOwnProps> + +/* @public */ +function connect< + TStateProps = {}, + TDispatchProps = {}, + TOwnProps = {}, + State = DefaultRootState +>( + mapStateToProps: MapStateToPropsParam, + mapDispatchToProps: MapDispatchToPropsNonObject +): InferableComponentEnhancerWithProps + +/* @public */ +function connect< + TStateProps = {}, + TDispatchProps = {}, + TOwnProps = {}, + State = DefaultRootState +>( + mapStateToProps: MapStateToPropsParam, + mapDispatchToProps: MapDispatchToPropsParam +): InferableComponentEnhancerWithProps< + TStateProps & ResolveThunks, + TOwnProps +> + +/* @public */ +function connect< + no_state = {}, + no_dispatch = {}, + TOwnProps = {}, + TMergedProps = {} +>( + mapStateToProps: null | undefined, + mapDispatchToProps: null | undefined, + mergeProps: MergeProps +): InferableComponentEnhancerWithProps + +/* @public */ +function connect< + TStateProps = {}, + no_dispatch = {}, + TOwnProps = {}, + TMergedProps = {}, + State = DefaultRootState +>( + mapStateToProps: MapStateToPropsParam, + mapDispatchToProps: null | undefined, + mergeProps: MergeProps +): InferableComponentEnhancerWithProps + +/* @public */ +function connect< + no_state = {}, + TDispatchProps = {}, + TOwnProps = {}, + TMergedProps = {} +>( + mapStateToProps: null | undefined, + mapDispatchToProps: MapDispatchToPropsParam, + mergeProps: MergeProps +): InferableComponentEnhancerWithProps + +/* @public */ +// @ts-ignore +function connect< + TStateProps = {}, + no_dispatch = {}, + TOwnProps = {}, + State = DefaultRootState +>( + mapStateToProps: MapStateToPropsParam, + mapDispatchToProps: null | undefined, + mergeProps: null | undefined, + options: ConnectOptions +): InferableComponentEnhancerWithProps + +/* @public */ +function connect( + mapStateToProps: null | undefined, + mapDispatchToProps: MapDispatchToPropsNonObject, + mergeProps: null | undefined, + options: ConnectOptions<{}, TStateProps, TOwnProps> +): InferableComponentEnhancerWithProps + +/* @public */ +function connect( + mapStateToProps: null | undefined, + mapDispatchToProps: MapDispatchToPropsParam, + mergeProps: null | undefined, + options: ConnectOptions<{}, TStateProps, TOwnProps> +): InferableComponentEnhancerWithProps, TOwnProps> + +/* @public */ +function connect< + TStateProps = {}, + TDispatchProps = {}, + TOwnProps = {}, + State = DefaultRootState +>( + mapStateToProps: MapStateToPropsParam, + mapDispatchToProps: MapDispatchToPropsNonObject, + mergeProps: null | undefined, + options: ConnectOptions +): InferableComponentEnhancerWithProps + +/* @public */ +function connect< + TStateProps = {}, + TDispatchProps = {}, + TOwnProps = {}, + State = DefaultRootState +>( + mapStateToProps: MapStateToPropsParam, + mapDispatchToProps: MapDispatchToPropsParam, + mergeProps: null | undefined, + options: ConnectOptions +): InferableComponentEnhancerWithProps< + TStateProps & ResolveThunks, + TOwnProps +> + +/* @public */ +function connect< + TStateProps = {}, + TDispatchProps = {}, + TOwnProps = {}, + TMergedProps = {}, + State = DefaultRootState +>( + mapStateToProps: MapStateToPropsParam, + mapDispatchToProps: MapDispatchToPropsParam, + mergeProps: MergeProps, + options?: ConnectOptions +): InferableComponentEnhancerWithProps + +/** + * Connects a React component to a Redux store. + * + * - Without arguments, just wraps the component, without changing the behavior / props + * + * - If 2 params are passed (3rd param, mergeProps, is skipped), default behavior + * is to override ownProps (as stated in the docs), so what remains is everything that's + * not a state or dispatch prop + * + * - When 3rd param is passed, we don't know if ownProps propagate and whether they + * should be valid component props, because it depends on mergeProps implementation. + * As such, it is the user's responsibility to extend ownProps interface from state or + * dispatch props or both when applicable + * + * @param mapStateToProps A function that extracts values from state + * @param mapDispatchToProps Setup for dispatching actions + * @param mergeProps Optional callback to merge state and dispatch props together + * @param options Options for configuring the connection + * + */ +function connect< + TStateProps = {}, + TDispatchProps = {}, + TOwnProps = {}, + TMergedProps = {}, + State = DefaultRootState +>( + mapStateToProps?: MapStateToPropsParam, + mapDispatchToProps?: MapDispatchToPropsParam, + mergeProps?: MergeProps, + { + pure = true, + areStatesEqual = strictEqual, + areOwnPropsEqual = shallowEqual, + areStatePropsEqual = shallowEqual, + areMergedPropsEqual = shallowEqual, + + // use React's forwardRef to expose a ref of the wrapped component + forwardRef = false, + + // the context consumer to use + context = ReactReduxContext, + }: ConnectOptions = {} +): unknown { + const Context = context + + type WrappedComponentProps = TOwnProps & ConnectProps + + const initMapStateToProps = match( + mapStateToProps, + // @ts-ignore + defaultMapStateToPropsFactories, + 'mapStateToProps' + )! + const initMapDispatchToProps = match( + mapDispatchToProps, + // @ts-ignore + defaultMapDispatchToPropsFactories, + 'mapDispatchToProps' + )! + const initMergeProps = match( + mergeProps, + // @ts-ignore + defaultMergePropsFactories, + 'mergeProps' + )! + + const shouldHandleStateChanges = Boolean(mapStateToProps) + + const wrapWithConnect: AdvancedComponentDecorator< + TOwnProps, + WrappedComponentProps + > = ((WrappedComponent) => { + if ( + process.env.NODE_ENV !== 'production' && + !isValidElementType(WrappedComponent) + ) { + throw new Error( + `You must pass a component to the function returned by connect. Instead received ${stringifyComponent( + WrappedComponent + )}` + ) + } + + const wrappedComponentName = + WrappedComponent.displayName || WrappedComponent.name || 'Component' + + const displayName = `Connect(${wrappedComponentName})` + + const selectorFactoryOptions: SelectorFactoryOptions = { + pure, + shouldHandleStateChanges, + displayName, + wrappedComponentName, + WrappedComponent, + initMapStateToProps, + initMapDispatchToProps, + // @ts-ignore + initMergeProps, + areStatesEqual, + areStatePropsEqual, + areOwnPropsEqual, + areMergedPropsEqual, + } + + const useChildPropsSelector = (context: ReactReduxContextValue) => { + return useMemo( + () => + defaultSelectorFactory( + context.store.dispatch, + selectorFactoryOptions + ), + [context.store] + ) + } + + const useStateAndSubscribe = ( + context: ReactReduxContextValue, + wrapperProps: Omit + ) => { + const childPropsSelector = useChildPropsSelector(context) + const getSnapshot = useCallback( + (store) => childPropsSelector(store.getState(), wrapperProps), + [childPropsSelector, wrapperProps] + ) + return useStoreSource(context!.storeSource, getSnapshot) + } + + const useDispatchOnly = ( + context: ReactReduxContextValue, + wrapperProps: Omit + ) => { + const childPropsSelector = useChildPropsSelector(context) + return childPropsSelector(context.store.getState(), wrapperProps) + } + + const useReduxContext = shouldHandleStateChanges + ? useStateAndSubscribe + : useDispatchOnly + + function ConnectFunction(props: ConnectProps & TOwnProps) { + const [propsContext, reactReduxForwardedRef, wrapperProps] = + useMemo(() => { + // Distinguish between actual "data" props that were passed to the wrapper component, + // and values needed to control behavior (forwarded refs, alternate context instances). + // To maintain the wrapperProps object reference, memoize this destructuring. + const { reactReduxForwardedRef, ...wrapperProps } = props + return [props.context, reactReduxForwardedRef, wrapperProps] + }, [props]) + + const ContextToUse: ReactReduxContextInstance = useMemo(() => { + // Users may optionally pass in a custom context instance to use instead of our ReactReduxContext. + // Memoize the check that determines which context instance we should use. + return propsContext && + propsContext.Consumer && + // @ts-ignore + isContextConsumer() + ? propsContext + : Context + }, [propsContext, Context]) + + // Retrieve the store and ancestor subscription via context, if available + const contextValue = useContext(ContextToUse) + + // The store _must_ exist as either a prop or in context. + // We'll check to see if it _looks_ like a Redux store first. + // This allows us to pass through a `store` prop that is just a plain value. + const didStoreComeFromProps = + Boolean(props.store) && + Boolean(props.store!.getState) && + Boolean(props.store!.dispatch) + const didStoreComeFromContext = + Boolean(contextValue) && + Boolean(contextValue!.storeSource) && + Boolean(contextValue!.store) + + if ( + process.env.NODE_ENV !== 'production' && + !didStoreComeFromProps && + !didStoreComeFromContext + ) { + throw new Error( + `Could not find "store" in the context of ` + + `"${displayName}". Either wrap the root component in a , ` + + `or pass a custom React context provider to and the corresponding ` + + `React context consumer to ${displayName} in connect options.` + ) + } + + const reduxContextValue: ReactReduxContextValue = useMemo(() => { + // Based on the previous check, one of these must be true + return didStoreComeFromProps + ? createReduxContext(props.store!) + : contextValue! + }, [didStoreComeFromProps, props.store, contextValue]) + + const childProps = useReduxContext(reduxContextValue, wrapperProps) + + // Now that all that's done, we can finally try to actually render the child component. + // We memoize the elements for the rendered child component as an optimization. + return useMemo( + () => ( + // @ts-ignore + + ), + [reactReduxForwardedRef, WrappedComponent, childProps] + ) + } + + // If we're in "pure" mode, ensure our wrapper component only re-renders when incoming props have changed. + const _Connect = pure ? React.memo(ConnectFunction) : ConnectFunction + + type ConnectedWrapperComponent = typeof _Connect & { + WrappedComponent: typeof WrappedComponent + } + + const Connect = _Connect as ConnectedComponent< + typeof WrappedComponent, + WrappedComponentProps + > + Connect.WrappedComponent = WrappedComponent + Connect.displayName = ConnectFunction.displayName = displayName + + if (forwardRef) { + const _forwarded = React.forwardRef(function forwardConnectRef( + props, + ref + ) { + // @ts-ignore + return + }) + + const forwarded = _forwarded as unknown as ConnectedWrapperComponent + forwarded.displayName = displayName + forwarded.WrappedComponent = WrappedComponent + return hoistStatics(forwarded, WrappedComponent) + } + + return hoistStatics(Connect, WrappedComponent) + }) as AdvancedComponentDecorator + + return wrapWithConnect +} + +export default connect diff --git a/src/components/connectAdvanced.tsx b/src/components/connectAdvanced.tsx deleted file mode 100644 index 77e433814..000000000 --- a/src/components/connectAdvanced.tsx +++ /dev/null @@ -1,514 +0,0 @@ -import hoistStatics from 'hoist-non-react-statics' -import React, { useContext, useMemo, useRef, useReducer } from 'react' -import { isValidElementType, isContextConsumer } from 'react-is' -import type { Store } from 'redux' -import type { SelectorFactory } from '../connect/selectorFactory' -import { createSubscription, Subscription } from '../utils/Subscription' -import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' -import type { AdvancedComponentDecorator, ConnectedComponent } from '../types' - -import { - ReactReduxContext, - ReactReduxContextValue, - ReactReduxContextInstance, -} from './Context' - -// Define some constant arrays just to avoid re-creating these -const EMPTY_ARRAY: [unknown, number] = [null, 0] -const NO_SUBSCRIPTION_ARRAY = [null, null] - -const stringifyComponent = (Comp: unknown) => { - try { - return JSON.stringify(Comp) - } catch (err) { - return String(Comp) - } -} - -function storeStateUpdatesReducer( - state: [unknown, number], - action: { payload: unknown } -) { - const [, updateCount] = state - return [action.payload, updateCount + 1] -} - -type EffectFunc = (...args: any[]) => void | ReturnType - -function useIsomorphicLayoutEffectWithArgs( - effectFunc: EffectFunc, - effectArgs: any[], - dependencies?: React.DependencyList -) { - useIsomorphicLayoutEffect(() => effectFunc(...effectArgs), dependencies) -} - -function captureWrapperProps( - lastWrapperProps: React.MutableRefObject, - lastChildProps: React.MutableRefObject, - renderIsScheduled: React.MutableRefObject, - wrapperProps: React.MutableRefObject, - actualChildProps: React.MutableRefObject, - childPropsFromStoreUpdate: React.MutableRefObject, - notifyNestedSubs: () => void -) { - // We want to capture the wrapper props and child props we used for later comparisons - lastWrapperProps.current = wrapperProps - lastChildProps.current = actualChildProps - renderIsScheduled.current = false - - // If the render was from a store update, clear out that reference and cascade the subscriber update - if (childPropsFromStoreUpdate.current) { - childPropsFromStoreUpdate.current = null - notifyNestedSubs() - } -} - -function subscribeUpdates( - shouldHandleStateChanges: boolean, - store: Store, - subscription: Subscription, - childPropsSelector: (state: unknown, props: unknown) => unknown, - lastWrapperProps: React.MutableRefObject, - lastChildProps: React.MutableRefObject, - renderIsScheduled: React.MutableRefObject, - childPropsFromStoreUpdate: React.MutableRefObject, - notifyNestedSubs: () => void, - forceComponentUpdateDispatch: React.Dispatch -) { - // If we're not subscribed to the store, nothing to do here - if (!shouldHandleStateChanges) return - - // Capture values for checking if and when this component unmounts - let didUnsubscribe = false - let lastThrownError: Error | null = null - - // We'll run this callback every time a store subscription update propagates to this component - const checkForUpdates = () => { - if (didUnsubscribe) { - // Don't run stale listeners. - // Redux doesn't guarantee unsubscriptions happen until next dispatch. - return - } - - const latestStoreState = store.getState() - - let newChildProps, error - try { - // Actually run the selector with the most recent store state and wrapper props - // to determine what the child props should be - newChildProps = childPropsSelector( - latestStoreState, - lastWrapperProps.current - ) - } catch (e) { - error = e - lastThrownError = e as Error | null - } - - if (!error) { - lastThrownError = null - } - - // If the child props haven't changed, nothing to do here - cascade the subscription update - if (newChildProps === lastChildProps.current) { - if (!renderIsScheduled.current) { - notifyNestedSubs() - } - } else { - // Save references to the new child props. Note that we track the "child props from store update" - // as a ref instead of a useState/useReducer because we need a way to determine if that value has - // been processed. If this went into useState/useReducer, we couldn't clear out the value without - // forcing another re-render, which we don't want. - lastChildProps.current = newChildProps - childPropsFromStoreUpdate.current = newChildProps - renderIsScheduled.current = true - - // If the child props _did_ change (or we caught an error), this wrapper component needs to re-render - forceComponentUpdateDispatch({ - type: 'STORE_UPDATED', - payload: { - error, - }, - }) - } - } - - // Actually subscribe to the nearest connected ancestor (or store) - subscription.onStateChange = checkForUpdates - subscription.trySubscribe() - - // Pull data from the store after first render in case the store has - // changed since we began. - checkForUpdates() - - const unsubscribeWrapper = () => { - didUnsubscribe = true - subscription.tryUnsubscribe() - subscription.onStateChange = null - - if (lastThrownError) { - // It's possible that we caught an error due to a bad mapState function, but the - // parent re-rendered without this component and we're about to unmount. - // This shouldn't happen as long as we do top-down subscriptions correctly, but - // if we ever do those wrong, this throw will surface the error in our tests. - // In that case, throw the error from here so it doesn't get lost. - throw lastThrownError - } - } - - return unsubscribeWrapper -} - -const initStateUpdates = () => EMPTY_ARRAY - -export interface ConnectProps { - reactReduxForwardedRef?: React.ForwardedRef - context?: ReactReduxContextInstance - store?: Store -} - -export interface ConnectAdvancedOptions { - getDisplayName?: (name: string) => string - methodName?: string - shouldHandleStateChanges?: boolean - forwardRef?: boolean - context?: typeof ReactReduxContext - pure?: boolean -} - -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: 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', - - // determines whether this HOC subscribes to store changes - shouldHandleStateChanges = true, - - // use React's forwardRef to expose a ref of the wrapped component - forwardRef = false, - - // the context consumer to use - context = ReactReduxContext, - - // additional options are passed through to the selectorFactory - ...connectOptions - }: ConnectAdvancedOptions & Partial = {} -) { - const Context = context - - type WrappedComponentProps = TOwnProps & ConnectProps - - /* - return function wrapWithConnect< - WC extends React.ComponentType< - Matching, GetProps> - > - >(WrappedComponent: WC) { - */ - const wrapWithConnect: AdvancedComponentDecorator< - TProps, - WrappedComponentProps - > = (WrappedComponent) => { - if ( - process.env.NODE_ENV !== 'production' && - !isValidElementType(WrappedComponent) - ) { - throw new Error( - `You must pass a component to the function returned by ` + - `${methodName}. Instead received ${stringifyComponent( - WrappedComponent - )}` - ) - } - - const wrappedComponentName = - WrappedComponent.displayName || WrappedComponent.name || 'Component' - - const displayName = getDisplayName(wrappedComponentName) - - const selectorFactoryOptions = { - ...connectOptions, - getDisplayName, - methodName, - shouldHandleStateChanges, - displayName, - wrappedComponentName, - WrappedComponent, - } - - const { pure } = connectOptions - - function createChildSelector(store: Store) { - return selectorFactory(store.dispatch, selectorFactoryOptions) - } - - // If we aren't running in "pure" mode, we don't want to memoize values. - // To avoid conditionally calling hooks, we fall back to a tiny wrapper - // that just executes the given callback immediately. - const usePureOnlyMemo = pure - ? useMemo - : (callback: () => void) => callback() - - function ConnectFunction(props: ConnectProps & TOwnProps) { - const [propsContext, reactReduxForwardedRef, wrapperProps] = - useMemo(() => { - // Distinguish between actual "data" props that were passed to the wrapper component, - // and values needed to control behavior (forwarded refs, alternate context instances). - // To maintain the wrapperProps object reference, memoize this destructuring. - const { reactReduxForwardedRef, ...wrapperProps } = props - return [props.context, reactReduxForwardedRef, wrapperProps] - }, [props]) - - const ContextToUse: ReactReduxContextInstance = useMemo(() => { - // Users may optionally pass in a custom context instance to use instead of our ReactReduxContext. - // Memoize the check that determines which context instance we should use. - return propsContext && - propsContext.Consumer && - // @ts-ignore - isContextConsumer() - ? propsContext - : Context - }, [propsContext, Context]) - - // Retrieve the store and ancestor subscription via context, if available - const contextValue = useContext(ContextToUse) - - // The store _must_ exist as either a prop or in context. - // We'll check to see if it _looks_ like a Redux store first. - // This allows us to pass through a `store` prop that is just a plain value. - const didStoreComeFromProps = - Boolean(props.store) && - Boolean(props.store!.getState) && - Boolean(props.store!.dispatch) - const didStoreComeFromContext = - Boolean(contextValue) && Boolean(contextValue!.store) - - if ( - process.env.NODE_ENV !== 'production' && - !didStoreComeFromProps && - !didStoreComeFromContext - ) { - throw new Error( - `Could not find "store" in the context of ` + - `"${displayName}". Either wrap the root component in a , ` + - `or pass a custom React context provider to and the corresponding ` + - `React context consumer to ${displayName} in connect options.` - ) - } - - // Based on the previous check, one of these must be true - const store: Store = didStoreComeFromProps - ? props.store! - : contextValue!.store - - const childPropsSelector = useMemo(() => { - // The child props selector needs the store reference as an input. - // Re-create this selector whenever the store changes. - return createChildSelector(store) - }, [store]) - - const [subscription, notifyNestedSubs] = useMemo(() => { - if (!shouldHandleStateChanges) return NO_SUBSCRIPTION_ARRAY - - // This Subscription'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 subscription = createSubscription( - store, - didStoreComeFromProps ? undefined : contextValue!.subscription - ) - - // `notifyNestedSubs` is duplicated to handle the case where the component is unmounted in - // the middle of the notification loop, where `subscription` will then be null. 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. - const notifyNestedSubs = - subscription.notifyNestedSubs.bind(subscription) - - return [subscription, notifyNestedSubs] - }, [store, didStoreComeFromProps, contextValue]) - - // Determine what {store, subscription} value should be put into nested context, if necessary, - // and memoize that value to avoid unnecessary context updates. - const overriddenContextValue = useMemo(() => { - if (didStoreComeFromProps) { - // This component is directly subscribed to a store from props. - // We don't want descendants reading from this store - pass down whatever - // the existing context value is from the nearest connected ancestor. - return contextValue! - } - - // Otherwise, put this component's subscription instance into context, so that - // connected descendants won't update until after this component is done - return { - ...contextValue, - subscription, - } as ReactReduxContextValue - }, [didStoreComeFromProps, contextValue, subscription]) - - // We need to force this wrapper component to re-render whenever a Redux store update - // causes a change to the calculated child component props (or we caught an error in mapState) - const [[previousStateUpdateResult], forceComponentUpdateDispatch] = - useReducer( - storeStateUpdatesReducer, - // @ts-ignore - EMPTY_ARRAY as any, - initStateUpdates - ) - - // Propagate any mapState/mapDispatch errors upwards - if (previousStateUpdateResult && previousStateUpdateResult.error) { - throw previousStateUpdateResult.error - } - - // Set up refs to coordinate values between the subscription effect and the render logic - const lastChildProps = useRef() - const lastWrapperProps = useRef(wrapperProps) - const childPropsFromStoreUpdate = useRef() - const renderIsScheduled = useRef(false) - - const actualChildProps = usePureOnlyMemo(() => { - // Tricky logic here: - // - This render may have been triggered by a Redux store update that produced new child props - // - However, we may have gotten new wrapper props after that - // If we have new child props, and the same wrapper props, we know we should use the new child props as-is. - // But, if we have new wrapper props, those might change the child props, so we have to recalculate things. - // So, we'll use the child props from store update only if the wrapper props are the same as last time. - if ( - childPropsFromStoreUpdate.current && - wrapperProps === lastWrapperProps.current - ) { - return childPropsFromStoreUpdate.current - } - - // TODO We're reading the store directly in render() here. Bad idea? - // This will likely cause Bad Things (TM) to happen in Concurrent Mode. - // Note that we do this because on renders _not_ caused by store updates, we need the latest store state - // to determine what the child props should be. - return childPropsSelector(store.getState(), wrapperProps) - }, [store, previousStateUpdateResult, wrapperProps]) - - // We need this to execute synchronously every time we re-render. However, React warns - // about useLayoutEffect in SSR, so we try to detect environment and fall back to - // just useEffect instead to avoid the warning, since neither will run anyway. - useIsomorphicLayoutEffectWithArgs(captureWrapperProps, [ - lastWrapperProps, - lastChildProps, - renderIsScheduled, - wrapperProps, - actualChildProps, - childPropsFromStoreUpdate, - notifyNestedSubs, - ]) - - // Our re-subscribe logic only runs when the store/subscription setup changes - useIsomorphicLayoutEffectWithArgs( - subscribeUpdates, - [ - shouldHandleStateChanges, - store, - subscription, - childPropsSelector, - lastWrapperProps, - lastChildProps, - renderIsScheduled, - childPropsFromStoreUpdate, - notifyNestedSubs, - forceComponentUpdateDispatch, - ], - [store, subscription, childPropsSelector] - ) - - // Now that all that's done, we can finally try to actually render the child component. - // We memoize the elements for the rendered child component as an optimization. - const renderedWrappedComponent = useMemo( - () => ( - // @ts-ignore - - ), - [reactReduxForwardedRef, WrappedComponent, actualChildProps] - ) - - // If React sees the exact same element reference as last time, it bails out of re-rendering - // that child, same as if it was wrapped in React.memo() or returned false from shouldComponentUpdate. - const renderedChild = useMemo(() => { - if (shouldHandleStateChanges) { - // If this component is subscribed to store updates, we need to pass its own - // subscription instance down to our descendants. That means rendering the same - // Context instance, and putting a different value into the context. - return ( - - {renderedWrappedComponent} - - ) - } - - return renderedWrappedComponent - }, [ContextToUse, renderedWrappedComponent, overriddenContextValue]) - - return renderedChild - } - - // If we're in "pure" mode, ensure our wrapper component only re-renders when incoming props have changed. - const _Connect = pure ? React.memo(ConnectFunction) : ConnectFunction - - type ConnectedWrapperComponent = typeof _Connect & { - WrappedComponent: typeof WrappedComponent - } - - const Connect = _Connect as ConnectedComponent< - typeof WrappedComponent, - WrappedComponentProps - > - Connect.WrappedComponent = WrappedComponent - Connect.displayName = ConnectFunction.displayName = displayName - - if (forwardRef) { - const _forwarded = React.forwardRef(function forwardConnectRef( - props, - ref - ) { - // @ts-ignore - return - }) - - const forwarded = _forwarded as ConnectedWrapperComponent - forwarded.displayName = displayName - forwarded.WrappedComponent = WrappedComponent - return hoistStatics(forwarded, WrappedComponent) - } - - return hoistStatics(Connect, WrappedComponent) - } - - return wrapWithConnect -} - -export default connectAdvanced diff --git a/src/connect/connect.ts b/src/connect/connect.ts deleted file mode 100644 index ae95fe28b..000000000 --- a/src/connect/connect.ts +++ /dev/null @@ -1,500 +0,0 @@ -/* eslint-disable valid-jsdoc, @typescript-eslint/no-unused-vars */ -import type { Dispatch, Action, AnyAction } from 'redux' -import connectAdvanced from '../components/connectAdvanced' -import type { ConnectAdvancedOptions } from '../components/connectAdvanced' -import shallowEqual from '../utils/shallowEqual' -import defaultMapDispatchToPropsFactories from './mapDispatchToProps' -import defaultMapStateToPropsFactories from './mapStateToProps' -import defaultMergePropsFactories from './mergeProps' -import defaultSelectorFactory, { - MapStateToPropsParam, - MapDispatchToPropsParam, - MergeProps, - MapDispatchToPropsNonObject, - SelectorFactory, -} from './selectorFactory' -import type { - DefaultRootState, - InferableComponentEnhancer, - InferableComponentEnhancerWithProps, - ResolveThunks, - DispatchProp, -} from '../types' - -/* - connect is a facade over connectAdvanced. It turns its args into a compatible - selectorFactory, which has the signature: - - (dispatch, options) => (nextState, nextOwnProps) => nextFinalProps - - connect passes its args to connectAdvanced as options, which will in turn pass them to - selectorFactory each time a Connect component instance is instantiated or hot reloaded. - - selectorFactory returns a final props selector from its mapStateToProps, - mapStateToPropsFactories, mapDispatchToProps, mapDispatchToPropsFactories, mergeProps, - mergePropsFactories, and pure args. - - The resulting final props selector is called by the Connect component instance whenever - it receives new props or store state. - */ - -function match( - arg: unknown, - factories: ((value: unknown) => T)[], - name: string -): T { - for (let i = factories.length - 1; i >= 0; i--) { - const result = factories[i](arg) - if (result) return result - } - - return ((dispatch: Dispatch, options: { wrappedComponentName: string }) => { - throw new Error( - `Invalid value of type ${typeof arg} for ${name} argument when connecting component ${ - options.wrappedComponentName - }.` - ) - }) as any -} - -function strictEqual(a: unknown, b: unknown) { - return a === b -} - -/** - * Infers the type of props that a connector will inject into a component. - */ -export type ConnectedProps = - TConnector extends InferableComponentEnhancerWithProps< - infer TInjectedProps, - any - > - ? unknown extends TInjectedProps - ? TConnector extends InferableComponentEnhancer - ? TInjectedProps - : never - : TInjectedProps - : never - -export interface ConnectOptions< - State = DefaultRootState, - TStateProps = {}, - TOwnProps = {}, - TMergedProps = {} -> extends ConnectAdvancedOptions { - pure?: boolean | undefined - areStatesEqual?: ((nextState: State, prevState: State) => boolean) | undefined - - areOwnPropsEqual?: ( - nextOwnProps: TOwnProps, - prevOwnProps: TOwnProps - ) => boolean - - areStatePropsEqual?: ( - nextStateProps: TStateProps, - prevStateProps: TStateProps - ) => boolean - areMergedPropsEqual?: ( - nextMergedProps: TMergedProps, - prevMergedProps: TMergedProps - ) => boolean - forwardRef?: boolean | undefined -} - -/* -export interface Connect { - // tslint:disable:no-unnecessary-generics - (): InferableComponentEnhancer - - ( - mapStateToProps: MapStateToPropsParam - ): InferableComponentEnhancerWithProps - - ( - mapStateToProps: null | undefined, - mapDispatchToProps: MapDispatchToPropsNonObject - ): InferableComponentEnhancerWithProps - - ( - mapStateToProps: null | undefined, - mapDispatchToProps: MapDispatchToPropsParam - ): InferableComponentEnhancerWithProps< - ResolveThunks, - TOwnProps - > - - ( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: MapDispatchToPropsNonObject - ): InferableComponentEnhancerWithProps< - TStateProps & TDispatchProps, - TOwnProps - > - - ( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: MapDispatchToPropsParam - ): InferableComponentEnhancerWithProps< - TStateProps & ResolveThunks, - TOwnProps - > - - ( - mapStateToProps: null | undefined, - mapDispatchToProps: null | undefined, - mergeProps: MergeProps - ): InferableComponentEnhancerWithProps - - < - TStateProps = {}, - no_dispatch = {}, - TOwnProps = {}, - TMergedProps = {}, - State = DefaultState - >( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: null | undefined, - mergeProps: MergeProps - ): InferableComponentEnhancerWithProps - - ( - mapStateToProps: null | undefined, - mapDispatchToProps: MapDispatchToPropsParam, - mergeProps: MergeProps - ): InferableComponentEnhancerWithProps - - ( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: null | undefined, - mergeProps: null | undefined, - options: ConnectOptions - ): InferableComponentEnhancerWithProps - - ( - mapStateToProps: null | undefined, - mapDispatchToProps: MapDispatchToPropsNonObject, - mergeProps: null | undefined, - options: ConnectOptions<{}, TStateProps, TOwnProps> - ): InferableComponentEnhancerWithProps - - ( - mapStateToProps: null | undefined, - mapDispatchToProps: MapDispatchToPropsParam, - mergeProps: null | undefined, - options: ConnectOptions<{}, TStateProps, TOwnProps> - ): InferableComponentEnhancerWithProps< - ResolveThunks, - TOwnProps - > - - ( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: MapDispatchToPropsNonObject, - mergeProps: null | undefined, - options: ConnectOptions - ): InferableComponentEnhancerWithProps< - TStateProps & TDispatchProps, - TOwnProps - > - - ( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: MapDispatchToPropsParam, - mergeProps: null | undefined, - options: ConnectOptions - ): InferableComponentEnhancerWithProps< - TStateProps & ResolveThunks, - TOwnProps - > - - < - TStateProps = {}, - TDispatchProps = {}, - TOwnProps = {}, - TMergedProps = {}, - State = DefaultState - >( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: MapDispatchToPropsParam, - mergeProps: MergeProps< - TStateProps, - TDispatchProps, - TOwnProps, - TMergedProps - >, - options?: ConnectOptions - ): InferableComponentEnhancerWithProps - // tslint:enable:no-unnecessary-generics -} -*/ - -// createConnect with default args builds the 'official' connect behavior. Calling it with -// different options opens up some testing and extensibility scenarios -export function createConnect({ - connectHOC = connectAdvanced, - mapStateToPropsFactories = defaultMapStateToPropsFactories, - mapDispatchToPropsFactories = defaultMapDispatchToPropsFactories, - mergePropsFactories = defaultMergePropsFactories, - selectorFactory = defaultSelectorFactory, -} = {}) { - /* @public */ - function connect(): InferableComponentEnhancer - - /* @public */ - function connect< - TStateProps = {}, - no_dispatch = {}, - TOwnProps = {}, - State = DefaultRootState - >( - mapStateToProps: MapStateToPropsParam - ): InferableComponentEnhancerWithProps - - /* @public */ - function connect( - mapStateToProps: null | undefined, - mapDispatchToProps: MapDispatchToPropsNonObject - ): InferableComponentEnhancerWithProps - - /* @public */ - function connect( - mapStateToProps: null | undefined, - mapDispatchToProps: MapDispatchToPropsParam - ): InferableComponentEnhancerWithProps< - ResolveThunks, - TOwnProps - > - - /* @public */ - function connect< - TStateProps = {}, - TDispatchProps = {}, - TOwnProps = {}, - State = DefaultRootState - >( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: MapDispatchToPropsNonObject - ): InferableComponentEnhancerWithProps< - TStateProps & TDispatchProps, - TOwnProps - > - - /* @public */ - function connect< - TStateProps = {}, - TDispatchProps = {}, - TOwnProps = {}, - State = DefaultRootState - >( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: MapDispatchToPropsParam - ): InferableComponentEnhancerWithProps< - TStateProps & ResolveThunks, - TOwnProps - > - - /* @public */ - function connect< - no_state = {}, - no_dispatch = {}, - TOwnProps = {}, - TMergedProps = {} - >( - mapStateToProps: null | undefined, - mapDispatchToProps: null | undefined, - mergeProps: MergeProps - ): InferableComponentEnhancerWithProps - - /* @public */ - function connect< - TStateProps = {}, - no_dispatch = {}, - TOwnProps = {}, - TMergedProps = {}, - State = DefaultRootState - >( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: null | undefined, - mergeProps: MergeProps - ): InferableComponentEnhancerWithProps - - /* @public */ - function connect< - no_state = {}, - TDispatchProps = {}, - TOwnProps = {}, - TMergedProps = {} - >( - mapStateToProps: null | undefined, - mapDispatchToProps: MapDispatchToPropsParam, - mergeProps: MergeProps - ): InferableComponentEnhancerWithProps - - /* @public */ - // @ts-ignore - function connect< - TStateProps = {}, - no_dispatch = {}, - TOwnProps = {}, - State = DefaultRootState - >( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: null | undefined, - mergeProps: null | undefined, - options: ConnectOptions - ): InferableComponentEnhancerWithProps - - /* @public */ - function connect( - mapStateToProps: null | undefined, - mapDispatchToProps: MapDispatchToPropsNonObject, - mergeProps: null | undefined, - options: ConnectOptions<{}, TStateProps, TOwnProps> - ): InferableComponentEnhancerWithProps - - /* @public */ - function connect( - mapStateToProps: null | undefined, - mapDispatchToProps: MapDispatchToPropsParam, - mergeProps: null | undefined, - options: ConnectOptions<{}, TStateProps, TOwnProps> - ): InferableComponentEnhancerWithProps< - ResolveThunks, - TOwnProps - > - - /* @public */ - function connect< - TStateProps = {}, - TDispatchProps = {}, - TOwnProps = {}, - State = DefaultRootState - >( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: MapDispatchToPropsNonObject, - mergeProps: null | undefined, - options: ConnectOptions - ): InferableComponentEnhancerWithProps< - TStateProps & TDispatchProps, - TOwnProps - > - - /* @public */ - function connect< - TStateProps = {}, - TDispatchProps = {}, - TOwnProps = {}, - State = DefaultRootState - >( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: MapDispatchToPropsParam, - mergeProps: null | undefined, - options: ConnectOptions - ): InferableComponentEnhancerWithProps< - TStateProps & ResolveThunks, - TOwnProps - > - - /* @public */ - function connect< - TStateProps = {}, - TDispatchProps = {}, - TOwnProps = {}, - TMergedProps = {}, - State = DefaultRootState - >( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: MapDispatchToPropsParam, - mergeProps: MergeProps< - TStateProps, - TDispatchProps, - TOwnProps, - TMergedProps - >, - options?: ConnectOptions - ): InferableComponentEnhancerWithProps - - /** - * Connects a React component to a Redux store. - * - * - Without arguments, just wraps the component, without changing the behavior / props - * - * - If 2 params are passed (3rd param, mergeProps, is skipped), default behavior - * is to override ownProps (as stated in the docs), so what remains is everything that's - * not a state or dispatch prop - * - * - When 3rd param is passed, we don't know if ownProps propagate and whether they - * should be valid component props, because it depends on mergeProps implementation. - * As such, it is the user's responsibility to extend ownProps interface from state or - * dispatch props or both when applicable - * - * @param mapStateToProps A function that extracts values from state - * @param mapDispatchToProps Setup for dispatching actions - * @param mergeProps Optional callback to merge state and dispatch props together - * @param options Options for configuring the connection - * - */ - function connect( - mapStateToProps?: unknown, - mapDispatchToProps?: unknown, - mergeProps?: unknown, - { - pure = true, - areStatesEqual = strictEqual, - areOwnPropsEqual = shallowEqual, - areStatePropsEqual = shallowEqual, - areMergedPropsEqual = shallowEqual, - ...extraOptions - }: ConnectOptions = {} - ): unknown { - const initMapStateToProps = match( - mapStateToProps, - // @ts-ignore - mapStateToPropsFactories, - 'mapStateToProps' - ) - const initMapDispatchToProps = match( - mapDispatchToProps, - // @ts-ignore - mapDispatchToPropsFactories, - 'mapDispatchToProps' - ) - const initMergeProps = match( - mergeProps, - // @ts-ignore - mergePropsFactories, - 'mergeProps' - ) - - return connectHOC(selectorFactory as SelectorFactory, { - // used in error messages - methodName: 'connect', - - // used to compute Connect's displayName from the wrapped component's displayName. - getDisplayName: (name) => `Connect(${name})`, - - // if mapStateToProps is falsy, the Connect component doesn't subscribe to store state changes - shouldHandleStateChanges: Boolean(mapStateToProps), - - // passed through to selectorFactory - initMapStateToProps, - initMapDispatchToProps, - initMergeProps, - pure, - areStatesEqual, - areOwnPropsEqual, - areStatePropsEqual, - areMergedPropsEqual, - - // any extra options args can override defaults of connect or connectAdvanced - ...extraOptions, - }) - } - - return connect -} - -/* @public */ -const connect = /*#__PURE__*/ createConnect() - -export default connect diff --git a/src/connect/mergeProps.ts b/src/connect/mergeProps.ts index a6aeac91d..c4f1b9583 100644 --- a/src/connect/mergeProps.ts +++ b/src/connect/mergeProps.ts @@ -1,19 +1,37 @@ import { Dispatch } from 'redux' import verifyPlainObject from '../utils/verifyPlainObject' -type MergeProps = (stateProps: TStateProps, dispatchProps: TDispatchProps, ownProps: TOwnProps) => TMergedProps +type MergeProps = ( + stateProps: TStateProps, + dispatchProps: TDispatchProps, + ownProps: TOwnProps +) => TMergedProps -export function defaultMergeProps(stateProps: TStateProps, dispatchProps: TDispatchProps, ownProps: TOwnProps) { +export function defaultMergeProps( + stateProps: TStateProps, + dispatchProps: TDispatchProps, + ownProps: TOwnProps +) { return { ...ownProps, ...stateProps, ...dispatchProps } } -interface InitMergeOptions { - displayName: string; - pure?: boolean; - areMergedPropsEqual: (a: any, b: any) => boolean; +interface InitMergeOptions { + displayName: string + pure?: boolean + areMergedPropsEqual: (a: any, b: any) => boolean } -export function wrapMergePropsFunc(mergeProps: MergeProps): (dispatch: Dispatch, options: InitMergeOptions) => MergeProps { +export function wrapMergePropsFunc< + TStateProps, + TDispatchProps, + TOwnProps, + TMergedProps +>( + mergeProps: MergeProps +): ( + dispatch: Dispatch, + options: InitMergeOptions +) => MergeProps { return function initMergePropsProxy( dispatch, { displayName, pure, areMergedPropsEqual } @@ -21,7 +39,11 @@ export function wrapMergePropsFunc(mergeProps: MergeProps) { +export function whenMergePropsIsFunction< + TStateProps, + TDispatchProps, + TOwnProps, + TMergedProps +>( + mergeProps: MergeProps +) { return typeof mergeProps === 'function' ? wrapMergePropsFunc(mergeProps) : undefined } -export function whenMergePropsIsOmitted(mergeProps?: MergeProps) { +export function whenMergePropsIsOmitted< + TStateProps, + TDispatchProps, + TOwnProps, + TMergedProps +>( + mergeProps?: MergeProps +) { return !mergeProps ? () => defaultMergeProps : undefined } diff --git a/src/connect/selectorFactory.ts b/src/connect/selectorFactory.ts index 2a2990a02..e76ae50a2 100644 --- a/src/connect/selectorFactory.ts +++ b/src/connect/selectorFactory.ts @@ -96,6 +96,7 @@ interface PureSelectorFactoryComparisonOptions< areStatesEqual: EqualityFn areOwnPropsEqual: EqualityFn areStatePropsEqual: EqualityFn + displayName: string pure?: boolean } @@ -207,29 +208,22 @@ export interface SelectorFactoryOptions< > extends PureSelectorFactoryComparisonOptions { initMapStateToProps: ( dispatch: Dispatch, - options: PureSelectorFactoryComparisonOptions & { - displayName: string - } + options: PureSelectorFactoryComparisonOptions ) => MapStateToPropsParam initMapDispatchToProps: ( dispatch: Dispatch, - options: PureSelectorFactoryComparisonOptions & { - displayName: string - } + options: PureSelectorFactoryComparisonOptions ) => MapDispatchToPropsParam initMergeProps: ( dispatch: Dispatch, - options: PureSelectorFactoryComparisonOptions & { - displayName: string - } + options: PureSelectorFactoryComparisonOptions ) => MergeProps - displayName: string } // TODO: Add more comments // If pure is true, the selector returned by selectorFactory will memoize its results, -// allowing connectAdvanced's shouldComponentUpdate to return false if final +// allowing connect's shouldComponentUpdate to return false if final // props have not changed. If false, the selector will always return a new // object and shouldComponentUpdate will always return true. @@ -259,12 +253,7 @@ export default function finalPropsSelectorFactory< const mergeProps = initMergeProps(dispatch, options) if (process.env.NODE_ENV !== 'production') { - verifySubselectors( - mapStateToProps, - mapDispatchToProps, - mergeProps, - options.displayName - ) + verifySubselectors(mapStateToProps, mapDispatchToProps, mergeProps) } const selectorFactory = options.pure diff --git a/src/connect/verifySubselectors.ts b/src/connect/verifySubselectors.ts index dd7705a2f..487cdb197 100644 --- a/src/connect/verifySubselectors.ts +++ b/src/connect/verifySubselectors.ts @@ -1,19 +1,15 @@ import warning from '../utils/warning' -function verify( - selector: unknown, - methodName: string, - displayName: string -): void { +function verify(selector: unknown, methodName: string): void { if (!selector) { - throw new Error(`Unexpected value for ${methodName} in ${displayName}.`) + throw new Error(`Unexpected value for ${methodName} in connect.`) } else if ( methodName === 'mapStateToProps' || methodName === 'mapDispatchToProps' ) { if (!Object.prototype.hasOwnProperty.call(selector, 'dependsOnOwnProps')) { warning( - `The selector for ${methodName} of ${displayName} did not specify a value for dependsOnOwnProps.` + `The selector for ${methodName} of connect did not specify a value for dependsOnOwnProps.` ) } } @@ -22,10 +18,9 @@ function verify( export default function verifySubselectors( mapStateToProps: unknown, mapDispatchToProps: unknown, - mergeProps: unknown, - displayName: string + mergeProps: unknown ): void { - verify(mapStateToProps, 'mapStateToProps', displayName) - verify(mapDispatchToProps, 'mapDispatchToProps', displayName) - verify(mergeProps, 'mergeProps', displayName) + verify(mapStateToProps, 'mapStateToProps') + verify(mapDispatchToProps, 'mapDispatchToProps') + verify(mergeProps, 'mergeProps') } diff --git a/src/exports.ts b/src/exports.ts index 69b0aca1b..21deac22e 100644 --- a/src/exports.ts +++ b/src/exports.ts @@ -1,10 +1,6 @@ import Provider from './components/Provider' import type { ProviderProps } from './components/Provider' -import connectAdvanced from './components/connectAdvanced' -import type { - ConnectAdvancedOptions, - ConnectProps, -} from './components/connectAdvanced' +import connect, { ConnectProps, ConnectedProps } from './components/connect' import type { SelectorFactory, Selector, @@ -20,14 +16,12 @@ import type { } from './connect/selectorFactory' import { ReactReduxContext } from './components/Context' import type { ReactReduxContextValue } from './components/Context' -import connect, { ConnectedProps } from './connect/connect' import { useDispatch, createDispatchHook } from './hooks/useDispatch' import { useSelector, createSelectorHook } from './hooks/useSelector' import { useStore, createStoreHook } from './hooks/useStore' import shallowEqual from './utils/shallowEqual' -import type { Subscription } from '../src/utils/Subscription' export * from './types' export type { @@ -39,7 +33,6 @@ export type { MapStateToPropsParam, ConnectProps, ConnectedProps, - ConnectAdvancedOptions, MapDispatchToPropsFunction, MapDispatchToProps, MapDispatchToPropsFactory, @@ -47,11 +40,9 @@ export type { MapDispatchToPropsNonObject, MergeProps, ReactReduxContextValue, - Subscription, } export { Provider, - connectAdvanced, ReactReduxContext, connect, useDispatch, diff --git a/src/hooks/useSelector.ts b/src/hooks/useSelector.ts index ee072d9f1..bbc0b3bb1 100644 --- a/src/hooks/useSelector.ts +++ b/src/hooks/useSelector.ts @@ -1,106 +1,11 @@ -import { useReducer, useRef, useMemo, useContext, useDebugValue } from 'react' +import { useCallback, useContext, useDebugValue, useRef } from 'react' import { useReduxContext as useDefaultReduxContext } from './useReduxContext' -import { createSubscription, Subscription } from '../utils/Subscription' -import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' import { ReactReduxContext } from '../components/Context' -import { AnyAction, Store } from 'redux' import { DefaultRootState, EqualityFn } from '../types' +import { useStoreSource } from '../utils/useStoreSource' const refEquality: EqualityFn = (a, b) => a === b -type TSelector = (state: S) => R - -function useSelectorWithStoreAndSubscription( - selector: TSelector, - equalityFn: EqualityFn, - store: Store, - contextSub: Subscription -): TSelectedState { - const [, forceRender] = useReducer((s) => s + 1, 0) - - const subscription = useMemo( - () => createSubscription(store, contextSub), - [store, contextSub] - ) - - const latestSubscriptionCallbackError = useRef() - const latestSelector = useRef>() - const latestStoreState = useRef() - const latestSelectedState = useRef() - - const storeState = store.getState() - let selectedState: TSelectedState | undefined - - try { - if ( - selector !== latestSelector.current || - storeState !== latestStoreState.current || - latestSubscriptionCallbackError.current - ) { - const newSelectedState = selector(storeState) - // ensure latest selected state is reused so that a custom equality function can result in identical references - if ( - latestSelectedState.current === undefined || - !equalityFn(newSelectedState, latestSelectedState.current) - ) { - selectedState = newSelectedState - } else { - selectedState = latestSelectedState.current - } - } else { - selectedState = latestSelectedState.current - } - } catch (err) { - if (latestSubscriptionCallbackError.current) { - ;( - err as Error - ).message += `\nThe error may be correlated with this previous error:\n${latestSubscriptionCallbackError.current.stack}\n\n` - } - - throw err - } - - useIsomorphicLayoutEffect(() => { - latestSelector.current = selector - latestStoreState.current = storeState - latestSelectedState.current = selectedState - latestSubscriptionCallbackError.current = undefined - }) - - useIsomorphicLayoutEffect(() => { - function checkForUpdates() { - try { - const newStoreState = store.getState() - const newSelectedState = latestSelector.current!(newStoreState) - - if (equalityFn(newSelectedState, latestSelectedState.current)) { - return - } - - latestSelectedState.current = newSelectedState - latestStoreState.current = newStoreState - } catch (err) { - // we ignore all errors here, since when the component - // is re-rendered, the selectors are called again, and - // will throw again, if neither props nor store state - // changed - latestSubscriptionCallbackError.current = err as Error - } - - forceRender() - } - - subscription.onStateChange = checkForUpdates - subscription.trySubscribe() - - checkForUpdates() - - return () => subscription.tryUnsubscribe() - }, [store, subscription]) - - return selectedState! -} - /** * Hook factory, which creates a `useSelector` hook bound to a given context. * @@ -135,15 +40,24 @@ export function createSelectorHook( ) } } - const { store, subscription: contextSub } = useReduxContext()! - - const selectedState = useSelectorWithStoreAndSubscription( - selector, - equalityFn, - store, - contextSub + const { storeSource } = useReduxContext()! + + const lastValue = useRef() + const getSnapshot = useCallback( + (store): Selected => { + const value = selector(store.getState()) + if ( + lastValue.current === undefined || + !equalityFn(value, lastValue.current) + ) { + lastValue.current = value + } + return lastValue.current + }, + [selector, equalityFn] ) + const selectedState = useStoreSource(storeSource, getSnapshot) useDebugValue(selectedState) return selectedState diff --git a/src/index.ts b/src/index.ts index f37b19b31..b44737ae2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1 @@ export * from './exports' - -import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates' -import { setBatch } from './utils/batch' - -// Enable batched updates in our subscriptions for use -// with standard React renderers (ReactDOM, React Native) -setBatch(batch) - -export { batch } diff --git a/src/utils/Subscription.ts b/src/utils/Subscription.ts deleted file mode 100644 index 0ea00c4fb..000000000 --- a/src/utils/Subscription.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { getBatch } from './batch' - -// encapsulates the subscription logic for connecting a component to the redux store, as -// well as nesting subscriptions of descendant components, so that we can ensure the -// ancestor components re-render before descendants - -type VoidFunc = () => void - -type Listener = { - callback: VoidFunc - next: Listener | null - prev: Listener | null -} - -function createListenerCollection() { - const batch = getBatch() - let first: Listener | null = null - let last: Listener | null = null - - return { - clear() { - first = null - last = null - }, - - notify() { - batch(() => { - let listener = first - while (listener) { - listener.callback() - listener = listener.next - } - }) - }, - - get() { - let listeners = [] - let listener = first - while (listener) { - listeners.push(listener) - listener = listener.next - } - return listeners - }, - - subscribe(callback: () => void) { - let isSubscribed = true - - let listener: Listener = (last = { - callback, - next: null, - prev: last, - }) - - if (listener.prev) { - listener.prev.next = listener - } else { - first = listener - } - - return function unsubscribe() { - if (!isSubscribed || first === null) return - isSubscribed = false - - if (listener.next) { - listener.next.prev = listener.prev - } else { - last = listener.prev - } - if (listener.prev) { - listener.prev.next = listener.next - } else { - first = listener.next - } - } - }, - } -} - -type ListenerCollection = ReturnType - -export interface Subscription { - addNestedSub: (listener: VoidFunc) => VoidFunc - notifyNestedSubs: VoidFunc - handleChangeWrapper: VoidFunc - isSubscribed: () => boolean - onStateChange?: VoidFunc | null - trySubscribe: VoidFunc - tryUnsubscribe: VoidFunc - getListeners: () => ListenerCollection -} - -const nullListeners = { - notify() {}, - get: () => [], -} as unknown as ListenerCollection - -export function createSubscription(store: any, parentSub?: Subscription) { - let unsubscribe: VoidFunc | undefined - let listeners: ListenerCollection = nullListeners - - function addNestedSub(listener: () => void) { - trySubscribe() - return listeners.subscribe(listener) - } - - function notifyNestedSubs() { - listeners.notify() - } - - function handleChangeWrapper() { - if (subscription.onStateChange) { - subscription.onStateChange() - } - } - - function isSubscribed() { - return Boolean(unsubscribe) - } - - function trySubscribe() { - if (!unsubscribe) { - unsubscribe = parentSub - ? parentSub.addNestedSub(handleChangeWrapper) - : store.subscribe(handleChangeWrapper) - - listeners = createListenerCollection() - } - } - - function tryUnsubscribe() { - if (unsubscribe) { - unsubscribe() - unsubscribe = undefined - listeners.clear() - listeners = nullListeners - } - } - - const subscription: Subscription = { - addNestedSub, - notifyNestedSubs, - handleChangeWrapper, - isSubscribed, - trySubscribe, - tryUnsubscribe, - getListeners: () => listeners, - } - - return subscription -} diff --git a/src/utils/batch.ts b/src/utils/batch.ts deleted file mode 100644 index 2d116eae0..000000000 --- a/src/utils/batch.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Default to a dummy "batch" implementation that just runs the callback -function defaultNoopBatch(callback: () => void) { - callback() -} - -let batch = defaultNoopBatch - -// Allow injecting another batching function later -export const setBatch = (newBatch: typeof defaultNoopBatch) => - (batch = newBatch) - -// Supply a getter just to skip dealing with ESM bindings -export const getBatch = () => batch diff --git a/src/utils/reactBatchedUpdates.native.ts b/src/utils/reactBatchedUpdates.native.ts deleted file mode 100644 index a92cd6768..000000000 --- a/src/utils/reactBatchedUpdates.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* eslint-disable import/namespace */ -/* eslint-disable import/named */ -import { unstable_batchedUpdates } from 'react-native' - -export { unstable_batchedUpdates } diff --git a/src/utils/reactBatchedUpdates.ts b/src/utils/reactBatchedUpdates.ts deleted file mode 100644 index 0fca6d85e..000000000 --- a/src/utils/reactBatchedUpdates.ts +++ /dev/null @@ -1 +0,0 @@ -export { unstable_batchedUpdates } from 'react-dom' diff --git a/src/utils/useIsomorphicLayoutEffect.native.ts b/src/utils/useIsomorphicLayoutEffect.native.ts deleted file mode 100644 index e80393ad9..000000000 --- a/src/utils/useIsomorphicLayoutEffect.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useLayoutEffect } from 'react' - -// Under React Native, we know that we always want to use useLayoutEffect - -export const useIsomorphicLayoutEffect = useLayoutEffect diff --git a/src/utils/useIsomorphicLayoutEffect.ts b/src/utils/useIsomorphicLayoutEffect.ts deleted file mode 100644 index 0e87d6e0c..000000000 --- a/src/utils/useIsomorphicLayoutEffect.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useEffect, useLayoutEffect } from 'react' - -// React currently throws a warning when using useLayoutEffect on the server. -// To get around it, we can conditionally useEffect on the server (no-op) and -// useLayoutEffect in the browser. We need useLayoutEffect to ensure the store -// subscription callback always has the selector from the latest render commit -// available, otherwise a store update may happen between render and the effect, -// which may cause missed updates; we also must ensure the store subscription -// is created synchronously, otherwise a store update may occur before the -// subscription is created and an inconsistent state may be observed - -export const useIsomorphicLayoutEffect = - typeof window !== 'undefined' && - typeof window.document !== 'undefined' && - typeof window.document.createElement !== 'undefined' - ? useLayoutEffect - : useEffect diff --git a/src/utils/useStoreSource.ts b/src/utils/useStoreSource.ts new file mode 100644 index 000000000..43b5fd16a --- /dev/null +++ b/src/utils/useStoreSource.ts @@ -0,0 +1,16 @@ +import { Store } from 'redux' +import { + MutableSource, + unstable_useMutableSource as useMutableSource, +} from 'react' + +const subscribe = (store: Store, callback: () => void) => { + return store.subscribe(callback) +} + +export const useStoreSource = ( + source: MutableSource, + getSnapshot: (store: Store) => Value +): Value => { + return useMutableSource(source, getSnapshot, subscribe) +} diff --git a/test/components/Provider.spec.tsx b/test/components/Provider.spec.tsx index f482ef0b4..492d10d6d 100644 --- a/test/components/Provider.spec.tsx +++ b/test/components/Provider.spec.tsx @@ -342,16 +342,19 @@ describe('React', () => { } } - const div = document.createElement('div') - ReactDOM.render( - -
- , - div - ) + const root = ReactDOM.createRoot(document.createElement('div')) + rtl.act(() => { + root.render( + +
+ + ) + }) expect(spy).toHaveBeenCalledTimes(0) - ReactDOM.unmountComponentAtNode(div) + rtl.act(() => { + root.unmount() + }) expect(spy).toHaveBeenCalledTimes(1) }) diff --git a/test/components/connect.spec.tsx b/test/components/connect.spec.tsx index a63660e99..64379da1a 100644 --- a/test/components/connect.spec.tsx +++ b/test/components/connect.spec.tsx @@ -402,7 +402,9 @@ describe('React', () => { expect(tester.getByTestId('x')).toHaveTextContent('true') props = {} - container.current!.forceUpdate() + rtl.act(() => { + container.current!.forceUpdate() + }) expect(tester.queryByTestId('x')).toBe(null) }) @@ -440,7 +442,9 @@ describe('React', () => { expect(tester.getByTestId('x')).toHaveTextContent('true') props = {} - container.current!.forceUpdate() + rtl.act(() => { + container.current!.forceUpdate() + }) expect(tester.getAllByTitle('prop').length).toBe(1) expect(tester.getByTestId('dispatch')).toHaveTextContent( @@ -842,8 +846,12 @@ describe('React', () => { ) - outerComponent.current!.setFoo('BAR') - outerComponent.current!.setFoo('DID') + rtl.act(() => { + outerComponent.current!.setFoo('BAR') + }) + rtl.act(() => { + outerComponent.current!.setFoo('DID') + }) expect(invocationCount).toEqual(1) }) @@ -888,8 +896,12 @@ describe('React', () => { ) - outerComponent.current!.setFoo('BAR') - outerComponent.current!.setFoo('DID') + rtl.act(() => { + outerComponent.current!.setFoo('BAR') + }) + rtl.act(() => { + outerComponent.current!.setFoo('DID') + }) expect(invocationCount).toEqual(3) }) @@ -937,8 +949,12 @@ describe('React', () => { ) - outerComponent.current!.setFoo('BAR') - outerComponent.current!.setFoo('BAZ') + rtl.act(() => { + outerComponent.current!.setFoo('BAR') + }) + rtl.act(() => { + outerComponent.current!.setFoo('BAZ') + }) expect(invocationCount).toEqual(3) expect(propsPassedIn).toEqual({ @@ -988,8 +1004,12 @@ describe('React', () => { ) - outerComponent.current!.setFoo('BAR') - outerComponent.current!.setFoo('DID') + rtl.act(() => { + outerComponent.current!.setFoo('BAR') + }) + rtl.act(() => { + outerComponent.current!.setFoo('DID') + }) expect(invocationCount).toEqual(1) }) @@ -1034,9 +1054,12 @@ describe('React', () => { ) - - outerComponent.current!.setFoo('BAR') - outerComponent.current!.setFoo('DID') + rtl.act(() => { + outerComponent.current!.setFoo('BAR') + }) + rtl.act(() => { + outerComponent.current!.setFoo('DID') + }) expect(invocationCount).toEqual(3) }) @@ -1084,8 +1107,12 @@ describe('React', () => { ) - outerComponent.current!.setFoo('BAR') - outerComponent.current!.setFoo('BAZ') + rtl.act(() => { + outerComponent.current!.setFoo('BAR') + }) + rtl.act(() => { + outerComponent.current!.setFoo('BAZ') + }) expect(invocationCount).toEqual(3) expect(propsPassedIn).toEqual({ @@ -1160,20 +1187,23 @@ describe('React', () => { string >((state) => ({ state }))(Child) - const div = document.createElement('div') - ReactDOM.render( - - - , - div - ) + const root = ReactDOM.createRoot(document.createElement('div')) + rtl.act(() => { + root.render( + + + + ) + }) try { rtl.act(() => { store.dispatch({ type: 'APPEND', body: 'A' }) }) } finally { - ReactDOM.unmountComponentAtNode(div) + rtl.act(() => { + root.unmount() + }) } }) @@ -1254,17 +1284,28 @@ describe('React', () => { const div = document.createElement('div') document.body.appendChild(div) - ReactDOM.render( - - - , - div - ) + + const root = ReactDOM.createRoot(div) + rtl.act(() => { + root.render( + + + + ) + }) const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) - linkA.current!.click() - linkB.current!.click() - linkB.current!.click() + rtl.act(() => { + linkA.current!.click() + }) + rtl.act(() => { + linkB.current!.click() + }) + rtl.act(() => { + linkB.current!.click()}) + rtl.act(() => { + root.unmount() + }) document.body.removeChild(div) // Called 3 times: @@ -1297,17 +1338,20 @@ describe('React', () => { (state) => ({ calls: mapStateToPropsCalls++ }), (dispatch) => ({ dispatch }) )(Container) - const div = document.createElement('div') - ReactDOM.render( - - - , - div - ) + const root = ReactDOM.createRoot(document.createElement('div')) + rtl.act(() => { + root.render( + + + + ) + }) expect(mapStateToPropsCalls).toBe(1) const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) - ReactDOM.unmountComponentAtNode(div) + rtl.act(() => { + root.unmount() + }) expect(spy).toHaveBeenCalledTimes(0) expect(mapStateToPropsCalls).toBe(1) spy.mockRestore() @@ -1327,20 +1371,21 @@ describe('React', () => { (dispatch) => ({ dispatch }) )(Inner) - const div = document.createElement('div') - store.subscribe(() => { - ReactDOM.unmountComponentAtNode(div) - }) - + const root = ReactDOM.createRoot(document.createElement('div')) rtl.act(() => { - ReactDOM.render( + root.render( - , - div + ) }) + store.subscribe(() => { + rtl.act(() => { + root.unmount() + }); + }) + expect(mapStateToPropsCalls).toBe(1) const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) rtl.act(() => { @@ -1405,15 +1450,18 @@ describe('React', () => { store.dispatch({ type: 'fetch' }) }) - const div = document.createElement('div') - ReactDOM.render( - - - , - div - ) + const root = ReactDOM.createRoot(document.createElement('div')) + rtl.act(() => { + root.render( + + + + ) + }) - ReactDOM.unmountComponentAtNode(div) + rtl.act(() => { + root.unmount() + }) }) }) @@ -1836,19 +1884,6 @@ describe('React', () => { ).toBe('Connect(Component)') }) - it('should allow custom displayName', () => { - class MyComponent extends React.Component { - render() { - return
- } - } - const ConnectedMyComponent = connect(null, null, null, { - getDisplayName: (name) => `Custom(${name})`, - })(MyComponent) - - expect(ConnectedMyComponent.displayName).toEqual('Custom(MyComponent)') - }) - it('should expose the wrapped component as WrappedComponent', () => { class Container extends Component { render() { @@ -2114,9 +2149,16 @@ describe('React', () => { ) expect(mapStateToProps).toHaveBeenCalledTimes(0) - store.dispatch({ type: 'INC' }) + rtl.act(() => { + store.dispatch({ type: 'INC' }) + }) expect(mapStateToProps).toHaveBeenCalledTimes(1) - store.dispatch({ type: 'INC' }) + rtl.act(() => { + store.dispatch({ type: 'INC' }) + }) + rtl.act(() => { + store.dispatch({ type: 'INC' }) + }) expect(mapStateToProps).toHaveBeenCalledTimes(1) }) }) @@ -2695,8 +2737,10 @@ describe('React', () => { ) expect(tester.getByTestId('statefulValue')).toHaveTextContent('0') - //@ts-ignore - externalSetState({ value: 1 }) + rtl.act(() => { + //@ts-ignore + externalSetState({ value: 1 }) + }) expect(tester.getByTestId('statefulValue')).toHaveTextContent('1') }) @@ -2744,12 +2788,12 @@ describe('React', () => { ) const Decorated = decorator(ImpureComponent) - let externalSetState - let storeGetter = { storeKey: 'foo' } type StatefulWrapperStateType = { storeGetter: typeof storeGetter } type StatefulWrapperPropsType = {} + let storeGetter = { storeKey: 'foo' } + let externalSetState: Dispatch class StatefulWrapper extends Component< StatefulWrapperPropsType, StatefulWrapperStateType @@ -2785,8 +2829,10 @@ describe('React', () => { // Impure update storeGetter.storeKey = 'bar' - //@ts-ignore - externalSetState({ storeGetter }) + rtl.act(() => { + //@ts-ignore + externalSetState({ storeGetter }) + }) // 4) After the the impure update expect(mapStateSpy).toHaveBeenCalledTimes(3) @@ -3317,8 +3363,12 @@ describe('React', () => { ) - outerComponent.current!.setState(({ count }) => ({ count: count + 1 })) - store.dispatch({ type: '' }) + rtl.act(() => { + outerComponent.current!.setState(({ count }) => ({ count: count + 1 })) + }) + rtl.act(() => { + store.dispatch({ type: '' }) + }) //@ts-ignore expect(propsPassedIn.count).toEqual(1) //@ts-ignore @@ -3484,8 +3534,10 @@ describe('React', () => { ) - store.dispatch({ type: '' }) - store.dispatch({ type: '' }) + rtl.act(() => { + store.dispatch({ type: '' }) + store.dispatch({ type: '' }) + }) outerComponent.current!.setState(({ count }) => ({ count: count + 1 })) expect(reduxCountPassedToMapState).toEqual(3) diff --git a/test/components/connectAdvanced.spec.js b/test/components/connectAdvanced.spec.js deleted file mode 100644 index 4df5df798..000000000 --- a/test/components/connectAdvanced.spec.js +++ /dev/null @@ -1,194 +0,0 @@ -import React, { Component } from 'react' -import * as rtl from '@testing-library/react' -import { Provider as ProviderMock, connectAdvanced } from '../../src/index' -import { createStore } from 'redux' -import '@testing-library/jest-dom/extend-expect' - -describe('React', () => { - describe('connectAdvanced', () => { - it('should map state and render on mount', () => { - const initialState = { - foo: 'bar', - } - - let mapCount = 0 - let renderCount = 0 - - const store = createStore(() => initialState) - - function Inner(props) { - renderCount++ - return
{JSON.stringify(props)}
- } - - const Container = connectAdvanced(() => { - return (state) => { - mapCount++ - return state - } - })(Inner) - - const tester = rtl.render( - - - - ) - - expect(tester.getByTestId('foo')).toHaveTextContent('bar') - - // Implementation detail: - // 1) Initial render - // 2) Post-mount subscription and update check - expect(mapCount).toEqual(2) - expect(renderCount).toEqual(1) - }) - - it('should render on reference change', () => { - let mapCount = 0 - let renderCount = 0 - - // force new reference on each dispatch - const store = createStore(() => ({ - foo: 'bar', - })) - - function Inner(props) { - renderCount++ - return
{JSON.stringify(props)}
- } - - const Container = connectAdvanced(() => { - return (state) => { - mapCount++ - return state - } - })(Inner) - - rtl.render( - - - - ) - - rtl.act(() => { - store.dispatch({ type: 'NEW_REFERENCE' }) - }) - - // Should have mapped the state on mount and on the dispatch - expect(mapCount).toEqual(3) - - // Should have rendered on mount and after the dispatch bacause the map - // state returned new reference - expect(renderCount).toEqual(2) - }) - - it('should not render when the returned reference does not change', () => { - const staticReference = { - foo: 'bar', - } - - let mapCount = 0 - let renderCount = 0 - - // force new reference on each dispatch - const store = createStore(() => ({ - foo: 'bar', - })) - - function Inner(props) { - renderCount++ - return
{JSON.stringify(props)}
- } - - const Container = connectAdvanced(() => { - return () => { - mapCount++ - // but return static reference - return staticReference - } - })(Inner) - - const tester = rtl.render( - - - - ) - - store.dispatch({ type: 'NEW_REFERENCE' }) - - expect(tester.getAllByTestId('foo')[0]).toHaveTextContent('bar') - - // The state should have been mapped 3 times: - // 1) Initial render - // 2) Post-mount update check - // 3) Dispatch - expect(mapCount).toEqual(3) - - // But the render should have been called only on mount since the map state - // did not return a new reference - expect(renderCount).toEqual(1) - }) - - it('should map state on own props change but not render when the reference does not change', () => { - const staticReference = { - foo: 'bar', - } - - let mapCount = 0 - let renderCount = 0 - - const store = createStore(() => staticReference) - - function Inner(props) { - renderCount++ - return
{JSON.stringify(props)}
- } - - const Container = connectAdvanced(() => { - return () => { - mapCount++ - // return the static reference - return staticReference - } - })(Inner) - - class OuterComponent extends Component { - constructor() { - super() - this.state = { foo: 'FOO' } - } - - setFoo(foo) { - this.setState({ foo }) - } - - render() { - return ( -
- -
- ) - } - } - - let outerComponent - rtl.render( - - (outerComponent = c)} /> - - ) - - outerComponent.setFoo('BAR') - - // The state should have been mapped 3 times: - // 1) Initial render - // 2) Post-mount update check - // 3) Prop change - expect(mapCount).toEqual(3) - - // render only on mount but skip on prop change because no new - // reference was returned - expect(renderCount).toEqual(1) - }) - }) -}) diff --git a/test/hooks/useReduxContext.spec.tsx b/test/hooks/useReduxContext.spec.tsx index 18b31fb0d..811f1da01 100644 --- a/test/hooks/useReduxContext.spec.tsx +++ b/test/hooks/useReduxContext.spec.tsx @@ -9,7 +9,7 @@ describe('React', () => { const { result } = renderHook(() => useReduxContext()) - expect(result.error.message).toMatch( + expect(result.error?.message).toMatch( /could not find react-redux context value/ ) diff --git a/test/hooks/useSelector.spec.tsx b/test/hooks/useSelector.spec.tsx index a9d48f11c..0092288b1 100644 --- a/test/hooks/useSelector.spec.tsx +++ b/test/hooks/useSelector.spec.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useReducer, useLayoutEffect } from 'react' import { createStore } from 'redux' -import { renderHook, act } from '@testing-library/react-hooks' +import { renderHook } from '@testing-library/react-hooks' import * as rtl from '@testing-library/react' import { Provider as ProviderMock, @@ -11,14 +11,12 @@ import { connect, createSelectorHook, } from '../../src/index' -import { useReduxContext } from '../../src/hooks/useReduxContext' import type { FunctionComponent, DispatchWithoutAction, ReactNode } from 'react' import type { Store, AnyAction } from 'redux' import type { ProviderProps, TypedUseSelectorHook, ReactReduxContextValue, - Subscription, } from '../../src/' describe('React', () => { @@ -74,7 +72,7 @@ describe('React', () => { expect(result.current).toEqual(0) expect(selector).toHaveBeenCalledTimes(2) - act(() => { + rtl.act(() => { normalStore.dispatch({ type: '' }) }) @@ -102,67 +100,13 @@ describe('React', () => { expect(renderedItems).toEqual([1]) - store.dispatch({ type: '' }) + rtl.act(() => { + store.dispatch({ type: '' }) + }) expect(renderedItems).toEqual([1, 2]) }) - it('subscribes to the store synchronously', () => { - let rootSubscription: Subscription - - const Parent = () => { - const { subscription } = useReduxContext() as ReactReduxContextValue - rootSubscription = subscription - const count = useNormalSelector((s) => s.count) - return count === 1 ? : null - } - - const Child = () => { - const count = useNormalSelector((s) => s.count) - return
{count}
- } - - rtl.render( - - - - ) - // @ts-ignore ts(2454) - expect(rootSubscription.getListeners().get().length).toBe(1) - - normalStore.dispatch({ type: '' }) - // @ts-ignore ts(2454) - expect(rootSubscription.getListeners().get().length).toBe(2) - }) - - it('unsubscribes when the component is unmounted', () => { - let rootSubscription: Subscription - - const Parent = () => { - const { subscription } = useReduxContext() as ReactReduxContextValue - rootSubscription = subscription - const count = useNormalSelector((s) => s.count) - return count === 0 ? : null - } - - const Child = () => { - const count = useNormalSelector((s) => s.count) - return
{count}
- } - - rtl.render( - - - - ) - // @ts-ignore ts(2454) - expect(rootSubscription.getListeners().get().length).toBe(2) - - normalStore.dispatch({ type: '' }) - // @ts-ignore ts(2454) - expect(rootSubscription.getListeners().get().length).toBe(1) - }) - it('notices store updates between render and store subscription effect', () => { const Comp = () => { const count = useNormalSelector((s) => s.count) @@ -279,7 +223,9 @@ describe('React', () => { expect(renderedItems.length).toBe(1) - store.dispatch({ type: '' }) + rtl.act(() => { + store.dispatch({ type: '' }) + }) expect(renderedItems.length).toBe(1) }) @@ -458,7 +404,9 @@ describe('React', () => { ) - normalStore.dispatch({ type: '' }) + rtl.act(() => { + normalStore.dispatch({ type: '' }) + }) expect(sawInconsistentState).toBe(false) @@ -484,7 +432,9 @@ describe('React', () => { expect(renderedItems.length).toBe(1) - normalStore.dispatch({ type: '' }) + rtl.act(() => { + normalStore.dispatch({ type: '' }) + }) expect(renderedItems.length).toBe(2) expect(renderedItems[0]).toBe(renderedItems[1]) diff --git a/test/integration/server-rendering.spec.tsx b/test/integration/server-rendering.spec.tsx index 7bf3975a5..605738be1 100644 --- a/test/integration/server-rendering.spec.tsx +++ b/test/integration/server-rendering.spec.tsx @@ -2,7 +2,7 @@ * @jest-environment node * * Set this so that `window` is undefined to correctly mimic a Node SSR scenario. - * That allows connectAdvanced to fall back to `useEffect` instead of `useLayoutEffect` + * That allows connect to fall back to `useEffect` instead of `useLayoutEffect` * to avoid ugly console warnings when used with SSR. */ diff --git a/test/react-native/batch-integration.tsx b/test/react-native/batch-integration.tsx deleted file mode 100644 index baae8589c..000000000 --- a/test/react-native/batch-integration.tsx +++ /dev/null @@ -1,606 +0,0 @@ -import React, { Component, useLayoutEffect } from 'react' -import { View, Button, Text, unstable_batchedUpdates } from 'react-native' -import { createStore, applyMiddleware } from 'redux' -import { - Provider as ProviderMock, - connect, - batch, - useSelector, - useDispatch, -} from '../../src/index' -import { useIsomorphicLayoutEffect } from '../../src/utils/useIsomorphicLayoutEffect' -import * as rtl from '@testing-library/react-native' -import '@testing-library/jest-native/extend-expect' - -import type { MiddlewareAPI, Dispatch as ReduxDispatch } from 'redux' - -describe('React Native', () => { - const propMapper = (prop: any) => { - switch (typeof prop) { - case 'object': - case 'boolean': - return JSON.stringify(prop) - case 'function': - return '[function ' + prop.name + ']' - default: - return prop - } - } - - interface PassthroughPropsType { - [x: string]: any - } - class Passthrough extends Component { - render() { - return ( - - {Object.keys(this.props).map((prop) => ( - - {propMapper(this.props[prop])} - - ))} - - ) - } - } - interface ActionType { - type: string - body?: string - } - function stringBuilder(prev = '', action: ActionType) { - return action.type === 'APPEND' ? prev + action.body : prev - } - - afterEach(() => rtl.cleanup()) - - describe('batch', () => { - it('batch should be RN unstable_batchedUpdates', () => { - expect(batch).toBe(unstable_batchedUpdates) - }) - }) - - describe('useIsomorphicLayoutEffect', () => { - it('useIsomorphicLayoutEffect should be useLayoutEffect', () => { - expect(useIsomorphicLayoutEffect).toBe(useLayoutEffect) - }) - }) - - describe('Subscription and update timing correctness', () => { - it('should pass state consistently to mapState', () => { - type RootStateType = string - type NoDispatch = {} - - const store = createStore(stringBuilder) - - rtl.act(() => { - store.dispatch({ type: 'APPEND', body: 'a' }) - }) - - let childMapStateInvokes = 0 - interface ContainerTStatePropsType { - state: RootStateType - } - type ContainerOwnOwnPropsType = {} - class Container extends Component { - emitChange() { - store.dispatch({ type: 'APPEND', body: 'b' }) - } - - render() { - return ( - -