├── .nvmrc ├── resources ├── demo.png └── debug.png ├── src ├── target.ts ├── react │ ├── index.ts │ ├── patch.ts │ └── use_store.ts ├── is_store.ts ├── consts.ts ├── index.ts ├── hooks.ts ├── errors.ts ├── types.ts ├── changes_subscribers.ts ├── is_idle.ts ├── store.ts ├── changes_counters.ts ├── batch.ts ├── subscriber.ts ├── changes_subscriber.ts ├── utils.ts ├── scheduler.ts ├── debug.ts └── on_change.ts ├── demo ├── index.html ├── webpack.config.js ├── tsconfig.json └── index.tsx ├── .editorconfig ├── test ├── modules │ ├── use_stores.ts │ ├── is_store.ts │ ├── changes_subscribers.ts │ ├── store.ts │ ├── changes_counters.ts │ ├── debug.ts │ ├── changes_subscriber.ts │ ├── is_idle.ts │ ├── hooks.ts │ ├── scheduler.ts │ ├── subscriber.ts │ ├── use_store.tsx │ ├── batch.ts │ └── on_change.ts └── fixtures │ └── app.tsx ├── tasks ├── fixtures.js └── benchmark.js ├── .gitignore ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.12.0 2 | -------------------------------------------------------------------------------- /resources/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabiospampinato/store/HEAD/resources/demo.png -------------------------------------------------------------------------------- /resources/debug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabiospampinato/store/HEAD/resources/debug.png -------------------------------------------------------------------------------- /src/target.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {target} from 'proxy-watcher'; 5 | 6 | /* EXPORT */ 7 | 8 | export default target; 9 | -------------------------------------------------------------------------------- /src/react/index.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import './patch'; 5 | import useStore from './use_store'; 6 | import useStores from './use_store'; 7 | 8 | /* EXPORT */ 9 | 10 | export {useStore, useStores}; 11 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Demo | Store 5 | 6 | 7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/is_store.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import ChangesSubscribers from './changes_subscribers'; 5 | 6 | /* IS STORE */ 7 | 8 | function isStore ( value: any ): boolean { 9 | 10 | return !!ChangesSubscribers.get ( value ); 11 | 12 | } 13 | 14 | /* EXPORT */ 15 | 16 | export default isStore; 17 | -------------------------------------------------------------------------------- /demo/webpack.config.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | mode: 'development', 4 | entry: './index.tsx', 5 | output: { 6 | filename: 'bundle.js' 7 | }, 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.tsx?$/, 12 | loaders: ['awesome-typescript-loader'] 13 | } 14 | ] 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/react/patch.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {unstable_batchedUpdates} from 'react-dom'; //TODO: Add support for react-native 5 | import Scheduler from '../scheduler'; 6 | 7 | /* PATCH */ 8 | 9 | const {batch} = Scheduler; 10 | 11 | Scheduler.batch = ( fn: Function ) => unstable_batchedUpdates ( () => batch ( fn ) ); 12 | -------------------------------------------------------------------------------- /test/modules/use_stores.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import {useStore, useStores} from '../../x/react'; 6 | 7 | /* USE STORES */ 8 | 9 | describe ( 'useStores', it => { 10 | 11 | it ( 'is an alias for useStore', t => { 12 | 13 | t.is ( useStores, useStore ); 14 | 15 | }); 16 | 17 | }); 18 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "jsx": "react", 5 | "experimentalDecorators": true, 6 | "lib": ["dom", "es2015", "es2016", "es2017", "es2018", "es2019"], 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "newLine": "LF", 10 | "target": "es2016" 11 | }, 12 | "exclude": [ 13 | "node_modules" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | 2 | /* CONSTS */ 3 | 4 | const EMPTY_ARRAY = []; 5 | 6 | const COMPARATOR_FALSE = () => false; 7 | 8 | const GLOBAL = ( typeof window === 'object' ? window : global ); 9 | 10 | const SELECTOR_IDENTITY = ( ...args ) => args.length > 1 ? args : args[0]; 11 | 12 | const NOOP = () => {}; 13 | 14 | /* EXPORT */ 15 | 16 | export {EMPTY_ARRAY, COMPARATOR_FALSE, GLOBAL, SELECTOR_IDENTITY, NOOP}; 17 | -------------------------------------------------------------------------------- /tasks/fixtures.js: -------------------------------------------------------------------------------- 1 | 2 | /* FIXTURE */ 3 | 4 | const OBJ = () => ({ 5 | foo: 123, 6 | bar: { deep: true }, 7 | arr: [1, 2, '3'], 8 | nan: NaN 9 | }); 10 | 11 | const NOOP = () => {}; 12 | 13 | const SELECTOR_SINGLE = obj => obj.bar; 14 | 15 | const SELECTOR_MULTIPLE = ( o1, o2 ) => o1.foo * o2.foo; 16 | 17 | /* EXPORT */ 18 | 19 | module.exports = {OBJ, NOOP, SELECTOR_SINGLE, SELECTOR_MULTIPLE}; 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import batch from './batch'; 5 | import debug from './debug'; 6 | import store from './store'; 7 | import target from './target'; 8 | import isIdle from './is_idle'; 9 | import isStore from './is_store'; 10 | import onChange from './on_change'; 11 | import Hooks from './hooks'; 12 | 13 | /* EXPORT */ 14 | 15 | export {batch, debug, store, target, isIdle, isStore, onChange, Hooks}; 16 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import Subscriber from './subscriber'; 5 | import {Store} from './types'; 6 | 7 | /* HOOKS */ 8 | 9 | const Hooks = { 10 | 11 | store: { 12 | 13 | change: new Subscriber<[Store, string[]]> (), 14 | 15 | changeBatch: new Subscriber<[Store, string[], string[]]> (), 16 | 17 | new: new Subscriber<[Store]> () 18 | 19 | } 20 | 21 | }; 22 | 23 | /* EXPORT */ 24 | 25 | export default Hooks; 26 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | 2 | /* ERRORS */ 3 | 4 | const Errors = { 5 | 6 | storeNotFound: (): Error => { 7 | 8 | return new Error ( 'Store not found, it either got garbage-collected (you must keep a reference to it) or you are passing "store" a non-proxied store somewhere' ); 9 | 10 | }, 11 | 12 | storesEmpty: (): Error => { 13 | 14 | return new Error ( 'An empty array of stores has been provided, you need to provide at least one store' ); 15 | 16 | } 17 | 18 | }; 19 | 20 | /* EXPORT */ 21 | 22 | export default Errors; 23 | -------------------------------------------------------------------------------- /test/modules/is_store.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import {watch} from 'proxy-watcher'; 6 | import {isStore, store} from '../../x'; 7 | 8 | /* IS STORE */ 9 | 10 | describe ( 'isStore', it => { 11 | 12 | it ( 'checks if the provided value is a store or not', t => { 13 | 14 | t.true ( isStore ( store ( {} ) ) ); 15 | t.true ( isStore ( store ( [] ) ) ); 16 | t.false ( isStore ( watch ( {}, () => {} )[0] ) ); 17 | t.false ( isStore ( {} ) ); 18 | t.false ( isStore ( [] ) ); 19 | t.false ( isStore ( 123 ) ); 20 | 21 | }); 22 | 23 | }); 24 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | 2 | /* TYPES */ 3 | 4 | type Primitive = null | undefined | string | number | boolean | symbol | bigint; 5 | 6 | type DebugGlobal = { 7 | stores: Store[], 8 | log: () => void 9 | }; 10 | 11 | type DebugOptions = { 12 | collapsed: boolean, 13 | logStoresNew: boolean, 14 | logChangesDiff: boolean, 15 | logChangesFull: boolean 16 | }; 17 | 18 | type Disposer = () => void; 19 | 20 | type Listener = ( ...args: Args ) => any; 21 | 22 | type Store = S; 23 | 24 | /* EXPORT */ 25 | 26 | export {Primitive, DebugGlobal, DebugOptions, Disposer, Listener, Store}; 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Numerous always-ignore extensions 3 | *.diff 4 | *.err 5 | *.log 6 | *.orig 7 | *.rej 8 | *.swo 9 | *.swp 10 | *.vi 11 | *.zip 12 | *~ 13 | *.sass-cache 14 | *.ruby-version 15 | *.rbenv-version 16 | 17 | # OS or Editor folders 18 | ._* 19 | .cache 20 | .DS_Store 21 | .idea 22 | .project 23 | .settings 24 | .tmproj 25 | *.esproj 26 | *.sublime-project 27 | *.sublime-workspace 28 | nbproject 29 | Thumbs.db 30 | .fseventsd 31 | .DocumentRevisions* 32 | .TemporaryItems 33 | .Trashes 34 | 35 | # Other paths to ignore 36 | bower_components 37 | node_modules 38 | package-lock.json 39 | dist 40 | x 41 | .nyc_output 42 | coverage 43 | -------------------------------------------------------------------------------- /src/changes_subscribers.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import ChangesSubscriber from './changes_subscriber'; 5 | import {Store} from './types'; 6 | 7 | /* CHANGES SUBSCRIBERS */ 8 | 9 | const ChangesSubscribers = { 10 | 11 | /* VARIABLES */ 12 | 13 | subscribers: new WeakMap (), 14 | 15 | /* API */ 16 | 17 | get: ( store: Store ): ChangesSubscriber | undefined => { 18 | 19 | return ChangesSubscribers.subscribers.get ( store ); 20 | 21 | }, 22 | 23 | set: ( store: Store, subscriber: ChangesSubscriber ): void => { 24 | 25 | ChangesSubscribers.subscribers.set ( store, subscriber ); 26 | 27 | } 28 | 29 | }; 30 | 31 | /* EXPORT */ 32 | 33 | export default ChangesSubscribers; 34 | -------------------------------------------------------------------------------- /src/is_idle.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* IMPORT */ 4 | 5 | import ChangesSubscribers from './changes_subscribers'; 6 | import Errors from './errors'; 7 | import Scheduler from './scheduler'; 8 | 9 | /* IS IDLE */ 10 | 11 | function isIdle ( store?: Store ): boolean { 12 | 13 | if ( store ) { 14 | 15 | const changes = ChangesSubscribers.get ( store ); 16 | 17 | if ( !changes ) throw Errors.storeNotFound (); 18 | 19 | return !Scheduler.queue.has ( changes['_trigger'] ) && !Scheduler.triggeringQueue.includes ( changes['_trigger'] ); 20 | 21 | } else { 22 | 23 | return !Scheduler.triggering && !Scheduler.queue.size; 24 | 25 | } 26 | 27 | } 28 | 29 | /* EXPORT */ 30 | 31 | export default isIdle; 32 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import './changes_counters'; 5 | import {watch} from 'proxy-watcher'; 6 | import ChangesSubscriber from './changes_subscriber'; 7 | import ChangesSubscribers from './changes_subscribers'; 8 | import Hooks from './hooks'; 9 | 10 | /* STORE */ 11 | 12 | function store ( store: Store ): Store { 13 | 14 | const [proxy] = watch ( store, paths => { 15 | 16 | Hooks.store.change.trigger ( proxy, paths ); 17 | 18 | changes.schedule ( paths ); 19 | 20 | }); 21 | 22 | const changes = new ChangesSubscriber ( proxy ); 23 | 24 | ChangesSubscribers.set ( proxy, changes ); 25 | 26 | Hooks.store.new.trigger ( proxy ); 27 | 28 | return proxy; 29 | 30 | } 31 | 32 | /* EXPORT */ 33 | 34 | export default store; 35 | -------------------------------------------------------------------------------- /test/modules/changes_subscribers.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import {store} from '../../x'; 6 | import ChangesSubscriber from '../../x/changes_subscriber'; 7 | import ChangesSubscribers from '../../x/changes_subscribers'; 8 | 9 | /* CHANGES SUBSCRIBERS */ 10 | 11 | describe ( 'ChangesSubscribers', it => { 12 | 13 | it ( 'retrieves the ChangeSubscriber for each store', t => { 14 | 15 | const proxy = store ( {} ), 16 | changes = ChangesSubscribers.get ( proxy ); 17 | 18 | t.true ( changes instanceof ChangesSubscriber ); 19 | 20 | }); 21 | 22 | it ( 'returned undefined if no ChangeSubscriber has been found ', t => { 23 | 24 | t.is ( ChangesSubscribers.get ( {} ), undefined ); 25 | 26 | }); 27 | 28 | }); 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "declaration": true, 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "inlineSourceMap": true, 9 | "jsx": "react", 10 | "lib": ["dom", "es2015", "es2016", "es2017", "es2018", "es2019"], 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "newLine": "LF", 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": false, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": false, 18 | "outDir": "x", 19 | "pretty": true, 20 | "strictNullChecks": true, 21 | "target": "es2016" 22 | }, 23 | "include": [ 24 | "src" 25 | ], 26 | "exclude": [ 27 | "node_modules" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /demo/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as React from 'react'; 3 | import {render} from 'react-dom'; 4 | import {debug, store} from '../x'; 5 | import {useStore} from '../x/react'; 6 | 7 | debug ({ 8 | collapsed: true, 9 | logStoresNew: true, 10 | logChangesDiff: true, 11 | logChangesFull: true 12 | }); 13 | 14 | const Counter = { 15 | store: store ({ value: 0 }), 16 | increment: () => Counter.store.value += 1, 17 | decrement: () => Counter.store.value -= 1 18 | }; 19 | 20 | function App () { 21 | const {value} = useStore ( Counter.store ); 22 | return ( 23 |
24 |
{value}
25 | 26 | 27 |
28 | ); 29 | } 30 | 31 | render ( 32 | , 33 | document.getElementById ( 'app' ) 34 | ); 35 | -------------------------------------------------------------------------------- /test/modules/store.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import {store} from '../../x'; 6 | 7 | /* STORE */ 8 | 9 | describe ( 'store', it => { 10 | 11 | it ( 'wraps an object in a transparent proxy', t => { 12 | 13 | const dataInitial = { 14 | foo: true, 15 | bar: [1, 2, { baz: true }] 16 | }; 17 | 18 | const dataFinal = { 19 | foo: true, 20 | bar: [1, 2, { baz: true, qux: 'str' }, 3], 21 | baz: true 22 | }; 23 | 24 | const proxy = store ( dataInitial ); 25 | 26 | t.false ( dataInitial === proxy ); 27 | t.deepEqual ( dataInitial, proxy ); 28 | 29 | proxy['baz'] = true; 30 | proxy.bar.push ( 3 ); 31 | proxy.bar[2]['qux'] = 'str'; 32 | 33 | t.deepEqual ( dataInitial, proxy ); 34 | t.deepEqual ( dataFinal, proxy ); 35 | 36 | }); 37 | 38 | }); 39 | -------------------------------------------------------------------------------- /test/modules/changes_counters.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import {store} from '../../x'; 6 | import ChangesCounters from '../../x/changes_counters'; 7 | 8 | /* CHANGES COUNTERS */ 9 | 10 | describe ( 'ChangesCounters', it => { 11 | 12 | it ( 'stores the number of times a store changed', t => { 13 | 14 | const data = { 15 | foo: true, 16 | bar: [1, 2, { baz: true }] 17 | }; 18 | 19 | const proxy = store ( data ); 20 | 21 | t.is ( ChangesCounters.get ( proxy ), 0 ); 22 | 23 | proxy.foo = true; 24 | 25 | t.is ( ChangesCounters.get ( proxy ), 0 ); 26 | 27 | proxy.foo = false; 28 | proxy.foo = true; 29 | proxy.foo = false; 30 | 31 | t.is ( ChangesCounters.get ( proxy ), 3 ); // Changes listened-for this way aren't coaleshed nor coalesced 32 | 33 | }); 34 | 35 | }); 36 | -------------------------------------------------------------------------------- /src/changes_counters.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import Hooks from './hooks'; 5 | import {Store} from './types'; 6 | 7 | /* CHANGES COUNTERS */ 8 | 9 | const ChangesCounters = { 10 | 11 | /* VARIABLES */ 12 | 13 | counters: new WeakMap (), 14 | 15 | /* API */ 16 | 17 | get: ( store: Store ): number => { 18 | 19 | return ChangesCounters.counters.get ( store ) || 0; 20 | 21 | }, 22 | 23 | getMultiple: ( stores: Store[] ): number[] => { 24 | 25 | return stores.map ( ChangesCounters.get ); 26 | 27 | }, 28 | 29 | increment: ( store: Store ): void => { 30 | 31 | ChangesCounters.counters.set ( store, ChangesCounters.get ( store ) + 1 ); 32 | 33 | } 34 | 35 | }; 36 | 37 | /* INIT */ 38 | 39 | Hooks.store.change.subscribe ( ChangesCounters.increment ); 40 | 41 | /* EXPORT */ 42 | 43 | export default ChangesCounters; 44 | -------------------------------------------------------------------------------- /src/batch.ts: -------------------------------------------------------------------------------- 1 | 2 | /* STATE */ 3 | 4 | let counter = 0, 5 | queue: Set = new Set (); 6 | 7 | /* BATCH */ 8 | 9 | function batch

> ( fn: () => P ): P { 10 | 11 | batch.start (); 12 | 13 | const promise = fn (); 14 | 15 | promise.then ( batch.stop, batch.stop ); 16 | 17 | return promise; 18 | 19 | } 20 | 21 | /* IS BATCHING */ 22 | 23 | batch.isActive = function (): boolean { 24 | 25 | return !!counter; 26 | 27 | }; 28 | 29 | /* START */ 30 | 31 | batch.start = function (): void { 32 | 33 | counter++; 34 | 35 | }; 36 | 37 | /* STOP */ 38 | 39 | batch.stop = function (): void { 40 | 41 | counter = Math.max ( 0, counter - 1 ); 42 | 43 | if ( counter || !queue.size ) return; 44 | 45 | const fns = [...queue]; 46 | 47 | queue.clear (); 48 | 49 | fns.forEach ( fn => fn () ); 50 | 51 | }; 52 | 53 | /* SCHEDULE */ 54 | 55 | batch.schedule = function ( fn: Function ): void { 56 | 57 | queue.add ( fn ); 58 | 59 | }; 60 | 61 | /* EXPORT */ 62 | 63 | export default batch; 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020-present Fabio Spampinato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/modules/debug.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import delay from 'promise-resolve-timeout'; 6 | import {debug, store} from '../../x'; 7 | 8 | /* DEBUG */ 9 | 10 | describe ( 'debug', it => { 11 | 12 | it.beforeEach ( () => { 13 | 14 | delete global.STORE; 15 | 16 | }); 17 | 18 | it.skip ( 'defines the STORE global', t => { //FIXME: For some reason the used globals are different, ava is probably messing with this 19 | 20 | t.is ( global.STORE, undefined ); 21 | 22 | const STORE = debug (); 23 | 24 | t.true ( STORE.stores instanceof Array ); 25 | t.is ( typeof STORE.log, 'function' ); 26 | t.deepEqual ( global.STORE, STORE ); 27 | 28 | }); 29 | 30 | it ( 'it called multiple times it will just return the global again', async t => { 31 | 32 | let callsNr = 0; 33 | 34 | console.groupEnd = () => callsNr++; 35 | console.log = () => {}; // Silencing it 36 | 37 | const STORE1 = debug ({ logStoresNew: true }), 38 | STORE2 = debug ({ logStoresNew: true }); 39 | 40 | t.deepEqual ( STORE1, STORE2 ); 41 | 42 | store ( {} ); 43 | 44 | await delay ( 100 ); 45 | 46 | t.is ( callsNr, 1 ); 47 | 48 | }); 49 | 50 | }); 51 | -------------------------------------------------------------------------------- /src/subscriber.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {NOOP} from './consts'; 5 | import Scheduler from './scheduler'; 6 | import {Disposer, Listener} from './types'; 7 | 8 | /* SUBSCRIBER */ 9 | 10 | class Subscriber { 11 | 12 | /* VARIABLES */ 13 | 14 | protected args: ListenerArgs | undefined = undefined; 15 | protected listeners: Listener[] = []; 16 | protected _trigger = this.trigger.bind ( this ); 17 | 18 | /* API */ 19 | 20 | subscribe ( listener: Listener ): Disposer { 21 | 22 | if ( this.listeners.indexOf ( listener ) >= 0 ) return NOOP; 23 | 24 | this.listeners.push ( listener ); 25 | 26 | return () => { 27 | 28 | this.listeners = this.listeners.filter ( l => l !== listener ); 29 | 30 | }; 31 | 32 | } 33 | 34 | schedule ( ...args: any[] ): void { // When scheduling arguments have to be provided via the `args` instance variable 35 | 36 | return Scheduler.schedule ( this._trigger ); 37 | 38 | } 39 | 40 | trigger ( ...args: ListenerArgs ): void { 41 | 42 | const {listeners} = this, 43 | {length} = listeners; 44 | 45 | for ( let i = 0; i < length; i++ ) { 46 | 47 | listeners[i].apply ( undefined, args ); 48 | 49 | } 50 | 51 | } 52 | 53 | } 54 | 55 | /* EXPORT */ 56 | 57 | export default Subscriber; 58 | -------------------------------------------------------------------------------- /src/changes_subscriber.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import batch from './batch'; 5 | import {EMPTY_ARRAY} from './consts'; 6 | import Hooks from './hooks'; 7 | import Subscriber from './subscriber'; 8 | import Utils from './utils'; 9 | import {Store} from './types'; 10 | 11 | /* CHANGES SUBSCRIBER */ 12 | 13 | class ChangesSubscriber extends Subscriber<[string[]]> { 14 | 15 | /* VARIABLES */ 16 | 17 | protected store: Store; 18 | protected paths: string[] | undefined; 19 | protected _sschedule = super.schedule.bind ( this ); 20 | 21 | /* CONSTRUCTOR */ 22 | 23 | constructor ( store: Store ) { 24 | 25 | super (); 26 | 27 | this.store = store; 28 | 29 | } 30 | 31 | /* API */ 32 | 33 | schedule ( paths: string[] ): void { 34 | 35 | this.paths ? this.paths.push ( ...paths ) : ( this.paths = paths.slice () ); 36 | 37 | if ( batch.isActive () ) return batch.schedule ( this._sschedule ); 38 | 39 | return super.schedule (); 40 | 41 | } 42 | 43 | trigger (): void { 44 | 45 | const paths = this.paths || EMPTY_ARRAY, 46 | roots = Utils.uniq ( Utils.paths.rootify ( paths ) ); 47 | 48 | this.paths = undefined; 49 | 50 | Hooks.store.changeBatch.trigger ( this.store, paths, roots ); 51 | 52 | super.trigger ( roots ); 53 | 54 | } 55 | 56 | } 57 | 58 | /* EXPORT */ 59 | 60 | export default ChangesSubscriber; 61 | -------------------------------------------------------------------------------- /test/modules/changes_subscriber.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import delay from 'promise-resolve-timeout'; 6 | import {store} from '../../x'; 7 | import ChangesSubscribers from '../../x/changes_subscribers'; 8 | 9 | /* CHANGES SUBSCRIBER */ 10 | 11 | describe ( 'ChangesSubscriber', it => { 12 | 13 | it ( 'it passes a cleaned-up array of changed root paths to the trigger', async t => { 14 | 15 | const proxy = store ({ 16 | 1: 1, 17 | 2: {}, 18 | foo: true, 19 | a: [1, 2, { baz: true }], 20 | o: { 21 | deep: { 22 | a: [1, 2, 3], 23 | deeper: true 24 | } 25 | } 26 | }); 27 | 28 | const changes = ChangesSubscribers.get ( proxy ); 29 | 30 | let paths = []; 31 | 32 | function listener ( _paths ) { 33 | paths = _paths; 34 | }; 35 | 36 | changes.subscribe ( listener ); 37 | 38 | proxy['1'] = 2; 39 | proxy['2'].foo = true; 40 | proxy.foo = false; 41 | proxy.foo = true; 42 | proxy.foo = true; 43 | proxy.a.push ( 3 ); 44 | proxy.a.push ( 4 ); 45 | proxy.a[2].baz = false; 46 | proxy.o.deep.foo = false; 47 | proxy.o.deep.a.push ( 4 ); 48 | proxy.o.deep.deeper = false; 49 | 50 | await delay ( 100 ); 51 | 52 | t.deepEqual ( paths, ['1', '2', 'foo', 'a', 'o'] ); 53 | t.is ( changes.paths, undefined ); 54 | 55 | }); 56 | 57 | }); 58 | -------------------------------------------------------------------------------- /test/modules/is_idle.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import delay from 'promise-resolve-timeout'; 6 | import {isIdle, onChange, store} from '../../x'; 7 | 8 | /* IS IDLE */ 9 | 10 | describe ( 'isIdle', it => { 11 | 12 | it.serial ( 'detects when there are pending changes', async t => { 13 | 14 | const proxy1 = store ({ foo: false }), 15 | proxy2 = store ({ foo: false }); 16 | 17 | t.true ( isIdle () ); 18 | t.true ( isIdle ( proxy1 ) ); 19 | t.true ( isIdle ( proxy2 ) ); 20 | 21 | proxy1.foo = true; 22 | 23 | t.false ( isIdle () ); 24 | t.false ( isIdle ( proxy1 ) ); 25 | t.true ( isIdle ( proxy2 ) ); 26 | 27 | await delay ( 100 ); 28 | 29 | t.true ( isIdle () ); 30 | t.true ( isIdle ( proxy1 ) ); 31 | t.true ( isIdle ( proxy2 ) ); 32 | 33 | }); 34 | 35 | it.serial ( 'detects when triggering changes', async t => { 36 | 37 | t.plan ( 6 ); 38 | 39 | const proxy1 = store ( { foo: false } ), 40 | proxy2 = store ( { foo: false } ); 41 | 42 | onChange ( proxy1, () => { 43 | 44 | t.false ( isIdle () ); 45 | t.false ( isIdle ( proxy1 ) ); 46 | t.true ( isIdle ( proxy2 ) ); 47 | 48 | }); 49 | 50 | proxy1.foo = true; 51 | 52 | await delay ( 100 ); 53 | 54 | t.true ( isIdle () ); 55 | t.true ( isIdle ( proxy1 ) ); 56 | t.true ( isIdle ( proxy2 ) ); 57 | 58 | }); 59 | 60 | }); 61 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {isProxy} from 'proxy-watcher'; 5 | import {Store} from './types'; 6 | 7 | /* UTILS */ 8 | 9 | const Utils = { 10 | 11 | isEqual: ( x: any[], y: any[] ): boolean => { 12 | 13 | if ( x.length !== y.length ) return false; 14 | 15 | for ( let i = 0, l = x.length; i < l; i++ ) { 16 | 17 | if ( x[i] !== y[i] ) return false; 18 | 19 | } 20 | 21 | return true; 22 | 23 | }, 24 | 25 | isStores: ( value: any ): value is Store[] => { 26 | 27 | return Array.isArray ( value ) && !isProxy ( value ); 28 | 29 | }, 30 | 31 | uniq: ( arr: T[] ): T[] => { 32 | 33 | if ( arr.length < 2 ) return arr; 34 | 35 | return Array.from ( new Set ( arr ).values () ); 36 | 37 | }, 38 | 39 | paths: { 40 | 41 | rootify: ( paths: string[] ): string[] => { 42 | 43 | return paths.map ( path => { 44 | 45 | const dotIndex = path.indexOf ( '.' ); 46 | 47 | if ( dotIndex < 0 ) return path; 48 | 49 | return path.slice ( 0, dotIndex ); 50 | 51 | }); 52 | 53 | } 54 | 55 | }, 56 | 57 | log: { 58 | 59 | group: ( title: string, collapsed: boolean = true, fn: Function ): void => { 60 | 61 | collapsed ? console.groupCollapsed ( title ) : console.group ( title ); 62 | 63 | fn (); 64 | 65 | console.groupEnd (); 66 | 67 | } 68 | 69 | } 70 | 71 | }; 72 | 73 | /* EXPORT */ 74 | 75 | export default Utils; 76 | -------------------------------------------------------------------------------- /src/scheduler.ts: -------------------------------------------------------------------------------- 1 | 2 | /* SCHEDULER */ 3 | 4 | const Scheduler = { 5 | 6 | /* VARIABLES */ 7 | 8 | queue: new Set (), 9 | triggering: false, 10 | triggeringQueue: [], 11 | triggerId: -1 as any, //TSC 12 | triggerClear: typeof clearImmediate === 'function' ? clearImmediate : clearTimeout, 13 | triggerSet: typeof setImmediate === 'function' ? setImmediate : setTimeout, 14 | 15 | /* API */ 16 | 17 | schedule: ( fn?: Function ): void => { 18 | 19 | if ( fn ) Scheduler.queue.add ( fn ); 20 | 21 | if ( Scheduler.triggerId !== -1 ) return; 22 | 23 | if ( Scheduler.triggering ) return; 24 | 25 | if ( !Scheduler.queue.size ) return; 26 | 27 | Scheduler.triggerId = Scheduler.triggerSet.call ( undefined, () => { 28 | 29 | Scheduler.triggerId = -1; 30 | 31 | Scheduler.trigger (); 32 | 33 | }, 0 ); 34 | 35 | }, 36 | 37 | unschedule: ( fn?: Function ): void => { 38 | 39 | if ( fn ) Scheduler.queue.delete ( fn ); 40 | 41 | if ( Scheduler.triggerId === -1 ) return; 42 | 43 | Scheduler.triggerClear.call ( undefined, Scheduler.triggerId ); 44 | 45 | Scheduler.triggerId = -1; 46 | 47 | }, 48 | 49 | batch: ( fn: Function ): void => { // Defined for extensibility purposes 50 | 51 | fn (); 52 | 53 | }, 54 | 55 | trigger: (): void => { 56 | 57 | Scheduler.unschedule (); 58 | 59 | if ( Scheduler.triggering ) return; 60 | 61 | if ( !Scheduler.queue.size ) return; 62 | 63 | Scheduler.triggering = true; 64 | 65 | Scheduler.batch ( () => { 66 | 67 | while ( Scheduler.queue.size ) { 68 | 69 | const triggeringQueue = Array.from ( Scheduler.queue ); 70 | 71 | Scheduler.triggeringQueue = triggeringQueue; 72 | 73 | Scheduler.queue.clear (); 74 | 75 | for ( let i = 0, l = triggeringQueue.length; i < l; i++ ) { 76 | 77 | triggeringQueue[i](); 78 | 79 | } 80 | 81 | Scheduler.triggeringQueue = []; 82 | 83 | } 84 | 85 | }); 86 | 87 | Scheduler.triggering = false; 88 | 89 | } 90 | 91 | }; 92 | 93 | /* EXPORT */ 94 | 95 | export default Scheduler; 96 | -------------------------------------------------------------------------------- /test/modules/hooks.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import delay from 'promise-resolve-timeout'; 6 | import {store, Hooks} from '../../x'; 7 | 8 | /* HOOKS */ 9 | 10 | describe ( 'Hooks', it => { 11 | 12 | it ( 'can dispose of a listener', t => { 13 | 14 | Hooks.store.new.subscribe ( t.fail )(); 15 | 16 | store ( {} ); 17 | 18 | t.pass (); 19 | 20 | }); 21 | 22 | describe ( 'store.change', it => { 23 | 24 | it ( 'triggers each time a change is detected', t => { 25 | 26 | t.plan ( 5 ); 27 | 28 | const data = { 29 | foo: true, 30 | bar: [1, 2, { baz: true }] 31 | }; 32 | 33 | const proxy = store ( data ); 34 | 35 | const results = [[proxy, ['foo']], [proxy, ['bar.0']]]; 36 | 37 | let callNr = 0; 38 | 39 | Hooks.store.change.subscribe ( ( proxy, paths ) => { 40 | t.deepEqual ( proxy, results[callNr][0] ); 41 | t.deepEqual ( paths, results[callNr][1] ); 42 | callNr++; 43 | }); 44 | 45 | proxy.foo = false; 46 | proxy.bar[0] = 0; 47 | 48 | t.is ( callNr, 2 ); 49 | 50 | }); 51 | 52 | }); 53 | 54 | describe ( 'store.changeBatch', it => { 55 | 56 | it.only ( 'triggers each time a change is detected (batched)', async t => { 57 | 58 | t.plan ( 5 ); 59 | 60 | const data = { 61 | foo: true, 62 | bar: [1, 2, { baz: true }] 63 | }; 64 | 65 | const proxy = store ( data ); 66 | 67 | let callNr = 0; 68 | 69 | Hooks.store.changeBatch.subscribe ( ( p, paths, roots ) => { 70 | t.deepEqual ( p, proxy ); 71 | t.deepEqual ( paths, ['foo', 'bar.0', 'bar.1'] ); 72 | t.deepEqual ( roots, ['foo', 'bar'] ); 73 | callNr++; 74 | }); 75 | 76 | proxy.foo = false; 77 | proxy.bar[0] = 0; 78 | proxy.bar[1] = 1; 79 | 80 | t.is ( callNr, 0 ); 81 | 82 | await delay ( 100 ); 83 | 84 | t.is ( callNr, 1 ); 85 | 86 | }); 87 | 88 | }); 89 | 90 | describe ( 'store.new', it => { 91 | 92 | it ( 'triggers whenever a store is created', t => { 93 | 94 | t.plan ( 2 ); 95 | 96 | const data = { 97 | foo: true, 98 | bar: [1, 2, { baz: true }] 99 | }; 100 | 101 | Hooks.store.new.subscribe ( proxy => { 102 | t.deepEqual ( data, proxy ); 103 | }); 104 | 105 | const proxy = store ( data ); 106 | 107 | t.deepEqual ( data, proxy ); 108 | 109 | }); 110 | 111 | }); 112 | 113 | }); 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fabiospampinato/store", 3 | "description": "A beautifully-simple framework-agnostic modern state management library.", 4 | "version": "1.11.0", 5 | "main": "x/index.js", 6 | "types": "x/index.d.ts", 7 | "scripts": { 8 | "benchmark": "node tasks/benchmark.js", 9 | "clean:dist": "rimraf x", 10 | "clean:coverage": "rimraf coverage .nyc_output", 11 | "clean": "npm run clean:dist && npm run clean:coverage", 12 | "compile": "tsc --skipLibCheck && tstei", 13 | "compile:watch": "tsc --skipLibCheck --watch", 14 | "test": "ava", 15 | "test:watch": "ava --watch", 16 | "coverage": "nyc --reporter=html ava", 17 | "report": "nyc report", 18 | "report:html": "open coverage/index.html", 19 | "demo:build": "cd demo && webpack", 20 | "demo:build:watch": "cd demo && webpack --watch", 21 | "demo:serve": "cd demo && open index.html", 22 | "prepublishOnly": "npm run clean && npm run compile && npm run coverage" 23 | }, 24 | "ava": { 25 | "compileEnhancements": false, 26 | "files": [ 27 | "test/modules/**.ts", 28 | "test/modules/**.tsx" 29 | ], 30 | "babel": { 31 | "extensions": [ 32 | "ts", 33 | "tsx" 34 | ] 35 | }, 36 | "require": [ 37 | "ts-node/register/transpile-only", 38 | "jsdom-global/register" 39 | ] 40 | }, 41 | "bugs": { 42 | "url": "https://github.com/fabiospampinato/store/issues" 43 | }, 44 | "license": "MIT", 45 | "author": { 46 | "name": "Fabio Spampinato", 47 | "email": "spampinabio@gmail.com" 48 | }, 49 | "repository": { 50 | "type": "git", 51 | "url": "https://github.com/fabiospampinato/store.git" 52 | }, 53 | "keywords": [ 54 | "state", 55 | "store", 56 | "management", 57 | "library", 58 | "node", 59 | "react", 60 | "reactive", 61 | "fast", 62 | "clean", 63 | "proxy" 64 | ], 65 | "dependencies": { 66 | "are-shallow-equal": "^1.1.1", 67 | "is-primitive": "^3.0.1", 68 | "plain-object-is-empty": "^1.0.0", 69 | "proxy-watcher": "^3.4.1" 70 | }, 71 | "peerDependencies": { 72 | "react": ">=16.6.0" 73 | }, 74 | "devDependencies": { 75 | "@types/react": "^16.9.41", 76 | "ava": "^2.4.0", 77 | "ava-spec": "^1.1.1", 78 | "awesome-typescript-loader": "^5.2.1", 79 | "benchloop": "^1.3.2", 80 | "enzyme": "^3.11.0", 81 | "enzyme-adapter-react-16": "^1.15.2", 82 | "jsdom": "^16.2.2", 83 | "jsdom-global": "^3.0.2", 84 | "nyc": "^15.1.0", 85 | "promise-resolve-timeout": "^1.2.1", 86 | "react": "^16.13.1", 87 | "react-dom": "^16.13.1", 88 | "rimraf": "^3.0.2", 89 | "ts-node": "^8.10.2", 90 | "typescript": "^3.9.5", 91 | "typescript-transform-export-interop": "^1.0.2", 92 | "webpack": "^4.43.0" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /test/fixtures/app.tsx: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import * as React from 'react'; 5 | import {useCallback, useState} from 'react'; 6 | import {store} from '../../x'; 7 | import {useStore} from '../../x/react'; 8 | 9 | /* APP */ 10 | 11 | const API = { 12 | store: store ({ value: 0 }), 13 | increment: () => API.store.value++, 14 | decrement: () => API.store.value-- 15 | }; 16 | 17 | const API2 = { 18 | store: store ({ value: 0 }), 19 | increment: () => API2.store.value++, 20 | decrement: () => API2.store.value-- 21 | }; 22 | 23 | const AppSingleWithoutSelector = ({ rendering }) => { 24 | rendering (); 25 | const {value} = useStore ( API.store ); 26 | return ( 27 |

28 |
{value}
29 |
Increment
30 |
Decrement
31 |
32 | ); 33 | }; 34 | 35 | const AppSingleWithSelector = ({ rendering }) => { 36 | rendering (); 37 | const value = useStore ( API.store, store => store.value ); 38 | return ( 39 |
40 |
{value}
41 |
Increment
42 |
Decrement
43 |
44 | ); 45 | }; 46 | 47 | const AppMultipleWithoutSelector = ({ rendering }) => { 48 | rendering (); 49 | const [store1, store2] = useStore ([ API.store, API2.store ]); 50 | return ( 51 |
52 |
{store1.value * store2.value}
53 |
Increment
54 |
Decrement
55 |
Increment
56 |
Decrement
57 |
58 | ); 59 | }; 60 | 61 | const AppMultipleWithSelector = ({ rendering }) => { 62 | rendering (); 63 | const value = useStore ( [API.store, API2.store], ( store1, store2 ) => store1.value * store2.value ); 64 | return ( 65 |
66 |
{value}
67 |
Increment
68 |
Decrement
69 |
Increment
70 |
Decrement
71 |
72 | ); 73 | }; 74 | 75 | const SWAP1 = store ({ value: 0 }); 76 | const SWAP2 = store ({ value: 100 }); 77 | 78 | const AppWithSwappedStore = ({ rendering }) => { 79 | rendering (); 80 | const [store, setStore] = useState ( SWAP1 ); 81 | const value = useStore ( store, store => store.value ); 82 | const swap = useCallback ( () => setStore ( SWAP2 ), [] ); 83 | return ( 84 |
85 |
{value}
86 |
Swap
87 |
88 | ); 89 | }; 90 | 91 | /* EXPORT */ 92 | 93 | export {API, API2, SWAP1, SWAP2, AppSingleWithoutSelector, AppSingleWithSelector, AppMultipleWithoutSelector, AppMultipleWithSelector, AppWithSwappedStore}; 94 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import isEmptyObject from 'plain-object-is-empty'; 5 | import {target} from 'proxy-watcher'; 6 | import ProxyWatcherUtils from 'proxy-watcher/dist/utils'; 7 | import ChangesSubscribers from './changes_subscribers'; 8 | import {GLOBAL} from './consts'; 9 | import Errors from './errors'; 10 | import Hooks from './hooks'; 11 | import Utils from './utils'; 12 | import {DebugGlobal, DebugOptions, Store} from './types'; 13 | 14 | /* DEBUG */ 15 | 16 | const defaultOptions: DebugOptions = { 17 | collapsed: true, 18 | logStoresNew: false, 19 | logChangesDiff: true, 20 | logChangesFull: false 21 | }; 22 | 23 | function debug ( options: Partial = {} ): DebugGlobal { 24 | 25 | if ( GLOBAL['STORE'] ) return GLOBAL['STORE']; 26 | 27 | options = Object.assign ( {}, debug.defaultOptions, options ); 28 | 29 | const STORE = GLOBAL['STORE'] = { 30 | stores: [] as Store[], //FIXME: This shouldn't store a strong reference to stores, but also a WeakSet doesn't allow to retrieve all of its values... 31 | log: () => { 32 | STORE.stores.forEach ( store => { 33 | console.log ( ProxyWatcherUtils.cloneDeep ( target ( store ) ) ); 34 | }); 35 | } 36 | }; 37 | 38 | Hooks.store.new.subscribe ( store => { 39 | 40 | STORE.stores.push ( store ); 41 | 42 | let storePrev = ProxyWatcherUtils.cloneDeep ( target ( store ) ); 43 | 44 | if ( options.logStoresNew ) { 45 | Utils.log.group ( 'Store - New', options.collapsed, () => { 46 | console.log ( storePrev ); 47 | }); 48 | } 49 | 50 | if ( options.logChangesFull || options.logChangesDiff ) { 51 | 52 | const changes = ChangesSubscribers.get ( store ); 53 | 54 | if ( !changes ) throw Errors.storeNotFound (); 55 | 56 | changes.subscribe ( () => { 57 | 58 | const storeNext = ProxyWatcherUtils.cloneDeep ( target ( store ) ); 59 | 60 | Utils.log.group ( `Store - Change - ${new Date ().toISOString ()}`, options.collapsed, () => { 61 | 62 | if ( options.logChangesDiff ) { 63 | 64 | const {added, updated, deleted} = ProxyWatcherUtils.diff ( storePrev, storeNext ); 65 | 66 | if ( !isEmptyObject ( added ) ) { 67 | console.log ( 'Added' ); 68 | console.log ( added ); 69 | } 70 | 71 | if ( !isEmptyObject ( updated ) ) { 72 | console.log ( 'Updated' ); 73 | console.log ( updated ); 74 | } 75 | 76 | if ( !isEmptyObject ( deleted ) ) { 77 | console.log ( 'Deleted' ); 78 | console.log ( deleted ); 79 | } 80 | 81 | } 82 | 83 | if ( options.logChangesFull ) { 84 | console.log ( 'New store' ); 85 | console.log ( storeNext ); 86 | console.log ( 'Old store' ) 87 | console.log ( storePrev ); 88 | } 89 | 90 | }); 91 | 92 | storePrev = storeNext; 93 | 94 | }); 95 | 96 | } 97 | 98 | }); 99 | 100 | return STORE; 101 | 102 | } 103 | 104 | debug.defaultOptions = defaultOptions; 105 | 106 | /* EXPORT */ 107 | 108 | export default debug; 109 | -------------------------------------------------------------------------------- /test/modules/scheduler.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import delay from 'promise-resolve-timeout'; 6 | import Scheduler from '../../x/scheduler'; 7 | 8 | /* SCHEDULER */ 9 | 10 | describe ( 'Scheduler', it => { 11 | 12 | it.beforeEach ( () => { 13 | 14 | Scheduler.unschedule () 15 | 16 | Scheduler.queue = new Set (); 17 | Scheduler.triggering = false; 18 | 19 | }); 20 | 21 | it.serial ( 'can schedule a function for execution, avoiding duplicates', async t => { 22 | 23 | const calls = []; 24 | 25 | function fn () { 26 | calls.push ( 1 ); 27 | } 28 | 29 | Scheduler.schedule ( fn ); 30 | Scheduler.schedule ( fn ); 31 | 32 | t.deepEqual ( calls, [] ); 33 | 34 | await delay ( 100 ); 35 | 36 | t.deepEqual ( calls, [1] ); 37 | 38 | }); 39 | 40 | it.serial ( 'can unschedule a function for execution', t => { 41 | 42 | const calls = []; 43 | 44 | function fn () { 45 | calls.push ( 1 ); 46 | } 47 | 48 | Scheduler.schedule ( fn ); 49 | Scheduler.unschedule ( fn ); 50 | Scheduler.trigger (); 51 | 52 | t.deepEqual ( calls, [] ); 53 | 54 | }); 55 | 56 | it.serial ( 'can schedule the current queue', async t => { 57 | 58 | const calls = []; 59 | 60 | function fn () { 61 | calls.push ( 1 ); 62 | } 63 | 64 | Scheduler.schedule ( fn ); 65 | Scheduler.unschedule (); 66 | Scheduler.schedule (); 67 | 68 | await delay ( 100 ); 69 | 70 | t.deepEqual ( calls, [1] ); 71 | 72 | }); 73 | 74 | it.serial ( 'can unschedule the current queue', async t => { 75 | 76 | const calls = []; 77 | 78 | function fn () { 79 | calls.push ( 1 ); 80 | } 81 | 82 | Scheduler.schedule ( fn ); 83 | Scheduler.unschedule (); 84 | 85 | await delay ( 100 ); 86 | 87 | t.deepEqual ( calls, [] ); 88 | 89 | Scheduler.trigger (); 90 | 91 | t.deepEqual ( calls, [1] ); 92 | 93 | }); 94 | 95 | it.serial ( 'can trigger execution', t => { 96 | 97 | const calls = []; 98 | 99 | function fn () { 100 | calls.push ( 1 ); 101 | } 102 | 103 | Scheduler.schedule ( fn ); 104 | Scheduler.unschedule (); 105 | Scheduler.trigger (); 106 | 107 | t.deepEqual ( calls, [1] ); 108 | 109 | }); 110 | 111 | it.serial ( 'can trigger execution of queued functions scheduled while triggering', t => { 112 | 113 | const calls = []; 114 | 115 | let rescheduled = false; 116 | 117 | function fn () { 118 | calls.push ( 1 ); 119 | if ( rescheduled ) return; 120 | rescheduled = true; 121 | Scheduler.schedule ( fn ); 122 | } 123 | 124 | Scheduler.schedule ( fn ); 125 | Scheduler.unschedule (); 126 | Scheduler.trigger (); 127 | 128 | t.deepEqual ( calls, [1, 1] ); 129 | 130 | }); 131 | 132 | it.serial ( 'is not susceptible to race conditions', t => { 133 | 134 | const calls = []; 135 | 136 | function fnRemove () { 137 | Scheduler.unschedule ( fnAdd ); 138 | calls.push ( 1 ); 139 | } 140 | 141 | function fnAdd () { 142 | Scheduler.schedule ( () => { 143 | calls.push ( 3 ); 144 | }); 145 | calls.push ( 2 ); 146 | } 147 | 148 | Scheduler.schedule ( fnRemove ); 149 | Scheduler.schedule ( fnAdd ); 150 | Scheduler.trigger (); 151 | 152 | t.deepEqual ( calls, [1, 2, 3] ); 153 | 154 | }); 155 | 156 | }); 157 | -------------------------------------------------------------------------------- /test/modules/subscriber.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import delay from 'promise-resolve-timeout'; 6 | import Subscriber from '../../x/subscriber'; 7 | 8 | /* SUBSCRIBER */ 9 | 10 | describe ( 'Subscriber', it => { 11 | 12 | it ( 'can add a listener, avoiding duplicates', t => { 13 | 14 | const subscriber = new Subscriber (), 15 | calls = []; 16 | 17 | function listener () { 18 | calls.push ( 1 ); 19 | } 20 | 21 | subscriber.subscribe ( listener ); 22 | subscriber.subscribe ( listener ); 23 | subscriber.trigger (); 24 | 25 | t.deepEqual ( calls, [1] ); 26 | 27 | }); 28 | 29 | it ( 'can remove a listener', t => { 30 | 31 | const subscriber = new Subscriber (), 32 | calls = []; 33 | 34 | function listener () { 35 | calls.push ( 1 ); 36 | } 37 | 38 | subscriber.subscribe ( listener )(); 39 | subscriber.subscribe ( listener )(); 40 | subscriber.trigger (); 41 | 42 | t.deepEqual ( calls, [] ); 43 | 44 | }); 45 | 46 | it ( 'can trigger the listeners', t => { 47 | 48 | const subscriber = new Subscriber (), 49 | calls = []; 50 | 51 | function listener () { 52 | calls.push ( 1 ); 53 | } 54 | 55 | subscriber.trigger (); 56 | subscriber.subscribe ( listener ); 57 | subscriber.trigger (); 58 | subscriber.trigger (); 59 | 60 | t.deepEqual ( calls, [1, 1] ); 61 | 62 | }); 63 | 64 | it ( 'is not susceptible to race conditions', t => { 65 | 66 | const subscriber = new Subscriber (), 67 | calls = []; 68 | 69 | const listenerRemoveDisposer = subscriber.subscribe ( function listenerRemove () { 70 | listenerAddDisposer (); 71 | calls.push ( 1 ); 72 | }); 73 | 74 | const listenerAddDisposer = subscriber.subscribe ( function listenerAdd () { 75 | subscriber.subscribe ( () => { 76 | calls.push ( 3 ); 77 | }); 78 | calls.push ( 2 ); 79 | }); 80 | 81 | subscriber.trigger (); 82 | 83 | t.deepEqual ( calls, [1, 2] ); 84 | 85 | subscriber.trigger (); 86 | 87 | t.deepEqual ( calls, [1, 2, 1, 3] ); 88 | 89 | }); 90 | 91 | it ( 'can schedule execution', async t => { 92 | 93 | const subscriber = new Subscriber (), 94 | calls = []; 95 | 96 | function listener () { 97 | calls.push ( 1 ); 98 | } 99 | 100 | subscriber.subscribe ( listener ); 101 | 102 | await delay ( 100 ); 103 | 104 | t.deepEqual ( calls, [] ); 105 | 106 | subscriber.schedule (); 107 | 108 | await delay ( 100 ); 109 | 110 | t.deepEqual ( calls, [1] ); 111 | 112 | }); 113 | 114 | it ( 'will pass to the listeners the instance arguments', t => { 115 | 116 | const subscriber = new Subscriber (), 117 | calls = [], 118 | args = []; 119 | 120 | function listener () { 121 | calls.push ( 1 ); 122 | args.push ( ...arguments ); 123 | } 124 | 125 | subscriber.subscribe ( listener ); 126 | 127 | t.deepEqual ( calls, [] ); 128 | t.deepEqual ( args, [] ); 129 | 130 | subscriber.trigger (); 131 | 132 | t.deepEqual ( calls, [1] ); 133 | t.deepEqual ( args, [] ); 134 | 135 | subscriber.trigger ( 'a' ); 136 | 137 | t.deepEqual ( calls, [1, 1] ); 138 | t.deepEqual ( args, ['a'] ); 139 | 140 | subscriber.trigger ( 'a', 'b' ); 141 | 142 | t.deepEqual ( calls, [1, 1, 1] ); 143 | t.deepEqual ( args, ['a', 'a', 'b'] ); 144 | 145 | }); 146 | 147 | }); 148 | -------------------------------------------------------------------------------- /test/modules/use_store.tsx: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import * as Enzyme from 'enzyme'; 6 | import {mount} from 'enzyme'; 7 | import Adapter1 from 'enzyme-adapter-react-16'; //UGLY: For whatever reason we need to use both kinds 8 | import * as Adapter2 from 'enzyme-adapter-react-16'; //UGLY: For whatever reason we need to use both kinds 9 | import delay from 'promise-resolve-timeout'; 10 | import * as React from 'react'; 11 | import {API, API2, SWAP1, SWAP2, AppSingleWithoutSelector, AppSingleWithSelector, AppMultipleWithoutSelector, AppMultipleWithSelector, AppWithSwappedStore} from '../fixtures/app'; 12 | import {useStore} from '../../x/react'; 13 | 14 | Enzyme.configure ({ adapter: new ( Adapter1 || Adapter2 ) () }); 15 | 16 | /* USE STORE */ 17 | 18 | describe ( 'useStore', it => { 19 | 20 | it.serial ( 'works with single stores', async t => { 21 | 22 | const Apps = [AppSingleWithoutSelector, AppSingleWithSelector], 23 | renders = [[1, 2, 3], [1, 2, 3]]; 24 | 25 | for ( const [index, App] of Apps.entries () ) { 26 | 27 | API.store.value = 0; 28 | 29 | let rendersNr = 0, 30 | rendering = () => rendersNr++; 31 | 32 | const app = mount ( React.createElement ( App, {rendering} ) ), 33 | getText = selector => app.find ( selector ).text (), 34 | getValue = () => getText ( '#value' ), 35 | click = selector => app.find ( selector ).simulate ( 'click' ); 36 | 37 | t.is ( getValue (), '0' ); 38 | t.is ( rendersNr, renders[index][0] ); 39 | 40 | click ( '#increment' ); 41 | click ( '#increment' ); 42 | click ( '#increment' ); 43 | 44 | await delay ( 100 ); 45 | 46 | t.is ( getValue (), '3' ); 47 | t.is ( rendersNr, renders[index][1] ); 48 | 49 | click ( '#decrement' ); 50 | 51 | await delay ( 100 ); 52 | 53 | t.is ( getValue (), '2' ); 54 | t.is ( rendersNr, renders[index][2] ); 55 | 56 | } 57 | 58 | }); 59 | 60 | it.serial ( 'works with multiple stores', async t => { 61 | 62 | const Apps = [AppMultipleWithoutSelector, AppMultipleWithSelector], 63 | renders = [[1, 2, 3, 4], [1, 1, 2, 3]]; 64 | 65 | for ( const [index, App] of Apps.entries () ) { 66 | 67 | API.store.value = 0; 68 | API2.store.value = 0; 69 | 70 | let rendersNr = 0, 71 | rendering = () => rendersNr++; 72 | 73 | const app = mount ( React.createElement ( App, {rendering} ) ), 74 | getText = selector => app.find ( selector ).text (), 75 | getValue = () => getText ( '#value' ), 76 | click = selector => app.find ( selector ).simulate ( 'click' ); 77 | 78 | t.is ( getValue (), '0' ); 79 | t.is ( rendersNr, renders[index][0] ); 80 | 81 | click ( '#one-increment' ); 82 | click ( '#one-increment' ); 83 | click ( '#one-increment' ); 84 | 85 | await delay ( 100 ); 86 | 87 | t.is ( getValue (), '0' ); 88 | t.is ( rendersNr, renders[index][1] ); 89 | 90 | click ( '#one-increment' ); 91 | click ( '#two-increment' ); 92 | 93 | await delay ( 100 ); 94 | 95 | t.is ( getValue (), '4' ); 96 | t.is ( rendersNr, renders[index][2] ); 97 | 98 | click ( '#one-increment' ); 99 | click ( '#two-increment' ); 100 | 101 | await delay ( 100 ); 102 | 103 | t.is ( getValue (), '10' ); 104 | t.is ( rendersNr, renders[index][3] ); 105 | 106 | } 107 | 108 | }); 109 | 110 | it.serial ( 'works with swapped stores', async t => { 111 | 112 | SWAP1.value = 0; 113 | SWAP2.value = 100; 114 | 115 | let rendersNr = 0, 116 | rendering = () => rendersNr++; 117 | 118 | const app = mount ( React.createElement ( AppWithSwappedStore, {rendering} ) ), 119 | getText = selector => app.find ( selector ).text (), 120 | getValue = () => getText ( '#value' ), 121 | click = selector => app.find ( selector ).simulate ( 'click' ); 122 | 123 | t.is ( getValue (), '0' ); 124 | t.is ( rendersNr, 1 ); 125 | 126 | click ( '#swap' ); 127 | 128 | await delay ( 100 ); 129 | 130 | t.is ( getValue (), '100' ); 131 | t.is ( rendersNr, 2 ); 132 | 133 | }); 134 | 135 | it ( 'throws if an empty array of stores has been provided ', t => { 136 | 137 | t.throws ( () => useStore ( [] ), /empty/i ); 138 | 139 | }); 140 | 141 | }); 142 | -------------------------------------------------------------------------------- /test/modules/batch.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import delay from 'promise-resolve-timeout'; 6 | import {batch, Hooks, onChange, store} from '../../x'; 7 | 8 | /* BATCH */ 9 | 10 | describe ( 'batch', it => { 11 | 12 | it.serial ( 'batches updates happening inside an asynchronous function', async t => { 13 | 14 | const proxy = store ({ foo: 123, bar: 123, baz: { test: 123 } }), 15 | onChangeCalls = [], 16 | hookChangeCalls = [], 17 | hookChangeBatchCalls = []; 18 | 19 | onChange ( proxy, () => { 20 | onChangeCalls.push ( 0 ); 21 | }); 22 | 23 | Hooks.store.change.subscribe ( ( proxy, paths ) => { 24 | hookChangeCalls.push ( paths ); 25 | }); 26 | 27 | Hooks.store.changeBatch.subscribe ( ( proxy, paths, root ) => { 28 | hookChangeBatchCalls.push ([ paths, root ]); 29 | }); 30 | 31 | await batch ( async () => { 32 | proxy.foo = 0; 33 | await delay ( 10 ); 34 | proxy.bar = 1; 35 | await delay ( 10 ); 36 | proxy.baz.test = 2; 37 | }); 38 | 39 | await delay ( 100 ); 40 | 41 | t.deepEqual ( onChangeCalls, [0] ); 42 | t.deepEqual ( hookChangeCalls, [['foo'], ['bar'], ['baz.test']] ); 43 | t.deepEqual ( hookChangeBatchCalls, [[['foo', 'bar', 'baz.test'], ['foo', 'bar', 'baz']]] ); 44 | 45 | }); 46 | 47 | it.serial ( 'supports throwing functions', async t => { 48 | 49 | const proxy = store ({ foo: 123, bar: 123, baz: { test: 123 } }), 50 | onChangeCalls = [], 51 | hookChangeCalls = [], 52 | hookChangeBatchCalls = []; 53 | 54 | onChange ( proxy, () => { 55 | onChangeCalls.push ( 0 ); 56 | }); 57 | 58 | Hooks.store.change.subscribe ( ( proxy, paths ) => { 59 | hookChangeCalls.push ( paths ); 60 | }); 61 | 62 | Hooks.store.changeBatch.subscribe ( ( proxy, paths, root ) => { 63 | hookChangeBatchCalls.push ([ paths, root ]); 64 | }); 65 | 66 | try { 67 | 68 | await batch ( async () => { 69 | proxy.foo = 0; 70 | await delay ( 10 ); 71 | proxy.bar = 1; 72 | throw new Error ( 'foo' ); 73 | await delay ( 10 ); 74 | proxy.baz.test = 2; 75 | }); 76 | 77 | } catch ( err ) { 78 | 79 | t.is ( err.message, 'foo' ); 80 | 81 | } 82 | 83 | await delay ( 100 ); 84 | 85 | t.deepEqual ( onChangeCalls, [0] ); 86 | t.deepEqual ( hookChangeCalls, [['foo'], ['bar']] ); 87 | t.deepEqual ( hookChangeBatchCalls, [[['foo', 'bar'], ['foo', 'bar']]] ); 88 | 89 | }); 90 | 91 | it.serial ( 'supports nested batches', async t => { 92 | 93 | const proxy = store ({ foo: 123, bar: 123, baz: { test: 123 } }), 94 | onChangeCalls = [], 95 | hookChangeCalls = [], 96 | hookChangeBatchCalls = []; 97 | 98 | onChange ( proxy, () => { 99 | onChangeCalls.push ( 0 ); 100 | }); 101 | 102 | Hooks.store.change.subscribe ( ( proxy, paths ) => { 103 | hookChangeCalls.push ( paths ); 104 | }); 105 | 106 | Hooks.store.changeBatch.subscribe ( ( proxy, paths, root ) => { 107 | hookChangeBatchCalls.push ([ paths, root ]); 108 | }); 109 | 110 | await batch ( async () => { 111 | proxy.foo = 0; 112 | await delay ( 10 ); 113 | await batch ( async () => { 114 | proxy.bar = 1; 115 | await delay ( 10 ); 116 | await batch ( async () => { 117 | proxy.baz.test = 2; 118 | await delay ( 10 ); 119 | }) 120 | }); 121 | }); 122 | 123 | await delay ( 100 ); 124 | 125 | t.deepEqual ( onChangeCalls, [0] ); 126 | t.deepEqual ( hookChangeCalls, [['foo'], ['bar'], ['baz.test']] ); 127 | t.deepEqual ( hookChangeBatchCalls, [[['foo', 'bar', 'baz.test'], ['foo', 'bar', 'baz']]] ); 128 | 129 | }); 130 | 131 | it.serial ( 'supports batching without the wrapper function', async t => { 132 | 133 | const proxy = store ({ foo: 123, bar: 123, baz: { test: 123 } }), 134 | onChangeCalls = [], 135 | hookChangeCalls = [], 136 | hookChangeBatchCalls = []; 137 | 138 | onChange ( proxy, () => { 139 | onChangeCalls.push ( 0 ); 140 | }); 141 | 142 | Hooks.store.change.subscribe ( ( proxy, paths ) => { 143 | hookChangeCalls.push ( paths ); 144 | }); 145 | 146 | Hooks.store.changeBatch.subscribe ( ( proxy, paths, root ) => { 147 | hookChangeBatchCalls.push ([ paths, root ]); 148 | }); 149 | 150 | batch.start (); 151 | proxy.foo = 0; 152 | await delay ( 10 ); 153 | proxy.bar = 1; 154 | await delay ( 10 ); 155 | proxy.baz.test = 2; 156 | batch.stop (); 157 | 158 | await delay ( 100 ); 159 | 160 | t.deepEqual ( onChangeCalls, [0] ); 161 | t.deepEqual ( hookChangeCalls, [['foo'], ['bar'], ['baz.test']] ); 162 | t.deepEqual ( hookChangeBatchCalls, [[['foo', 'bar', 'baz.test'], ['foo', 'bar', 'baz']]] ); 163 | 164 | }); 165 | 166 | }); 167 | -------------------------------------------------------------------------------- /tasks/benchmark.js: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | const {store, isStore, onChange} = require ( '../x' ), 5 | {default: Scheduler} = require ( '../x/scheduler' ), 6 | {OBJ, NOOP, SELECTOR_SINGLE, SELECTOR_MULTIPLE} = require ( './fixtures' ), 7 | benchmark = require ( 'benchloop' ); 8 | 9 | Scheduler.schedule = fn => fn && fn (); 10 | 11 | /* BENCHMARK */ 12 | 13 | benchmark.defaultOptions = Object.assign ( benchmark.defaultOptions, { 14 | iterations: 7500, 15 | log: 'compact' 16 | }); 17 | 18 | benchmark ({ 19 | name: 'store', 20 | beforeEach: ctx => { 21 | ctx.obj = OBJ (); 22 | }, 23 | fn: ctx => { 24 | store ( ctx.obj ); 25 | } 26 | }); 27 | 28 | benchmark.group ( 'isStore', () => { 29 | 30 | benchmark ({ 31 | name: 'no', 32 | beforeEach: ctx => { 33 | ctx.obj = OBJ (); 34 | }, 35 | fn: ctx => { 36 | isStore ( ctx.obj ); 37 | } 38 | }); 39 | 40 | benchmark ({ 41 | name: 'yes', 42 | beforeEach: ctx => { 43 | ctx.proxy = store ( OBJ () ); 44 | }, 45 | fn: ctx => { 46 | isStore ( ctx.proxy ); 47 | } 48 | }); 49 | 50 | }); 51 | 52 | benchmark.group ( 'onChange', () => { 53 | 54 | benchmark.group ( 'register', () => { 55 | 56 | benchmark.group ( 'single', () => { 57 | 58 | benchmark ({ 59 | name: 'all', 60 | beforeEach: ctx => { 61 | ctx.proxy = store ( OBJ () ); 62 | }, 63 | fn: ctx => { 64 | onChange ( ctx.proxy, NOOP ); 65 | } 66 | }); 67 | 68 | benchmark ({ 69 | name: 'selector', 70 | beforeEach: ctx => { 71 | ctx.proxy = store ( OBJ () ); 72 | }, 73 | fn: ctx => { 74 | onChange ( ctx.proxy, SELECTOR_SINGLE, NOOP ); 75 | } 76 | }); 77 | 78 | }); 79 | 80 | benchmark.group ( 'multiple', () => { 81 | 82 | benchmark ({ 83 | name: 'all', 84 | beforeEach: ctx => { 85 | ctx.proxy1 = store ( OBJ () ); 86 | ctx.proxy2 = store ( OBJ () ); 87 | }, 88 | fn: ctx => { 89 | onChange ( [ctx.proxy1, ctx.proxy2], NOOP ); 90 | } 91 | }); 92 | 93 | benchmark ({ 94 | name: 'selector', 95 | beforeEach: ctx => { 96 | ctx.proxy1 = store ( OBJ () ); 97 | ctx.proxy2 = store ( OBJ () ); 98 | }, 99 | fn: ctx => { 100 | onChange ( [ctx.proxy1, ctx.proxy2], SELECTOR_MULTIPLE, NOOP ); 101 | } 102 | }); 103 | 104 | }); 105 | 106 | }); 107 | 108 | benchmark.group ( 'trigger', () => { 109 | 110 | benchmark.group ( 'single', () => { 111 | 112 | benchmark.group ( 'all', () => { 113 | 114 | benchmark ({ 115 | name: 'no', 116 | beforeEach: ctx => { 117 | ctx.proxy = store ( OBJ () ); 118 | onChange ( ctx.proxy, NOOP ); 119 | }, 120 | fn: ctx => { 121 | ctx.proxy.foo = 123; 122 | } 123 | }); 124 | 125 | benchmark ({ 126 | name: 'yes', 127 | beforeEach: ctx => { 128 | ctx.proxy = store ( OBJ () ); 129 | onChange ( ctx.proxy, NOOP ); 130 | }, 131 | fn: ctx => { 132 | ctx.proxy.foo = 1234; 133 | } 134 | }); 135 | 136 | }); 137 | 138 | benchmark.group ( 'selector', () => { 139 | 140 | benchmark ({ 141 | name: 'no', 142 | beforeEach: ctx => { 143 | ctx.proxy = store ( OBJ () ); 144 | onChange ( ctx.proxy, SELECTOR_SINGLE, NOOP ); 145 | }, 146 | fn: ctx => { 147 | ctx.proxy.bar.deep = true; 148 | } 149 | }); 150 | 151 | benchmark ({ 152 | name: 'yes', 153 | beforeEach: ctx => { 154 | ctx.proxy = store ( OBJ () ); 155 | onChange ( ctx.proxy, SELECTOR_SINGLE, NOOP ); 156 | }, 157 | fn: ctx => { 158 | ctx.proxy.bar.deep = false; 159 | } 160 | }); 161 | 162 | }); 163 | 164 | }); 165 | 166 | benchmark.group ( 'multiple', () => { 167 | 168 | benchmark.group ( 'all', () => { 169 | 170 | benchmark ({ 171 | name: 'no', 172 | beforeEach: ctx => { 173 | ctx.proxy1 = store ( OBJ () ); 174 | ctx.proxy2 = store ( OBJ () ); 175 | onChange ( [ctx.proxy1, ctx.proxy2], NOOP ); 176 | }, 177 | fn: ctx => { 178 | ctx.proxy1.foo = 123; 179 | ctx.proxy2.foo = 123; 180 | } 181 | }); 182 | 183 | benchmark ({ 184 | name: 'yes', 185 | beforeEach: ctx => { 186 | ctx.proxy1 = store ( OBJ () ); 187 | ctx.proxy2 = store ( OBJ () ); 188 | onChange ( [ctx.proxy1, ctx.proxy2], NOOP ); 189 | }, 190 | fn: ctx => { 191 | ctx.proxy1.foo = 1234; 192 | ctx.proxy2.foo = 1234; 193 | } 194 | }); 195 | 196 | }); 197 | 198 | benchmark.group ( 'selector', () => { 199 | 200 | benchmark ({ 201 | name: 'no', 202 | beforeEach: ctx => { 203 | ctx.proxy1 = store ( OBJ () ); 204 | ctx.proxy2 = store ( OBJ () ); 205 | onChange ( [ctx.proxy1, ctx.proxy2], SELECTOR_MULTIPLE, NOOP ); 206 | }, 207 | fn: ctx => { 208 | ctx.proxy1.bar.deep = true; 209 | ctx.proxy2.bar.deep = true; 210 | } 211 | }); 212 | 213 | benchmark ({ 214 | name: 'yes', 215 | beforeEach: ctx => { 216 | ctx.proxy1 = store ( OBJ () ); 217 | ctx.proxy2 = store ( OBJ () ); 218 | onChange ( [ctx.proxy1, ctx.proxy2], SELECTOR_MULTIPLE, NOOP ); 219 | }, 220 | fn: ctx => { 221 | ctx.proxy1.bar.deep = false; 222 | ctx.proxy2.bar.deep = false; 223 | } 224 | }); 225 | 226 | }); 227 | 228 | }); 229 | 230 | }); 231 | 232 | }); 233 | 234 | benchmark.summary (); 235 | -------------------------------------------------------------------------------- /src/react/use_store.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import areShallowEqual from 'are-shallow-equal'; 5 | import {useCallback, useDebugValue, useEffect, useMemo, useRef, useState} from 'react'; 6 | import ChangesCounters from '../changes_counters'; 7 | import {EMPTY_ARRAY, COMPARATOR_FALSE, SELECTOR_IDENTITY} from '../consts'; 8 | import Errors from '../errors'; 9 | import onChange from '../on_change'; 10 | import Utils from '../utils'; 11 | import {Primitive} from '../types'; 12 | 13 | /* USE STORE */ 14 | 15 | function useStore ( stores: [S1, S2, S3, S4, S5, S6, S7, S8, S9] ): [S1, S2, S3, S4, S5, S6, S7, S8, S9]; 16 | function useStore ( stores: [S1, S2, S3, S4, S5, S6, S7, S8] ): [S1, S2, S3, S4, S5, S6, S7, S8]; 17 | function useStore ( stores: [S1, S2, S3, S4, S5, S6, S7] ): [S1, S2, S3, S4, S5, S6, S7]; 18 | function useStore ( stores: [S1, S2, S3, S4, S5, S6] ): [S1, S2, S3, S4, S5, S6]; 19 | function useStore ( stores: [S1, S2, S3, S4, S5] ): [S1, S2, S3, S4, S5]; 20 | function useStore ( stores: [S1, S2, S3, S4] ): [S1, S2, S3, S4]; 21 | function useStore ( stores: [S1, S2, S3] ): [S1, S2, S3]; 22 | function useStore ( stores: [S1, S2] ): [S1, S2]; 23 | function useStore ( store: S1 ): S1; 24 | function useStore ( stores: [S1, S2, S3, S4, S5, S6, S7, S8, S9], selector: ( ...stores: [S1, S2, S3, S4, S5, S6, S7, S8, S9] ) => R, dependencies?: any[] ): R; 25 | function useStore ( stores: [S1, S2, S3, S4, S5, S6, S7, S8], selector: ( ...stores: [S1, S2, S3, S4, S5, S6, S7, S8] ) => R, dependencies?: any[] ): R; 26 | function useStore ( stores: [S1, S2, S3, S4, S5, S6, S7], selector: ( ...stores: [S1, S2, S3, S4, S5, S6, S7] ) => R, dependencies?: any[] ): R; 27 | function useStore ( stores: [S1, S2, S3, S4, S5, S6], selector: ( ...stores: [S1, S2, S3, S4, S5, S6] ) => R, dependencies?: any[] ): R; 28 | function useStore ( stores: [S1, S2, S3, S4, S5], selector: ( ...stores: [S1, S2, S3, S4, S5] ) => R, dependencies?: any[] ): R; 29 | function useStore ( stores: [S1, S2, S3, S4], selector: ( ...stores: [S1, S2, S3, S4] ) => R, dependencies?: any[] ): R; 30 | function useStore ( stores: [S1, S2, S3], selector: ( ...stores: [S1, S2, S3] ) => R, dependencies?: any[] ): R; 31 | function useStore ( stores: [S1, S2], selector: ( ...stores: [S1, S2] ) => R, dependencies?: any[] ): R; 32 | function useStore ( store: S1, selector: ( store: S1 ) => R, dependencies?: any[] ): R; 33 | function useStore ( stores: [S1, S2, S3, S4, S5, S6, S7, S8, S9], selector: ( ...stores: [S1, S2, S3, S4, S5, S6, S7, S8, S9] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, dependencies?: any[] ): R; 34 | function useStore ( stores: [S1, S2, S3, S4, S5, S6, S7, S8], selector: ( ...stores: [S1, S2, S3, S4, S5, S6, S7, S8] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, dependencies?: any[] ): R; 35 | function useStore ( stores: [S1, S2, S3, S4, S5, S6, S7], selector: ( ...stores: [S1, S2, S3, S4, S5, S6, S7] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, dependencies?: any[] ): R; 36 | function useStore ( stores: [S1, S2, S3, S4, S5, S6], selector: ( ...stores: [S1, S2, S3, S4, S5, S6] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, dependencies?: any[] ): R; 37 | function useStore ( stores: [S1, S2, S3, S4, S5], selector: ( ...stores: [S1, S2, S3, S4, S5] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, dependencies?: any[] ): R; 38 | function useStore ( stores: [S1, S2, S3, S4], selector: ( ...stores: [S1, S2, S3, S4] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, dependencies?: any[] ): R; 39 | function useStore ( stores: [S1, S2, S3], selector: ( ...stores: [S1, S2, S3] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, dependencies?: any[] ): R; 40 | function useStore ( stores: [S1, S2], selector: ( ...stores: [S1, S2] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, dependencies?: any[] ): R; 41 | function useStore ( store: S1, selector: ( store: S1 ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, dependencies?: any[] ): R; 42 | function useStore ( store: Store | Store[], selector: (( store: Store ) => R) | (( ...stores: Store[] ) => R) = SELECTOR_IDENTITY, comparator?: (( dataPrev: Exclude, dataNext: Exclude ) => boolean) | any[], dependencies?: any[] ): Store | Store[] | R { 43 | 44 | if ( !dependencies && !comparator ) return useStore ( store, selector, COMPARATOR_FALSE, EMPTY_ARRAY ); 45 | 46 | if ( !dependencies ) { 47 | 48 | if ( typeof comparator === 'function' ) return useStore ( store, selector, comparator, EMPTY_ARRAY ); 49 | 50 | return useStore ( store, selector, COMPARATOR_FALSE, comparator ); 51 | 52 | } 53 | 54 | const stores = Utils.isStores ( store ) ? store : [store]; 55 | 56 | if ( !stores.length ) throw Errors.storesEmpty (); 57 | 58 | const mountedRef = useRef ( false ), 59 | storesRef = useRef (), 60 | storesMemo = ( storesRef.current && areShallowEqual ( storesRef.current, stores ) ) ? storesRef.current : stores, 61 | selectorMemo = useCallback ( selector, dependencies ), 62 | selectorRef = useRef ( selectorMemo ), // Storing a ref so we won't have to resubscribe if the selector changes 63 | comparatorMemo = useCallback ( comparator as any, dependencies ), //TSC 64 | comparatorRef = useRef ( comparatorMemo ), // Storing a ref so we won't have to resubscribe if the comparator changes 65 | changesCountersRendering = useMemo ( () => ChangesCounters.getMultiple ( storesMemo ), [storesMemo] ), // Storing the number of changes at rendering time, in order to understand if changes happened before now and commit time 66 | valueRef = useRef ( undefined as any ), // Using a ref in order not to trigger *any* unnecessary re-renders //TSC 67 | setUpdateId = useState ()[1], // Dummy state used for triggering updates 68 | forceUpdate = useCallback ( () => setUpdateId ( Symbol () ), [] ); 69 | 70 | if ( storesRef.current !== storesMemo || selectorRef.current !== selectorMemo || comparatorRef.current !== comparatorMemo ) { 71 | 72 | storesRef.current = storesMemo; 73 | selectorRef.current = selectorMemo; 74 | comparatorRef.current = comparatorMemo; 75 | 76 | const value = selectorMemo.apply ( undefined, storesMemo ); 77 | 78 | if ( !Object.is ( value, valueRef.current ) ) { 79 | 80 | valueRef.current = value; 81 | 82 | } 83 | 84 | } 85 | 86 | useDebugValue ( valueRef.current ); 87 | 88 | useEffect ( () => { 89 | 90 | mountedRef.current = true; 91 | 92 | return () => { 93 | 94 | mountedRef.current = false; 95 | 96 | }; 97 | 98 | }, [] ); 99 | 100 | useEffect ( () => { 101 | 102 | /* COUNTERS */ // Checking if something changed while we weren't subscribed yet, updating 103 | 104 | const changesCounterMounting = ChangesCounters.getMultiple ( storesMemo ); 105 | 106 | if ( !Utils.isEqual ( changesCountersRendering, changesCounterMounting ) ) { 107 | 108 | const value = selectorRef.current.apply ( undefined, storesMemo ); 109 | 110 | if ( !Object.is ( value, valueRef.current ) ) { 111 | 112 | valueRef.current = value; 113 | 114 | forceUpdate (); 115 | 116 | } 117 | 118 | } 119 | 120 | /* SUBSCRIPTION */ 121 | 122 | return onChange ( storesMemo, ( ...stores ) => selectorRef.current.apply ( undefined, stores ), ( dataPrev, dataNext ) => comparatorRef.current.call ( undefined, dataPrev, dataNext ), ( ...values ) => { 123 | 124 | if ( !mountedRef.current ) return; 125 | 126 | const value = values.length > 1 ? values : values[0]; 127 | 128 | valueRef.current = value; 129 | 130 | forceUpdate (); 131 | 132 | }); 133 | 134 | }, [storesMemo, changesCountersRendering] ); 135 | 136 | return valueRef.current; 137 | 138 | } 139 | 140 | /* EXPORT */ 141 | 142 | export default useStore; 143 | -------------------------------------------------------------------------------- /src/on_change.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import areShallowEqual from 'are-shallow-equal'; 5 | import * as isPrimitive from 'is-primitive'; 6 | import {record} from 'proxy-watcher'; 7 | import {EMPTY_ARRAY, COMPARATOR_FALSE, SELECTOR_IDENTITY} from './consts'; 8 | import ChangesSubscribers from './changes_subscribers'; 9 | import Errors from './errors'; 10 | import Scheduler from './scheduler'; 11 | import Utils from './utils'; 12 | import {Primitive, Disposer, Listener} from './types'; 13 | 14 | /* ON CHANGE */ 15 | 16 | function onChange ( stores: [S1, S2, S3, S4, S5, S6, S7, S8, S9], listener: Listener<[S1, S2, S3, S4, S5, S6, S7, S8, S9]> ): Disposer; 17 | function onChange ( stores: [S1, S2, S3, S4, S5, S6, S7, S8], listener: Listener<[S1, S2, S3, S4, S5, S6, S7, S8]> ): Disposer; 18 | function onChange ( stores: [S1, S2, S3, S4, S5, S6, S7], listener: Listener<[S1, S2, S3, S4, S5, S6, S7]> ): Disposer; 19 | function onChange ( stores: [S1, S2, S3, S4, S5, S6], listener: Listener<[S1, S2, S3, S4, S5, S6]> ): Disposer; 20 | function onChange ( stores: [S1, S2, S3, S4, S5], listener: Listener<[S1, S2, S3, S4, S5]> ): Disposer; 21 | function onChange ( stores: [S1, S2, S3, S4], listener: Listener<[S1, S2, S3, S4]> ): Disposer; 22 | function onChange ( stores: [S1, S2, S3], listener: Listener<[S1, S2, S3]> ): Disposer; 23 | function onChange ( stores: [S1, S2], listener: Listener<[S1, S2]> ): Disposer; 24 | function onChange ( store: S1, listener: Listener<[S1]> ): Disposer; 25 | function onChange ( stores: [S1, S2, S3, S4, S5, S6, S7, S8, S9], selector: ( ...stores: [S1, S2, S3, S4, S5, S6, S7, S8, S9] ) => R, listener: Listener<[R]> ): Disposer; 26 | function onChange ( stores: [S1, S2, S3, S4, S5, S6, S7, S8], selector: ( ...stores: [S1, S2, S3, S4, S5, S6, S7, S8] ) => R, listener: Listener<[R]> ): Disposer; 27 | function onChange ( stores: [S1, S2, S3, S4, S5, S6, S7], selector: ( ...stores: [S1, S2, S3, S4, S5, S6, S7] ) => R, listener: Listener<[R]> ): Disposer; 28 | function onChange ( stores: [S1, S2, S3, S4, S5, S6], selector: ( ...stores: [S1, S2, S3, S4, S5, S6] ) => R, listener: Listener<[R]> ): Disposer; 29 | function onChange ( stores: [S1, S2, S3, S4, S5], selector: ( ...stores: [S1, S2, S3, S4, S5] ) => R, listener: Listener<[R]> ): Disposer; 30 | function onChange ( stores: [S1, S2, S3, S4], selector: ( ...stores: [S1, S2, S3, S4] ) => R, listener: Listener<[R]> ): Disposer; 31 | function onChange ( stores: [S1, S2, S3], selector: ( ...stores: [S1, S2, S3] ) => R, listener: Listener<[R]> ): Disposer; 32 | function onChange ( stores: [S1, S2], selector: ( ...stores: [S1, S2] ) => R, listener: Listener<[R]> ): Disposer; 33 | function onChange ( store: S1, selector: ( store: S1 ) => R, listener: Listener<[R]> ): Disposer; 34 | function onChange ( stores: [S1, S2, S3, S4, S5, S6, S7, S8, S9], selector: ( ...stores: [S1, S2, S3, S4, S5, S6, S7, S8, S9] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, listener: Listener<[R]> ): Disposer; 35 | function onChange ( stores: [S1, S2, S3, S4, S5, S6, S7, S8], selector: ( ...stores: [S1, S2, S3, S4, S5, S6, S7, S8] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, listener: Listener<[R]> ): Disposer; 36 | function onChange ( stores: [S1, S2, S3, S4, S5, S6, S7], selector: ( ...stores: [S1, S2, S3, S4, S5, S6, S7] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, listener: Listener<[R]> ): Disposer; 37 | function onChange ( stores: [S1, S2, S3, S4, S5, S6], selector: ( ...stores: [S1, S2, S3, S4, S5, S6] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, listener: Listener<[R]> ): Disposer; 38 | function onChange ( stores: [S1, S2, S3, S4, S5], selector: ( ...stores: [S1, S2, S3, S4, S5] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, listener: Listener<[R]> ): Disposer; 39 | function onChange ( stores: [S1, S2, S3, S4], selector: ( ...stores: [S1, S2, S3, S4] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, listener: Listener<[R]> ): Disposer; 40 | function onChange ( stores: [S1, S2, S3], selector: ( ...stores: [S1, S2, S3] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, listener: Listener<[R]> ): Disposer; 41 | function onChange ( stores: [S1, S2], selector: ( ...stores: [S1, S2] ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, listener: Listener<[R]> ): Disposer; 42 | function onChange ( store: S1, selector: ( store: S1 ) => R, comparator: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, listener: Listener<[R]> ): Disposer; 43 | function onChange ( store: Store | Store[], selector: (( store: Store ) => R) | (( ...stores: Store[] ) => R), comparator?: ( dataPrev: Exclude, dataNext: Exclude ) => boolean, listener?: Listener<[R] | Store[]> ): Disposer { 44 | 45 | if ( !listener && !comparator ) return onChange ( store, SELECTOR_IDENTITY, COMPARATOR_FALSE, selector ); 46 | 47 | if ( !listener ) return onChange ( store, selector, COMPARATOR_FALSE, comparator as any ); //TSC 48 | 49 | const stores = Utils.isStores ( store ) ? store : [store], 50 | storesNr = stores.length; 51 | 52 | if ( !storesNr ) throw Errors.storesEmpty (); 53 | 54 | const disposers: Disposer[] = []; 55 | 56 | let rootsChangeAllCache: Map = new Map (), 57 | dataPrev = selector.apply ( undefined, stores ); // Fetching initial data 58 | 59 | const handler = () => { 60 | 61 | if ( selector === SELECTOR_IDENTITY ) return listener.apply ( undefined, stores ); 62 | 63 | let data; 64 | 65 | const rootsChangeAll = rootsChangeAllCache, 66 | rootsGetAll = record ( stores, () => data = selector.apply ( undefined, stores ) ) as unknown as Map; //TSC 67 | 68 | rootsChangeAllCache = new Map (); 69 | 70 | if ( isPrimitive ( data ) || isPrimitive ( dataPrev ) ) { // Primitives involved 71 | 72 | if ( Object.is ( data, dataPrev ) ) return; // The selected primitive didn't actually change 73 | 74 | dataPrev = data; 75 | 76 | return listener ( data ); 77 | 78 | } 79 | 80 | const comparatorDataPrev = dataPrev; 81 | 82 | dataPrev = data; 83 | 84 | const isDataIdentity = ( storesNr === 1 ) ? data === stores[0] : areShallowEqual ( data, stores ); 85 | 86 | if ( isDataIdentity ) return listener.apply ( undefined, stores ); 87 | 88 | const isDataStore = stores.indexOf ( data ) >= 0; 89 | 90 | if ( isDataStore && ( rootsChangeAll.get ( data ) || EMPTY_ARRAY ).length ) return listener ( data ); 91 | 92 | const isSimpleSelector = Array.from ( rootsGetAll.values () ).every ( paths => !paths.length ); 93 | 94 | if ( isSimpleSelector ) return listener ( data ); 95 | 96 | for ( let i = 0; i < storesNr; i++ ) { 97 | 98 | const store = stores[i], 99 | rootsChange = rootsChangeAll.get ( store ) || EMPTY_ARRAY; 100 | 101 | if ( !rootsChange.length ) continue; 102 | 103 | const rootsGet = rootsGetAll.get ( store ) || EMPTY_ARRAY; 104 | 105 | if ( !rootsGet.length ) continue; 106 | 107 | const changed = Utils.uniq ( rootsGet ).some ( rootGet => rootsChange.indexOf ( rootGet ) >= 0 ); 108 | 109 | if ( !changed ) continue; 110 | 111 | if ( comparator && comparator !== COMPARATOR_FALSE && comparator ( comparatorDataPrev, data ) ) return; // Custom comparator says nothing changed 112 | 113 | return listener ( data ); 114 | 115 | } 116 | 117 | }; 118 | 119 | for ( let i = 0; i < storesNr; i++ ) { 120 | 121 | const store = stores[i], 122 | changes = ChangesSubscribers.get ( store ); 123 | 124 | if ( !changes ) throw Errors.storeNotFound (); 125 | 126 | const disposer = changes.subscribe ( rootsChange => { 127 | 128 | const rootsChangePrev = rootsChangeAllCache.get ( store ); 129 | 130 | if ( rootsChangePrev ) { 131 | 132 | rootsChangePrev.push ( ...rootsChange ); 133 | 134 | } else { 135 | 136 | rootsChangeAllCache.set ( store, rootsChange ); 137 | 138 | } 139 | 140 | if ( storesNr === 1 ) return handler (); 141 | 142 | return Scheduler.schedule ( handler ); 143 | 144 | }); 145 | 146 | disposers.push ( disposer ); 147 | 148 | } 149 | 150 | return () => { 151 | 152 | for ( let i = 0, l = disposers.length; i < l; i++ ) disposers[i](); 153 | 154 | }; 155 | 156 | } 157 | 158 | /* EXPORT */ 159 | 160 | export default onChange; 161 | -------------------------------------------------------------------------------- /test/modules/on_change.ts: -------------------------------------------------------------------------------- 1 | 2 | /* IMPORT */ 3 | 4 | import {describe} from 'ava-spec'; 5 | import areShallowEqual from 'are-shallow-equal'; 6 | import delay from 'promise-resolve-timeout'; 7 | import {onChange, store} from '../../x'; 8 | 9 | /* ON CHANGE */ 10 | 11 | describe ( 'onChange', it => { 12 | 13 | describe ( 'single store', it => { 14 | 15 | it ( 'schedules a call to the listener when a mutation is made to the object', async t => { 16 | 17 | const tests = [ 18 | /* NO MUTATION */ 19 | [proxy => proxy.foo = 123, false], 20 | [proxy => proxy.bar = { deep: true }, false], 21 | [proxy => proxy.arr = [1, 2, '3'], false], 22 | [proxy => proxy.arr[0] = 1, false], 23 | [proxy => proxy.arr.length = 3, false], 24 | [proxy => proxy.nan = NaN, false], 25 | [proxy => delete proxy.qux, false], 26 | /* MUTATION */ 27 | [proxy => proxy.foo = 1234, true], 28 | [proxy => proxy.bar = { deep: false }, true], 29 | [proxy => proxy.bar = { deep: undefined }, true], 30 | [proxy => proxy.bar = { deep: null }, true], 31 | [proxy => proxy.bar = { deep: NaN }, true], 32 | [proxy => proxy.bar = { deep2: '123' }, true], 33 | [proxy => proxy.bar = { deep2: undefined }, true], 34 | [proxy => proxy.bar = { deep2: null }, true], 35 | [proxy => proxy.bar = { deep2: NaN }, true], 36 | [proxy => proxy.arr = [1], true], 37 | [proxy => proxy.arr[0] = 2, true], 38 | [proxy => proxy.arr.push ( 4 ), true], 39 | [proxy => proxy.arr.length = 4, true], 40 | [proxy => proxy.nan = Infinity, true], 41 | [proxy => proxy.qux = undefined, true], 42 | [proxy => delete proxy['foo'], true] 43 | ]; 44 | 45 | for ( const [fn, shouldMutate] of tests ) { 46 | 47 | let callsNr = 0; 48 | 49 | const proxy = store ({ 50 | foo: 123, 51 | bar: { deep: true }, 52 | arr: [1, 2, '3'], 53 | nan: NaN 54 | }); 55 | 56 | function listener ( data ) { 57 | t.deepEqual ( data, proxy ); 58 | callsNr++; 59 | } 60 | 61 | onChange ( proxy, listener ); 62 | 63 | fn ( proxy ); 64 | 65 | t.is ( callsNr, 0 ); 66 | 67 | await delay ( 100 ); 68 | 69 | t.is ( callsNr, Number ( shouldMutate ) ); 70 | 71 | } 72 | 73 | }); 74 | 75 | it ( 'works with simple selectors', async t => { 76 | 77 | let callsNr = 0; 78 | 79 | const proxy = store ({ 80 | foo: 123, 81 | bar: 123 82 | }); 83 | 84 | function listener () { 85 | callsNr++; 86 | } 87 | 88 | onChange ( proxy, proxy => proxy, listener ); 89 | onChange ( proxy, proxy => ([ proxy ]), listener ); 90 | onChange ( proxy, proxy => { proxy.bar; return proxy; }, listener ); 91 | 92 | proxy.foo = 0; 93 | 94 | t.is ( callsNr, 0 ); 95 | 96 | await delay ( 100 ); 97 | 98 | t.is ( callsNr, 3 ); 99 | 100 | }); 101 | 102 | it ( 'supports an optional selector', async t => { 103 | 104 | const proxy = store ({ foo: 123, bar: { deep: [1, 2, 3] } }), 105 | calls = []; 106 | 107 | function listener1 ( data ) { 108 | t.true ( typeof data === 'number' ); 109 | calls.push ( 1 ); 110 | } 111 | 112 | function listener2 ( data ) { 113 | t.is ( data, proxy.foo ); 114 | calls.push ( 2 ); 115 | } 116 | 117 | function listener3 ( data ) { 118 | t.deepEqual ( data, proxy.bar ); 119 | calls.push ( 3 ); 120 | } 121 | 122 | onChange ( proxy, () => Math.random (), listener1 ); 123 | onChange ( proxy, data => data.foo, listener2 ); 124 | onChange ( proxy, data => data.bar, listener3 ); 125 | 126 | proxy['baz'] = true; 127 | proxy.foo = 1234; 128 | proxy.bar['foo'] = true; 129 | 130 | await delay ( 100 ); 131 | 132 | t.deepEqual ( calls, [1, 2, 3] ); 133 | 134 | }); 135 | 136 | it ( 'supports an optional comparator', async t => { 137 | 138 | const proxy = store ({ foo: 123, bar: { deep: [1, 2, 3] }, baz: { title: 'Title', desc: 'Description' } }), 139 | calls = []; 140 | 141 | function listener1 ( data ) { 142 | t.true ( typeof data === 'number' ); 143 | calls.push ( 1 ); 144 | } 145 | 146 | function comparator1a ( dataPrev, dataNext ) { 147 | t.fail (); 148 | calls.push ( '1a' ); 149 | return false; 150 | } 151 | 152 | function listener2 ( data ) { 153 | t.is ( data, proxy.foo ); 154 | calls.push ( 2 ); 155 | } 156 | 157 | function comparator2a ( dataPrev, dataNext ) { 158 | t.fail (); 159 | calls.push ( '2a' ); 160 | return false; 161 | } 162 | 163 | function listener3 ( data ) { 164 | t.deepEqual ( data, proxy.bar ); 165 | calls.push ( 3 ); 166 | } 167 | 168 | function comparator3a ( dataPrev, dataNext ) { 169 | t.deepEqual ( dataPrev, dataNext ); 170 | calls.push ( '3a' ); 171 | return true; 172 | } 173 | 174 | function comparator3b ( dataPrev, dataNext ) { 175 | t.deepEqual ( dataPrev, dataNext ); 176 | calls.push ( '3b' ); 177 | return false; 178 | } 179 | 180 | function listener4 ( data ) { 181 | t.deepEqual ( data, { title: 'Title' } ); 182 | calls.push ( 4 ); 183 | } 184 | 185 | function comparator4a ( dataPrev, dataNext ) { 186 | t.deepEqual ( dataPrev, dataNext ); 187 | calls.push ( '4a' ); 188 | return areShallowEqual ( dataPrev, { title: 'Title' } ); 189 | } 190 | 191 | function listener5 ( data ) { 192 | t.deepEqual ( data, { title: proxy.baz.title, desc: proxy.baz.desc } ); 193 | calls.push ( 5 ); 194 | } 195 | 196 | function comparator5a ( dataPrev, dataNext ) { 197 | calls.push ( '5a' ); 198 | return areShallowEqual ( dataPrev, dataNext ); 199 | } 200 | 201 | onChange ( proxy, () => Math.random (), comparator1a, listener1 ); 202 | onChange ( proxy, data => data.foo, comparator2a, listener2 ); 203 | onChange ( proxy, data => data.bar, comparator3a, listener3 ); 204 | onChange ( proxy, data => data.bar, comparator3b, listener3 ); 205 | onChange ( proxy, data => ({ title: data.baz.title }), comparator4a, listener4 ); 206 | onChange ( proxy, data => ({ title: data.baz.title, desc: data.baz.desc }), comparator5a, listener5 ); 207 | 208 | proxy['qux'] = true; 209 | 210 | await delay ( 100 ); 211 | 212 | t.deepEqual ( calls, [1] ); 213 | 214 | proxy.foo = 1234; 215 | 216 | await delay ( 100 ); 217 | 218 | t.deepEqual ( calls, [1, 1, 2] ); 219 | 220 | proxy.bar['foo'] = true; 221 | 222 | await delay ( 100 ); 223 | 224 | t.deepEqual ( calls, [1, 1, 2, 1, '3a', '3b', 3] ); 225 | 226 | proxy.baz.desc += '2'; 227 | 228 | await delay ( 100 ); 229 | 230 | t.deepEqual ( calls, [1, 1, 2, 1, '3a', '3b', 3, 1, '4a', '5a', 5] ); 231 | 232 | proxy.baz['extra'] = 'foo'; 233 | 234 | await delay ( 100 ); 235 | 236 | t.deepEqual ( calls, [1, 1, 2, 1, '3a', '3b', 3, 1, '4a', '5a', 5, 1, '4a', '5a'] ); 237 | 238 | }); 239 | 240 | it ( 'doesn\'t schedule a call to the listener if the return value of the selector didn\'t actually change', async t => { 241 | 242 | const proxy = store ({ foo: 123, bar: { deep: [1, 2, 3] } }), 243 | calls = []; 244 | 245 | onChange ( proxy, () => calls.push ( 0 ) ); 246 | onChange ( proxy, state => state.foo, () => calls.push ( 1 ) ); 247 | onChange ( proxy, state => state.bar, () => calls.push ( 2 ) ); 248 | onChange ( proxy, state => state.bar.deep[0], () => calls.push ( 3 ) ); 249 | 250 | proxy.foo = 123; 251 | proxy.bar = proxy.bar; 252 | proxy.bar = { deep: [1, 2, 3] }; 253 | proxy.bar.deep[0] = 1; 254 | 255 | await delay ( 100 ); 256 | 257 | t.deepEqual ( calls, [] ); 258 | 259 | proxy.foo = 1234; 260 | 261 | await delay ( 100 ); 262 | 263 | t.deepEqual ( calls, [0, 1] ); 264 | 265 | proxy.bar['foo'] = true; 266 | 267 | await delay ( 100 ); 268 | 269 | t.deepEqual ( calls, [0, 1, 0, 2] ); 270 | 271 | proxy.bar.deep.push ( 4 ); 272 | 273 | await delay ( 100 ); 274 | 275 | t.deepEqual ( calls, [0, 1, 0, 2, 0, 2] ); 276 | 277 | proxy.bar.deep[0] = 2; 278 | 279 | await delay ( 100 ); 280 | 281 | t.deepEqual ( calls, [0, 1, 0, 2, 0, 2, 0, 2, 3] ); 282 | 283 | }); 284 | 285 | it ( 'compares primitives', async t => { 286 | 287 | const proxy = store ({ bool: true, number: 123, void: true }), 288 | calls = []; 289 | 290 | onChange ( proxy, state => state.bool, () => calls.push ( 1 ) ); 291 | onChange ( proxy, state => state.number, () => calls.push ( 2 ) ); 292 | onChange ( proxy, state => state.void, () => calls.push ( 3 ) ); 293 | 294 | proxy.bool = false; 295 | proxy.number = 0; 296 | proxy.bool = true; 297 | proxy.number = 123; 298 | proxy.void = undefined; 299 | 300 | await delay ( 100 ); 301 | 302 | t.deepEqual ( calls, [3] ); 303 | 304 | }); 305 | 306 | it ( 'throws if no ChangeSubscriber has been found ', t => { 307 | 308 | t.throws ( () => onChange ( {}, () => {} ), /garbage-collected/i ); 309 | 310 | }); 311 | 312 | it ( 'returns a disposer', async t => { 313 | 314 | const proxy = store ({ foo: 123 }); 315 | 316 | let callsNr = 0; 317 | 318 | function listener () { 319 | callsNr++; 320 | } 321 | 322 | onChange ( proxy, listener )(); 323 | onChange ( proxy, () => Math.random (), listener )(); 324 | 325 | proxy.foo = 1234; 326 | 327 | await delay ( 100 ); 328 | 329 | t.is ( callsNr, 0 ); 330 | 331 | }); 332 | 333 | }); 334 | 335 | describe ( 'multiple stores', it => { 336 | 337 | it ( 'coalesces mutations from different stores', async t => { 338 | 339 | let callsNr = 0; 340 | 341 | const proxy1 = store ({ foo: 123 }), 342 | proxy2 = store ({ bar: 123 }); 343 | 344 | function listener ( data1, data2 ) { 345 | t.deepEqual ( data1, proxy1 ); 346 | t.deepEqual ( data2, proxy2 ); 347 | callsNr++; 348 | } 349 | 350 | onChange ( [proxy1, proxy2], listener ); 351 | 352 | proxy1.foo = 0; 353 | proxy2.bar = 0; 354 | 355 | t.is ( callsNr, 0 ); 356 | 357 | await delay ( 100 ); 358 | 359 | t.is ( callsNr, 1 ); 360 | 361 | }); 362 | 363 | it ( 'schedules a call to the listener when a mutation is made to the object', async t => { 364 | 365 | const tests = [ 366 | /* NO MUTATION */ 367 | [proxy => proxy.foo = 123, false], 368 | [proxy => proxy.bar = { deep: true }, false], 369 | [proxy => proxy.arr = [1, 2, '3'], false], 370 | [proxy => proxy.arr[0] = 1, false], 371 | [proxy => proxy.arr.length = 3, false], 372 | [proxy => proxy.nan = NaN, false], 373 | [proxy => delete proxy.qux, false], 374 | /* MUTATION */ 375 | [proxy => proxy.foo = 1234, true], 376 | [proxy => proxy.bar = { deep: false }, true], 377 | [proxy => proxy.bar = { deep: undefined }, true], 378 | [proxy => proxy.bar = { deep: null }, true], 379 | [proxy => proxy.bar = { deep: NaN }, true], 380 | [proxy => proxy.bar = { deep2: '123' }, true], 381 | [proxy => proxy.bar = { deep2: undefined }, true], 382 | [proxy => proxy.bar = { deep2: null }, true], 383 | [proxy => proxy.bar = { deep2: NaN }, true], 384 | [proxy => proxy.arr = [1], true], 385 | [proxy => proxy.arr[0] = 2, true], 386 | [proxy => proxy.arr.push ( 4 ), true], 387 | [proxy => proxy.arr.length = 4, true], 388 | [proxy => proxy.nan = Infinity, true], 389 | [proxy => proxy.qux = undefined, true], 390 | [proxy => delete proxy['foo'], true] 391 | ]; 392 | 393 | for ( const [fn, shouldMutate] of tests ) { 394 | 395 | let callsNr = 0; 396 | 397 | const proxy1 = store ({ 398 | foo: 123, 399 | bar: { deep: true }, 400 | arr: [1, 2, '3'], 401 | nan: NaN 402 | }); 403 | 404 | const proxy2 = store ({ 405 | foo: 123, 406 | bar: { deep: true }, 407 | arr: [1, 2, '3'], 408 | nan: NaN 409 | }); 410 | 411 | function listener ( data1, data2 ) { 412 | t.deepEqual ( data1, proxy1 ); 413 | t.deepEqual ( data2, proxy2 ); 414 | callsNr++; 415 | } 416 | 417 | onChange ( [proxy1, proxy2], listener ); 418 | 419 | fn ( proxy1 ); 420 | 421 | t.is ( callsNr, 0 ); 422 | 423 | await delay ( 100 ); 424 | 425 | t.is ( callsNr, Number ( shouldMutate ) ); 426 | 427 | callsNr = 0; 428 | 429 | fn ( proxy2 ); 430 | 431 | await delay ( 100 ); 432 | 433 | t.is ( callsNr, Number ( shouldMutate ) ); 434 | 435 | } 436 | 437 | }); 438 | 439 | it ( 'works with simple selectors', async t => { 440 | 441 | let callsNr = 0; 442 | 443 | const proxy1 = store ({ foo: 123, bar: 123 }), 444 | proxy2 = store ({ foo: 123, bar: 123 }); 445 | 446 | function listener () { 447 | callsNr++; 448 | } 449 | 450 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy1, listener ); 451 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy2, listener ); 452 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => ([ proxy1 ]), listener ); 453 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => ([ proxy2 ]), listener ); 454 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => ([ proxy1, proxy2 ]), listener ); 455 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => { proxy1.bar; return proxy1; }, listener ); 456 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => { proxy2.bar; return proxy2; }, listener ); 457 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => { proxy1.bar; return proxy2; }, listener ); 458 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => { proxy2.bar; return proxy1; }, listener ); 459 | 460 | proxy1.foo = 0; 461 | proxy2.foo = 0; 462 | 463 | t.is ( callsNr, 0 ); 464 | 465 | await delay ( 100 ); 466 | 467 | t.is ( callsNr, 9 ); 468 | 469 | }); 470 | 471 | it ( 'supports an optional selector', async t => { 472 | 473 | const proxy1 = store ({ foo: 123, bar: { deep: [1, 2, 3] } }), 474 | proxy2 = store ({ foo: 123, bar: { deep: [1, 2, 3] } }), 475 | calls = []; 476 | 477 | function listener1 ( data ) { 478 | t.true ( typeof data === 'number' ); 479 | calls.push ( 1 ); 480 | } 481 | 482 | function listener2 ( data ) { 483 | t.is ( data, proxy1.foo ); 484 | calls.push ( 2 ); 485 | } 486 | 487 | function listener3 ( data ) { 488 | t.is ( data, proxy1.bar ); 489 | calls.push ( 3 ); 490 | } 491 | 492 | function listener4 ( data ) { 493 | t.is ( data, proxy2.foo ); 494 | calls.push ( 4 ); 495 | } 496 | 497 | function listener5 ( data ) { 498 | t.is ( data, proxy2.bar ); 499 | calls.push ( 5 ); 500 | } 501 | 502 | onChange ( [proxy1, proxy2], () => Math.random (), listener1 ); 503 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy1.foo, listener2 ); 504 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy1.bar, listener3 ); 505 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy2.foo, listener4 ); 506 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy2.bar, listener5 ); 507 | 508 | proxy1['baz'] = true; 509 | proxy1.foo = 1234; 510 | proxy1.bar['foo'] = true; 511 | 512 | await delay ( 100 ); 513 | 514 | t.deepEqual ( calls, [1, 2, 3] ); 515 | 516 | proxy2['baz'] = true; 517 | proxy2.foo = 1234; 518 | proxy2.bar['foo'] = true; 519 | 520 | await delay ( 100 ); 521 | 522 | t.deepEqual ( calls, [1, 2, 3, 1, 4, 5] ); 523 | 524 | }); 525 | 526 | //TODO: Add 'supports an optional comparator' test 527 | 528 | it ( 'doesn\'t forget previously mutated roots in non flushed changes', async t => { 529 | 530 | const proxy1 = store ({ foo: 123, bar: { deep: [1, 2, 3] } }), 531 | proxy2 = store ({ foo: 123, bar: { deep: [1, 2, 3] } }), 532 | calls = []; 533 | 534 | function listener ( data ) { 535 | t.is ( data, proxy1.bar.deep ); 536 | calls.push ( 1 ); 537 | } 538 | 539 | onChange ( proxy1, () => proxy1.foo = 0 ); 540 | onChange ( [proxy1, proxy2], proxy1 => proxy1.bar.deep, listener ); 541 | 542 | proxy1.bar.deep = [1]; 543 | 544 | await delay ( 100 ); 545 | 546 | t.deepEqual ( calls, [1] ); 547 | 548 | }); 549 | 550 | it ( 'doesn\'t schedule a call to the listener if the return value of the selector didn\'t actually change', async t => { 551 | 552 | const proxy1 = store ({ foo: 123, bar: { deep: [1, 2, 3] } }), 553 | proxy2 = store ({ foo: 123, bar: { deep: [1, 2, 3] } }), 554 | calls = []; 555 | 556 | onChange ( [proxy1, proxy2], () => calls.push ( 0 ) ); 557 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy1.foo, () => calls.push ( 1 ) ); 558 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy1.bar, () => calls.push ( 2 ) ); 559 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy1.bar.deep[0], () => calls.push ( 3 ) ); 560 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy2.foo, () => calls.push ( 4 ) ); 561 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy2.bar, () => calls.push ( 5 ) ); 562 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy2.bar.deep[0], () => calls.push ( 6 ) ); 563 | 564 | proxy1.foo = 123; 565 | proxy1.bar = proxy1.bar; 566 | proxy1.bar = { deep: [1, 2, 3] }; 567 | proxy1.bar.deep[0] = 1; 568 | 569 | proxy2.foo = 123; 570 | proxy2.bar = proxy2.bar; 571 | proxy2.bar = { deep: [1, 2, 3] }; 572 | proxy2.bar.deep[0] = 1; 573 | 574 | await delay ( 100 ); 575 | 576 | t.deepEqual ( calls, [] ); 577 | 578 | proxy1.foo = 1234; 579 | 580 | await delay ( 100 ); 581 | 582 | t.deepEqual ( calls, [0, 1] ); 583 | 584 | proxy2.foo = 1234; 585 | 586 | await delay ( 100 ); 587 | 588 | t.deepEqual ( calls, [0, 1, 0, 4] ); 589 | 590 | proxy1.bar['foo'] = true; 591 | 592 | await delay ( 100 ); 593 | 594 | t.deepEqual ( calls, [0, 1, 0, 4, 0, 2] ); 595 | 596 | proxy2.bar['foo'] = true; 597 | 598 | await delay ( 100 ); 599 | 600 | t.deepEqual ( calls, [0, 1, 0, 4, 0, 2, 0, 5] ); 601 | 602 | proxy1.bar.deep.push ( 4 ); 603 | 604 | await delay ( 100 ); 605 | 606 | t.deepEqual ( calls, [0, 1, 0, 4, 0, 2, 0, 5, 0, 2] ); 607 | 608 | proxy2.bar.deep.push ( 4 ); 609 | 610 | await delay ( 100 ); 611 | 612 | t.deepEqual ( calls, [0, 1, 0, 4, 0, 2, 0, 5, 0, 2, 0, 5] ); 613 | 614 | proxy1.bar.deep[0] = 2; 615 | 616 | await delay ( 100 ); 617 | 618 | t.deepEqual ( calls, [0, 1, 0, 4, 0, 2, 0, 5, 0, 2, 0, 5, 0, 2, 3] ); 619 | 620 | proxy2.bar.deep[0] = 2; 621 | 622 | await delay ( 100 ); 623 | 624 | t.deepEqual ( calls, [0, 1, 0, 4, 0, 2, 0, 5, 0, 2, 0, 5, 0, 2, 3, 0, 5, 6] ); 625 | 626 | }); 627 | 628 | it ( 'compares primitives', async t => { 629 | 630 | const proxy1 = store ({ bool: true, number: 123, void: true }), 631 | proxy2 = store ({ bool: true, number: 123, void: true }), 632 | calls = []; 633 | 634 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy1.bool, () => calls.push ( 1 ) ); 635 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy1.number, () => calls.push ( 2 ) ); 636 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy1.void, () => calls.push ( 3 ) ); 637 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy2.bool, () => calls.push ( 4 ) ); 638 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy2.number, () => calls.push ( 5 ) ); 639 | onChange ( [proxy1, proxy2], ( proxy1, proxy2 ) => proxy2.void, () => calls.push ( 6 ) ); 640 | 641 | proxy1.bool = false; 642 | proxy1.number = 0; 643 | proxy1.bool = true; 644 | proxy1.number = 123; 645 | proxy1.void = undefined; 646 | 647 | proxy2.bool = false; 648 | proxy2.number = 0; 649 | proxy2.bool = true; 650 | proxy2.number = 123; 651 | proxy2.void = undefined; 652 | 653 | await delay ( 100 ); 654 | 655 | t.deepEqual ( calls, [3, 6] ); 656 | 657 | }); 658 | 659 | it ( 'throws if no ChangeSubscriber has been found ', t => { 660 | 661 | t.throws ( () => onChange ( [{}, []], () => {} ), /garbage-collected/i ); 662 | 663 | }); 664 | 665 | it ( 'throws if an empty array of stores has been provided ', t => { 666 | 667 | t.throws ( () => onChange ( [], () => {} ), /empty/i ); 668 | 669 | }); 670 | 671 | it ( 'returns a disposer', async t => { 672 | 673 | const proxy1 = store ({ foo: 123 }), 674 | proxy2 = store ({ foo: 123 }); 675 | 676 | let callsNr = 0; 677 | 678 | function listener () { 679 | callsNr++; 680 | } 681 | 682 | onChange ( [proxy1, proxy2], listener )(); 683 | onChange ( [proxy1, proxy2], () => Math.random (), listener )(); 684 | 685 | proxy1.foo = 1234; 686 | proxy2.foo = 1234; 687 | 688 | await delay ( 100 ); 689 | 690 | t.is ( callsNr, 0 ); 691 | 692 | }); 693 | 694 | }); 695 | 696 | }); 697 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Store 2 | 3 | A beautifully-simple framework-agnostic modern state management library. 4 | 5 | ![Debug](resources/demo.png) 6 | 7 | ## Features 8 | 9 | - **Simple**: there's barely anything to learn and no boilerplate code required. Thanks to our usage of [`Proxy`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy)s you just have to wrap your state with [`store`](#store), mutate it and retrieve values from it just like if it was a regular object, and listen to changes via [`onChange`](#onchange) or [`useStore`](#usestore). 10 | - **Framework-agnostic**: Store doesn't make any assumptions about your UI framework of choice, in fact it can also be used without one. 11 | - **React support**: an hook for React is provided, because that's the UI framework I'm using. Support for other UI frameworks can be added easily, PRs are very welcome. 12 | - **TypeScript-ready**: Store is written in TypeScript and enables you to get a fully typed app with no extra effort. 13 | 14 | Read more about how Store compares against other libraries in the [FAQ](#faq) section below. 15 | 16 | ## Install 17 | 18 | ```sh 19 | npm install --save store@npm:@fabiospampinato/store 20 | ``` 21 | 22 | ## Usage 23 | 24 | - Core 25 | - [`store`](#store) 26 | - [`isStore`](#isstore) 27 | - [`isIdle`](#isidle) 28 | - [`onChange`](#onchange) 29 | - [`batch`](#batch) 30 | - [`target`](#target) 31 | - [`debug`](#debug) 32 | - [`Hooks`](#hooks) 33 | - Extra/React 34 | - [`useStore`](#usestore) 35 | - [`useStores`](#usestores) 36 | 37 | ### Core 38 | 39 | #### `store` 40 | 41 | The first step is wrapping the objects containing the state of your app with the `store` function, this way Store will be able to transparently detect when mutations occur. 42 | 43 | Example usage: 44 | 45 | ```ts 46 | import {store} from 'store'; 47 | 48 | const CounterApp = { 49 | store: store ({ value: 0 }), 50 | increment: () => CounterApp.store.value += 1, 51 | decrement: () => CounterApp.store.value -= 1 52 | }; 53 | ``` 54 | 55 | - ℹ️ The object passed to `store` can contain a variety of values: 56 | - These are fully supported: [primitives](https://developer.mozilla.org/en-US/docs/Glossary/Primitive), [functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Functions), [getters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get), [setters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/set), [Dates](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date), [RegExps](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp), [Objects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object), [Arrays](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array), [ArrayBuffers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer), [TypedArrays](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray), [Maps](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) and [Sets](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set). 57 | - These are partially supported: [Promises](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Promise), [WeakMaps](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap), [WeakSets](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakSet) and custom classes. Basically mutations happening inside them won't be detected, however setting any of these as a value will be detected as a mutation. 58 | - ℹ️ `store` will wrap your object with a [`Proxy`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Proxy), which will detect mutations, and return a proxied object. 59 | - ℹ️ Never mutate the raw object passed to `store` directly, as those mutations won't be detected, always go through the proxied object returned by `store` instead. I'd suggest you to wrap your raw objects with `store` immediately so you won't even keep a reference to them. 60 | - ℹ️ In order to trigger a change simply mutate the proxied object returned by `store` as if it was a regular object. 61 | - ℹ️ Mutations happening at locations that need to be reached via a [Symbol](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol) aren't detected (e.g. `{ [Symbol ()]: { undetected: true }`). 62 | 63 | #### `isStore` 64 | 65 | This function checks if the passed value is a recognized `Proxy` object or not. 66 | 67 | Example usage: 68 | 69 | ```ts 70 | import {store, isStore} from 'store'; 71 | 72 | isStore ( store ( {} ) ); // => true 73 | isStore ( {} ); // => false 74 | ``` 75 | 76 | #### `isIdle` 77 | 78 | When no store is passed to it it checks if all known stores have no pending updates, i.e. some changes happened to them and at least one `onChange` listener has not been called yet. 79 | 80 | If a store is passed it checks only if the passed store has no pending updates. 81 | 82 | This is its interface: 83 | 84 | ```ts 85 | function isIdle ( store?: Store ): boolean; 86 | ``` 87 | 88 | Example usage: 89 | 90 | ```ts 91 | import {store, isIdle} from 'store'; 92 | 93 | const proxy1 = store ( {} ); 94 | const proxy2 = store ( {} ); 95 | 96 | isIdle (); // => true 97 | isIdle ( proxy1 ); // => true 98 | isIdle ( proxy2 ); // => true 99 | 100 | proxy1.foo = true; 101 | 102 | isIdle (); // => false 103 | isIdle ( proxy1 ); // => false 104 | isIdle ( proxy2 ); // => true 105 | ``` 106 | 107 | #### `onChange` 108 | 109 | Next you'll probably want to listen for changes to your stores, the `onChange` function is how you do that in a framework-agnostic way. 110 | 111 | This is its interface: 112 | 113 | ```ts 114 | // Single store, without selector, listen to all changes to the store 115 | function onChange ( store: Store, listener: ( data: Store ) => any ): Disposer; 116 | 117 | // Multiple stores, without selector, listen to all changes to any store 118 | function onChange ( stores: Store[], listener: ( ...data: Store[] ) => any ): Disposer; 119 | 120 | // Single store, with selector, listen to only changes that cause the value returned by the selector to change 121 | function onChange ( store: Store, selector: ( store: Store ) => Data, listener: ( data: Data ) => any ): Disposer; 122 | 123 | // Single store, with selector, with comparator, listen to only changes that cause the value returned by the selector to change and the comparator to return true 124 | function onChange ( store: Store, selector: ( store: Store ) => Data, comparator: ( dataPrev: Data, dataNext: Data ) => boolean, listener: ( data: Data ) => any ): Disposer; 125 | 126 | // Multiple stores, with selector, listen to only changes that cause the value returned by the selector to change 127 | function onChange ( stores: Store[], selector: ( ...stores: Store[] ) => Data, listener: ( data: Data ) => any ): Disposer; 128 | 129 | // Multiple stores, with selector, with comparator, listen to only changes that cause the value returned by the selector to change and the comparator to return true 130 | function onChange ( stores: Store[], selector: ( ...stores: Store[] ) => Data, comparator: ( dataPrev: Data, dataNext: Data ) => boolean, listener: ( data: Data ) => any ): Disposer; 131 | ``` 132 | 133 | - The `store`/`stores` argument is either a single proxied object retuned by the [`store`](#store) function or an array of those. 134 | - The `listener` argument is the function that will be called when a change to the provided stores occurs. It will be called with the value returned by the `selector`, if a selector was provided, or with all the provided stores as its arguments otherwise. 135 | - The `selector` optional argument is a function that computes some value that will be passed to the listener as its first argument. It's called with all the provided stores as its arguments. 136 | - The `comparator` optional argument is a function that checks for equality between the previous value returned by the selector and the current one. 137 | - The return value is a disposer, a function that when called will terminate this specific listening operation. 138 | 139 | Example usage: 140 | 141 | ```ts 142 | import areShallowEqual from 'are-shallow-equal'; 143 | import {store, onChange} from 'store'; 144 | 145 | const CounterApp = { 146 | store: store ({ value: 0 }), 147 | increment: () => CounterApp.store.value += 1, 148 | decrement: () => CounterApp.store.value -= 1 149 | }; 150 | 151 | // No selector 152 | 153 | const disposer1 = onChange ( CounterApp.store, store => { 154 | console.log ( 'Value changed, new value:', store.value ); 155 | disposer1 (); // Preventing this listener to be called again 156 | }); 157 | 158 | // With selector 159 | 160 | const disposer2 = onChange ( CounterApp.store, store => store.value % 2 === 0, isEven => { 161 | console.log ( 'Is the new value even?', isEven ); 162 | }); 163 | 164 | // With selector, with comparator 165 | 166 | const disposer = onChange ( CounterApp.store, store => ({ sqrt: Math.sqrt ( store.value ) }), areShallowEqual, ({ sqrt }) => { 167 | console.log ( 'The new square root is:', sqrt ); 168 | }); 169 | 170 | CounterApp.increment (); // This will cause a mutation, causing the listeners to be called 171 | CounterApp.increment (); // This will cause another mutation, but the listeners will still be called once as these mutations are occurring in a single event loop tick 172 | 173 | setTimeout ( CounterApp.increment, 100 ); // This will cause the remaining listener to be called again 174 | ``` 175 | 176 | - ℹ️ Using a selector that retrieves only parts of the store will improve performance. 177 | - ℹ️ It's possible that the listener will be called even if the object returned by the selector, or the entire store, didn't actually change. 178 | - ℹ️ Calls to `listener`s are automatically coalesced and batched together for performance, so if you synchronously, i.e. within a single event loop tick, mutate a store multiple times and there's a listener listening for those changes that listener will only be called once. 179 | - ℹ️ Using a comparator can improve performance if your selector only selects a small part of a large object. 180 | - ℹ️ The comparator is not called if the library is certain that the value returned by the selector did or didn't change. 181 | - ℹ️ As a consequence of this the comparator is never called with primitive values. 182 | - ℹ️ When using a comparator the selector should return a new object, not one that might get mutated, or the comparator will effectively get called with the same objects. 183 | 184 | #### `batch` 185 | 186 | Synchronous mutations, i.e. mutations that happen within a single event loop tick, are batched and coalesced together automatically, if you sometimes also want to batch and coalesce mutations happening inside an asynchronous function or two arbitrary points in time you can use the `batch` function. 187 | 188 | This is its interface: 189 | 190 | ```ts 191 | function batch

> ( fn: () => P ): P; 192 | 193 | // Helper methods 194 | batch.start = function (): void; 195 | batch.stop = function (): void; 196 | ``` 197 | 198 | Example usage: 199 | 200 | ```ts 201 | import {batch, store} from 'store'; 202 | 203 | const myStore = store ( { foo: 123 } ); 204 | 205 | // Function-based batching 206 | 207 | batch ( async () => { 208 | myStore.foo = 0; 209 | await someAsyncFunction (); 210 | myStore.foo = 1; 211 | }); 212 | 213 | // Manual batching 214 | 215 | batch.start (); 216 | for ( const nr of [1, 2, 3, 4, 5] ) { 217 | myStore.foo = nr; 218 | await someAsyncFunction (); 219 | } 220 | batch.stop (); 221 | ``` 222 | 223 | - ℹ️ This function is critical for performance when performing a very large number of mutations in an asynchronous way. 224 | - ℹ️ When batching and coalescing asynchronous mutations by passing a function to `batch` everything is taken care of for you: if the passed function throws batching is stopped automatically, nested `batch` calls are not a problem either. 225 | - ℹ️ When batching and coalescing asynchronous mutations manually using `batch.start` and `batch.stop` you have to make sure that `batch.stop` is always called the same number of times that `batch.start` was called, or batching will never stop. So make sure that for instance thrown errors or early exits are not an issue. 226 | 227 | #### `target` 228 | 229 | This function unwraps a store and returns the raw plain object used under the hood. 230 | 231 | This is its interface: 232 | 233 | ```ts 234 | function target ( store: T ): T; 235 | ``` 236 | 237 | Example usage: 238 | 239 | ```ts 240 | import {store, target, isStore} from 'store'; 241 | 242 | const myStore = store ( { foo: 123 } ); 243 | const rawObject = target ( myStore ); 244 | 245 | isStore ( myStore ); // => true 246 | isStore ( rawObject ); // => false 247 | ``` 248 | 249 | #### `debug` 250 | 251 | `debug` provides a simple way to access your stores and see at a glance how and when they change from the DevTools. 252 | 253 | ![Debug](resources/debug.png) 254 | 255 | This is its interface: 256 | 257 | ```ts 258 | type Global = { 259 | stores: Store[], // Access all stores 260 | log: () => void // Log all stores 261 | }; 262 | 263 | type Options = { 264 | collapsed: true, // Whether the logged groups should be collapsed 265 | logStoresNew: false, // Whether to log new store that have been created 266 | logChangesDiff: true, // Whether to log diffs (added, updated, removed) state changes 267 | logChangesFull: false // Whether to log the previous and current state in their entirity 268 | }; 269 | 270 | function debug ( options?: Options ): Global; 271 | ``` 272 | 273 | Example usage: 274 | 275 | ```ts 276 | import {debug} from 'store'; 277 | 278 | debug (); 279 | ``` 280 | 281 | Once called, `debug` defines a global object named `STORE`, which you can then access from the DevTools, and returns it. 282 | 283 | Example usage: 284 | 285 | ```ts 286 | STORE.stores[0].value += 1; // Manually triggering a mutation 287 | STORE.log (); // Logging all stores to the console 288 | ``` 289 | 290 | - ℹ️ It's important to call `debug` before creating any stores. 291 | - ℹ️ It's important to call `debug` only during development, as it may perform some potentially slow computations. 292 | 293 | #### `Hooks` 294 | 295 | `Hooks` provides a simple way to "hook" into Store's internal events. 296 | 297 | Each hook has the following interface: 298 | 299 | ```ts 300 | class Hook { 301 | subscribe ( listener: Function ): Disposer 302 | } 303 | ``` 304 | 305 | - `subscribe` registers a function for being called every time that hook is triggered. 306 | - The returned value is a disposer, a function that when called will terminate this specific subscription. 307 | 308 | These are all the currently available hooks: 309 | 310 | ```ts 311 | const Hooks = { 312 | store: { 313 | change: Hook, // Triggered whenever a store is mutated 314 | changeBatch: Hook, // Triggered whenever a store is mutated (batched) 315 | new: Hook // Triggered whenever a new store is created. This hook is used internally for implementing `debug` 316 | } 317 | }; 318 | ``` 319 | 320 | Example usage: 321 | 322 | ```ts 323 | import {Hooks} from 'store'; 324 | 325 | const disposer = Hooks.store.new.subscribe ( store => { 326 | console.log ( 'New store:', store ); 327 | }); 328 | 329 | disposer (); 330 | ``` 331 | 332 | If you need some more hooks for your Store plugin let me know and I'll make sure to add them. 333 | 334 | We currently don't have an official "Store DevTools Extension", but it would be super cool to have one. Perhaps it could provide a GUI for [`debug`](#debug)'s functionalities, and/or implement other features like time-travel debugging. If you're interested in developing this please do get in touch! 😃 335 | 336 | ### Extra/React 337 | 338 | These extra features, intended to be used with React, are available from a dedicated subpackage. 339 | 340 | #### `useStore` 341 | 342 | `useStore` is a React [hook](https://reactjs.org/docs/hooks-intro.html) for accessing a store's, or multiple stores's, values from within a functional component in a way that makes the component re-render whenever those values change. 343 | 344 | This is its interface: 345 | 346 | ```ts 347 | // Single store, without selector, re-render after any change to the store 348 | function useStore ( store: Store ): Store; 349 | 350 | // Multiple stores, without selector, re-render after any change to any store 351 | function useStore ( stores: Store[] ): Store[]; 352 | 353 | // Single store, with selector, re-render only after changes that cause the value returned by the selector to change 354 | function useStore ( store: Store, selector: ( store: Store ) => Data, dependencies: ReadonlyArray = [] ): Data; 355 | 356 | // Single store, with selector, with comparator, re-render only after changes that cause the value returned by the selector to change and the comparator to return true 357 | function useStore ( store: Store, selector: ( store: Store ) => Data, comparator: ( dataPrev: Data, dataNext: Data ) => boolean, dependencies: ReadonlyArray = [] ): Data; 358 | 359 | // Multiple stores, with selector, re-render only after changes that cause the value returned by the selector to change 360 | function useStore ( stores: Store[], selector: ( ...args: Store[] ) => Data, dependencies: ReadonlyArray = [] ): Data; 361 | 362 | // Multiple stores, with selector, with comparator, re-render only after changes that cause the value returned by the selector to change and the comparator to return true 363 | function useStore ( stores: Store[], selector: ( ...args: Store[] ) => Data, comparator: ( dataPrev: Data, dataNext: Data ) => boolean, dependencies: ReadonlyArray = [] ): Data; 364 | ``` 365 | 366 | - The `store`/`stores` argument is either a single proxied object retuned by the [`store`](#store) function or an array of those. 367 | - The `selector` optional argument if a function that computes some value that will be the return value of the hook. It's called with all the passed stores as its arguments. 368 | - The `comparator` optional argument is a function that checks for equality between the previous value returned by the selector and the current one. 369 | - The `dependencies` optional argument is an array of dependencies used to inform React about any objects your selector function will reference from outside of its innermost scope, ensuring the selector gets called again if any of those change. 370 | - The return value is whatever `selector` returns, if a selector was provided, or the entire store if only one store was provided, or the entire array of stores otherwise. 371 | 372 | Example usage: 373 | 374 | ```tsx 375 | import areShallowEqual from 'are-shallow-equal'; 376 | import {store, onChange} from 'store'; 377 | import {useStore} from 'store/x/react'; 378 | 379 | const CounterApp = { 380 | store: store ({ value: 0 }), 381 | increment: () => CounterApp.store.value += 1, 382 | decrement: () => CounterApp.store.value -= 1 383 | }; 384 | 385 | // No selector 386 | 387 | const CounterComponent1 = () => { 388 | const {value} = useStore ( ConunterApp.store ); 389 | return ( 390 |

391 |
{value}
392 | 393 | 394 |
395 | ) 396 | }; 397 | 398 | // With selector 399 | 400 | const CounterComponent2 = () => { 401 | const isEven = useStore ( ConunterApp.store, store => store.value % 2 === 0 ); 402 | return ( 403 |
404 |
Is the value even? {isEven}
405 | 406 | 407 |
408 | ) 409 | }; 410 | 411 | // With selector, with comparator 412 | 413 | const CounterComponent3 = () => { 414 | const {sqrt} = useStore ( ConunterApp.store, store => ({ sqrt: Math.sqrt ( store.value ) }), areShallowEqual ); 415 | return ( 416 |
417 |
The square root is: {sqrt}
418 | 419 | 420 |
421 | ) 422 | }; 423 | ``` 424 | 425 | - ℹ️ You basically just need to wrap the parts of your component that access any value from any store in a `useStore` hook, in order to make the component re-render whenever any of the retireved values change. 426 | - ℹ️ You don't need to use `useStore` for accessing methods that mutate the store, you can just reference them directly. 427 | - ℹ️ Using a selector that retrieves only parts of the store will improve performance. 428 | - ℹ️ It's possible that the component will be re-rendered even if the object returned by the selector, or the entire store, didn't actually change. 429 | - ℹ️ Re-renders are automatically coalesced and batched together for performance, so if synchronously, i.e. within a single event loop tick, the stores you're listening to are mutated multiple times the related components will only be re-rendered once. 430 | - ℹ️ Using a comparator can improve performance if your selector only selects a small part of a large object. 431 | - ℹ️ The comparator is not called if the library is certain that the value returned by the selector did or didn't change. 432 | - ℹ️ As a consequence of this the comparator is never called with primitive values. 433 | - ℹ️ When using a comparator the selector should return a new object, not one that might get mutated, or the comparator will effectively get called with the same objects. 434 | 435 | #### `useStores` 436 | 437 | `useStores` is just an alias for `useStore`, this alias is provided in case passing multiple stores to an hook called `useStore` doesn't feel quite right to you. 438 | 439 | Example import: 440 | 441 | ```tsx 442 | import {useStores} from 'store/x/react'; 443 | ``` 444 | 445 | ## FAQ 446 | 447 | ### Why not using [Redux](https://github.com/reduxjs/redux), [Unstated](https://github.com/jamiebuilds/unstated), [Overstated](https://github.com/fabiospampinato/overstated), [react-easy-state](https://github.com/RisingStack/react-easy-state) etc.? 448 | 449 | I'll personally use this library over more popular ones for a few reasons: 450 | 451 | - **Simpler APIs**: almost all other state management libraries I've encountered have APIs that don't resonate with me, often they feel unnecessarily bloated. I don't want to write "actions", I don't want to write "reducers", I don't want to litter my code with decorators or unnecessary boilerplate. 452 | - **Fewer footguns**: many other libraries I've encountered have multiple footguns to be aware of, some which may cause hard-to-debug bugs. With Store you won't update your stores incorrectly once you have wrapped them with [`store`](#store), you won't have to specially handle asynchronicity, and you won't have to carefully update your stores in an immutable fashion. 453 | - **Fewer restrictions**: most other libraries require you to structure your stores in a specific way, update them with library-specific APIs, perhaps require the usage of classes, and/or are tied to a specific UI framework. Store is more flexible in this regard: your stores are just proxied objects, you can manipulate them however you like, adopt a more functional coding style if you prefer, and the library isn't tied to any specific UI framework, in fact you can use it to manage your purely server-side state too. 454 | - **Easy type-safety**: some libraries don't play very well with TypeScript and/or require you to manually write some types, Store just works with no extra effort. 455 | 456 | ### Why not using Store? 457 | 458 | You might not want to use Store if: the design choices I made don't resonate with you, you need something more battle-tested, you need to support some of the ~5% of the outdated browsers where [`Proxy` isn't available](https://caniuse.com/#search=proxy), or you need the absolute maximum performance from your state management library since you know that will be your bottleneck, which is very unlikely. 459 | 460 | ## License 461 | 462 | MIT © Fabio Spampinato 463 | --------------------------------------------------------------------------------