From 5e9a9510b4b8f101c91d05119196e4b282f10acd Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 9 Jul 2021 15:09:55 -0400 Subject: [PATCH 01/13] Fix ESLint "unused var" errors for types --- .eslintrc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.eslintrc b/.eslintrc index 09dbe7f1e..ef431c2b8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -36,7 +36,10 @@ "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, + // note you must disable the base rule as it can report incorrect errors + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error"] }, "plugins": ["@typescript-eslint", "import", "react"], "globals": { From 7ec1ab3d32bc45b995f78004531de4d95e0c8d56 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 9 Jul 2021 15:10:06 -0400 Subject: [PATCH 02/13] Add file of type test helpers --- test/typeTestHelpers.ts | 66 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 test/typeTestHelpers.ts 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) From e6148f085039755d673b4c706d41771654f0ae48 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 9 Jul 2021 15:28:43 -0400 Subject: [PATCH 03/13] Add devDeps for TS types --- package.json | 4 +++- yarn.lock | 47 +++++++++++++++++++++++++++-------------------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 19ee78f8f..c2783b273 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,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 +79,9 @@ "@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", "@typescript-eslint/eslint-plugin": "^4.28.0", "@typescript-eslint/parser": "^4.28.0", "babel-eslint": "^10.1.0", diff --git a/yarn.lock b/yarn.lock index 9094f2a79..79dcb1fb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3457,16 +3457,6 @@ __metadata: languageName: node linkType: hard -"@types/hoist-non-react-statics@npm:^3.3.0": - version: 3.3.1 - resolution: "@types/hoist-non-react-statics@npm:3.3.1" - dependencies: - "@types/react": "*" - hoist-non-react-statics: ^3.3.0 - checksum: 16ab4c45d4920fa378c8be76554b10061247fc04d2c8af11bdb7d520b3967e9c06d7ad5efd9b0f1657fbc4d095f62c6e1325f03b9141eb1ef2c8095b96fd42f8 - languageName: node - linkType: hard - "@types/html-minifier-terser@npm:^5.0.0": version: 5.1.1 resolution: "@types/html-minifier-terser@npm:5.1.1" @@ -3577,6 +3567,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 +3609,12 @@ __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/hoist-non-react-statics": ^3.3.0 "@types/react": "*" - hoist-non-react-statics: ^3.3.0 - redux: ^4.0.0 - checksum: c19c8f94dbadae42e9622c0b1b38324bbc6f5997fdc3989e71b202bf750d5869f73bb349ca4102f624188c2212c7907d3c3594449f099818abc87d1478d20996 + checksum: 82da85bcfba524fb83946df50e433b8cc942dd30f5f3f6e0756816960ce4b85db6faa2da9f2b83124087fbe0d124580edf7b1017628b78fb28fee057e91512cb languageName: node linkType: hard @@ -3644,6 +3638,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" @@ -8978,7 +8983,7 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.1.0, hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2": +"hoist-non-react-statics@npm:^3.1.0, hoist-non-react-statics@npm:^3.3.2": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" dependencies: @@ -14451,7 +14456,9 @@ __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 "@typescript-eslint/eslint-plugin": ^4.28.0 "@typescript-eslint/parser": ^4.28.0 babel-eslint: ^10.1.0 @@ -14751,7 +14758,7 @@ __metadata: languageName: node linkType: hard -"redux@npm:^4.0.0, redux@npm:^4.0.5": +"redux@npm:^4.0.5": version: 4.1.0 resolution: "redux@npm:4.1.0" dependencies: From 5a184f6fa6b964c55168a91c57bb71199c13261a Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 9 Jul 2021 15:30:05 -0400 Subject: [PATCH 04/13] Add config for running typetests --- package.json | 1 + test/tsconfig.test.json | 17 +++++++++++++++++ test/typetests/tsconfig.json | 3 +++ 3 files changed, 21 insertions(+) create mode 100644 test/tsconfig.test.json create mode 100644 test/typetests/tsconfig.json diff --git a/package.json b/package.json index c2783b273..6e253833e 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "prepare": "yarn clean && yarn build", "pretest": "yarn lint", "test": "jest", + "type-tests": "yarn tsc -p test/typetests", "coverage": "codecov" }, "workspaces": [ diff --git a/test/tsconfig.test.json b/test/tsconfig.test.json new file mode 100644 index 000000000..3325d64dd --- /dev/null +++ b/test/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.base.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/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" +} From d4490427bee5b184bd3603df60d26753155dff1b Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 9 Jul 2021 15:30:38 -0400 Subject: [PATCH 05/13] Add initial failing typetest file copied from DT --- test/typetests/react-redux-types.typetest.tsx | 354 ++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 test/typetests/react-redux-types.typetest.tsx diff --git a/test/typetests/react-redux-types.typetest.tsx b/test/typetests/react-redux-types.typetest.tsx new file mode 100644 index 000000000..12d3662a6 --- /dev/null +++ b/test/typetests/react-redux-types.typetest.tsx @@ -0,0 +1,354 @@ +import { Component, ReactElement } from 'react' +import * as React from 'react' +import * as ReactDOM from 'react-dom' +import { Store, Dispatch, bindActionCreators } from 'redux' +import { connect, Provider } from '../../src/index' + +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) + +@connect(mapStateToProps) +class CounterContainer extends Component {} + +// 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(): JSX.Element { + // ... + 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 +} +declare var actionCreators: () => { + action: Function +} +declare var addTodo: () => { type: string } +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 +): DispatchProps & 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: typeof TestComponent = null +ATestComponent = TestComponent +ATestComponent = WrappedTestComponent + +let anElement: ReactElement +; +; +; + +class NonComponent {} +// this doesn't compile +//connect()(NonComponent); + +// stateless functions +interface HelloMessageProps { + name: string +} +function HelloMessage(props: HelloMessageProps) { + return
Hello {props.name}
+} +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) + + // This compiles, which is bad. + React.createElement(ConnectedWithoutOwnProps, { anything: 'goes!' }) + + // This compiles, as expected. + React.createElement(ConnectedWithOwnProps, { own: 'string' }) + + // This should not compile, which is good. + // React.createElement(ConnectedWithOwnProps, { missingOwn: true }); + + // This compiles, as expected. + React.createElement(ConnectedWithTypeHint, { own: 'string' }) + + // This should not compile, which is good. + // React.createElement(ConnectedWithTypeHint, { missingOwn: true }); +} From 96597d9578929abdf9a178396876f1410314074f Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 9 Jul 2021 16:37:25 -0400 Subject: [PATCH 06/13] Re-add existing React-Redux types for reference --- package.json | 1 + yarn.lock | 27 +++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6e253833e..46b304014 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@types/object-assign": "^4.0.30", "@types/react": "^17.0.14", "@types/react-dom": "^17.0.9", + "@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/yarn.lock b/yarn.lock index 79dcb1fb0..705391916 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3457,6 +3457,16 @@ __metadata: languageName: node linkType: hard +"@types/hoist-non-react-statics@npm:^3.3.0": + version: 3.3.1 + resolution: "@types/hoist-non-react-statics@npm:3.3.1" + dependencies: + "@types/react": "*" + hoist-non-react-statics: ^3.3.0 + checksum: 16ab4c45d4920fa378c8be76554b10061247fc04d2c8af11bdb7d520b3967e9c06d7ad5efd9b0f1657fbc4d095f62c6e1325f03b9141eb1ef2c8095b96fd42f8 + languageName: node + linkType: hard + "@types/html-minifier-terser@npm:^5.0.0": version: 5.1.1 resolution: "@types/html-minifier-terser@npm:5.1.1" @@ -3618,6 +3628,18 @@ __metadata: 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: b247ff7ce31cede226f4606571bf975aeec91fe911e65e72cecaaac7234d4d694a7be0791419bb4259c7012b662a96267f694daaacbc18f3157fc7f955af55c9 + languageName: node + linkType: hard + "@types/react-test-renderer@npm:*": version: 17.0.1 resolution: "@types/react-test-renderer@npm:17.0.1" @@ -8983,7 +9005,7 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.1.0, hoist-non-react-statics@npm:^3.3.2": +"hoist-non-react-statics@npm:^3.1.0, hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" dependencies: @@ -14459,6 +14481,7 @@ __metadata: "@types/object-assign": ^4.0.30 "@types/react": ^17.0.14 "@types/react-dom": ^17.0.9 + "@types/react-redux": ^7.1.18 "@typescript-eslint/eslint-plugin": ^4.28.0 "@typescript-eslint/parser": ^4.28.0 babel-eslint: ^10.1.0 @@ -14758,7 +14781,7 @@ __metadata: languageName: node linkType: hard -"redux@npm:^4.0.5": +"redux@npm:^4.0.0, redux@npm:^4.0.5": version: 4.1.0 resolution: "redux@npm:4.1.0" dependencies: From 0ebe0ebd0475e9affe701b19d663e529d0ad4e9f Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 9 Jul 2021 16:38:58 -0400 Subject: [PATCH 07/13] Fix most of the failing type test cases - Updated `connect` to use the entire `Connect` overload config - Fixed declaration of `wrapWithConnect` - Fixed a bunch of weird problems in the type tests file with actions, dispatch, etc - used `ts-expect-error for actual intended failures --- src/components/connectAdvanced.tsx | 12 +- src/connect/connect.ts | 169 +++++++++++++++++- test/typetests/react-redux-types.typetest.tsx | 47 ++--- 3 files changed, 196 insertions(+), 32 deletions(-) diff --git a/src/components/connectAdvanced.tsx b/src/components/connectAdvanced.tsx index 56bcd65e2..cb5112bb4 100644 --- a/src/components/connectAdvanced.tsx +++ b/src/components/connectAdvanced.tsx @@ -7,10 +7,11 @@ import React, { useLayoutEffect, } from 'react' import { isValidElementType, isContextConsumer } from 'react-is' -import type { Store } from 'redux' +import type { Store, AnyAction } from 'redux' import type { SelectorFactory } from '../connect/selectorFactory' import { createSubscription, Subscription } from '../utils/Subscription' import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' +import type { DispatchProp, Matching, GetProps } from '../types' import { ReactReduxContext, @@ -235,9 +236,12 @@ export default function connectAdvanced< ) { const Context = context - return function wrapWithConnect( - WrappedComponent: WC - ) { + return function wrapWithConnect< + WC extends React.ComponentClass< + Matching, GetProps>, + any + > + >(WrappedComponent: WC) { if ( process.env.NODE_ENV !== 'production' && !isValidElementType(WrappedComponent) diff --git a/src/connect/connect.ts b/src/connect/connect.ts index d83513b2f..367596c77 100644 --- a/src/connect/connect.ts +++ b/src/connect/connect.ts @@ -1,4 +1,4 @@ -import type { Dispatch } from 'redux' +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 +9,15 @@ import defaultSelectorFactory, { MapStateToPropsParam, MapDispatchToPropsParam, MergeProps, + MapDispatchToPropsNonObject, } 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 @@ -77,6 +84,150 @@ export interface ConnectOptions< forwardRef?: boolean | undefined } +/** + * 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 + * @param mapDispatchToProps + * @param mergeProps + * @param options + */ +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,10 @@ export function createConnect({ mergePropsFactories = defaultMergePropsFactories, selectorFactory = defaultSelectorFactory, } = {}) { - return function connect( - mapStateToProps: MapStateToPropsParam, - mapDispatchToProps: MapDispatchToPropsParam, - mergeProps: MergeProps, + const connect: Connect = ( + mapStateToProps?: Parameters[0], + mapDispatchToProps?: Parameters[1], + mergeProps?: Parameters[2], { pure = true, areStatesEqual = strictEqual, @@ -97,8 +248,8 @@ export function createConnect({ areStatePropsEqual = shallowEqual, areMergedPropsEqual = shallowEqual, ...extraOptions - }: ConnectOptions = {} - ) { + }: ConnectOptions | undefined = {} + ) => { const initMapStateToProps = match( mapStateToProps, // @ts-ignore @@ -143,6 +294,8 @@ export function createConnect({ ...extraOptions, }) } + + return connect } export default /*#__PURE__*/ createConnect() diff --git a/test/typetests/react-redux-types.typetest.tsx b/test/typetests/react-redux-types.typetest.tsx index 12d3662a6..c6587433a 100644 --- a/test/typetests/react-redux-types.typetest.tsx +++ b/test/typetests/react-redux-types.typetest.tsx @@ -1,7 +1,8 @@ +/* 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 } from 'redux' +import { Store, Dispatch, bindActionCreators, AnyAction } from 'redux' import { connect, Provider } from '../../src/index' import objectAssign from 'object-assign' @@ -29,7 +30,7 @@ function mapStateToProps(state: CounterState) { } // Which action creators does it want to receive by props? -function mapDispatchToProps(dispatch: Dispatch) { +function mapDispatchToProps(dispatch: Dispatch) { return { onIncrement: () => dispatch(increment()), } @@ -67,7 +68,7 @@ connect( )(Counter) class App extends Component { - render(): JSX.Element { + render(): React.ReactNode { // ... return null } @@ -88,12 +89,15 @@ interface TodoProps { } interface DispatchProps { addTodo(userId: number, text: string): void - action: Function + // action: Function } -declare var actionCreators: () => { - action: Function -} -declare var addTodo: () => { type: string } + +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 } @@ -132,7 +136,7 @@ connect(mapStateToProps2, actionCreators)(TodoApp) // return { todos: state.todos }; //} -function mapDispatchToProps2(dispatch: Dispatch) { +function mapDispatchToProps2(dispatch: Dispatch) { return { actions: bindActionCreators(actionCreators, dispatch) } } @@ -144,7 +148,7 @@ connect(mapStateToProps2, mapDispatchToProps2)(TodoApp) // return { todos: state.todos }; //} -function mapDispatchToProps3(dispatch: Dispatch) { +function mapDispatchToProps3(dispatch: Dispatch) { return bindActionCreators({ addTodo }, dispatch) } @@ -156,7 +160,7 @@ connect(mapStateToProps2, mapDispatchToProps3)(TodoApp) // return { todos: state.todos }; //} -function mapDispatchToProps4(dispatch: Dispatch) { +function mapDispatchToProps4(dispatch: Dispatch) { return { todoActions: bindActionCreators(todoActionCreators, dispatch), counterActions: bindActionCreators(counterActionCreators, dispatch), @@ -171,7 +175,7 @@ connect(mapStateToProps2, mapDispatchToProps4)(TodoApp) // return { todos: state.todos }; //} -function mapDispatchToProps5(dispatch: Dispatch) { +function mapDispatchToProps5(dispatch: Dispatch) { return { actions: bindActionCreators( objectAssign({}, todoActionCreators, counterActionCreators), @@ -188,7 +192,7 @@ connect(mapStateToProps2, mapDispatchToProps5)(TodoApp) // return { todos: state.todos }; //} -function mapDispatchToProps6(dispatch: Dispatch) { +function mapDispatchToProps6(dispatch: Dispatch) { return bindActionCreators( objectAssign({}, todoActionCreators, counterActionCreators), dispatch @@ -215,7 +219,7 @@ function mergeProps( stateProps: TodoState, dispatchProps: DispatchProps, ownProps: TodoProps -): DispatchProps & TodoState { +): { addTodo: (userId: string) => void } & TodoState { return objectAssign({}, ownProps, { todos: stateProps.todos[ownProps.userId], addTodo: (text: string) => dispatchProps.addTodo(ownProps.userId, text), @@ -236,7 +240,7 @@ class TestComponent extends Component {} const WrappedTestComponent = connect()(TestComponent) // return value of the connect()(TestComponent) is of the type TestComponent -let ATestComponent: typeof TestComponent = null +let ATestComponent: typeof TestComponent ATestComponent = TestComponent ATestComponent = WrappedTestComponent @@ -247,7 +251,8 @@ let anElement: ReactElement class NonComponent {} // this doesn't compile -//connect()(NonComponent); +// @ts-expect-error +connect()(NonComponent) // stateless functions interface HelloMessageProps { @@ -284,7 +289,7 @@ namespace TestStatelessFunctionWithMapArguments { } const mapDispatchToProps = ( - dispatch: Dispatch, + dispatch: Dispatch, ownProps: GreetingProps ) => { return { @@ -337,18 +342,20 @@ namespace TestTOwnPropsInference { mapStateToPropsWithoutOwnProps )(OwnPropsComponent) - // This compiles, which is bad. + // @ts-expect-error React.createElement(ConnectedWithoutOwnProps, { anything: 'goes!' }) // This compiles, as expected. React.createElement(ConnectedWithOwnProps, { own: 'string' }) // This should not compile, which is good. - // React.createElement(ConnectedWithOwnProps, { missingOwn: true }); + // @ts-expect-error + React.createElement(ConnectedWithOwnProps, { missingOwn: true }) // This compiles, as expected. React.createElement(ConnectedWithTypeHint, { own: 'string' }) // This should not compile, which is good. - // React.createElement(ConnectedWithTypeHint, { missingOwn: true }); + // @ts-expect-error + React.createElement(ConnectedWithTypeHint, { missingOwn: true }) } From 23c5bd0163e93c2f8e16b9a36d585fe4f913283b Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 9 Jul 2021 19:09:29 -0400 Subject: [PATCH 08/13] Silence more annoying lint rules --- .eslintrc | 5 +++-- .gitignore | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.eslintrc b/.eslintrc index ef431c2b8..afacdf656 100644 --- a/.eslintrc +++ b/.eslintrc @@ -37,9 +37,10 @@ "react/jsx-no-undef": 2, "react/jsx-wrap-multilines": 2, "react/no-string-refs": 0, - // note you must disable the base rule as it can report incorrect errors "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error"] + "@typescript-eslint/no-unused-vars": ["error"], + "no-redeclare": "off", + "@typescript-eslint/no-redeclare": ["error"] }, "plugins": ["@typescript-eslint", "import", "react"], "globals": { 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 From dffd2001ba43f0ab933f2e9ebd4fb12e43bcae5e Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 9 Jul 2021 19:09:44 -0400 Subject: [PATCH 09/13] Add react-is and fix API Extractor command --- package.json | 3 ++- yarn.lock | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 46b304014..ae298c88c 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "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", @@ -83,6 +83,7 @@ "@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", diff --git a/yarn.lock b/yarn.lock index 705391916..f832e723b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3628,6 +3628,15 @@ __metadata: 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" @@ -14481,6 +14490,7 @@ __metadata: "@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 From 2debcb5594c30659a9ad59a47b6ca7b2ea5773b6 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 9 Jul 2021 19:10:18 -0400 Subject: [PATCH 10/13] Rework connect types to match current DT typedefs --- src/components/connectAdvanced.tsx | 50 ++++-- src/connect/connect.ts | 255 +++++++++++++++++++++++++---- src/exports.ts | 3 +- src/types.ts | 4 +- 4 files changed, 263 insertions(+), 49 deletions(-) diff --git a/src/components/connectAdvanced.tsx b/src/components/connectAdvanced.tsx index cb5112bb4..d9d4aa4d1 100644 --- a/src/components/connectAdvanced.tsx +++ b/src/components/connectAdvanced.tsx @@ -11,7 +11,13 @@ import type { Store, AnyAction } from 'redux' import type { SelectorFactory } from '../connect/selectorFactory' import { createSubscription, Subscription } from '../utils/Subscription' import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' -import type { DispatchProp, Matching, GetProps } from '../types' +import type { + DispatchProp, + Matching, + GetProps, + AdvancedComponentDecorator, + ConnectedComponent, +} from '../types' import { ReactReduxContext, @@ -183,16 +189,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: @@ -236,12 +233,19 @@ export default function connectAdvanced< ) { const Context = context + type WrappedComponentProps = TOwnProps & ConnectProps + + /* return function wrapWithConnect< - WC extends React.ComponentClass< - Matching, GetProps>, - any + WC extends React.ComponentType< + Matching, GetProps> > >(WrappedComponent: WC) { + */ + const wrapWithConnect: AdvancedComponentDecorator< + TProps, + WrappedComponentProps + > = (WrappedComponent) => { if ( process.env.NODE_ENV !== 'production' && !isValidElementType(WrappedComponent) @@ -487,7 +491,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 @@ -496,12 +507,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) @@ -509,4 +519,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 367596c77..ae95fe28b 100644 --- a/src/connect/connect.ts +++ b/src/connect/connect.ts @@ -1,3 +1,4 @@ +/* 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' @@ -10,6 +11,7 @@ import defaultSelectorFactory, { MapDispatchToPropsParam, MergeProps, MapDispatchToPropsNonObject, + SelectorFactory, } from './selectorFactory' import type { DefaultRootState, @@ -59,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 = {}, @@ -84,25 +101,7 @@ export interface ConnectOptions< forwardRef?: boolean | undefined } -/** - * 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 - * @param mapDispatchToProps - * @param mergeProps - * @param options - */ +/* export interface Connect { // tslint:disable:no-unnecessary-generics (): InferableComponentEnhancer @@ -227,6 +226,7 @@ export interface Connect { ): 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 @@ -237,10 +237,207 @@ export function createConnect({ mergePropsFactories = defaultMergePropsFactories, selectorFactory = defaultSelectorFactory, } = {}) { - const connect: Connect = ( - mapStateToProps?: Parameters[0], - mapDispatchToProps?: Parameters[1], - mergeProps?: Parameters[2], + /* @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, @@ -248,8 +445,8 @@ export function createConnect({ areStatePropsEqual = shallowEqual, areMergedPropsEqual = shallowEqual, ...extraOptions - }: ConnectOptions | undefined = {} - ) => { + }: ConnectOptions = {} + ): unknown { const initMapStateToProps = match( mapStateToProps, // @ts-ignore @@ -269,8 +466,7 @@ export function createConnect({ 'mergeProps' ) - // @ts-ignore - return connectHOC(selectorFactory, { + return connectHOC(selectorFactory as SelectorFactory, { // used in error messages methodName: 'connect', @@ -298,4 +494,7 @@ export function createConnect({ 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/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 } From c526e3b9d94f21a88044fad1d550c6f50c0df4ba Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 9 Jul 2021 19:10:35 -0400 Subject: [PATCH 11/13] Get all type tests working and add some additional tests --- etc/react-redux.api.md | 59 ++++++++----------- test/tsconfig.test.json | 2 +- test/typetests/react-redux-types.typetest.tsx | 57 +++++++++++++++--- 3 files changed, 77 insertions(+), 41 deletions(-) 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/test/tsconfig.test.json b/test/tsconfig.test.json index 3325d64dd..772b7a17f 100644 --- a/test/tsconfig.test.json +++ b/test/tsconfig.test.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig.base.json", + "extends": "../tsconfig.json", "compilerOptions": { "allowSyntheticDefaultImports": true, "esModuleInterop": true, diff --git a/test/typetests/react-redux-types.typetest.tsx b/test/typetests/react-redux-types.typetest.tsx index c6587433a..26142b0bf 100644 --- a/test/typetests/react-redux-types.typetest.tsx +++ b/test/typetests/react-redux-types.typetest.tsx @@ -3,7 +3,8 @@ 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 } from '../../src/index' +import { connect, Provider, ConnectedProps } from '../../src/index' +import { expectType } from '../typeTestHelpers' import objectAssign from 'object-assign' @@ -38,8 +39,8 @@ function mapDispatchToProps(dispatch: Dispatch) { connect(mapStateToProps, mapDispatchToProps)(Counter) -@connect(mapStateToProps) class CounterContainer extends Component {} +const ConnectedCounterContainer = connect(mapStateToProps)(CounterContainer) // Ensure connect's first two arguments can be replaced by wrapper functions interface ICounterStateProps { @@ -48,19 +49,21 @@ interface ICounterStateProps { interface ICounterDispatchProps { onIncrement: () => void } -connect( +connect( () => mapStateToProps, () => mapDispatchToProps )(Counter) // only first argument -connect(() => mapStateToProps)(Counter) +connect(() => mapStateToProps)( + Counter +) // wrap only one argument -connect( +connect( mapStateToProps, () => mapDispatchToProps )(Counter) // with extra arguments -connect( +connect( () => mapStateToProps, () => mapDispatchToProps, (s: ICounterStateProps, d: ICounterDispatchProps) => objectAssign({}, s, d), @@ -240,7 +243,7 @@ class TestComponent extends Component {} const WrappedTestComponent = connect()(TestComponent) // return value of the connect()(TestComponent) is of the type TestComponent -let ATestComponent: typeof TestComponent +let ATestComponent: React.ComponentType ATestComponent = TestComponent ATestComponent = WrappedTestComponent @@ -249,6 +252,9 @@ let anElement: ReactElement ; ; +// @ts-expect-error +; + class NonComponent {} // this doesn't compile // @ts-expect-error @@ -359,3 +365,40 @@ namespace TestTOwnPropsInference { // @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 + ) +} From 5b68e0e288a2ee79444fb64afb6075420dbf21bc Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 9 Jul 2021 19:12:57 -0400 Subject: [PATCH 12/13] Try adding TS type test matrix --- .github/workflows/test.yml | 54 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) 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 From 299824ed55b0eb726c243e35d8ef4ec8fecc944f Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 9 Jul 2021 19:33:31 -0400 Subject: [PATCH 13/13] Remove tuple label syntax that fails on TS 3.9 and type errors --- src/components/connectAdvanced.tsx | 22 +++++----------------- src/hooks/useSelector.ts | 6 ++++-- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src/components/connectAdvanced.tsx b/src/components/connectAdvanced.tsx index d9d4aa4d1..77e433814 100644 --- a/src/components/connectAdvanced.tsx +++ b/src/components/connectAdvanced.tsx @@ -1,23 +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, AnyAction } from 'redux' +import type { Store } from 'redux' import type { SelectorFactory } from '../connect/selectorFactory' import { createSubscription, Subscription } from '../utils/Subscription' import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' -import type { - DispatchProp, - Matching, - GetProps, - AdvancedComponentDecorator, - ConnectedComponent, -} from '../types' +import type { AdvancedComponentDecorator, ConnectedComponent } from '../types' import { ReactReduxContext, @@ -38,7 +26,7 @@ const stringifyComponent = (Comp: unknown) => { } function storeStateUpdatesReducer( - state: [payload: unknown, counter: number], + state: [unknown, number], action: { payload: unknown } ) { const [, updateCount] = state @@ -115,7 +103,7 @@ function subscribeUpdates( ) } catch (e) { error = e - lastThrownError = e + lastThrownError = e as Error | null } if (!error) { 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()