From 89857b8e27acbdb4d1fa63eb46889e3c5aaeaadb Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 21 Dec 2021 17:37:16 -0500 Subject: [PATCH 01/11] Update typetest setup to actually check files correctly --- package.json | 2 +- test/tsconfig.test.json | 5 +++-- test/typetests/tsconfig.json | 6 +++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index f1d9fd12b..788b7ff37 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "prepare": "yarn clean && yarn build", "pretest": "yarn lint", "test": "jest", - "type-tests": "yarn tsc -p test/typetests", + "type-tests": "yarn tsc -p test/typetests/tsconfig.json", "coverage": "codecov" }, "peerDependencies": { diff --git a/test/tsconfig.test.json b/test/tsconfig.test.json index 772b7a17f..461da76db 100644 --- a/test/tsconfig.test.json +++ b/test/tsconfig.test.json @@ -1,17 +1,18 @@ { - "extends": "../tsconfig.json", "compilerOptions": { "allowSyntheticDefaultImports": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "emitDeclarationOnly": false, + "declaration": false, "strict": true, "noEmit": true, "target": "es2018", "jsx": "react", "baseUrl": ".", "skipLibCheck": true, - "noImplicitReturns": false + "noImplicitReturns": false, + "experimentalDecorators": true, } } diff --git a/test/typetests/tsconfig.json b/test/typetests/tsconfig.json index 38ca0b13b..5d96894d5 100644 --- a/test/typetests/tsconfig.json +++ b/test/typetests/tsconfig.json @@ -1,3 +1,7 @@ { - "extends": "../tsconfig.test.json" + "extends": "../tsconfig.test.json", + "compilerOptions": { + }, + "include": ["./*.ts*"], + "exclude": [] } From f580e0ebc21373badee928cff885c2f31cdc35f9 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 21 Dec 2021 17:37:50 -0500 Subject: [PATCH 02/11] Port additional typetests from DefinitelyTyped --- .../connect-mapstate-mapdispatch.tsx | 460 +++++++++ test/typetests/connect-options-and-issues.tsx | 872 ++++++++++++++++++ test/typetests/counterApp.ts | 56 ++ test/typetests/hooks.tsx | 233 +++++ test/typetests/react-redux-types.typetest.tsx | 128 +-- 5 files changed, 1660 insertions(+), 89 deletions(-) create mode 100644 test/typetests/connect-mapstate-mapdispatch.tsx create mode 100644 test/typetests/connect-options-and-issues.tsx create mode 100644 test/typetests/counterApp.ts create mode 100644 test/typetests/hooks.tsx diff --git a/test/typetests/connect-mapstate-mapdispatch.tsx b/test/typetests/connect-mapstate-mapdispatch.tsx new file mode 100644 index 000000000..1c09daf0c --- /dev/null +++ b/test/typetests/connect-mapstate-mapdispatch.tsx @@ -0,0 +1,460 @@ +/* eslint-disable @typescript-eslint/no-unused-vars, no-inner-declarations */ + +import * as React from 'react' +import * as ReactDOM from 'react-dom' +import { + Store, + Dispatch, + AnyAction, + ActionCreator, + createStore, + bindActionCreators, + ActionCreatorsMapObject, + Reducer, +} from 'redux' +import { + connect, + ConnectedProps, + Provider, + DispatchProp, + MapStateToProps, + ReactReduxContext, + ReactReduxContextValue, + Selector, + shallowEqual, + MapDispatchToProps, + useDispatch, + useSelector, + useStore, + createDispatchHook, + createSelectorHook, + createStoreHook, + TypedUseSelectorHook, +} from '../../src/index' + +// Test cases written in a way to isolate types and variables and verify the +// output of `connect` to make sure the signature is what is expected + +const CustomContext = React.createContext( + null +) as unknown as typeof ReactReduxContext + +function Empty() { + interface OwnProps { + dispatch: Dispatch + foo: string + } + + class TestComponent extends React.Component {} + + const Test = connect()(TestComponent) + + const verify = +} + +function MapState() { + interface OwnProps { + foo: string + } + interface StateProps { + bar: number + } + + class TestComponent extends React.Component {} + + const mapStateToProps = (_: any) => ({ + bar: 1, + }) + + const Test = connect(mapStateToProps)(TestComponent) + + const verify = +} + +function MapStateWithDispatchProp() { + interface OwnProps { + foo: string + } + interface StateProps { + bar: number + dispatch: Dispatch + } + + class TestComponent extends React.Component {} + + const mapStateToProps = (_: any) => ({ + bar: 1, + }) + + const Test = connect(mapStateToProps)(TestComponent) + + const verify = +} + +function MapStateFactory() { + interface OwnProps { + foo: string + } + interface StateProps { + bar: number + } + + class TestComponent extends React.Component {} + + const mapStateToProps = () => () => ({ + bar: 1, + }) + + const Test = connect(mapStateToProps)(TestComponent) + + const verify = +} + +function MapDispatch() { + interface OwnProps { + foo: string + } + interface DispatchProps { + onClick: () => void + } + + class TestComponent extends React.Component {} + + const mapDispatchToProps = { onClick: () => {} } + + const TestNull = connect(null, mapDispatchToProps)(TestComponent) + + const verifyNull = + + const TestUndefined = connect(undefined, mapDispatchToProps)(TestComponent) + + const verifyUndefined = +} + +function MapDispatchUnion() { + interface OwnProps { + foo: string + } + interface DispatchProps { + onClick: () => void + } + + class TestComponent extends React.Component {} + + // We deliberately cast the right-hand side to `any` because otherwise + // TypeScript would maintain the literal value, when we deliberately want to + // test the union type here (as per the annotation). See + // https://github.com/Microsoft/TypeScript/issues/30310#issuecomment-472218182. + const mapDispatchToProps: MapDispatchToProps = + {} as any + + const TestNull = connect(null, mapDispatchToProps)(TestComponent) + + const verifyNull = + + const TestUndefined = connect(undefined, mapDispatchToProps)(TestComponent) + + const verifyUndefined = +} + +function MapDispatchWithThunkActionCreators() { + const simpleAction = (payload: boolean) => ({ + type: 'SIMPLE_ACTION', + payload, + }) + const thunkAction = + (param1: number, param2: string) => + async (dispatch: Dispatch, { foo }: OwnProps) => { + return foo + } + interface OwnProps { + foo: string + } + interface TestComponentProps extends OwnProps { + simpleAction: typeof simpleAction + thunkAction(param1: number, param2: string): Promise + } + class TestComponent extends React.Component {} + + const mapStateToProps = ({ foo }: { foo: string }) => ({ foo }) + const mapDispatchToProps = { simpleAction, thunkAction } + + const Test1 = connect(null, mapDispatchToProps)(TestComponent) + const Test2 = connect(mapStateToProps, mapDispatchToProps)(TestComponent) + const Test3 = connect(null, mapDispatchToProps, null, { + context: CustomContext, + })(TestComponent) + const Test4 = connect(mapStateToProps, mapDispatchToProps, null, { + context: CustomContext, + })(TestComponent) + const verify = ( +
+ ; + + ; + +
+ ) +} + +function MapManualDispatchThatLooksLikeThunk() { + interface OwnProps { + foo: string + } + interface TestComponentProps extends OwnProps { + remove: (item: string) => () => object + } + class TestComponent extends React.Component { + render() { + return
+ } + } + + const mapStateToProps = ({ foo }: { foo: string }) => ({ foo }) + function mapDispatchToProps(dispatch: Dispatch) { + return { + remove(item: string) { + return () => dispatch({ type: 'REMOVE_ITEM', item }) + }, + } + } + + const Test1 = connect(null, mapDispatchToProps)(TestComponent) + const Test2 = connect(mapStateToProps, mapDispatchToProps)(TestComponent) + const Test3 = connect(null, mapDispatchToProps, null, { + context: CustomContext, + })(TestComponent) + const Test4 = connect(mapStateToProps, mapDispatchToProps, null, { + context: CustomContext, + })(TestComponent) + const verify = ( +
+ ; + + ; + +
+ ) +} + +function MapStateAndDispatchObject() { + interface ClickPayload { + count: number + } + const onClick: ActionCreator = () => ({ count: 1 }) + const dispatchToProps = { + onClick, + } + + interface OwnProps { + foo: string + } + interface StateProps { + bar: number + } + interface DispatchProps { + onClick: ActionCreator + } + + const mapStateToProps = (_: any, __: OwnProps): StateProps => ({ + bar: 1, + }) + + class TestComponent extends React.Component< + OwnProps & StateProps & DispatchProps + > {} + + const Test = connect(mapStateToProps, dispatchToProps)(TestComponent) + + const verify = +} + +function MapDispatchFactory() { + interface OwnProps { + foo: string + } + interface DispatchProps { + onClick: () => void + } + + class TestComponent extends React.Component {} + + const mapDispatchToPropsFactory = () => () => ({ + onClick: () => {}, + }) + + const TestNull = connect(null, mapDispatchToPropsFactory)(TestComponent) + + const verifyNull = + + const TestUndefined = connect( + undefined, + mapDispatchToPropsFactory + )(TestComponent) + + const verifyUndefined = +} + +function MapStateAndDispatch() { + interface OwnProps { + foo: string + } + interface StateProps { + bar: number + } + interface DispatchProps { + onClick: () => void + } + + class TestComponent extends React.Component< + OwnProps & StateProps & DispatchProps + > {} + + const mapStateToProps = () => ({ + bar: 1, + }) + + const mapDispatchToProps = () => ({ + onClick: () => {}, + }) + + const Test = connect(mapStateToProps, mapDispatchToProps)(TestComponent) + + const verify = +} + +function MapStateFactoryAndDispatch() { + interface OwnProps { + foo: string + } + interface StateProps { + bar: number + } + interface DispatchProps { + onClick: () => void + } + + const mapStateToPropsFactory = () => () => ({ + bar: 1, + }) + + const mapDispatchToProps = () => ({ + onClick: () => {}, + }) + + class TestComponent extends React.Component< + OwnProps & StateProps & DispatchProps + > {} + + const Test = connect( + mapStateToPropsFactory, + mapDispatchToProps + )(TestComponent) + + const verify = +} + +function MapStateFactoryAndDispatchFactory() { + interface OwnProps { + foo: string + } + interface StateProps { + bar: number + } + interface DispatchProps { + onClick: () => void + } + + const mapStateToPropsFactory = () => () => ({ + bar: 1, + }) + + const mapDispatchToPropsFactory = () => () => ({ + onClick: () => {}, + }) + + class TestComponent extends React.Component< + OwnProps & StateProps & DispatchProps + > {} + + const Test = connect( + mapStateToPropsFactory, + mapDispatchToPropsFactory + )(TestComponent) + + const verify = +} + +function MapStateAndDispatchAndMerge() { + interface OwnProps { + foo: string + } + interface StateProps { + bar: number + } + interface DispatchProps { + onClick: () => void + } + + class TestComponent extends React.Component< + OwnProps & StateProps & DispatchProps + > {} + + const mapStateToProps = () => ({ + bar: 1, + }) + + const mapDispatchToProps = () => ({ + onClick: () => {}, + }) + + const mergeProps = ( + stateProps: StateProps, + dispatchProps: DispatchProps + ) => ({ ...stateProps, ...dispatchProps }) + + const Test = connect( + mapStateToProps, + mapDispatchToProps, + mergeProps + )(TestComponent) + + const verify = +} + +function MapStateAndOptions() { + interface State { + state: string + } + interface OwnProps { + foo: string + } + interface StateProps { + bar: number + } + interface DispatchProps { + dispatch: Dispatch + } + + class TestComponent extends React.Component< + OwnProps & StateProps & DispatchProps + > {} + + const mapStateToProps = (state: State) => ({ + bar: 1, + }) + + const areStatePropsEqual = (next: StateProps, current: StateProps) => true + + const Test = connect( + mapStateToProps, + null, + null, + { + pure: true, + areStatePropsEqual, + } + )(TestComponent) + + const verify = +} diff --git a/test/typetests/connect-options-and-issues.tsx b/test/typetests/connect-options-and-issues.tsx new file mode 100644 index 000000000..ee77aa11b --- /dev/null +++ b/test/typetests/connect-options-and-issues.tsx @@ -0,0 +1,872 @@ +/* eslint-disable @typescript-eslint/no-unused-vars, react/prop-types */ +import * as PropTypes from 'prop-types' +import * as React from 'react' +import * as ReactDOM from 'react-dom' +import { + Store, + Dispatch, + AnyAction, + ActionCreator, + createStore, + bindActionCreators, + ActionCreatorsMapObject, + Reducer, +} from 'redux' +import { + connect, + Connect, + ConnectedProps, + Provider, + DispatchProp, + MapStateToProps, + ReactReduxContext, + ReactReduxContextValue, + Selector, + shallowEqual, + MapDispatchToProps, + useDispatch, + useSelector, + useStore, + createDispatchHook, + createSelectorHook, + createStoreHook, + TypedUseSelectorHook, +} from '../../src/index' + +// Test cases written in a way to isolate types and variables and verify the +// output of `connect` to make sure the signature is what is expected + +const CustomContext = React.createContext( + null +) as unknown as typeof ReactReduxContext + +// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/16021 +function TestMergedPropsInference() { + interface StateProps { + state: string + } + + interface DispatchProps { + dispatch: string + } + + interface OwnProps { + own: string + } + + interface MergedProps { + merged: string + } + + class MergedPropsComponent extends React.Component { + render() { + return
+ } + } + + function mapStateToProps(state: any): StateProps { + return { state: 'string' } + } + + function mapDispatchToProps(dispatch: Dispatch): DispatchProps { + return { dispatch: 'string' } + } + + const ConnectedWithOwnAndState = connect< + StateProps, + void, + OwnProps, + MergedProps + >(mapStateToProps, undefined, (stateProps: StateProps) => ({ + merged: 'merged', + }))(MergedPropsComponent) + + const ConnectedWithOwnAndDispatch = connect< + void, + DispatchProps, + OwnProps, + MergedProps + >( + undefined, + mapDispatchToProps, + (stateProps: undefined, dispatchProps: DispatchProps) => ({ + merged: 'merged', + }) + )(MergedPropsComponent) + + const ConnectedWithOwn = connect( + undefined, + undefined, + () => ({ + merged: 'merged', + }) + )(MergedPropsComponent) +} + +function Issue16652() { + interface PassedProps { + commentIds: string[] + } + + interface GeneratedStateProps { + comments: Array<{ id: string } | undefined> + } + + class CommentList extends React.Component< + PassedProps & GeneratedStateProps & DispatchProp + > {} + + const mapStateToProps = ( + state: any, + ownProps: PassedProps + ): GeneratedStateProps => { + return { + comments: ownProps.commentIds.map((id) => ({ id })), + } + } + + const ConnectedCommentList = connect( + mapStateToProps + )(CommentList) + + ; +} + +function Issue15463() { + interface SpinnerProps { + showGlobalSpinner: boolean + } + + class SpinnerClass extends React.Component { + render() { + return
+ } + } + + const Spinner = connect((state: any) => { + return { showGlobalSpinner: true } + })(SpinnerClass) + + ; +} + +function RemoveInjectedAndPassOnRest() { + interface TProps { + showGlobalSpinner: boolean + foo: string + } + class SpinnerClass extends React.Component { + render() { + return
+ } + } + + const Spinner = connect((state: any) => { + return { showGlobalSpinner: true } + })(SpinnerClass) + + ; +} + +function TestControlledComponentWithoutDispatchProp() { + interface MyState { + count: number + } + + interface MyProps { + label: string + // `dispatch` is optional, but setting it to anything + // other than Dispatch will cause an error + // + // dispatch: Dispatch; // OK + // dispatch: number; // ERROR + } + + function mapStateToProps(state: MyState) { + return { + label: `The count is ${state.count}`, + } + } + + class MyComponent extends React.Component { + render() { + return {this.props.label} + } + } + + const MyFuncComponent = (props: MyProps) => {props.label} + + const MyControlledComponent = connect(mapStateToProps)(MyComponent) + const MyControlledFuncComponent = connect(mapStateToProps)(MyFuncComponent) +} + +function TestDispatchToPropsAsObject() { + const onClick: ActionCreator<{}> = () => ({}) + const mapStateToProps = (state: any) => { + return { + title: state.app.title as string, + } + } + const dispatchToProps = { + onClick, + } + + type Props = { title: string } & typeof dispatchToProps + const HeaderComponent: React.FunctionComponent = (props) => { + return

{props.title}

+ } + + const Header = connect(mapStateToProps, dispatchToProps)(HeaderComponent) + ;
+} + +function TestInferredFunctionalComponentWithExplicitOwnProps() { + interface Props { + title: string + extraText: string + onClick: () => void + } + + const Header = connect( + ( + { app: { title } }: { app: { title: string } }, + { extraText }: { extraText: string } + ) => ({ + title, + extraText, + }), + (dispatch) => ({ + onClick: () => dispatch({ type: 'test' }), + }) + )(({ title, extraText, onClick }: Props) => { + return ( +

+ {title} {extraText} +

+ ) + }) + ;
+} + +function TestInferredFunctionalComponentWithImplicitOwnProps() { + interface Props { + title: string + extraText: string + onClick: () => void + } + + const Header = connect( + ({ app: { title } }: { app: { title: string } }) => ({ + title, + }), + (dispatch) => ({ + onClick: () => dispatch({ type: 'test' }), + }) + )(({ title, extraText, onClick }: Props) => { + return ( +

+ {title} {extraText} +

+ ) + }) + ;
+} + +function TestWrappedComponent() { + interface InnerProps { + name: string + } + const Inner: React.FunctionComponent = (props) => { + return

{props.name}

+ } + + const mapStateToProps = (state: any) => { + return { + name: 'Connected', + } + } + const Connected = connect(mapStateToProps)(Inner) + + // `Inner` and `Connected.WrappedComponent` require explicit `name` prop + const TestInner = (props: any) => + const TestWrapped = (props: any) => ( + + ) + // `Connected` does not require explicit `name` prop + const TestConnected = (props: any) => +} + +function TestWithoutTOwnPropsDecoratedInference() { + interface ForwardedProps { + forwarded: string + } + + interface OwnProps { + own: string + } + + interface StateProps { + state: string + } + + class WithoutOwnPropsComponentClass extends React.Component< + ForwardedProps & StateProps & DispatchProp + > { + render() { + return
+ } + } + + const WithoutOwnPropsComponentStateless: React.FunctionComponent< + ForwardedProps & StateProps & DispatchProp + > = () =>
+ + function mapStateToProps4(state: any, ownProps: OwnProps): StateProps { + return { state: 'string' } + } + + // these decorations should compile, it is perfectly acceptable to receive props and ignore them + const ConnectedWithOwnPropsClass = connect(mapStateToProps4)( + WithoutOwnPropsComponentClass + ) + const ConnectedWithOwnPropsStateless = connect(mapStateToProps4)( + WithoutOwnPropsComponentStateless + ) + const ConnectedWithTypeHintClass = connect( + mapStateToProps4 + )(WithoutOwnPropsComponentClass) + const ConnectedWithTypeHintStateless = connect( + mapStateToProps4 + )(WithoutOwnPropsComponentStateless) + + // This should compile + React.createElement(ConnectedWithOwnPropsClass, { + own: 'string', + forwarded: 'string', + }) + React.createElement(ConnectedWithOwnPropsClass, { + own: 'string', + forwarded: 'string', + }) + + // This should not compile, it is missing ForwardedProps + // @ts-expect-error + React.createElement(ConnectedWithOwnPropsClass, { own: 'string' }) + // @ts-expect-error + React.createElement(ConnectedWithOwnPropsStateless, { own: 'string' }) + + // This should compile + React.createElement(ConnectedWithOwnPropsClass, { + own: 'string', + forwarded: 'string', + }) + React.createElement(ConnectedWithOwnPropsStateless, { + own: 'string', + forwarded: 'string', + }) + + // This should not compile, it is missing ForwardedProps + // @ts-expect-error + React.createElement(ConnectedWithTypeHintClass, { own: 'string' }) + // @ts-expect-error + React.createElement(ConnectedWithTypeHintStateless, { own: 'string' }) // $ExpectError + + interface AllProps { + own: string + state: string + } + + class AllPropsComponent extends React.Component< + AllProps & DispatchProp + > { + render() { + return
+ } + } + + type PickedOwnProps = Pick + type PickedStateProps = Pick + + const mapStateToPropsForPicked: MapStateToProps< + PickedStateProps, + PickedOwnProps, + {} + > = (state: any): PickedStateProps => { + return { state: 'string' } + } + const ConnectedWithPickedOwnProps = connect(mapStateToPropsForPicked)( + AllPropsComponent + ) + ; +} + +// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/25321#issuecomment-387659500 +function ProviderAcceptsStoreWithCustomAction() { + const reducer: Reducer< + { foo: number } | undefined, + { type: 'foo'; payload: number } + > = (state) => state + + const store = createStore(reducer) + + const Whatever = () => ( + +
Whatever
+
+ ) +} + +function TestOptionalPropsMergedCorrectly() { + interface OptionalDecorationProps { + foo: string + bar: number + optionalProp?: boolean | undefined + dependsOnDispatch?: (() => void) | undefined + } + + class Component extends React.Component { + render() { + return
+ } + } + + interface MapStateProps { + foo: string + bar: number + optionalProp: boolean + } + + interface MapDispatchProps { + dependsOnDispatch: () => void + } + + function mapStateToProps(state: any): MapStateProps { + return { + foo: 'foo', + bar: 42, + optionalProp: true, + } + } + + function mapDispatchToProps(dispatch: any): MapDispatchProps { + return { + dependsOnDispatch: () => {}, + } + } + + connect(mapStateToProps, mapDispatchToProps)(Component) +} + +function TestMoreGeneralDecorationProps() { + // connect() should support decoration props that are more permissive + // than the injected props, as long as the injected props can satisfy + // the decoration props. + interface MoreGeneralDecorationProps { + foo: string | number + bar: number | 'foo' + optionalProp?: boolean | object | undefined + dependsOnDispatch?: (() => void) | undefined + } + + class Component extends React.Component { + render() { + return
+ } + } + + interface MapStateProps { + foo: string + bar: number + optionalProp: boolean + } + + interface MapDispatchProps { + dependsOnDispatch: () => void + } + + function mapStateToProps(state: any): MapStateProps { + return { + foo: 'foo', + bar: 42, + optionalProp: true, + } + } + + function mapDispatchToProps(dispatch: any): MapDispatchProps { + return { + dependsOnDispatch: () => {}, + } + } + + connect(mapStateToProps, mapDispatchToProps)(Component) +} + +function TestFailsMoreSpecificInjectedProps() { + interface MoreSpecificDecorationProps { + foo: string + bar: number + dependsOnDispatch: () => void + } + + class Component extends React.Component { + render() { + return
+ } + } + + interface MapStateProps { + foo: string | number + bar: number | 'foo' + dependsOnDispatch?: (() => void) | undefined + } + + interface MapDispatchProps { + dependsOnDispatch?: (() => void) | undefined + } + + function mapStateToProps(state: any): MapStateProps { + return { + foo: 'foo', + bar: 42, + } + } + + function mapDispatchToProps(dispatch: any): MapDispatchProps { + return { + dependsOnDispatch: () => {}, + } + } + + // Since it is possible the injected props could fail to satisfy the decoration props, + // the following line should fail to compile. + // @ts-expect-error + connect(mapStateToProps, mapDispatchToProps)(Component) + + // Confirm that this also fails with functional components + const FunctionalComponent = (props: MoreSpecificDecorationProps) => null + // @ts-expect-error + connect(mapStateToProps, mapDispatchToProps)(Component) +} + +function TestLibraryManagedAttributes() { + interface OwnProps { + bar: number + fn: () => void + } + + interface ExternalOwnProps { + bar?: number | undefined + fn: () => void + } + + interface MapStateProps { + foo: string + } + + class Component extends React.Component { + static defaultProps = { + bar: 0, + } + + render() { + return
+ } + } + + function mapStateToProps(state: any): MapStateProps { + return { + foo: 'foo', + } + } + + const ConnectedComponent = connect(mapStateToProps)(Component) + ; {}} /> + + const ConnectedComponent2 = connect( + mapStateToProps + )(Component) + ; {}} /> +} + +function TestPropTypes() { + interface OwnProps { + bar: number + fn: () => void + } + + interface MapStateProps { + foo: string + } + + class Component extends React.Component { + static propTypes = { + foo: PropTypes.string.isRequired, + bar: PropTypes.number.isRequired, + fn: PropTypes.func.isRequired, + } + + render() { + return
+ } + } + + function mapStateToProps(state: any): MapStateProps { + return { + foo: 'foo', + } + } + + const ConnectedComponent = connect(mapStateToProps)(Component) + ; {}} bar={0} /> + + const ConnectedComponent2 = connect( + mapStateToProps + )(Component) + ; {}} bar={0} /> +} + +function TestNonReactStatics() { + interface OwnProps { + bar: number + } + + interface MapStateProps { + foo: string + } + + class Component extends React.Component { + static defaultProps = { + bar: 0, + } + + static meaningOfLife = 42 + + render() { + return
+ } + } + + function mapStateToProps(state: any): MapStateProps { + return { + foo: 'foo', + } + } + + Component.meaningOfLife + Component.defaultProps.bar + + const ConnectedComponent = connect(mapStateToProps)(Component) + + // This is a non-React static and should be hoisted as-is. + ConnectedComponent.meaningOfLife + + // This is a React static, so it's not hoisted. + // However, ConnectedComponent is still a ComponentClass, which specifies `defaultProps` + // as an optional static member. We can force an error (and assert that `defaultProps` + // wasn't hoisted) by reaching into the `defaultProps` object without a null check. + // @ts-expect-error + ConnectedComponent.defaultProps.bar +} + +function TestProviderContext() { + const store: Store = createStore((state = {}) => state) + const nullContext = React.createContext(null) + + // To ensure type safety when consuming the context in an app, a null-context does not suffice. + // @ts-expect-error + ; + ; +
+ + + // react-redux exports a default context used internally if none is supplied, used as shown below. + class ComponentWithDefaultContext extends React.Component { + static contextType = ReactReduxContext + } + + ; + + + + // Null is not a valid value for the context. + // @ts-expect-error + ; +} + +function testConnectedProps() { + interface OwnProps { + own: string + } + const Component: React.FC = ({ own, dispatch }) => null + + const connector = connect() + type ReduxProps = ConnectedProps + + const ConnectedComponent = connect(Component) +} + +function testConnectedPropsWithState() { + interface OwnProps { + own: string + } + const Component: React.FC = ({ + own, + injected, + dispatch, + }) => { + injected.slice() + return null + } + + const connector = connect((state: any) => ({ injected: '' })) + type ReduxProps = ConnectedProps + + const ConnectedComponent = connect(Component) +} + +function testConnectedPropsWithStateAndActions() { + interface OwnProps { + own: string + } + const actionCreator = () => ({ type: 'action' }) + + const Component: React.FC = ({ + own, + injected, + actionCreator, + }) => { + actionCreator() + return null + } + + const ComponentWithDispatch: React.FC = ({ + own, + // @ts-expect-error + dispatch, + }) => null + + const connector = connect((state: any) => ({ injected: '' }), { + actionCreator, + }) + type ReduxProps = ConnectedProps + + const ConnectedComponent = connect(Component) +} + +function testConnectReturnType() { + const TestComponent: React.FC = () => null + + const Test = connect()(TestComponent) + + const myHoc1 = (C: React.ComponentClass

): React.ComponentType

