Skip to content

Commit

Permalink
Feat: Initial implementation of redux-pauseable-store (#7)
Browse files Browse the repository at this point in the history
* Scaffolding for redux-pauseable-store

* Minimal functionality for redux-pauseable-store

* Initial test coverage
  • Loading branch information
spautz committed Jun 27, 2020
1 parent 4a543fe commit cd246e8
Show file tree
Hide file tree
Showing 16 changed files with 374 additions and 28 deletions.
2 changes: 1 addition & 1 deletion jest-base.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module.exports = {
'^.+\\.tsx?$': 'ts-jest',
},
testRegex: '(/__tests__/.*|/tests/.*|(\\.|/)(test|spec))\\.[jt]sx?$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
moduleFileExtensions: ['js', 'jsx', 'json', 'ts', 'tsx', 'node'],
modulePathIgnorePatterns: ['dist/'],
collectCoverage: true,
coverageReporters: ['json', 'html'],
Expand Down
1 change: 1 addition & 0 deletions packages/dev-helpers/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"name": "react-hibernate-dev-helpers",
"private": true,
"version": "0.0.2",
"description": "A react-router Switch which can leave inactive routes mounted-but-inactive until you navigate back",
"keywords": [],
Expand Down
6 changes: 3 additions & 3 deletions packages/dev-helpers/src/components/DemoContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import TextField from '@material-ui/core/TextField';

import NestedState from './NestedState';

import { countAction, HelperState } from '../redux';
import { incrementAction, DevHelperState } from '../redux';

export interface DemoContainerProps {
title: string;
withRedux?: boolean;
}

const selectEntireState = (state: HelperState): HelperState => state;
const selectEntireState = (state: DevHelperState): DevHelperState => state;

let totalInstanceCount = 0;

Expand Down Expand Up @@ -56,7 +56,7 @@ const DemoContainer: React.FC<DemoContainerProps> = (props: DemoContainerProps):
<Button
variant="contained"
onClick={(): void => {
dispatch(countAction());
dispatch(incrementAction());
}}
>
Dispatch a counter update
Expand Down
1 change: 0 additions & 1 deletion packages/dev-helpers/src/redux/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export { default as reduxDecorator } from './reduxDecorator';
export * from './reduxDecorator';

export { default as store } from './store';
export * from './store';
8 changes: 5 additions & 3 deletions packages/dev-helpers/src/redux/reduxDecorator.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useMemo } from 'react';
import { Provider } from 'react-redux';

import store from './store';
import { createDevHelperStore } from './store';

const reduxDecorator = (storyFn: () => ReactNode): ReactNode => {
return <Provider store={store}>{storyFn()}</Provider>;
const devHelperStore = useMemo(createDevHelperStore, []);

return <Provider store={devHelperStore}>{storyFn()}</Provider>;
};

export default reduxDecorator;
80 changes: 60 additions & 20 deletions packages/dev-helpers/src/redux/store.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,77 @@
import { createStore } from 'redux';
import { createStore, Store } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';

// We only need one action: this is only to demonstrate that updates can be frozen
const COUNT_ACTION = 'COUNT_ACTION';
const INCREMENT_ACTION = 'INCREMENT_!' as const;
const DECREMENT_ACTION = 'DECREMENT_!' as const;
const SET_ACTION = 'SET_!' as const;

