diff --git a/.eslintrc b/.eslintrc index 09dbe7f1e..afacdf656 100644 --- a/.eslintrc +++ b/.eslintrc @@ -36,7 +36,11 @@ "react/jsx-uses-react": 1, "react/jsx-no-undef": 2, "react/jsx-wrap-multilines": 2, - "react/no-string-refs": 0 + "react/no-string-refs": 0, + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error"], + "no-redeclare": "off", + "@typescript-eslint/no-redeclare": ["error"] }, "plugins": ["@typescript-eslint", "import", "react"], "globals": { diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0809e1767..1551143dd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,3 +35,57 @@ jobs: - name: Collect coverage run: yarn coverage + + test-types: + name: Test Types with TypeScript ${{ matrix.ts }} + + needs: [build] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['14.x'] + ts: ['3.9', '4.0', '4.1', '4.2', '4.3', 'next'] + steps: + - name: Checkout repo + uses: actions/checkout@v2 + + - name: Use node ${{ matrix.node }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node }} + + - uses: actions/cache@v2 + with: + path: .yarn/cache + key: yarn-${{ hashFiles('yarn.lock') }} + restore-keys: yarn- + + - name: Install deps + run: yarn install + + - name: Install TypeScript ${{ matrix.ts }} + run: yarn add typescript@${{ matrix.ts }} + + # - uses: actions/download-artifact@v2 + # with: + # name: package + # path: packages/toolkit + + # - name: Install build artifact + # run: yarn add ./package.tgz + + # - run: sed -i -e /@remap-prod-remove-line/d ./tsconfig.base.json ./jest.config.js ./src/tests/*.* ./src/query/tests/*.* + + # - name: "@ts-ignore stuff that didn't exist pre-4.1 in the tests" + # if: ${{ matrix.ts < 4.1 }} + # run: sed -i -e 's/@pre41-ts-ignore/@ts-ignore/' -e '/pre41-remove-start/,/pre41-remove-end/d' ./src/tests/*.* ./src/query/tests/*.ts* + + # - name: 'disable strictOptionalProperties' + # if: ${{ matrix.ts == 'next' }} + # run: sed -i -e 's|//\(.*strictOptionalProperties.*\)$|\1|' tsconfig.base.json + + - name: Test types + run: | + yarn tsc --version + yarn type-tests diff --git a/.gitignore b/.gitignore index 9f3046c66..531827adb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ dist lib coverage es +temp/ +react-redux-*/ .cache .yarnrc diff --git a/etc/react-redux.api.md b/etc/react-redux.api.md index a5eedaa0e..978c8fe91 100644 --- a/etc/react-redux.api.md +++ b/etc/react-redux.api.md @@ -4,7 +4,6 @@ ```ts -/// /// import { Action } from 'redux'; @@ -15,18 +14,13 @@ import { ComponentClass } from 'react'; import { ComponentType } from 'react'; import { Context } from 'react'; import { Dispatch } from 'redux'; -import { ForwardRefExoticComponent } from 'react'; -import hoistStatics from 'hoist-non-react-statics'; -import { MemoExoticComponent } from 'react'; -import { NamedExoticComponent } from 'react'; -import { NonReactStatics } from 'hoist-non-react-statics'; +import type { NonReactStatics } from 'hoist-non-react-statics'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; -import { RefAttributes } from 'react'; import { Store } from 'redux'; // @public (undocumented) -export type AdvancedComponentDecorator = (component: ComponentType) => NamedExoticComponent; +export type AdvancedComponentDecorator = (component: ComponentType) => ComponentType; // @public (undocumented) export type AnyIfEmpty = keyof T extends never ? any : T; @@ -34,30 +28,26 @@ export type AnyIfEmpty = keyof T extends never ? any : T; export { batch } // @public (undocumented) -export const connect: (mapStateToProps: MapStateToPropsParam, mapDispatchToProps: unknown, mergeProps: MergeProps, { pure, areStatesEqual, areOwnPropsEqual, areStatePropsEqual, areMergedPropsEqual, ...extraOptions }?: ConnectOptions) => >(WrappedComponent: WC) => (ForwardRefExoticComponent> & { - WrappedComponent: WC; -} & NonReactStatics) | ((({ - (props: ConnectProps & TOwnProps): JSX.Element; - displayName: string; -} | MemoExoticComponent< { -(props: ConnectProps & TOwnProps): JSX.Element; -displayName: string; -}>) & { - WrappedComponent: WC; -}) & NonReactStatics); - -// @public (undocumented) -export function connectAdvanced(selectorFactory: SelectorFactory, { getDisplayName, methodName, shouldHandleStateChanges, forwardRef, context, ...connectOptions }?: ConnectAdvancedOptions & Partial): >(WrappedComponent: WC) => (React_2.ForwardRefExoticComponent> & { - WrappedComponent: WC; -} & hoistStatics.NonReactStatics) | ((({ - (props: ConnectProps & TOwnProps_1): JSX.Element; - displayName: string; -} | React_2.MemoExoticComponent<{ - (props: ConnectProps & TOwnProps_1): JSX.Element; - displayName: string; -}>) & { - WrappedComponent: WC; -}) & hoistStatics.NonReactStatics); +export const connect: { + (): InferableComponentEnhancer; + (mapStateToProps: MapStateToPropsParam): InferableComponentEnhancerWithProps, TOwnProps>; + (mapStateToProps: null | undefined, mapDispatchToProps: MapDispatchToPropsNonObject): InferableComponentEnhancerWithProps; + (mapStateToProps: null | undefined, mapDispatchToProps: MapDispatchToPropsParam): InferableComponentEnhancerWithProps, TOwnProps_2>; + (mapStateToProps: MapStateToPropsParam, mapDispatchToProps: MapDispatchToPropsNonObject): InferableComponentEnhancerWithProps; + (mapStateToProps: MapStateToPropsParam, mapDispatchToProps: MapDispatchToPropsParam): InferableComponentEnhancerWithProps, TOwnProps_4>; + (mapStateToProps: null | undefined, mapDispatchToProps: null | undefined, mergeProps: MergeProps): InferableComponentEnhancerWithProps; + (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 & TStateProps_4, TOwnProps_8>; + (mapStateToProps: null | undefined, mapDispatchToProps: MapDispatchToPropsNonObject, mergeProps: null | undefined, options: ConnectOptions<{}, TStateProps_5, TOwnProps_9, {}>): InferableComponentEnhancerWithProps; + (mapStateToProps: null | undefined, mapDispatchToProps: MapDispatchToPropsParam, mergeProps: null | undefined, options: ConnectOptions<{}, TStateProps_6, TOwnProps_10, {}>): InferableComponentEnhancerWithProps, TOwnProps_10>; + (mapStateToProps: MapStateToPropsParam, mapDispatchToProps: MapDispatchToPropsNonObject, mergeProps: null | undefined, options: ConnectOptions): InferableComponentEnhancerWithProps; + (mapStateToProps: MapStateToPropsParam, mapDispatchToProps: MapDispatchToPropsParam, mergeProps: null | undefined, options: ConnectOptions): InferableComponentEnhancerWithProps, TOwnProps_12>; + (mapStateToProps: MapStateToPropsParam, mapDispatchToProps: MapDispatchToPropsParam, mergeProps: MergeProps, options?: ConnectOptions | undefined): InferableComponentEnhancerWithProps; +}; + +// @public (undocumented) +export function connectAdvanced(selectorFactory: SelectorFactory, { getDisplayName, methodName, shouldHandleStateChanges, forwardRef, context, ...connectOptions }?: ConnectAdvancedOptions & Partial): AdvancedComponentDecorator; // @public (undocumented) export interface ConnectAdvancedOptions { @@ -76,10 +66,13 @@ export interface ConnectAdvancedOptions { } // @public (undocumented) -export type ConnectedComponent, P> = NamedExoticComponent> & NonReactStatics & { +export type ConnectedComponent, P> = ComponentType