=> C + // @ts-expect-error + myHoc1(Test) + + const myHoc2 = (C: React.FC

): React.ComponentType

=> C + myHoc2(Test) +} + +function testRef() { + const FunctionalComponent: React.FC = () => null + const ForwardedFunctionalComponent = React.forwardRef(() => null) + class ClassComponent extends React.Component {} + + const ConnectedFunctionalComponent = connect()(FunctionalComponent) + const ConnectedForwardedFunctionalComponent = connect()( + ForwardedFunctionalComponent + ) + const ConnectedClassComponent = connect()(ClassComponent) + + // Should not be able to pass any type of ref to a FunctionalComponent + // ref is not a valid property + ;()} + > + ; {}} + > + + // @ts-expect-error + ; + + // Should be able to pass modern refs to a ForwardRefExoticComponent + const modernRef: React.Ref | undefined = undefined + ; + // Should not be able to use legacy string refs + ; + // ref type should agree with type of the forwarded ref + ;()} + > + ; {}} + > + + // Should be able to use all refs including legacy string + const classLegacyRef: React.LegacyRef | undefined = undefined + ; + ;()} + > + ; {}} + > + ; + // ref type should be the typeof the wrapped component + ;()} + > + // @ts-expect-error + ; {}}> +} + +function testConnectDefaultState() { + connect((state) => { + // $ExpectType DefaultRootState + const s = state + return state + }) + + const connectWithDefaultState: Connect<{ value: number }> = connect + connectWithDefaultState((state) => { + // $ExpectType { value: number; } + const s = state + return state + }) +} + +function testPreserveDiscriminatedUnions() { + type OwnPropsT = { + color: string + } & ( + | { + type: 'plain' + } + | { + type: 'localized' + params: Record | undefined + } + ) + + class MyText extends React.Component {} + + const ConnectedMyText = connect()(MyText) + const someParams = { key: 'value', foo: 'bar' } + + ; + // @ts-expect-error + ; + // @ts-expect-error + ; + ; +} diff --git a/test/typetests/counterApp.ts b/test/typetests/counterApp.ts new file mode 100644 index 000000000..7b976d814 --- /dev/null +++ b/test/typetests/counterApp.ts @@ -0,0 +1,56 @@ +import { + createSlice, + createAsyncThunk, + configureStore, + ThunkAction, + Action, +} from '@reduxjs/toolkit' + +export interface CounterState { + counter: number +} + +const initialState: CounterState = { + counter: 0, +} + +export const counterSlice = createSlice({ + name: 'counter', + initialState, + reducers: { + increment(state) { + state.counter++ + }, + }, +}) + +export function fetchCount(amount = 1) { + return new Promise<{ data: number }>((resolve) => + setTimeout(() => resolve({ data: amount }), 500) + ) +} + +export const incrementAsync = createAsyncThunk( + 'counter/fetchCount', + async (amount: number) => { + const response = await fetchCount(amount) + // The value we return becomes the `fulfilled` action payload + return response.data + } +) + +export const { increment } = counterSlice.actions + +const counterStore = configureStore({ + reducer: counterSlice.reducer, + middleware: (gdm) => gdm(), +}) + +export type AppDispatch = typeof counterStore.dispatch +export type RootState = ReturnType +export type AppThunk = ThunkAction< + ReturnType, + RootState, + unknown, + Action +> diff --git a/test/typetests/hooks.tsx b/test/typetests/hooks.tsx new file mode 100644 index 000000000..5e82ade76 --- /dev/null +++ b/test/typetests/hooks.tsx @@ -0,0 +1,233 @@ +/* eslint-disable @typescript-eslint/no-unused-vars, no-inner-declarations */ + +import * as React from 'react' +import * as ReactDOM from 'react-dom' +import { + Store, + Dispatch, + AnyAction, + ActionCreator, + createStore, + bindActionCreators, + ActionCreatorsMapObject, + Reducer, +} from 'redux' +import { + connect, + ConnectedProps, + Provider, + DispatchProp, + MapStateToProps, + ReactReduxContext, + ReactReduxContextValue, + Selector, + shallowEqual, + MapDispatchToProps, + useDispatch, + useSelector, + useStore, + createDispatchHook, + createSelectorHook, + createStoreHook, + TypedUseSelectorHook, +} from '../../src/index' + +import { + CounterState, + counterSlice, + increment, + incrementAsync, + AppDispatch, + AppThunk, + RootState, + fetchCount, +} from './counterApp' + +function preTypedHooksSetup() { + // Standard hooks setup + const useAppDispatch = () => useDispatch() + const useAppSelector: TypedUseSelectorHook = useSelector + + function CounterComponent() { + const dispatch = useAppDispatch() + + return ( +