diff --git a/packages/dev-helpers/package.json b/packages/dev-helpers/package.json index ea85ba1..80c1d4e 100644 --- a/packages/dev-helpers/package.json +++ b/packages/dev-helpers/package.json @@ -53,6 +53,7 @@ "@material-ui/core": "^4.9.4", "@testing-library/react": "^10.2.1", "history": "^4.10.1", + "prop-types": "^15.7.2", "react-is": "^16.13.0", "react-redux": "^7.2.0", "react-router": "^5.1.2", diff --git a/packages/dev-helpers/src/components/RenderCount.tsx b/packages/dev-helpers/src/components/RenderCount.tsx new file mode 100644 index 0000000..49cd5f7 --- /dev/null +++ b/packages/dev-helpers/src/components/RenderCount.tsx @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React, { useRef } from 'react'; + +import Typography from '@material-ui/core/Typography'; + +interface RenderCountProps { + prefix?: string; +} + +const RenderCount: React.FC = (props) => { + const { prefix = 'Render count: ' } = props; + + const renderCountRef = useRef(0); + renderCountRef.current++; + + return ( + + {prefix} + {renderCountRef.current} + + ); +}; + +RenderCount.propTypes = { + prefix: PropTypes.string, +}; + +export default RenderCount; diff --git a/packages/dev-helpers/src/components/index.ts b/packages/dev-helpers/src/components/index.ts index 600c473..73311e4 100644 --- a/packages/dev-helpers/src/components/index.ts +++ b/packages/dev-helpers/src/components/index.ts @@ -3,3 +3,6 @@ export * from './DemoContainer'; export { default as NestedState } from './NestedState'; export * from './NestedState'; + +export { default as RenderCount } from './RenderCount'; +export * from './RenderCount'; diff --git a/packages/dev-helpers/src/redux/index.ts b/packages/dev-helpers/src/redux/index.ts index 4a920ee..81fdbc3 100644 --- a/packages/dev-helpers/src/redux/index.ts +++ b/packages/dev-helpers/src/redux/index.ts @@ -1,4 +1,7 @@ export { default as reduxDecorator } from './reduxDecorator'; export * from './reduxDecorator'; +export { default as useCountSelector } from './useCountSelector'; +export * from './useCountSelector'; + export * from './store'; diff --git a/packages/dev-helpers/src/redux/useCountSelector.ts b/packages/dev-helpers/src/redux/useCountSelector.ts new file mode 100644 index 0000000..516aa7e --- /dev/null +++ b/packages/dev-helpers/src/redux/useCountSelector.ts @@ -0,0 +1,11 @@ +import { useSelector } from 'react-redux'; + +import { DevHelperState } from './store'; + +const countSelector = (state: DevHelperState) => state.count; + +const useCountSelector = (): number => { + return useSelector(countSelector); +}; + +export default useCountSelector; diff --git a/packages/react-hibernate/package.json b/packages/react-hibernate/package.json index 4fb6efa..cedcbb4 100644 --- a/packages/react-hibernate/package.json +++ b/packages/react-hibernate/package.json @@ -59,7 +59,6 @@ }, "peerDependencies": { "react": ">=16.8.0", - "react-dom": ">=16.8.0", - "react-router": ">=5.0.0" + "react-dom": ">=16.8.0" } } diff --git a/packages/react-pauseable-containers/package.json b/packages/react-pauseable-containers/package.json index e8d140e..7856585 100644 --- a/packages/react-pauseable-containers/package.json +++ b/packages/react-pauseable-containers/package.json @@ -50,10 +50,12 @@ "test:watch": "echo \"@TODO: tests for pauseable-containers\"", "types": "tsc --noEmit --p tsconfig.json --jsx react" }, - "dependencies": {}, + "dependencies": { + "prop-types": "^15.7.2", + "redux-pauseable-store": "0.0.3" + }, "devDependencies": { - "react-hibernate-dev-helpers": "0.0.2", - "react-router-hibernate": "0.0.2" + "react-hibernate-dev-helpers": "0.0.2" }, "peerDependencies": { "react": ">=16.8.0", diff --git a/packages/react-pauseable-containers/src/PauseableComponentContainer.tsx b/packages/react-pauseable-containers/src/PauseableComponentContainer.tsx index 156a670..38f092f 100644 --- a/packages/react-pauseable-containers/src/PauseableComponentContainer.tsx +++ b/packages/react-pauseable-containers/src/PauseableComponentContainer.tsx @@ -9,11 +9,7 @@ class PauseableComponentContainer extends React.Component = ({ - shouldUpdate, - children, -}: PropsWithChildren): ReactElement | null => { - const store = useStore(); - const staticStoreRef = React.useRef(); - const wasActiveRef = React.useRef(); - - const stateWhenLastActive = React.useRef(); - - if (shouldUpdate) { - // Track stuff for when we go inactive - stateWhenLastActive.current = store.getState(); - } else { - if (wasActiveRef.current) { - // We're going inactive: freeze the store contents to the last-active state - staticStoreRef.current = { - ...store, - getState: (): ReturnType => stateWhenLastActive.current, - }; - } else { - // We're somehow being rendered in an initially-inactive state: that can't be right - if (process.env.NODE_ENV !== 'production') { - console.warn( - 'PauseableReduxContainer is being mounted with shouldUpdate=false: this is probably a bug', - ); - } - return null; - } - } - - wasActiveRef.current = shouldUpdate; - return ( - {children} +export interface PauseableReduxContainerProps extends PauseableContainerProps { + children: React.ReactNode; + dispatchWhenPaused?: boolean | null; +} + +const PauseableReduxContainer: React.FC = (props) => { + const { dispatchWhenPaused, shouldUpdate, children } = props; + + const parentStore = useStore(); + const pauseableStore = React.useMemo( + () => + createPauseableStore(parentStore, { + // A change to the `shouldUpdate` prop will already cause a rerender, so we don't need an extra notification + notifyListersOnUnpause: false, + }), + [parentStore], ); + + pauseableStore.setPaused(!shouldUpdate); + pauseableStore.setDispatch(dispatchWhenPaused); + + return {children}; +}; + +PauseableReduxContainer.defaultProps = { + dispatchWhenPaused: null, +}; + +PauseableReduxContainer.propTypes = { + children: PropTypes.node.isRequired, + dispatchWhenPaused: PropTypes.bool, + shouldUpdate: PropTypes.bool.isRequired, }; export default PauseableReduxContainer; diff --git a/packages/react-pauseable-containers/stories/PauseableComponentItem.tsx b/packages/react-pauseable-containers/stories/PauseableComponentItem.tsx new file mode 100644 index 0000000..cfec797 --- /dev/null +++ b/packages/react-pauseable-containers/stories/PauseableComponentItem.tsx @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; + +import Checkbox from '@material-ui/core/Checkbox'; +import Chip from '@material-ui/core/Chip'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Paper from '@material-ui/core/Paper'; +import Typography from '@material-ui/core/Typography'; + +import { RenderCount } from 'react-hibernate-dev-helpers'; + +import { PauseableComponentContainer } from '../src'; + +export interface PauseableComponentItemProps { + count: number; +} + +const PauseableComponentItem: React.FC = (props) => { + const { count } = props; + + const [shouldUpdate, setShouldUpdate] = useState(true); + + return ( + + setShouldUpdate(event.target.checked)} + /> + } + label="shouldUpdate" + /> +
+ + + count: + + + +
+
+ ); +}; + +PauseableComponentItem.propTypes = { + count: PropTypes.number.isRequired, +}; + +export default PauseableComponentItem; diff --git a/packages/react-pauseable-containers/stories/PauseableReduxItem.tsx b/packages/react-pauseable-containers/stories/PauseableReduxItem.tsx new file mode 100644 index 0000000..57d20ba --- /dev/null +++ b/packages/react-pauseable-containers/stories/PauseableReduxItem.tsx @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; + +import Checkbox from '@material-ui/core/Checkbox'; +import Chip from '@material-ui/core/Chip'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Typography from '@material-ui/core/Typography'; +import Paper from '@material-ui/core/Paper'; + +import { RenderCount, useCountSelector } from 'react-hibernate-dev-helpers'; + +import { PauseableComponentContainer, PauseableReduxContainer } from '../src'; + +interface PauseableReduxItemProps { + dispatchWhenPaused?: boolean; +} + +const PauseableReduxItem: React.FC = (props) => { + const { dispatchWhenPaused } = props; + const count = useCountSelector(); + + const [shouldUpdate, setShouldUpdate] = useState(true); + + return ( + + setShouldUpdate(event.target.checked)} + /> + } + label="shouldUpdate" + /> + + + + count: + + + + + + ); +}; + +PauseableReduxItem.propTypes = { + dispatchWhenPaused: PropTypes.bool, +}; + +PauseableReduxItem.defaultProps = { + dispatchWhenPaused: false, +}; + +export default PauseableReduxItem; diff --git a/packages/react-pauseable-containers/stories/ReduxMonitor.tsx b/packages/react-pauseable-containers/stories/ReduxMonitor.tsx new file mode 100644 index 0000000..04a55e2 --- /dev/null +++ b/packages/react-pauseable-containers/stories/ReduxMonitor.tsx @@ -0,0 +1,29 @@ +import React, { useCallback } from 'react'; + +import Chip from '@material-ui/core/Chip'; + +import { incrementAction, useCountSelector } from 'react-hibernate-dev-helpers'; + +import Button from '@material-ui/core/Button'; +import { useDispatch } from 'react-redux'; + +const PauseableReduxItem: React.FC = () => { + const dispatch = useDispatch(); + const count = useCountSelector(); + + const increment = useCallback(() => dispatch(incrementAction()), []); + + return ( + <> + +
+ Redux count: + +
+ + ); +}; + +export default PauseableReduxItem; diff --git a/packages/react-pauseable-containers/stories/pauseableContainers.stories.tsx b/packages/react-pauseable-containers/stories/pauseableContainers.stories.tsx index 29dbda2..8cbe90c 100644 --- a/packages/react-pauseable-containers/stories/pauseableContainers.stories.tsx +++ b/packages/react-pauseable-containers/stories/pauseableContainers.stories.tsx @@ -1,142 +1,85 @@ -import React, { ReactElement, ReactNode } from 'react'; -import { MemoryRouter, Redirect, Route, RouteProps } from 'react-router'; -import { NavLink } from 'react-router-dom'; +import React, { ReactNode, useCallback, useState } from 'react'; +import { withKnobs } from '@storybook/addon-knobs'; + +import Button from '@material-ui/core/Button'; +import Chip from '@material-ui/core/Chip'; import Typography from '@material-ui/core/Typography'; import 'typeface-roboto'; -import { DemoContainer, reduxDecorator } from 'react-hibernate-dev-helpers'; -import { HibernatingRoute, HibernatingSwitch } from 'react-router-hibernate'; +import { reduxDecorator } from 'react-hibernate-dev-helpers'; -import { PauseableReduxContainer, PauseableComponentContainer } from '../src'; +import PauseableComponentItem from './PauseableComponentItem'; +import PauseableReduxItem from './PauseableReduxItem'; +import ReduxMonitor from './ReduxMonitor'; export default { - title: 'Pauseable Containers', - decorators: [reduxDecorator], + title: 'React Pauseable Containers', + decorators: [reduxDecorator, withKnobs], }; -export const WithNoWrapper = (): ReactNode => ( - - Route1 - {' | '} - Route2 - {' | '} - Route3 id=1 - {' | '} - Route3 id=2 - {' | '} - Route3 id=3 - - - With no WrapperComponent set, components will rerender when you return to them - - - - - - - - - - - - - - -); - -export const WithWrapperComponent = (): ReactNode => ( - - Route1 - {' | '} - Route2 - {' | '} - Route3 id=1 - {' | '} - Route3 id=2 - {' | '} - Route3 id=3 - - - With the PauseableComponentContainer, components do not automatically rerender when you return - to them - - - - - - - - - - - - - - -); - -export const WithReduxWrapper = (): ReactNode => ( - - Route1 - {' | '} - Route2 - {' | '} - Route3 id=1 - {' | '} - Route3 id=2 - {' | '} - Route3 id=3 - - - With the PauseableReduxContainer, redux updates do not cause a rerender in hibernating routes. - Due to the freezing/unfreezing of redux, however, the render count goes up by two when - switching (once when entering hibernation, once when leaving it) - - - - - - - - - - - - - - -); - -const MyCustomRoute = (props: RouteProps): ReactElement => ; - -export const MixRoutesWithWrapperComponent = (): ReactNode => ( - - Non-hibernating Route 1 - {' | '} - Non-hibernating Route 2 - {' | '} - Hibernating id=1 - {' | '} - Hibernating id=2 - {' | '} - Hibernating id=3 +export const PauseableComponentContainerStory = (): ReactNode => { + const [count, setCount] = useState(0); + + const increment = useCallback(() => setCount((n) => n + 1), []); + + return ( +
+ + <PauseableComponentContainer> + + + The parent state includes a count variable which is passed to each child. + + + Each child is wrapped in a PauseableComponentContainer whose{' '} + shouldUpdate prop is controlled by the checkbox. + + +
+ Parent count: + +
+ + + +
+ ); +}; +PauseableComponentContainerStory.story = { name: 'PauseableComponentContainer' }; + +const PauseableReduxContainerDemo = () => { + return ( +
+ + <PauseableReduxContainer> + + + The parent state includes a count variable which is passed to each child. + + + Each child is wrapped in a PauseableReduxContainer whose{' '} + shouldUpdate prop is controlled by the checkbox. + + + + + + + +
+ ); +}; - - The first two screens are never retained, the last three are - +export const PauseableReduxContainerStory = (): ReactNode => { + return ; +}; +PauseableReduxContainerStory.story = { + name: 'PauseableReduxContainer', + decorators: [reduxDecorator], +}; - - - - - - - - - - - - -
-); +// @TODO: A story where the PauseableReduxItems can dispatch actions +// const dispatchWhenPaused = boolean('Allow dispatches from paused children', false); diff --git a/packages/redux-pauseable-store/src/createPauseableStore.tsx b/packages/redux-pauseable-store/src/createPauseableStore.tsx index 1bb89e4..29cdb59 100644 --- a/packages/redux-pauseable-store/src/createPauseableStore.tsx +++ b/packages/redux-pauseable-store/src/createPauseableStore.tsx @@ -6,11 +6,15 @@ const createPauseableStore = ( parentStore: Store, options?: PauseableStoreOptions, ): PauseableStoreInstance => { - const { isPaused: isInitiallyPaused = false, canDispatch: canInitiallyDispatch = 'warn' } = - options || {}; + const { + isPaused: isInitiallyPaused = false, + canDispatch: canInitiallyDispatch = 'warn', + notifyListersOnUnpause: notifyListenersInitially = true, + } = options || {}; const pauseableStore = {} as PauseableStoreInstance; let stateAtPause = isInitiallyPaused ? parentStore.getState() : null; + const listeners: Array<() => void> = []; const dispatch = (action: Action) => { if ( @@ -30,12 +34,23 @@ const createPauseableStore = ( }; const subscribe = (listener: () => void) => { - return parentStore.subscribe(() => { + listeners.push(listener); + + const wrappedListener = () => { // Ignore when paused if (!pauseableStore.isPaused) { listener(); } - }); + }; + + const unsubscribe = parentStore.subscribe(wrappedListener); + const wrappedUnsubscribe = () => { + const indexOfListener = listeners.findIndex(listener); + listeners.splice(indexOfListener, 1); + unsubscribe(); + }; + + return wrappedUnsubscribe; }; const getState = () => { @@ -47,8 +62,17 @@ const createPauseableStore = ( const setPaused = (newIsPaused: boolean) => { pauseableStore.isPaused = newIsPaused; + const currentState = parentStore.getState(); - stateAtPause = newIsPaused ? parentStore.getState() : null; + if (newIsPaused) { + stateAtPause = currentState; + } else { + if (pauseableStore.notifyListersOnUnpause && currentState !== stateAtPause) { + // Let subscribers know that something has changed + listeners.forEach((listener) => listener()); + } + stateAtPause = null; + } }; const setDispatch = (newCanDispatch: boolean | 'warn') => { @@ -75,6 +99,7 @@ const createPauseableStore = ( setPaused, canDispatch: canInitiallyDispatch, setDispatch, + notifyListersOnUnpause: notifyListenersInitially, _parentStore: parentStore, }); diff --git a/packages/redux-pauseable-store/src/types.ts b/packages/redux-pauseable-store/src/types.ts index a692364..922134d 100644 --- a/packages/redux-pauseable-store/src/types.ts +++ b/packages/redux-pauseable-store/src/types.ts @@ -4,7 +4,9 @@ export type PauseableStoreOptions = Partial<{ /** Whether the pauseable store receives state updates from its parent store */ isPaused: boolean; /** Whether the pauseable store can dispatch actions to its parent store */ - canDispatch: boolean | 'warn'; + canDispatch: boolean | null | 'warn'; + /** Whether to notify subscribed listeners when the store unpauses, if it was updated while paused */ + notifyListersOnUnpause: boolean; }>; // @TODO: Apply template types from Redux: @@ -14,11 +16,12 @@ export interface PauseableStoreInstance extends Store { // All options are accessible via the store instance isPaused: PauseableStoreOptions['isPaused']; canDispatch: PauseableStoreOptions['canDispatch']; + notifyListersOnUnpause: PauseableStoreOptions['notifyListersOnUnpause']; /** Enables or disables updates from the parent store */ - setPaused: (isPaused: boolean) => void; + setPaused: (isPaused: PauseableStoreOptions['isPaused']) => void; /** Enables or disables dispatching actions to the parent store */ - setDispatch: (canDispatch: boolean | 'warn') => void; + setDispatch: (canDispatch: PauseableStoreOptions['canDispatch']) => void; /** For debugging only: don't touch this unless you know what you're doing */ _parentStore: Store;