& NonReactStatics & { WrappedComponent: C; }; +// @public +export type ConnectedProps = TConnector extends InferableComponentEnhancerWithProps ? unknown extends TInjectedProps ? TConnector extends InferableComponentEnhancer ? TInjectedProps : never : TInjectedProps : never; + // @public (undocumented) export interface ConnectProps { // (undocumented) diff --git a/package.json b/package.json index 19ee78f8f..ae298c88c 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,13 @@ "build:types": "tsc", "build": "yarn build:types && yarn build:commonjs && yarn build:es && yarn build:umd && yarn build:umd:min", "clean": "rimraf lib dist es coverage", - "api-types": "api-extractor --local", + "api-types": "api-extractor run --local", "format": "prettier --write \"{src,test}/**/*.{js,ts}\" \"docs/**/*.md\"", "lint": "eslint src --ext ts,js test/utils test/components test/hooks", "prepare": "yarn clean && yarn build", "pretest": "yarn lint", "test": "jest", + "type-tests": "yarn tsc -p test/typetests", "coverage": "codecov" }, "workspaces": [ @@ -54,7 +55,6 @@ }, "dependencies": { "@babel/runtime": "^7.12.1", - "@types/react-redux": "^7.1.16", "hoist-non-react-statics": "^3.3.2", "loose-envify": "^1.4.0", "prop-types": "^15.7.2", @@ -80,6 +80,11 @@ "@testing-library/react": "^12.0.0", "@testing-library/react-hooks": "^3.4.2", "@testing-library/react-native": "^7.1.0", + "@types/object-assign": "^4.0.30", + "@types/react": "^17.0.14", + "@types/react-dom": "^17.0.9", + "@types/react-is": "^17.0.1", + "@types/react-redux": "^7.1.18", "@typescript-eslint/eslint-plugin": "^4.28.0", "@typescript-eslint/parser": "^4.28.0", "babel-eslint": "^10.1.0", diff --git a/src/components/connectAdvanced.tsx b/src/components/connectAdvanced.tsx index 56bcd65e2..77e433814 100644 --- a/src/components/connectAdvanced.tsx +++ b/src/components/connectAdvanced.tsx @@ -1,16 +1,11 @@ import hoistStatics from 'hoist-non-react-statics' -import React, { - useContext, - useMemo, - useRef, - useReducer, - useLayoutEffect, -} from 'react' +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, @@ -31,7 +26,7 @@ const stringifyComponent = (Comp: unknown) => { } function storeStateUpdatesReducer( - state: [payload: unknown, counter: number], + state: [unknown, number], action: { payload: unknown } ) { const [, updateCount] = state @@ -108,7 +103,7 @@ function subscribeUpdates( ) } catch (e) { error = e - lastThrownError = e + lastThrownError = e as Error | null } if (!error) { @@ -182,16 +177,7 @@ export interface ConnectAdvancedOptions { pure?: boolean } -interface AnyObject { - [x: string]: any -} - -export default function connectAdvanced< - S, - TProps, - TOwnProps, - TFactoryOptions extends AnyObject = {} ->( +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: @@ -235,9 +221,19 @@ export default function connectAdvanced< ) { const Context = context - return function wrapWithConnect( - WrappedComponent: WC - ) { + 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) @@ -483,7 +479,14 @@ export default function connectAdvanced< // 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 - const Connect = _Connect as typeof _Connect & { WrappedComponent: WC } + type ConnectedWrapperComponent = typeof _Connect & { + WrappedComponent: typeof WrappedComponent + } + + const Connect = _Connect as ConnectedComponent< + typeof WrappedComponent, + WrappedComponentProps + > Connect.WrappedComponent = WrappedComponent Connect.displayName = ConnectFunction.displayName = displayName @@ -492,12 +495,11 @@ export default function connectAdvanced< props, ref ) { + // @ts-ignore return }) - const forwarded = _forwarded as typeof _forwarded & { - WrappedComponent: WC - } + const forwarded = _forwarded as ConnectedWrapperComponent forwarded.displayName = displayName forwarded.WrappedComponent = WrappedComponent return hoistStatics(forwarded, WrappedComponent) @@ -505,4 +507,8 @@ export default function connectAdvanced< return hoistStatics(Connect, WrappedComponent) } + + return wrapWithConnect } + +export default connectAdvanced diff --git a/src/connect/connect.ts b/src/connect/connect.ts index d83513b2f..ae95fe28b 100644 --- a/src/connect/connect.ts +++ b/src/connect/connect.ts @@ -1,4 +1,5 @@ -import type { Dispatch } from 'redux' +/* 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' @@ -9,8 +10,16 @@ import defaultSelectorFactory, { MapStateToPropsParam, MapDispatchToPropsParam, MergeProps, + MapDispatchToPropsNonObject, + SelectorFactory, } from './selectorFactory' -import type { DefaultRootState } from '../types' +import type { + DefaultRootState, + InferableComponentEnhancer, + InferableComponentEnhancerWithProps, + ResolveThunks, + DispatchProp, +} from '../types' /* connect is a facade over connectAdvanced. It turns its args into a compatible @@ -52,6 +61,21 @@ 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 = {}, @@ -77,6 +101,133 @@ export interface ConnectOptions< 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({ @@ -86,10 +237,207 @@ export function createConnect({ mergePropsFactories = defaultMergePropsFactories, selectorFactory = defaultSelectorFactory, } = {}) { - return function connect( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: MapDispatchToPropsParam, - mergeProps: MergeProps, + /* @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, @@ -97,8 +445,8 @@ export function createConnect({ areStatePropsEqual = shallowEqual, areMergedPropsEqual = shallowEqual, ...extraOptions - }: ConnectOptions = {} - ) { + }: ConnectOptions = {} + ): unknown { const initMapStateToProps = match( mapStateToProps, // @ts-ignore @@ -118,8 +466,7 @@ export function createConnect({ 'mergeProps' ) - // @ts-ignore - return connectHOC(selectorFactory, { + return connectHOC(selectorFactory as SelectorFactory, { // used in error messages methodName: 'connect', @@ -143,6 +490,11 @@ export function createConnect({ ...extraOptions, }) } + + return connect } -export default /*#__PURE__*/ createConnect() +/* @public */ +const connect = /*#__PURE__*/ createConnect() + +export default connect diff --git a/src/exports.ts b/src/exports.ts index c29bc3bff..c8ef76a64 100644 --- a/src/exports.ts +++ b/src/exports.ts @@ -20,7 +20,7 @@ import type { } from './connect/selectorFactory' import { ReactReduxContext } from './components/Context' import type { ReactReduxContextValue } from './components/Context' -import connect from './connect/connect' +import connect, { ConnectedProps } from './connect/connect' import { useDispatch, createDispatchHook } from './hooks/useDispatch' import { useSelector, createSelectorHook } from './hooks/useSelector' @@ -37,6 +37,7 @@ export type { MapStateToPropsFactory, MapStateToPropsParam, ConnectProps, + ConnectedProps, ConnectAdvancedOptions, MapDispatchToPropsFunction, MapDispatchToProps, diff --git a/src/hooks/useSelector.ts b/src/hooks/useSelector.ts index c6be70ba4..ee072d9f1 100644 --- a/src/hooks/useSelector.ts +++ b/src/hooks/useSelector.ts @@ -52,7 +52,9 @@ function useSelectorWithStoreAndSubscription( } } catch (err) { if (latestSubscriptionCallbackError.current) { - err.message += `\nThe error may be correlated with this previous error:\n${latestSubscriptionCallbackError.current.stack}\n\n` + ;( + err as Error + ).message += `\nThe error may be correlated with this previous error:\n${latestSubscriptionCallbackError.current.stack}\n\n` } throw err @@ -82,7 +84,7 @@ function useSelectorWithStoreAndSubscription( // is re-rendered, the selectors are called again, and // will throw again, if neither props nor store state // changed - latestSubscriptionCallbackError.current = err + latestSubscriptionCallbackError.current = err as Error } forceRender() diff --git a/src/types.ts b/src/types.ts index dc2a91f48..affe26c22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,7 +45,7 @@ export interface DispatchProp { export type AdvancedComponentDecorator = ( component: ComponentType -) => NamedExoticComponent +) => ComponentType /** * A property P will be present if: @@ -98,7 +98,7 @@ export type GetProps = C extends ComponentType export type ConnectedComponent< C extends ComponentType, P -> = NamedExoticComponent> & +> = ComponentType

