From 7e3abdf03173b1df20b9888ae0734d906fec53ee Mon Sep 17 00:00:00 2001 From: Greg Tatum Date: Tue, 17 Nov 2020 12:28:45 -0600 Subject: [PATCH 1/2] Add TypeScript to the project --- package.json | 5 ++++- tsconfig.json | 20 ++++++++++++++++++++ yarn.lock | 40 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 tsconfig.json diff --git a/package.json b/package.json index 821315950e..f433c2183f 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "test-coverage": "run-s test-build-coverage test-serve-coverage", "test-alex": "alex ./docs-* *.md", "test-lockfile": "lockfile-lint --path yarn.lock --allowed-hosts yarn --validate-https", - "test-debug": "node --inspect-brk node_modules/.bin/jest --runInBand" + "test-debug": "node --inspect-brk node_modules/.bin/jest --runInBand", + "ts": "tsc" }, "license": "MPL-2.0", "repository": { @@ -44,6 +45,7 @@ "url": "https://github.com/firefox-devtools/profiler" }, "dependencies": { + "@types/react-redux": "^7.1.11", "array-move": "^3.0.1", "array-range": "^1.0.1", "clamp": "^1.0.1", @@ -71,6 +73,7 @@ "redux-logger": "^3.0.6", "redux-thunk": "^2.2.0", "reselect": "^4.0.0", + "typescript": "^4.0.5", "url": "^0.11.0", "weaktuplemap": "^1.0.0" }, diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..6068cb2f97 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "module": "esnext", + "allowSyntheticDefaultImports": true, + // Make the type checking as strict as possible. + "strict": true, + // Allow esnext syntax. Otherwise the default is ES5 only. + "target": "esnext", + "lib": ["esnext", "dom"], + // Do not modify the output, allow eslint to do so. + "jsx": "preserve", + "noEmit": true + }, + "include": ["./src/**/*.ts"], + "exclude": [ + "node_modules", + "bin" + ] +} diff --git a/yarn.lock b/yarn.lock index b52ecc3b3e..db62f960f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1515,6 +1515,14 @@ dependencies: "@types/node" "*" +"@types/hoist-non-react-statics@^3.3.0": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/html-minifier-terser@^5.0.0": version "5.1.0" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.0.tgz#551a4589b6ee2cc9c1dff08056128aec29b94880" @@ -1604,11 +1612,34 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.0.2.tgz#5bb52ee68d0f8efa9cc0099920e56be6cc4e37f3" integrity sha512-IkVfat549ggtkZUthUzEX49562eGikhSYeVGX97SkMFn+sTZrgRewXjQ4tPKFPCykZHkX1Zfd9OoELGqKU2jJA== +"@types/prop-types@*": + version "15.7.3" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" + integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== + "@types/q@^1.5.1": version "1.5.2" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== +"@types/react-redux@^7.1.11": + version "7.1.11" + resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.11.tgz#a18e8ab3651e8e8cc94798934927937c66021217" + integrity sha512-OjaFlmqy0CRbYKBoaWF84dub3impqnLJUrz4u8PRjDzaa4n1A2cVmjMV81shwXyAD5x767efhA8STFGJz/r1Zg== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + +"@types/react@*": + version "16.9.56" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.56.tgz#ea25847b53c5bec064933095fc366b1462e2adf0" + integrity sha512-gIkl4J44G/qxbuC6r2Xh+D3CGZpJ+NdWTItAPmZbR5mUS+JQ8Zvzpl0ea5qT/ZT3ZNTUcDKUVqV3xBE8wv/DyQ== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + "@types/responselike@*": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29" @@ -6178,7 +6209,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -10747,7 +10778,7 @@ redux-thunk@^2.2.0: resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== -redux@^4.0.5: +redux@^4.0.0, redux@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== @@ -12815,6 +12846,11 @@ typescript@^3.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w== +typescript@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.5.tgz#ae9dddfd1069f1cb5beb3ef3b2170dd7c1332389" + integrity sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ== + typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.35" resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.35.tgz#b86abfe440e6ee69102eebb0e8c5a916dd182ff9" From a0afd5dbd56dbfe22d09dcdb67166faff1b5ec50 Mon Sep 17 00:00:00 2001 From: Greg Tatum Date: Tue, 17 Nov 2020 12:30:57 -0600 Subject: [PATCH 2/2] Add initial typescript tests --- src/test/typescript/profiler-connect.tsx | 215 +++++++++++++++++++++++ src/test/typescript/types.ts | 75 ++++++++ src/test/typescript/utils.ts | 67 +++++++ 3 files changed, 357 insertions(+) create mode 100644 src/test/typescript/profiler-connect.tsx create mode 100644 src/test/typescript/types.ts create mode 100644 src/test/typescript/utils.ts diff --git a/src/test/typescript/profiler-connect.tsx b/src/test/typescript/profiler-connect.tsx new file mode 100644 index 0000000000..5e72f2ef6c --- /dev/null +++ b/src/test/typescript/profiler-connect.tsx @@ -0,0 +1,215 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at . */ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import * as React from 'react'; + +import { + State, + Action, + ThunkAction, + ConnectedProps, + DeThunkObj, +} from './types'; + +import { assertValueHasType, typeToValue, connect } from './utils'; + +/** + * These type tests create various values that should all type check correctly to show + * that the react-redux system is working correctly. + */ + +type OwnProps = { + ownPropString: string; + ownPropNumber: number; +}; + +type StateProps = { + statePropString: string; + statePropNumber: number; +}; + +type ExampleActionCreator = (a: string) => Action; +type ExampleThunkActionCreator = (a: string) => ThunkAction; + +type DispatchProps = { + dispatchString: ExampleActionCreator; + dispatchThunkNumber: ExampleThunkActionCreator; +}; + +type Props = ConnectedProps; + +class ExampleComponent extends React.PureComponent { + render() { + // Ensure that the React component has the correct types inside of it. + assertValueHasType(this.props.ownPropString); + assertValueHasType(this.props.ownPropNumber); + assertValueHasType(this.props.statePropString); + assertValueHasType(this.props.statePropNumber); + + // The action creators are properly wrapped by dispatch. + assertValueHasType<(a: string) => Action>(this.props.dispatchString); + assertValueHasType<(a: string) => number>(this.props.dispatchThunkNumber); + assertValueHasType(this.props.dispatchThunkNumber('foo')); + + assertValueHasType<(a: string) => number>(this.props.dispatchThunkNumber); + assertValueHasType(this.props.dispatchThunkNumber('foo')); + + return null; + } +} + +const validMapStateToProps = (state: State, ownProps: OwnProps): StateProps => { + return { + statePropString: 'string', + statePropNumber: 0, + }; +}; + +const mapStateToPropsNoOwnProps = (state: State): StateProps => { + return { + statePropString: 'string', + statePropNumber: 0, + }; +}; + +const dispatchString = typeToValue<(a: string) => Action>(); +const dispatchThunkNumber = typeToValue<(a: string) => ThunkAction>(); + +const validDispatchToProps = { + dispatchString, + dispatchThunkNumber: dispatchThunkNumber, +}; + +const ConnectedExampleComponent = connect( + validMapStateToProps, + validDispatchToProps +)(ExampleComponent); + +{ + // Test mapStateToProps with no OwnProps. + connect( + mapStateToPropsNoOwnProps, + validDispatchToProps + )(ExampleComponent); +} + +{ + // Test de-Thunking an object + type DispatchPropsThunked = { + action: (a: string) => Action; + thunk0: () => ThunkAction; + thunk1: (a: string) => ThunkAction; + thunk2: (a: string, b: number) => ThunkAction; + }; + + type ExpectedDispatchPropsDeThunked = { + action: (a: string) => Action; + thunk0: () => number; + thunk1: (a: string) => number; + thunk2: (a: string, b: number) => number; + }; + + type ActualDispatchPropsDeThunked = DeThunkObj; + + assertValueHasType( + typeToValue() + ); +} + +{ + connect( + (state: State): StateProps => ({ + statePropString: 'string', + statePropNumber: 0, + // @ts-expect-error - Extra value in StateProps + extraValue: null, + }), + validDispatchToProps + )(ExampleComponent); +} + +{ + connect( + (state: State): StateProps => ({ + statePropString: 'string', + // @ts-expect-error - Expected a number, not a string. + statePropNumber: 'not a number', + }), + validDispatchToProps + )(ExampleComponent); +} + +{ + const missingValueProps = typeToValue<{ + dispatchThunkNumber: (a: string) => ThunkAction; + }>(); + + connect( + validMapStateToProps, + // @ts-expect-error - mapDispatchToProps will error if a value is omitted. + missingValueProps + )(ExampleComponent); +} + +{ + const wrongProps = typeToValue<{ + dispatchString: (a: string) => string; + dispatchThunkNumber: (a: string) => ThunkAction; + }>(); + + connect( + validMapStateToProps, + // @ts-expect-error - mapDispatchToProps will error if a variable type definition is wrong. + wrongProps + )(ExampleComponent); +} + +// TODO - This assertion is not working. +// +// { +// const extraValueProps = typeToValue< +// DispatchProps & { +// extraProperty: (a: string) => string; +// } +// >(); +// connect( +// validMapStateToProps, +// // @ts-expect-error - mapDispatchToProps will error if an extra property is given. +// extraValueProps +// )(ExampleComponent); +// } + +{ + const deThunkedDispatchProps = typeToValue>(); + connect( + validMapStateToProps, + // @ts-expect-error - De-thunking dispatch props early on is an error. + deThunkedDispatchProps + )(ExampleComponent); +} + +{ + // The connected component correctly takes OwnProps. + ; +} + +{ + ; +} + +{ + // @ts-expect-error - It throws an error when an OwnProps is incorrect. + ; +} + +{ + // @ts-expect-error - It throws an error if no OwnProps are provided. + ; +} diff --git a/src/test/typescript/types.ts b/src/test/typescript/types.ts new file mode 100644 index 0000000000..92a29fc5f4 --- /dev/null +++ b/src/test/typescript/types.ts @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at . */ + +// Stub out the store types. +export interface State { + fake: 'example'; +} +export type Action = { type: 'FAKE_EXAMPLE1' } | { type: 'FAKE_EXAMPLE2' }; + +export type ConnectedProps< + OwnProps, + StateProps, + DispatchProps extends DispatchPropsBounds +> = Readonly>; + +type ThunkDispatch = (action: ThunkAction) => Returns; +export type PlainDispatch = (action: Action) => Action; +export type GetState = () => State; + +/** + * A thunk action. + */ +export type ThunkAction = ( + dispatch: Dispatch, + getState: GetState +) => Returns; + +/** + * The `dispatch` function can accept either a plain action or a thunk action. + * This is similar to a type `(action: Action | ThunkAction) => any` except this + * allows to type the return value as well. + */ +export type Dispatch = PlainDispatch & ThunkDispatch; + +/** + * Remove the (GetState, Dispatch) from the ThunkAction. + * + * For example: + * From: (...args) => (GetState, Dispatch) => Return + * To: (...args) => Return + * + * Or more simply: + * From: (...args) => ThunkAction + * To: (...args) => Return + */ +export type DeThunk ThunkAction> = ( + ...args: Parameters +) => ReturnType>; + +type ActionCreatorBounds = (...args: any[]) => Action; +type ThunkActionCreatorBounds = (...args: any[]) => ThunkAction; + +/** + * Use this to extend a generic. + * + * For example: + * type MyType + */ +export type DispatchPropsBounds = { + [key: string]: ActionCreatorBounds | ThunkActionCreatorBounds; +}; + +/** + * Apply DeThunk to an object, like DispatchProps. + */ +// prettier-ignore +export type DeThunkObj< + DispatchProps extends DispatchPropsBounds +> = { + [Key in keyof DispatchProps]: + DispatchProps[Key] extends ThunkActionCreatorBounds + ? DeThunk + : DispatchProps[Key]; +}; diff --git a/src/test/typescript/utils.ts b/src/test/typescript/utils.ts new file mode 100644 index 0000000000..1d13980cec --- /dev/null +++ b/src/test/typescript/utils.ts @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at . */ + +import { State, DeThunkObj, DispatchPropsBounds } from './types'; +import { connect as reactReduxConnect } from 'react-redux'; + +/** + * Convert a type into a fake value, which is useful for writing tests. + */ +export function typeToValue(): T { + return null as any; +} + +/** + * Create a type assertion on a value, which is useful to test if a type is working + * as expected. + * + * TODO - It would also be nice to create an AssertEqual type, but I'm not + * sure how to do it at this time. + */ +export function assertValueHasType(_assertion: A): void {} + +/** + * Coerce one type explicitly to another. Just using "as" will attempt to match + * the previous object to the new one. This is a somewhat loose transformation. + * This function allows for an explicit escape hatch where one type is explicitly + * transformed into another. This has a benefit that the coercion is made explicit. + */ +export function coerce(item: A): B { + return item as any; +} + +/** + * Provide a custom connect function that wires into the default react-redux one. + * + * It maintains the legacy ordering from the Flow to TS migration. + * + * Legacy (Flow) ordering: + * + * + * And maps it to the built-in ordering of: + * + * + * In addition, it applies the de-thunking strategy of removing the (GetState, Dispatch) + * from the Thunk action signature. + */ +export function connect< + OwnProps, + StateProps, + ThunkedDispatch extends DispatchPropsBounds +>( + mapStateToProps: + | ((state: State, ownProps: OwnProps) => StateProps) + | ((state: State) => StateProps), + mapDispatchToProps: ThunkedDispatch +) { + return reactReduxConnect< + StateProps, + DeThunkObj, + OwnProps, + State + >( + mapStateToProps, + coerce>(mapDispatchToProps) + ); +}