diff --git a/package.json b/package.json index 92e42caf8..f085ce387 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "easy-peasy", - "version": "2.1.1", + "version": "2.2.0-alpha.2", "description": "Easy peasy global state for React", "license": "MIT", "main": "dist/easy-peasy.cjs.js", diff --git a/src/__tests__/issues.test.js b/src/__tests__/issues.test.js new file mode 100644 index 000000000..de5230bd8 --- /dev/null +++ b/src/__tests__/issues.test.js @@ -0,0 +1,54 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { render } from 'react-testing-library'; + +import { action, createStore, useStore, StoreProvider } from '../index'; + +test('issue#136', async () => { + // arrange + const store = createStore({ + items: { + a: { id: 'a', name: 'A' }, + b: { id: 'b', name: 'B' }, + c: { id: 'c', name: 'C' }, + }, + deleteB: action(state => { + delete state.items.b; + }), + }); + + const ListItem = ({ id }) => { + const { name } = useStore(s => s.items[id], [id]); + return name; + }; + + function App() { + const itemIds = useStore(s => Object.keys(s.items)); + const items = itemIds.map(id => ); + return
{items}
; + } + + const app = ( + + + + ); + + const { rerender, getByTestId } = render(app); + + expect(getByTestId('items').innerHTML).toBe('ABC'); + + act(() => { + store.dispatch.deleteB(); + }); + + rerender( + + + , + ); + + expect(getByTestId('items').innerHTML).toBe('AC'); + + await new Promise(resolve => setTimeout(resolve, 1000)); +}); diff --git a/src/hooks.js b/src/hooks.js index 26c628742..d4272a2d7 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -1,10 +1,16 @@ -import { useState, useEffect, useContext, useRef } from 'react'; +import { useState, useEffect, useContext, useRef, useMemo } from 'react'; import shallowEqual from 'shallowequal'; import EasyPeasyContext from './context'; import { isStateObject } from './lib'; +let idx = 0; + export function useStore(mapState, dependencies = []) { - const store = useContext(EasyPeasyContext); + const id = useMemo(() => { + idx += 1; + return idx; + }, []); + const { store, addListener, removeListener } = useContext(EasyPeasyContext); const [state, setState] = useState(mapState(store.getState())); // As our effect only fires on mount and unmount it won't have the state // changes visible to it, therefore we use a mutable ref to track this. @@ -12,9 +18,8 @@ export function useStore(mapState, dependencies = []) { // Helps avoid firing of events when unsubscribed, i.e. unmounted const isActive = useRef(true); useEffect(() => { - const calculateState = () => { - const newState = mapState(store.getState()); - isActive.current = true; + const handleStoreUpdate = storeState => { + const newState = mapState(storeState); if ( newState === stateRef.current || (isStateObject(newState) && @@ -29,23 +34,24 @@ export function useStore(mapState, dependencies = []) { setState(stateRef.current); } }; - calculateState(); - const unsubscribe = store.subscribe(calculateState); + isActive.current = true; + handleStoreUpdate(store.getState()); + addListener(id, handleStoreUpdate); return () => { - unsubscribe(); isActive.current = false; + removeListener(id); }; }, dependencies); return state; } export function useActions(mapActions) { - const store = useContext(EasyPeasyContext); + const { store } = useContext(EasyPeasyContext); return mapActions(store.dispatch); } export function useDispatch() { - const store = useContext(EasyPeasyContext); + const { store } = useContext(EasyPeasyContext); return store.dispatch; } diff --git a/src/provider.js b/src/provider.js index 294ecd20a..d3f4372a8 100644 --- a/src/provider.js +++ b/src/provider.js @@ -1,10 +1,35 @@ -import React from 'react'; +import React, { useEffect, useMemo } from 'react'; import PropTypes from 'prop-types'; import StoreContext from './context'; -const StoreProvider = ({ children, store }) => ( - {children} -); +const createCtx = store => { + const callbackMap = {}; + return { + addListener: (id, fn) => { + callbackMap[id] = fn; + }, + removeListener: id => { + const listener = callbackMap[id]; + if (listener) { + delete callbackMap[id]; + } + }, + getListeners: () => Object.values(callbackMap), + store, + }; +}; + +const StoreProvider = ({ children, store }) => { + const ctx = useMemo(() => createCtx(store), [store]); + useEffect(() => { + const unsubscribe = store.subscribe(() => { + const state = store.getState(); + ctx.getListeners().map(fn => fn(state)); + }); + return unsubscribe; + }); + return {children}; +}; StoreProvider.propTypes = { children: PropTypes.node.isRequired,