& NonReactStatics & { WrappedComponent: C } diff --git a/test/tsconfig.test.json b/test/tsconfig.test.json new file mode 100644 index 000000000..772b7a17f --- /dev/null +++ b/test/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "emitDeclarationOnly": false, + "strict": true, + "noEmit": true, + "target": "es2018", + "jsx": "react", + "baseUrl": ".", + "skipLibCheck": true, + "noImplicitReturns": false + } +} diff --git a/test/typeTestHelpers.ts b/test/typeTestHelpers.ts new file mode 100644 index 000000000..08da24571 --- /dev/null +++ b/test/typeTestHelpers.ts @@ -0,0 +1,66 @@ +/** + * return True if T is `any`, otherwise return False + * taken from https://github.com/joonhocho/tsdef + * + * @internal + */ +export type IsAny = + // test if we are going the left AND right path in the condition + true | false extends (T extends never ? true : false) ? True : False + +/** + * return True if T is `unknown`, otherwise return False + * taken from https://github.com/joonhocho/tsdef + * + * @internal + */ +export type IsUnknown = unknown extends T + ? IsAny + : False + +export function expectType(t: T): T { + return t +} + +type Equals = IsAny< + T, + never, + IsAny +> +export function expectExactType(t: T) { + return >(u: U) => {} +} + +type EnsureUnknown = IsUnknown +export function expectUnknown>(t: T) { + return t +} + +type EnsureAny = IsAny +export function expectExactAny>(t: T) { + return t +} + +type IsNotAny = IsAny +export function expectNotAny>(t: T): T { + return t +} + +expectType('5' as string) +expectType('5' as const) +expectType('5' as any) +expectExactType('5' as const)('5' as const) +// @ts-expect-error +expectExactType('5' as string)('5' as const) +// @ts-expect-error +expectExactType('5' as any)('5' as const) +expectUnknown('5' as unknown) +// @ts-expect-error +expectUnknown('5' as const) +// @ts-expect-error +expectUnknown('5' as any) +expectExactAny('5' as any) +// @ts-expect-error +expectExactAny('5' as const) +// @ts-expect-error +expectExactAny('5' as unknown) diff --git a/test/typetests/react-redux-types.typetest.tsx b/test/typetests/react-redux-types.typetest.tsx new file mode 100644 index 000000000..26142b0bf --- /dev/null +++ b/test/typetests/react-redux-types.typetest.tsx @@ -0,0 +1,404 @@ +/* eslint-disable @typescript-eslint/no-unused-vars, no-inner-declarations */ +import { Component, ReactElement } from 'react' +import * as React from 'react' +import * as ReactDOM from 'react-dom' +import { Store, Dispatch, bindActionCreators, AnyAction } from 'redux' +import { connect, Provider, ConnectedProps } from '../../src/index' +import { expectType } from '../typeTestHelpers' + +import objectAssign from 'object-assign' + +// +// Quick Start +// https://github.com/rackt/react-redux/blob/master/docs/quick-start.md#quick-start +// + +interface CounterState { + counter: number +} +declare var increment: Function + +class Counter extends Component { + render() { + return + } +} + +function mapStateToProps(state: CounterState) { + return { + value: state.counter, + } +} + +// Which action creators does it want to receive by props? +function mapDispatchToProps(dispatch: Dispatch) { + return { + onIncrement: () => dispatch(increment()), + } +} + +connect(mapStateToProps, mapDispatchToProps)(Counter) + +class CounterContainer extends Component {} +const ConnectedCounterContainer = connect(mapStateToProps)(CounterContainer) + +// Ensure connect's first two arguments can be replaced by wrapper functions +interface ICounterStateProps { + value: number +} +interface ICounterDispatchProps { + onIncrement: () => void +} +connect( + () => mapStateToProps, + () => mapDispatchToProps +)(Counter) +// only first argument +connect(() => mapStateToProps)( + Counter +) +// wrap only one argument +connect( + mapStateToProps, + () => mapDispatchToProps +)(Counter) +// with extra arguments +connect( + () => mapStateToProps, + () => mapDispatchToProps, + (s: ICounterStateProps, d: ICounterDispatchProps) => objectAssign({}, s, d), + { pure: true } +)(Counter) + +class App extends Component { + render(): React.ReactNode { + // ... + return null + } +} + +const targetEl = document.getElementById('root') + +ReactDOM.render({() => }, targetEl) + +declare var store: Store +class MyRootComponent extends Component {} +class TodoApp extends Component {} +interface TodoState { + todos: string[] | string +} +interface TodoProps { + userId: number +} +interface DispatchProps { + addTodo(userId: number, text: string): void + // action: Function +} + +const addTodo = (userId: number, text: string) => ({ + type: 'todos/todoAdded', + payload: { userId, text }, +}) +const actionCreators = { addTodo } +type AddTodoAction = ReturnType +declare var todoActionCreators: { [type: string]: (...args: any[]) => any } +declare var counterActionCreators: { [type: string]: (...args: any[]) => any } + +ReactDOM.render( + {() => }, + document.body +) + +// Inject just dispatch and don't listen to store + +connect()(TodoApp) + +// Inject dispatch and every field in the global state + +connect((state: TodoState) => state)(TodoApp) + +// Inject dispatch and todos + +function mapStateToProps2(state: TodoState) { + return { todos: state.todos } +} + +export default connect(mapStateToProps2)(TodoApp) + +// Inject todos and all action creators (addTodo, completeTodo, ...) + +//function mapStateToProps(state) { +// return { todos: state.todos }; +//} + +connect(mapStateToProps2, actionCreators)(TodoApp) + +// Inject todos and all action creators (addTodo, completeTodo, ...) as actions + +//function mapStateToProps(state) { +// return { todos: state.todos }; +//} + +function mapDispatchToProps2(dispatch: Dispatch) { + return { actions: bindActionCreators(actionCreators, dispatch) } +} + +connect(mapStateToProps2, mapDispatchToProps2)(TodoApp) + +// Inject todos and a specific action creator (addTodo) + +//function mapStateToProps(state) { +// return { todos: state.todos }; +//} + +function mapDispatchToProps3(dispatch: Dispatch) { + return bindActionCreators({ addTodo }, dispatch) +} + +connect(mapStateToProps2, mapDispatchToProps3)(TodoApp) + +// Inject todos, todoActionCreators as todoActions, and counterActionCreators as counterActions + +//function mapStateToProps(state) { +// return { todos: state.todos }; +//} + +function mapDispatchToProps4(dispatch: Dispatch) { + return { + todoActions: bindActionCreators(todoActionCreators, dispatch), + counterActions: bindActionCreators(counterActionCreators, dispatch), + } +} + +connect(mapStateToProps2, mapDispatchToProps4)(TodoApp) + +// Inject todos, and todoActionCreators and counterActionCreators together as actions + +//function mapStateToProps(state) { +// return { todos: state.todos }; +//} + +function mapDispatchToProps5(dispatch: Dispatch) { + return { + actions: bindActionCreators( + objectAssign({}, todoActionCreators, counterActionCreators), + dispatch + ), + } +} + +connect(mapStateToProps2, mapDispatchToProps5)(TodoApp) + +// Inject todos, and all todoActionCreators and counterActionCreators directly as props + +//function mapStateToProps(state) { +// return { todos: state.todos }; +//} + +function mapDispatchToProps6(dispatch: Dispatch) { + return bindActionCreators( + objectAssign({}, todoActionCreators, counterActionCreators), + dispatch + ) +} + +connect(mapStateToProps2, mapDispatchToProps6)(TodoApp) + +// Inject todos of a specific user depending on props + +function mapStateToProps3(state: TodoState, ownProps: TodoProps): TodoState { + return { todos: state.todos[ownProps.userId] } +} + +connect(mapStateToProps3)(TodoApp) + +// Inject todos of a specific user depending on props, and inject props.userId into the action + +//function mapStateToProps(state) { +// return { todos: state.todos }; +//} + +function mergeProps( + stateProps: TodoState, + dispatchProps: DispatchProps, + ownProps: TodoProps +): { addTodo: (userId: string) => void } & TodoState { + return objectAssign({}, ownProps, { + todos: stateProps.todos[ownProps.userId], + addTodo: (text: string) => dispatchProps.addTodo(ownProps.userId, text), + }) +} + +connect(mapStateToProps2, actionCreators, mergeProps)(TodoApp) + +interface TestProp { + property1: number + someOtherProperty?: string +} +interface TestState { + isLoaded: boolean + state1: number +} +class TestComponent extends Component {} +const WrappedTestComponent = connect()(TestComponent) + +// return value of the connect()(TestComponent) is of the type TestComponent +let ATestComponent: React.ComponentType +ATestComponent = TestComponent +ATestComponent = WrappedTestComponent + +let anElement: ReactElement +; +; +; + +// @ts-expect-error +; + +class NonComponent {} +// this doesn't compile +// @ts-expect-error +connect()(NonComponent) + +// stateless functions +interface HelloMessageProps { + name: string +} +function HelloMessage(props: HelloMessageProps) { + return

