From 8f05b6dff1457b2b4575b14c0e8dd37aaf7b8c25 Mon Sep 17 00:00:00 2001 From: Aral Roca Gomez Date: Fri, 5 Nov 2021 20:36:44 +0100 Subject: [PATCH] Convert onAfterUpdate to be more easy, tiny and powerful (#22) * Convert onAfterUpdate to be more easy, tiny and powerful * Move prevStore declaration to avoid memory problems * Update eslint rule * Add test * Update tests/onAfterUpdate.test.js --- .eslintrc.js | 1 + README.md | 85 ++++++++------- package/index.js | 20 ++-- tests/onAfterUpdate.test.js | 210 +++++++++++++++++++++++------------- 4 files changed, 187 insertions(+), 129 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index b3ffcee..9b9b723 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,6 +31,7 @@ module.exports = { 'require-jsdoc': 0, 'react/prop-types': 0, 'prefer-const': 0, + 'linebreak-style': ['error', process.platform === 'win32' ? 'windows' : 'unix'], 'max-len': [ 'error', {'code': 80, 'ignoreStrings': true}, diff --git a/README.md b/README.md index 9de7b89..1e51640 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ const initialStore = { cart: { price: 0, items: [] }, }; -function onAfterUpdate({ path, value, prevValue }) +function onAfterUpdate({ store, prevStore }) console.log("This callback is executed after an update"); } @@ -212,10 +212,10 @@ function Example() { _Input:_ -| name | type | description | example | -| --------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Initial value | `any` | This parameter is **not mandatory**. It only makes sense for new store properties that have not been defined before within the `createStore`. If the value has already been initialized inside the `createStore` this parameter has no effect. | `const [price, setPrice] = useStore.cart.price(0)` | -| event after an update | `function` | This parameter is **not mandatory**. Adds an event that is executed every time there is a change inside the indicated store portion. | `const [price, setPrice] = useStore.cart.price(0, onAfterUpdate)`
And the function:
`function onAfterUpdate({ path, value, prevValue }){ console.log({ prevValue, value }) }`
| +| name | type | description | example | +| --------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Initial value | `any` | This parameter is **not mandatory**. It only makes sense for new store properties that have not been defined before within the `createStore`. If the value has already been initialized inside the `createStore` this parameter has no effect. | `const [price, setPrice] = useStore.cart.price(0)` | +| event after an update | `function` | This parameter is **not mandatory**. Adds an event that is executed every time there is a change inside the indicated store portion. | `const [price, setPrice] = useStore.cart.price(0, onAfterUpdate)`
And the function:
`function onAfterUpdate({ store, prevStore }){ console.log({ store, prevStore }) }`
| _Output:_ @@ -235,25 +235,21 @@ It works exactly like `useStore` but with **some differences**: - It's **not possible to register events** that are executed after a change. ```js - getStore.cart.price(0, onAfterPriceChange) // ❌ + getStore.cart.price(0, onAfterPriceChange); // ❌ - function onAfterPriceChange({ path, value, prevValue }) { - const [,setErrorMsg] = getStore.errorMsg() - setErrorMsg(value > 99 ? 'price should be lower than $99') + function onAfterPriceChange({ store, prevStore }) { + // ... } ``` - If the intention is to register events that last forever, it has to be done within the `createStore`: ```js - const { getStore } = createStore(initialStore, onAfterUpdate) // ✅ - - function onAfterUpdate({ path, value, prevValue }) { - if(path === 'cart.price') { - const [,setErrorMsg] = getStore.errorMsg() - setErrorMsg(value > 99 ? 'price should be lower than $99') - } - } + const { getStore } = createStore(initialStore, onAfterUpdate); // ✅ + + function onAfterUpdate({ store, prevStore }) { + // .. + } ``` Very useful to use it: @@ -357,18 +353,21 @@ There are 2 ways to register: - **Permanent** events: Inside `createStore`. This event will always be executed for each change made within the store. ```js - export const { useStore, getStore } = createStore(initialStore, onAfterUpdate); - - function onAfterUpdate({ path, prevValue, value }) { - if (path !== "count") return; - - const [, setCount] = getStore.count(); - const [errorMsg, setErrorMsg] = getStore.errorMsg(); + export const { useStore, getStore } = createStore( + initialStore, + onAfterUpdate + ); - if (value > 99) { - setCount(prevValue); + function onAfterUpdate({ store, prevStore }) { + // Add an error msg + if (store.count > 99 && !store.errorMsg) { + const [, setErrorMsg] = getStore.errorMsg(); setErrorMsg("The count value should be lower than 100"); - } else if (errorMsg) { + return; + } + // Remove error msg + if (store.count >= 99 && store.errorMsg) { + const [, setErrorMsg] = getStore.errorMsg(); setErrorMsg(); } } @@ -381,14 +380,15 @@ There are 2 ways to register: const [count, setCount] = useStore.count(0, onAfterUpdate); const [errorMsg, setErrorMsg] = useStore.errorMsg(); - // The event lasts as long as this component lives, but - // it's also executed if the "count" property is updated - // elsewhere. - function onAfterUpdate({ value, prevValue }) { - if (value > 99) { - setCount(prevValue); + // The event lasts as long as this component lives + function onAfterUpdate({ store, prevStore }) { + // Add an error msg + if (store.count > 99 && !store.errorMsg) { setErrorMsg("The count value should be lower than 100"); - } else if (errorMsg) { + return; + } + // Remove error msg + if (store.count >= 99 && store.errorMsg) { setErrorMsg(); } } @@ -569,16 +569,15 @@ export const { useStore, getStore } = createStore( onAfterUpdate ); -function onAfterUpdate({ path }) { - if (path === "cart" || path.startsWith("cart.")) updateCalculatedCartProps(); -} - -// Price always will be items.length * 3 -function updateCalculatedCartProps() { - const [items] = getStore.cart.items(); - const [price, setPrice] = getStore.cart.price(); +function onAfterUpdate({ store }) { + const { items, price } = store.cart; const calculatedPrice = items.length * 3; - if (price !== calculatedPrice) setPrice(calculatedPrice); + + // Price always will be items.length * 3 + if (price !== calculatedPrice) { + const [, setPrice] = getStore.cart.price(); + setPrice(calculatedPrice); + } } ``` diff --git a/package/index.js b/package/index.js index cb749c9..496ef78 100644 --- a/package/index.js +++ b/package/index.js @@ -110,10 +110,10 @@ export default function createStore(defaultStore = {}, callback) { useEffect(() => { subscription._subscribe(path, forceRender); - subscription._subscribe(path, callback); + subscription._subscribe(DOT, callback); return () => { subscription._unsubscribe(path, forceRender); - subscription._unsubscribe(path, callback); + subscription._unsubscribe(DOT, callback); }; }, []); } @@ -126,9 +126,9 @@ export default function createStore(defaultStore = {}, callback) { */ function updateField(path = '') { let fieldPath = Array.isArray(path) ? path : path.split(DOT); - let prevValue = getField(allStore, fieldPath); return (newValue) => { + let prevStore = allStore; let value = newValue; if (typeof newValue === 'function') { @@ -143,10 +143,8 @@ export default function createStore(defaultStore = {}, callback) { // Notifying to all subscribers subscription._notify(DOT+path, { - path: fieldPath.join(DOT), - value, - prevValue, - getStore, + prevStore, + store: allStore, }); }; } @@ -200,10 +198,10 @@ function createSubscription() { if (!listeners[path]) listeners[path] = new Set(); listeners[path].add(listener); }, - _notify(path, param) { - Object.keys(listeners).forEach((listenersKey) => { - if (path.startsWith(listenersKey) || listenersKey.startsWith(path)) { - listeners[listenersKey].forEach((listener) => listener(param)); + _notify(path, params) { + Object.keys(listeners).forEach((listenerKey) => { + if (path.startsWith(listenerKey) || listenerKey.startsWith(path)) { + listeners[listenerKey].forEach((listener) => listener(params)); } }); }, diff --git a/tests/onAfterUpdate.test.js b/tests/onAfterUpdate.test.js index 39e03d8..71d9e7b 100644 --- a/tests/onAfterUpdate.test.js +++ b/tests/onAfterUpdate.test.js @@ -1,14 +1,14 @@ import {render, screen} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import {act} from 'react-dom/test-utils'; +import {Component} from 'react'; import '@babel/polyfill'; import createStore from '../package/index'; -import {act} from 'react-dom/test-utils'; -import {Component} from 'react'; describe('onAfterUpdate callback', () => { - it('should be possible to remove an onAfterUpdate event when a component with useStore is unmounted', () => { + it('should be possible to remove an onAfterUpdate event when a component with useStore.test is unmounted', () => { const callback = jest.fn(); const {useStore, getStore} = createStore({mount: true}); @@ -26,7 +26,6 @@ describe('onAfterUpdate callback', () => { render(); const update = getStore.test()[1]; - const unmount = () => getStore.mount()[1](false); expect(callback).toHaveBeenCalledTimes(0); @@ -36,10 +35,49 @@ describe('onAfterUpdate callback', () => { act(() => update({})); expect(callback).toHaveBeenCalledTimes(2); - act(() => unmount()); + act(() => getStore.mount()[1](false)); + expect(callback).toHaveBeenCalledTimes(3); + + // Updating twice to confirm that updates don't call the callback when the + // component with the useStore is unmounted + act(() => update({})); + act(() => update({})); + expect(callback).toHaveBeenCalledTimes(3); + }); + + it('should be possible to remove an onAfterUpdate event when a component with useStore is unmounted', () => { + const callback = jest.fn(); + const {useStore, getStore} = createStore({mount: true}); + + function RegisterCallback() { + useStore(undefined, callback); + return null; + } + + function Test() { + const [mount] = useStore.mount(); + if (!mount) return null; + return ; + } + + render(); + + const update = getStore.test()[1]; + + expect(callback).toHaveBeenCalledTimes(0); + act(() => update({})); + expect(callback).toHaveBeenCalledTimes(1); + act(() => update({})); expect(callback).toHaveBeenCalledTimes(2); + + act(() => getStore.mount()[1](false)); + expect(callback).toHaveBeenCalledTimes(3); + + act(() => update({})); + act(() => update({})); + expect(callback).toHaveBeenCalledTimes(3); }); it('should be possible to remove an onAfterUpdate event when a component with withStore is unmounted', () => { @@ -63,7 +101,6 @@ describe('onAfterUpdate callback', () => { render(); const update = getStore.test()[1]; - const unmount = () => getStore.mount()[1](false); expect(callback).toHaveBeenCalledTimes(0); @@ -73,17 +110,18 @@ describe('onAfterUpdate callback', () => { act(() => update({})); expect(callback).toHaveBeenCalledTimes(2); - act(() => unmount()); + act(() => getStore.mount()[1](false)); + expect(callback).toHaveBeenCalledTimes(3); + act(() => update({})); act(() => update({})); - expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledTimes(3); }); it('should work via createStore', () => { const callback = jest.fn(); const initialStore = {cart: {price: 0, items: []}, username: 'Aral'}; const {useStore} = createStore(initialStore, callback); - const testCallback = testItem(callback); function Test() { const [, setPrice] = useStore.cart.price(); @@ -105,44 +143,21 @@ describe('onAfterUpdate callback', () => { userEvent.click(screen.getByTestId('click')); expect(callback).toHaveBeenCalledTimes(2); - testCallback({value: 1, prevValue: 0, times: 1, path: 'cart.price'}); - testCallback({ - value: 'Arala', - prevValue: 'Aral', - times: 2, - path: 'username', - }); userEvent.click(screen.getByTestId('click')); expect(callback).toHaveBeenCalledTimes(4); - testCallback({value: 2, prevValue: 1, times: 3, path: 'cart.price'}); - testCallback({ - value: 'Aralaa', - prevValue: 'Arala', - times: 4, - path: 'username', - }); userEvent.click(screen.getByTestId('click')); userEvent.click(screen.getByTestId('click')); userEvent.click(screen.getByTestId('click')); expect(callback).toHaveBeenCalledTimes(10); - testCallback({value: 5, prevValue: 4, times: 9, path: 'cart.price'}); - testCallback({ - value: 'Aralaaaaa', - prevValue: 'Aralaaaa', - times: 10, - path: 'username', - }); }); - it('Should be possible to register more than 1 onAfterUpdate (createStore + useStore)', () => { + it('Should be possible to register more than 1 onAfterUpdate (createStore + useStore.cart.price)', () => { const callbackCreateStore = jest.fn(); const callbackUseStore = jest.fn(); const initialStore = {cart: {price: 0, items: []}, username: 'Aral'}; const {useStore} = createStore(initialStore, callbackCreateStore); - const testCallbackCreateStore = testItem(callbackCreateStore); - const testCallbackUseStore = testItem(callbackUseStore); function Test() { const [, setPrice] = useStore.cart.price(0, callbackUseStore); @@ -164,29 +179,83 @@ describe('onAfterUpdate callback', () => { userEvent.click(screen.getByTestId('click')); expect(callbackCreateStore).toHaveBeenCalledTimes(2); - testCallbackCreateStore({value: 1, prevValue: 0, times: 1, path: 'cart.price'}); - testCallbackCreateStore({ - value: 'Arala', - prevValue: 'Aral', - times: 2, - path: 'username', - }); + expect(callbackUseStore).toHaveBeenCalledTimes(2); + }); + + it('Should be possible to register more than 1 onAfterUpdate (createStore + useStore)', () => { + const callbackCreateStore = jest.fn(); + const callbackUseStore = jest.fn(); + const initialStore = {cart: {price: 0, items: []}, username: 'Aral'}; + const {useStore} = createStore(initialStore, callbackCreateStore); + + function Test() { + const [, setStore] = useStore(undefined, callbackUseStore); + + function onClick() { + setStore((store) => ({ + ...store, + cart: { + ...store.cart, + price: store.cart.price + 1, + }, + })); + } + + return ( + + ); + } + + render(); + + userEvent.click(screen.getByTestId('click')); + expect(callbackCreateStore).toHaveBeenCalledTimes(1); expect(callbackUseStore).toHaveBeenCalledTimes(1); - testCallbackUseStore({value: 1, prevValue: 0, times: 1, path: 'cart.price'}); + }); + + it('Should return the prevStore and store', () => { + const initialStore = {cart: {price: 0}}; + const onAfterUpdate = jest.fn(); + const {useStore} = createStore(initialStore, onAfterUpdate); + + function Test() { + const [, setPrice] = useStore.cart.price(0); + + return ( + + ); + } + + render(); + + userEvent.click(screen.getByTestId('click')); + expect(onAfterUpdate).toHaveBeenCalledTimes(1); + const params = onAfterUpdate.mock.calls[0][0]; + expect(params.store).toMatchObject({cart: {price: 1}}); + expect(params.prevStore).toMatchObject({cart: {price: 0}}); + + userEvent.click(screen.getByTestId('click')); + expect(onAfterUpdate).toHaveBeenCalledTimes(2); + const params2 = onAfterUpdate.mock.calls[1][0]; + expect(params2.store).toMatchObject({cart: {price: 2}}); + expect(params2.prevStore).toMatchObject({cart: {price: 1}}); }); it('Updating the prevValue should work as limit | via createStore', () => { - const callback = ({path, prevValue, value, getStore}) => { - if ( - (path === 'cart.price' && value > 4) || - (path === 'cart' && value.price > 4) - ) { + const initialStore = {cart: {price: 0}}; + const {useStore, getStore} = createStore(initialStore, callback); + + function callback({prevStore, store}) { + const {price} = store.cart; + if (price > 4) { const [, setPrice] = getStore.cart.price(); - setPrice(prevValue); + setPrice(prevStore.cart.price); } - }; - const initialStore = {cart: {price: 0}}; - const {useStore} = createStore(initialStore, callback); + } function Test() { const [price, setPrice] = useStore.cart.price(); @@ -229,15 +298,13 @@ describe('onAfterUpdate callback', () => { const initialStore = {cart: {price: 0, items: []}}; const {useStore, getStore} = createStore(initialStore, onAfterUpdate); - function onAfterUpdate({path}) { - if (path === 'cart' || path.startsWith('cart.')) onUpdateCart(); - } - - function onUpdateCart() { - const [items] = getStore.cart.items(); - const [price, setPrice] = getStore.cart.price(); + function onAfterUpdate({store}) { + const {items, price} = store.cart; const calculatedPrice = items.length * 3; - if (price !== calculatedPrice) setPrice(calculatedPrice); + if (price !== calculatedPrice) { + const [, setPrice] = getStore.cart.price(); + setPrice(calculatedPrice); + } } function Test() { @@ -286,16 +353,20 @@ describe('onAfterUpdate callback', () => { it('Updating another value using the getStore should work', () => { const initialStore = {cart: {price: 0}, limit: false}; - const {useStore} = createStore(initialStore, onAfterUpdate); + const {useStore, getStore} = createStore(initialStore, onAfterUpdate); const renderTestApp = jest.fn(); const renderTest = jest.fn(); - function onAfterUpdate({path, prevValue, value, getStore}) { - if (path !== 'cart.price') return; + function onAfterUpdate({prevStore, store}) { + const price = store.cart.price; + const prevPrice = prevStore.cart.price; const [, setLimit] = getStore.limit(); - const [, setPrice] = getStore.cart.price(); - if (value > 4) setPrice(prevValue); - setLimit(value > 4); + + if (price > 4) { + const [, setPrice] = getStore.cart.price(); + setPrice(prevPrice); + setLimit(true); + } } function Test() { @@ -349,14 +420,3 @@ describe('onAfterUpdate callback', () => { expect(btn.textContent).toBe('No more than 4!! :)'); }); }); - -function testItem(fn) { - return ({path, prevValue, value, times}) => { - const params = fn.mock.calls[times - 1][0]; - expect(params.path).toBe(path); - expect(params.prevValue).toBe(prevValue); - expect(params.value).toBe(value); - expect(typeof params.getStore !== 'undefined').toBeTruthy(); - return params.getStore; - }; -}