Convert redux-devtools package to TypeScript

This commit is contained in:
Nathan Bierema 2020-05-17 07:35:13 -04:00
parent 93d6bc4350
commit 8eb67453aa
13 changed files with 196 additions and 106 deletions

View File

@ -1,4 +1,10 @@
{ {
"presets": ["@babel/preset-env", "@babel/preset-react"], "presets": [
"plugins": ["@babel/plugin-proposal-class-properties"] "@babel/env",
"@babel/react",
"@babel/typescript"
],
"plugins": [
"@babel/proposal-class-properties"
]
} }

View File

@ -0,0 +1,2 @@
lib
examples

View File

@ -0,0 +1,21 @@
module.exports = {
extends: '../../.eslintrc',
overrides: [
{
files: ['*.ts', '*.tsx'],
extends: '../../eslintrc.ts.react.base.json',
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json']
}
},
{
files: ['test/*.ts', 'test/*.tsx'],
extends: '../../eslintrc.ts.react.jest.base.json',
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./test/tsconfig.json']
}
}
]
};

View File

@ -0,0 +1,2 @@
lib
examples

View File

@ -1,18 +0,0 @@
// Redux version 4.0.0
// Type definitions for redux-devtools 3.4.1
// TypeScript Version: 2.8.1
import * as React from "react";
import { StoreEnhancer } from "redux";
export interface DevTools {
new (): JSX.ElementClass;
instrument(opts?: any): StoreEnhancer;
}
export declare function createDevTools(el: React.ReactElement<any>): DevTools;
export declare function persistState(debugSessionKey: string): StoreEnhancer;
declare const factory: { instrument(opts?: any): () => StoreEnhancer };
export default factory;

View File

@ -3,10 +3,17 @@
"version": "3.5.0", "version": "3.5.0",
"description": "Redux DevTools with hot reloading and time travel", "description": "Redux DevTools with hot reloading and time travel",
"main": "lib/index.js", "main": "lib/index.js",
"types": "lib/index.d.ts",
"scripts": { "scripts": {
"type-check": "tsc --noEmit",
"type-check:watch": "npm run type-check -- --watch",
"clean": "rimraf lib", "clean": "rimraf lib",
"build": "babel src --out-dir lib", "build": "npm run build:types && npm run build:js",
"test": "jest", "build:types": "tsc --emitDeclarationOnly",
"build:js": "babel src --out-dir lib --extendsions \".ts,.tsx\" --source-maps inline",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tx --fix",
"test": "tsc --project test/tsconfig.json --noEmit && jest",
"prepare": "npm run build", "prepare": "npm run build",
"prepublishOnly": "npm run test && npm run clean && npm run build" "prepublishOnly": "npm run test && npm run clean && npm run build"
}, },
@ -38,22 +45,32 @@
"@babel/plugin-proposal-class-properties": "^7.3.0", "@babel/plugin-proposal-class-properties": "^7.3.0",
"@babel/preset-env": "^7.3.1", "@babel/preset-env": "^7.3.1",
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.9.0",
"@types/lodash": "^4.2.0",
"@types/react": "^0.14.9 || ^15.3.0 || ^16.0.0",
"@types/react-redux": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
"@typescript-eslint/eslint-plugin": "^2.31.0",
"@typescript-eslint/parser": "^2.31.0",
"babel-loader": "^8.0.5", "babel-loader": "^8.0.5",
"jest": "^24.1.0", "jest": "^24.1.0",
"react": "^16.0.0", "react": "^0.14.9 || ^15.3.0 || ^16.0.0",
"react-dom": "^16.0.0", "react-dom": "^16.0.0",
"react-redux": "^6.0.0", "react-redux": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
"redux": "^4.0.0", "redux": "^3.5.2 || ^4.0.0",
"rimraf": "^2.3.4" "rimraf": "^2.3.4",
"typescript": "^3.8.3"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^0.14.9 || ^15.3.0 || ^16.0.0",
"@types/react-redux": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
"react": "^0.14.9 || ^15.3.0 || ^16.0.0", "react": "^0.14.9 || ^15.3.0 || ^16.0.0",
"react-redux": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "react-redux": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
"redux": "^3.5.2 || ^4.0.0" "redux": "^3.5.2 || ^4.0.0"
}, },
"dependencies": { "dependencies": {
"prop-types": "^15.5.7", "@types/prop-types": "^15.5.7",
"lodash": "^4.2.0", "lodash": "^4.2.0",
"prop-types": "^15.5.7",
"redux-devtools-instrument": "^1.9.0" "redux-devtools-instrument": "^1.9.0"
} }
} }