+} +let ConnectedHelloMessage = connect()(HelloMessage) +ReactDOM.render( + , + document.getElementById('content') +) +ReactDOM.render( + , + document.getElementById('content') +) + +// stateless functions that uses mapStateToProps and mapDispatchToProps +namespace TestStatelessFunctionWithMapArguments { + interface GreetingProps { + name: string + onClick: () => void + } + + function Greeting(props: GreetingProps) { + return
Hello {props.name}
+ } + + const mapStateToProps = (state: any, ownProps: GreetingProps) => { + return { + name: 'Connected! ' + ownProps.name, + } + } + + const mapDispatchToProps = ( + dispatch: Dispatch, + ownProps: GreetingProps + ) => { + return { + onClick: () => { + dispatch({ type: 'GREETING', name: ownProps.name }) + }, + } + } + + const ConnectedGreeting = connect( + mapStateToProps, + mapDispatchToProps + )(Greeting) +} + +// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/8787 +namespace TestTOwnPropsInference { + interface OwnProps { + own: string + } + + interface StateProps { + state: string + } + + class OwnPropsComponent extends React.Component { + render() { + return
+ } + } + + function mapStateToPropsWithoutOwnProps(state: any): StateProps { + return { state: 'string' } + } + + function mapStateToPropsWithOwnProps( + state: any, + ownProps: OwnProps + ): StateProps { + return { state: 'string' } + } + + const ConnectedWithoutOwnProps = connect(mapStateToPropsWithoutOwnProps)( + OwnPropsComponent + ) + const ConnectedWithOwnProps = connect(mapStateToPropsWithOwnProps)( + OwnPropsComponent + ) + const ConnectedWithTypeHint = connect( + mapStateToPropsWithoutOwnProps + )(OwnPropsComponent) + + // @ts-expect-error + React.createElement(ConnectedWithoutOwnProps, { anything: 'goes!' }) + + // This compiles, as expected. + React.createElement(ConnectedWithOwnProps, { own: 'string' }) + + // This should not compile, which is good. + // @ts-expect-error + React.createElement(ConnectedWithOwnProps, { missingOwn: true }) + + // This compiles, as expected. + React.createElement(ConnectedWithTypeHint, { own: 'string' }) + + // This should not compile, which is good. + // @ts-expect-error + React.createElement(ConnectedWithTypeHint, { missingOwn: true }) +} + +namespace ConnectedPropsTest { + interface RootState { + isOn: boolean + } + + const mapState1 = (state: RootState) => ({ + isOn: state.isOn, + }) + + const mapDispatch1 = { + toggleOn: () => ({ type: 'TOGGLE_IS_ON' }), + } + + const connector1 = connect(mapState1, mapDispatch1) + + // The inferred type will look like: + // {isOn: boolean, toggleOn: () => void} + type PropsFromRedux1 = ConnectedProps + + expectType<{ isOn: boolean; toggleOn: () => void }>({} as PropsFromRedux1) + + const exampleThunk = (id: number) => async (dispatch: Dispatch) => { + return 'test' + } + + const mapDispatch2 = { exampleThunk } + + // Connect should "resolve thunks", so that instead of typing the return value of the + // prop as the thunk function, it dives down and uses the return value of the thunk function itself + const connector2 = connect(null, mapDispatch2) + type PropsFromRedux2 = ConnectedProps + + expectType<{ exampleThunk: (id: number) => Promise }>( + {} as PropsFromRedux2 + ) +} diff --git a/test/typetests/tsconfig.json b/test/typetests/tsconfig.json new file mode 100644 index 000000000..38ca0b13b --- /dev/null +++ b/test/typetests/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.test.json" +} diff --git a/yarn.lock b/yarn.lock index 9094f2a79..f832e723b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3577,6 +3577,13 @@ __metadata: languageName: node linkType: hard +"@types/object-assign@npm:^4.0.30": + version: 4.0.30 + resolution: "@types/object-assign@npm:4.0.30" + checksum: dd9b5d5e183707bf4c1d911a98aa2d004b08ce901b8b52ac5231267997f5ebb933fad03528e8a39f1090d5cb572f6b0f0dd797912ee9815b8bb12312c85a43fc + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.0 resolution: "@types/parse-json@npm:4.0.0" @@ -3612,15 +3619,33 @@ __metadata: languageName: node linkType: hard -"@types/react-redux@npm:^7.1.16": - version: 7.1.16 - resolution: "@types/react-redux@npm:7.1.16" +"@types/react-dom@npm:^17.0.9": + version: 17.0.9 + resolution: "@types/react-dom@npm:17.0.9" + dependencies: + "@types/react": "*" + checksum: 82da85bcfba524fb83946df50e433b8cc942dd30f5f3f6e0756816960ce4b85db6faa2da9f2b83124087fbe0d124580edf7b1017628b78fb28fee057e91512cb + languageName: node + linkType: hard + +"@types/react-is@npm:^17.0.1": + version: 17.0.1 + resolution: "@types/react-is@npm:17.0.1" + dependencies: + "@types/react": "*" + checksum: d0ea951ddcde54bcaf6ea227595b7345085c316f2791277a4acfdbbe63cc0de098264269c5463050d7e1308042303556980f9dda32eb2f619eace2efd4e9bdd4 + languageName: node + linkType: hard + +"@types/react-redux@npm:^7.1.18": + version: 7.1.18 + resolution: "@types/react-redux@npm:7.1.18" dependencies: "@types/hoist-non-react-statics": ^3.3.0 "@types/react": "*" hoist-non-react-statics: ^3.3.0 redux: ^4.0.0 - checksum: c19c8f94dbadae42e9622c0b1b38324bbc6f5997fdc3989e71b202bf750d5869f73bb349ca4102f624188c2212c7907d3c3594449f099818abc87d1478d20996 + checksum: b247ff7ce31cede226f4606571bf975aeec91fe911e65e72cecaaac7234d4d694a7be0791419bb4259c7012b662a96267f694daaacbc18f3157fc7f955af55c9 languageName: node linkType: hard @@ -3644,6 +3669,17 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:^17.0.14": + version: 17.0.14 + resolution: "@types/react@npm:17.0.14" + dependencies: + "@types/prop-types": "*" + "@types/scheduler": "*" + csstype: ^3.0.2 + checksum: 3c5373845b0869d9ce3db16917c87707cdfdb6c08d9ca7ed90a2a097cb3cbec8d037d37e81c3cf5af721d92f8fdc62431405f1874f9a02dea6ec05e5d81e77fe + languageName: node + linkType: hard + "@types/resolve@npm:1.17.1": version: 1.17.1 resolution: "@types/resolve@npm:1.17.1" @@ -14451,7 +14487,11 @@ __metadata: "@testing-library/react": ^12.0.0 "@testing-library/react-hooks": ^3.4.2 "@testing-library/react-native": ^7.1.0 - "@types/react-redux": ^7.1.16 + "@types/object-assign": ^4.0.30 + "@types/react": ^17.0.14 + "@types/react-dom": ^17.0.9 + "@types/react-is": ^17.0.1 + "@types/react-redux": ^7.1.18 "@typescript-eslint/eslint-plugin": ^4.28.0 "@typescript-eslint/parser": ^4.28.0 babel-eslint: ^10.1.0