export type HelperState = {
export type DevHelperState = {
count: number;
lastTimestamp: number;
};
export type HelperAction = {
type: typeof COUNT_ACTION;

type DevHelperAction_Increment = {
type: typeof INCREMENT_ACTION;
};
type DevHelperAction_Decrement = {
type: typeof DECREMENT_ACTION;
};
type DevHelperAction_Set = {
type: typeof SET_ACTION;
payload: {
newValue: number;
};
};

export type DevHelperAction =
| DevHelperAction_Increment
| DevHelperAction_Decrement
| DevHelperAction_Set;

const initialState: HelperState = {
const initialState: DevHelperState = {
count: 0,
lastTimestamp: 0,
};

const countAction = (): HelperAction => ({ type: COUNT_ACTION });
const incrementAction = (): DevHelperAction_Increment => ({ type: INCREMENT_ACTION });
const decrementAction = (): DevHelperAction_Decrement => ({ type: DECREMENT_ACTION });
const setAction = (newValue: number): DevHelperAction_Set => ({
type: SET_ACTION,
payload: { newValue },
});

const reducer = (state: HelperState = initialState, action: HelperAction): HelperState => {
const reducer = (state: DevHelperState = initialState, action: DevHelperAction): DevHelperState => {
const { type } = action;
if (type === COUNT_ACTION) {
return {
...state,
count: state.count + 1,
lastTimestamp: Date.now(),
};
switch (type) {
case INCREMENT_ACTION: {
return {
...state,
count: state.count + 1,
};
}

case DECREMENT_ACTION: {
return {
...state,
count: state.count - 1,
};
}

case SET_ACTION: {
const {
payload: { newValue },
} = action as DevHelperAction_Set;
return {
...state,
count: newValue,
};
}

default:
break;
}

return state;
};

const store = createStore(reducer, initialState, composeWithDevTools());
const createDevHelperStore = (): Store => createStore(reducer, initialState, composeWithDevTools());

export default store;
export { countAction };
export { createDevHelperStore, incrementAction, decrementAction, setAction };
23 changes: 23 additions & 0 deletions packages/redux-pauseable-store/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Unless https://github.com/prettier/prettier/issues/4081 is ever resolved,
# this MUST be copied into each package dir for local runs to work.

# Whitelist supported files only
**/*.*
!**/*.css
!**/*.html
!**/*.js
!**/*.jsx
!**/*.json
!**/*.less
!**/*.md
!**/*.scss
!**/*.ts
!**/*.tsx

# Unless they're somewhere we can ignore
build/
dist/
coverage/
coverage-local/
node_modules/
storybook-static/
41 changes: 41 additions & 0 deletions packages/redux-pauseable-store/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Redux-Pauseable-Store

**This package is in active development. Things will change rapidly, and it is not yet production-ready. Feedback is welcome.**

Derive one redux store from another, then pause it.

Part of [React Hibernate](../../)

[![npm version](https://img.shields.io/npm/v/redux-pauseable-store.svg)](https://www.npmjs.com/package/redux-pauseable-store)
[![gzip size](https://img.shields.io/bundlephobia/minzip/redux-pauseable-store)](https://bundlephobia.com/result?p=redux-pauseable-store@latest)

## Danger

You probably don't need this library. When used incorrectly it will do more harm than good.

Consider using [`<PauseableReduxContainer>`](../react-pauseable-containers#pauseablereduxcontainer)
from [React Pauseable Containers](../react-pauseable-containers) instead.

## Overview

Whenever Redux updates, its new data will be provided to all components that are subscribed to it. This library
interrupts that.

## Details

If you want to **completely** stop a subtree from rerendering for some time -- as [React Router Hibernate](../react-router-hibernate/),
[React hibernate](../react-hibernate), and [React Pauseable Containers](../react-pauseable-containers) do -- then you
have to prevent anything which would trigger a state change in any component in that subtree.

React-Redux's [useSelector hook](https://react-redux.js.org/next/api/hooks#useselector) works by
[forcing a rerender](https://github.com/reduxjs/react-redux/blob/5402f24db139f7ff01c7f873d136ea7ee3b8d1cb/src/hooks/useSelector.js#L15)
outside of the normal render cycle: it triggers a state change.

This library overwrites the context provider for React Redux to trick it into subscribing to a second Redux store --
which we can then pause and unpause.

## Usage

```
// @TODO
```
7 changes: 7 additions & 0 deletions packages/redux-pauseable-store/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* eslint-env node */
const baseConfig = require('../../jest-base.config');

module.exports = {
...baseConfig,
coverageDirectory: 'coverage-local',
};
58 changes: 58 additions & 0 deletions packages/redux-pauseable-store/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "redux-pauseable-store",
"version": "0.0.2",
"description": "Derive one redux store from another, then pause it",
"keywords": [],
"license": "MIT",
"homepage": "https://github.com/spautz/react-hibernate/packages/redux-pauseable-store#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/spautz/react-hibernate.git"
},
"bugs": {
"url": "https://github.com/spautz/react-hibernate/issues"
},
"author": "Steven Pautz <spautz@gmail.com>",
"files": [
"dist/",
"src/",
"LICENSE",
"README.md"
],
"source": "src/index.ts",
"main": "dist/index.js",
"module": "dist/index.esm.js",
"jsnext:main": "dist/index.esm.js",
"types": "dist/index.d.ts",
"sideEffects": false,
"scripts": {
"____ BASE COMMANDS _________________________________________________": "",
"build": "microbundle build --jsx React.createElement",
"build:watch": "microbundle watch --jsx React.createElement",
"clean": "rimraf dist/ node_modules/.cache/",
"format": "prettier --write \"**/*.*\"",
"format:checkup": "prettier --list-different \"**/*.*\"",
"lint": "eslint \"**/*.{js,jsx,json,ts,tsx}\"",
"release": "standard-version --sign --release-as ",
"test": "jest",
"test:clean": "rimraf coverage-local/",
"test:coverage": "jest --coverage",
"test:watch": "jest --watch",
"types": "tsc --noEmit --p tsconfig.json --jsx react",
"____ HOOKS _________________________________________________________": "",
"prepare": "yon build",
"prebuild": "yon clean",
"prerelease": "yon clean",
"prepublishOnly": "yon checkup && yon build",
"pretest": "yon test:clean",
"____ INTEGRATION ___________________________________________________": "",
"dev": "yon run format && yon run types && yon run lint",
"checkup": "yon format:checkup && yon run types && yon run lint",
"all": "yon run dev && yon run test:coverage && yon run build"
},
"dependencies": {},
"devDependencies": {},
"peerDependencies": {
"redux": "^4.0.0"
}
}
4 changes: 4 additions & 0 deletions packages/redux-pauseable-store/prettier.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* eslint-env node */
const baseConfig = require('../../prettier.config');

module.exports = baseConfig;
81 changes: 81 additions & 0 deletions packages/redux-pauseable-store/src/createPauseableStore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Action, Store } from 'redux';

import { PauseableStoreInstance, PauseableStoreOptions } from './types';

const createPauseableStore = (
parentStore: Store,
options?: PauseableStoreOptions,
): PauseableStoreInstance => {
const { isPaused: isInitiallyPaused = false, canDispatch: canInitiallyDispatch = 'warn' } =
options || {};

const pauseableStore = {} as PauseableStoreInstance;
let stateAtPause = isInitiallyPaused ? parentStore.getState() : null;

const dispatch = (action: Action) => {
if (pauseableStore.canDispatch === 'warn') {
console.warn(
'Something is trying to dispatch an action to a PauseableStore. Set `canDispatch` to true or false to disable this warning.',
{ action, pauseableStore },
);
}
if (pauseableStore.canDispatch) {
return parentStore.dispatch(action);
}
return null;
};

const subscribe = (listener: () => void) => {
return parentStore.subscribe(() => {
// Ignore when paused
if (!pauseableStore.isPaused) {
listener();
}
});
};

const getState = () => {
if (pauseableStore.isPaused) {
return stateAtPause;
}
return parentStore.getState();
};

const setPaused = (newIsPaused: boolean) => {
pauseableStore.isPaused = newIsPaused;

stateAtPause = newIsPaused ? parentStore.getState() : null;
};

const setDispatch = (newCanDispatch: boolean | 'warn') => {
pauseableStore.canDispatch = newCanDispatch;
};

const replaceReducer = () => {
throw new Error(
'Cannot replaceReducer on a pauseableStore: replaceReducer on the parent store, instead',
);
};

Object.assign(pauseableStore, {
// Redux Store interface
...parentStore,
dispatch,
subscribe,
getState,
replaceReducer,
// @TODO: Do we also need to handle [$$observable]: observable, ?

// PauseableStore additions
isPaused: isInitiallyPaused,
setPaused,
canDispatch: canInitiallyDispatch,
setDispatch,

_parentStore: parentStore,
});

return pauseableStore;
};

export default createPauseableStore;
4 changes: 4 additions & 0 deletions packages/redux-pauseable-store/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { default as createPauseableStore } from './createPauseableStore';
export * from './createPauseableStore';

export * from './types';
Loading

0 comments on commit cd246e8

Please sign in to comment.