Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,16 @@
"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": {
"type": "git",
"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",
Expand Down Expand Up @@ -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"
},
Expand Down
215 changes: 215 additions & 0 deletions src/test/typescript/profiler-connect.tsx
Original file line number Diff line number Diff line change
@@ -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 <http://mozilla.org/MPL/2.0/>. */
/* 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<number>;

type DispatchProps = {
dispatchString: ExampleActionCreator;
dispatchThunkNumber: ExampleThunkActionCreator;
};

type Props = ConnectedProps<OwnProps, StateProps, DispatchProps>;

class ExampleComponent extends React.PureComponent<Props> {
render() {
// Ensure that the React component has the correct types inside of it.
assertValueHasType<string>(this.props.ownPropString);
assertValueHasType<number>(this.props.ownPropNumber);
assertValueHasType<string>(this.props.statePropString);
assertValueHasType<number>(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<number>(this.props.dispatchThunkNumber('foo'));

assertValueHasType<(a: string) => number>(this.props.dispatchThunkNumber);
assertValueHasType<number>(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<number>>();

const validDispatchToProps = {
dispatchString,
dispatchThunkNumber: dispatchThunkNumber,
};

const ConnectedExampleComponent = connect<OwnProps, StateProps, DispatchProps>(
validMapStateToProps,
validDispatchToProps
)(ExampleComponent);

{
// Test mapStateToProps with no OwnProps.
connect<OwnProps, StateProps, DispatchProps>(
mapStateToPropsNoOwnProps,
validDispatchToProps
)(ExampleComponent);
}

{
// Test de-Thunking an object
type DispatchPropsThunked = {
action: (a: string) => Action;
thunk0: () => ThunkAction<number>;
thunk1: (a: string) => ThunkAction<number>;
thunk2: (a: string, b: number) => ThunkAction<number>;
};

type ExpectedDispatchPropsDeThunked = {
action: (a: string) => Action;
thunk0: () => number;
thunk1: (a: string) => number;
thunk2: (a: string, b: number) => number;
};

type ActualDispatchPropsDeThunked = DeThunkObj<DispatchPropsThunked>;

assertValueHasType<ExpectedDispatchPropsDeThunked>(
typeToValue<ActualDispatchPropsDeThunked>()
);
}

{
connect<OwnProps, StateProps, DispatchProps>(
(state: State): StateProps => ({
statePropString: 'string',
statePropNumber: 0,
// @ts-expect-error - Extra value in StateProps
extraValue: null,
}),
validDispatchToProps
)(ExampleComponent);
}

{
connect<OwnProps, StateProps, DispatchProps>(
(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<number>;
}>();

connect<OwnProps, StateProps, DispatchProps>(
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<number>;
}>();

connect<OwnProps, StateProps, DispatchProps>(
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<OwnProps, StateProps, DispatchProps>(
// validMapStateToProps,
// // @ts-expect-error - mapDispatchToProps will error if an extra property is given.
// extraValueProps
// )(ExampleComponent);
// }

{
const deThunkedDispatchProps = typeToValue<DeThunkObj<DispatchProps>>();
connect<OwnProps, StateProps, DispatchProps>(
validMapStateToProps,
// @ts-expect-error - De-thunking dispatch props early on is an error.
deThunkedDispatchProps
)(ExampleComponent);
}

{
// The connected component correctly takes OwnProps.
<ConnectedExampleComponent ownPropString="string" ownPropNumber={0} />;
}

{
<ConnectedExampleComponent
ownPropString="string"
ownPropNumber={0}
// @ts-expect-error - The connected component must not accept more props.
ownPropsExtra={0}
/>;
}

{
// @ts-expect-error - It throws an error when an OwnProps is incorrect.
<ConnectedExampleComponent ownPropString={0} ownPropNumber={0} />;
}

{
// @ts-expect-error - It throws an error if no OwnProps are provided.
<ConnectedExampleComponent />;
}
75 changes: 75 additions & 0 deletions src/test/typescript/types.ts
Original file line number Diff line number Diff line change
@@ -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 <http://mozilla.org/MPL/2.0/>. */

// 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<OwnProps & StateProps & DeThunkObj<DispatchProps>>;

type ThunkDispatch = <Returns>(action: ThunkAction<Returns>) => Returns;
export type PlainDispatch = (action: Action) => Action;
export type GetState = () => State;

/**
* A thunk action.
*/
export type ThunkAction<Returns> = (
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<Return>
* To: (...args) => Return
*/
export type DeThunk<T extends (...args: any[]) => ThunkAction<any>> = (
...args: Parameters<T>
) => ReturnType<ReturnType<T>>;

type ActionCreatorBounds = (...args: any[]) => Action;
type ThunkActionCreatorBounds = (...args: any[]) => ThunkAction<any>;

/**
* Use this to extend a generic.
*
* For example:
* type MyType<DispatchProps extends DispatchPropsBounds>
*/
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]>
: DispatchProps[Key];
};
67 changes: 67 additions & 0 deletions src/test/typescript/utils.ts
Original file line number Diff line number Diff line change
@@ -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 <http://mozilla.org/MPL/2.0/>. */

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>(): 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<A, B> type, but I'm not
* sure how to do it at this time.
*/
export function assertValueHasType<A>(_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<A, B>(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:
* <OwnProps, StateProps, DispatchProps>
*
* And maps it to the built-in ordering of:
* <OwnProps, StateProps, DispatchProps>
*
* 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<ThunkedDispatch>,
OwnProps,
State
>(
mapStateToProps,
coerce<ThunkedDispatch, DeThunkObj<ThunkedDispatch>>(mapDispatchToProps)
);
}
Loading