View File

@ -1,10 +1,15 @@
import React, { Children, Component } from 'react'; import React, { Children, Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect, Provider, ReactReduxContext } from 'react-redux'; import { connect, Provider, ReactReduxContext } from 'react-redux';
import instrument from 'redux-devtools-instrument'; import instrument, {
EnhancedStore,
LiftedState,
LiftedStore,
Options
} from 'redux-devtools-instrument';
import { Action } from 'redux';
function logError(type) { function logError(type: string) {
/* eslint-disable no-console */
if (type === 'NoStore') { if (type === 'NoStore') {
console.error( console.error(
'Redux DevTools could not render. You must pass the Redux store ' + 'Redux DevTools could not render. You must pass the Redux store ' +
@ -18,16 +23,51 @@ function logError(type) {
'using createStore()?' 'using createStore()?'
); );
} }
/* eslint-enable no-console */
} }
export default function createDevTools(children) { interface Props<
const monitorElement = Children.only(children); S,
A extends Action,
MonitorState,
MonitorAction extends Action<unknown>
> {
store?: EnhancedStore<S, A, MonitorState, MonitorAction>;
}
type Monitor<
S,
A extends Action<unknown>,
MonitorProps,
MonitorState,
MonitorAction extends Action<unknown>
> = React.ReactElement<
MonitorProps,
React.ComponentType<MonitorProps & LiftedState<S, A, MonitorState>> & {
update(
monitorProps: MonitorProps,
state: MonitorState | undefined,
action: MonitorAction
): MonitorState;
}
>;
export default function createDevTools<
S,
A extends Action<unknown>,
MonitorProps,
MonitorState,
MonitorAction extends Action<unknown>
>(children: Monitor<S, A, MonitorProps, MonitorState, MonitorAction>) {
const monitorElement = Children.only(children)!;
const monitorProps = monitorElement.props; const monitorProps = monitorElement.props;
const Monitor = monitorElement.type; const Monitor = monitorElement.type;
const ConnectedMonitor = connect(state => state)(Monitor); const ConnectedMonitor = connect(
(state: LiftedState<S, A, MonitorState>) => state
)(Monitor as React.ComponentType<any>);
return class DevTools extends Component { return class DevTools extends Component<
Props<S, A, MonitorState, MonitorAction>
> {
static contextTypes = { static contextTypes = {
store: PropTypes.object store: PropTypes.object
}; };
@ -36,13 +76,18 @@ export default function createDevTools(children) {
store: PropTypes.object store: PropTypes.object
}; };
static instrument = options => liftedStore?: LiftedStore<S, A, MonitorState, MonitorAction>;
static instrument = (options: Options<S, A, MonitorState, MonitorAction>) =>
instrument( instrument(
(state, action) => Monitor.update(monitorProps, state, action), (state, action) => Monitor.update(monitorProps, state, action),
options options
); );
constructor(props, context) { constructor(
props: Props<S, A, MonitorState, MonitorAction>,
context: { store?: EnhancedStore<S, A, MonitorState, MonitorAction> }
) {
super(props, context); super(props, context);
if (ReactReduxContext) { if (ReactReduxContext) {
@ -60,7 +105,7 @@ export default function createDevTools(children) {
if (context.store) { if (context.store) {
this.liftedStore = context.store.liftedStore; this.liftedStore = context.store.liftedStore;
} else { } else {
this.liftedStore = props.store.liftedStore; this.liftedStore = props.store!.liftedStore;
} }
if (!this.liftedStore) { if (!this.liftedStore) {
@ -88,12 +133,28 @@ export default function createDevTools(children) {
logError('NoStore'); logError('NoStore');
return null; return null;
} }
if (!props.store.liftedStore) { if (
!((props.store as unknown) as EnhancedStore<
S,
A,
MonitorState,
MonitorAction
>).liftedStore
) {
logError('NoLiftedStore'); logError('NoLiftedStore');
return null; return null;
} }
return ( return (
<Provider store={props.store.liftedStore}> <Provider
store={
((props.store as unknown) as EnhancedStore<
S,
A,
MonitorState,
MonitorAction
>).liftedStore
}
>
<ConnectedMonitor {...monitorProps} /> <ConnectedMonitor {...monitorProps} />
</Provider> </Provider>
); );

View File

@ -1,7 +1,8 @@
export { export {
default as instrument, default as instrument,
ActionCreators, ActionCreators,
ActionTypes ActionTypes,
LiftedAction
} from 'redux-devtools-instrument'; } from 'redux-devtools-instrument';
export { default as persistState } from './persistState'; export { default as persistState } from './persistState';
export { default as createDevTools } from './createDevTools'; export { default as createDevTools } from './createDevTools';

View File

@ -1,16 +1,18 @@
import mapValues from 'lodash/mapValues'; import mapValues from 'lodash/mapValues';
import identity from 'lodash/identity'; import identity from 'lodash/identity';
import { Action, PreloadedState, Reducer, StoreEnhancer } from 'redux';
import { LiftedState } from 'redux-devtools-instrument';
export default function persistState( export default function persistState<S, A extends Action<unknown>>(
sessionId, sessionId?: string,
deserializeState = identity, deserializeState: (state: S) => S = identity,
deserializeAction = identity deserializeAction: (action: A) => A = identity
) { ): StoreEnhancer {
if (!sessionId) { if (!sessionId) {
return next => (...args) => next(...args); return next => (...args) => next(...args);
} }
function deserialize(state) { function deserialize(state: LiftedState<S, A>): LiftedState<S, A> {
return { return {
...state, ...state,
actionsById: mapValues(state.actionsById, liftedAction => ({ actionsById: mapValues(state.actionsById, liftedAction => ({
@ -25,7 +27,10 @@ export default function persistState(
}; };
} }
return next => (reducer, initialState, enhancer) => { return next => <S, A extends Action<unknown>>(
reducer: Reducer<S, A>,
initialState?: PreloadedState<S>
) => {
const key = `redux-dev-session-${sessionId}`; const key = `redux-dev-session-${sessionId}`;
let finalInitialState; let finalInitialState;
@ -44,11 +49,14 @@ export default function persistState(
} }
} }
const store = next(reducer, finalInitialState, enhancer); const store = next(
reducer,
finalInitialState as PreloadedState<S> | undefined
);
return { return {
...store, ...store,
dispatch(action) { dispatch<T extends A>(action: T) {
store.dispatch(action); store.dispatch(action);
try { try {

View File

@ -0,0 +1,5 @@
declare namespace NodeJS {
interface Global {
localStorage: Storage;
}
}

View File

@ -1,8 +1,9 @@
import { instrument, persistState } from '../src'; import { instrument, persistState } from '../src';
import { compose, createStore } from 'redux'; import { compose, createStore } from 'redux';
import './globalLocalStorage.d.ts';
describe('persistState', () => { describe('persistState', () => {
let savedLocalStorage = global.localStorage; const savedLocalStorage = global.localStorage;
delete global.localStorage; delete global.localStorage;
beforeEach(() => { beforeEach(() => {
@ -19,6 +20,12 @@ describe('persistState', () => {
}, },
clear() { clear() {
this.store = {}; this.store = {};
},
get length() {
return this.store.length;
},
key(index) {
throw new Error('Unimplemented');
} }
}; };
}); });
@ -27,7 +34,8 @@ describe('persistState', () => {
global.localStorage = savedLocalStorage; global.localStorage = savedLocalStorage;
}); });
const reducer = (state = 0, action) => { type Action = { type: 'INCREMENT' } | { type: 'DECREMENT' };
const reducer = (state = 0, action: Action) => {
switch (action.type) { switch (action.type) {
case 'INCREMENT': case 'INCREMENT':
return state + 1; return state + 1;
@ -41,10 +49,7 @@ describe('persistState', () => {
it('should persist state', () => { it('should persist state', () => {
const store = createStore( const store = createStore(
reducer, reducer,
compose( compose(instrument(), persistState('id'))
instrument(),
persistState('id')
)
); );
expect(store.getState()).toBe(0); expect(store.getState()).toBe(0);
@ -54,46 +59,29 @@ describe('persistState', () => {
const store2 = createStore( const store2 = createStore(
reducer, reducer,
compose( compose(instrument(), persistState('id'))
instrument(),
persistState('id')
)
); );
expect(store2.getState()).toBe(2); expect(store2.getState()).toBe(2);
}); });
it('should not persist state if no session id', () => { it('should not persist state if no session id', () => {
const store = createStore( const store = createStore(reducer, compose(instrument(), persistState()));
reducer,
compose(
instrument(),
persistState()
)
);
expect(store.getState()).toBe(0); expect(store.getState()).toBe(0);
store.dispatch({ type: 'INCREMENT' }); store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'INCREMENT' }); store.dispatch({ type: 'INCREMENT' });
expect(store.getState()).toBe(2); expect(store.getState()).toBe(2);
const store2 = createStore( const store2 = createStore(reducer, compose(instrument(), persistState()));
reducer,
compose(
instrument(),
persistState()
)
);
expect(store2.getState()).toBe(0); expect(store2.getState()).toBe(0);
}); });
it('should run with a custom state deserializer', () => { it('should run with a custom state deserializer', () => {
const oneLess = state => (state === undefined ? -1 : state - 1); const oneLess = (state: number | undefined) =>
state === undefined ? -1 : state - 1;
const store = createStore( const store = createStore(
reducer, reducer,
compose( compose(instrument(), persistState('id', oneLess))
instrument(),
persistState('id', oneLess)
)
); );
expect(store.getState()).toBe(0); expect(store.getState()).toBe(0);
@ -103,23 +91,17 @@ describe('persistState', () => {
const store2 = createStore( const store2 = createStore(
reducer, reducer,
compose( compose(instrument(), persistState('id', oneLess))
instrument(),
persistState('id', oneLess)
)
); );
expect(store2.getState()).toBe(1); expect(store2.getState()).toBe(1);
}); });
it('should run with a custom action deserializer', () => { it('should run with a custom action deserializer', () => {
const incToDec = action => const incToDec = (action: Action) =>
action.type === 'INCREMENT' ? { type: 'DECREMENT' } : action; action.type === 'INCREMENT' ? ({ type: 'DECREMENT' } as const) : action;
const store = createStore( const store = createStore(
reducer, reducer,
compose( compose(instrument(), persistState('id', undefined, incToDec))
instrument(),
persistState('id', undefined, incToDec)
)
); );
expect(store.getState()).toBe(0); expect(store.getState()).toBe(0);
@ -129,24 +111,17 @@ describe('persistState', () => {
const store2 = createStore( const store2 = createStore(
reducer, reducer,
compose( compose(instrument(), persistState('id', undefined, incToDec))
instrument(),
persistState('id', undefined, incToDec)
)
); );
expect(store2.getState()).toBe(-2); expect(store2.getState()).toBe(-2);
}); });
it('should warn if read from localStorage fails', () => { it('should warn if read from localStorage fails', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const spy = jest.spyOn(console, 'warn').mockImplementation(() => {
// noop
});
delete global.localStorage.getItem; delete global.localStorage.getItem;
createStore( createStore(reducer, compose(instrument(), persistState('id')));
reducer,
compose(
instrument(),
persistState('id')
)
);
expect(spy.mock.calls[0]).toContain( expect(spy.mock.calls[0]).toContain(
'Could not read debug session from localStorage:' 'Could not read debug session from localStorage:'
@ -156,14 +131,13 @@ describe('persistState', () => {
}); });
it('should warn if write to localStorage fails', () => { it('should warn if write to localStorage fails', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const spy = jest.spyOn(console, 'warn').mockImplementation(() => {
// noop
});
delete global.localStorage.setItem; delete global.localStorage.setItem;
const store = createStore( const store = createStore(
reducer, reducer,
compose( compose(instrument(), persistState('id'))
instrument(),
persistState('id')
)
); );
store.dispatch({ type: 'INCREMENT' }); store.dispatch({ type: 'INCREMENT' });

View File

@ -0,0 +1,4 @@
{
"extends": "../../../tsconfig.react.base.json",
"include": ["../src", "."]
}

View File

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.react.base.json",
"compilerOptions": {
"outDir": "lib"
},
"include": ["src"]
}