= 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 | 
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 | 
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 |
Increment
393 |
Decrement
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 |
Increment
406 |
Decrement
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 |
Increment
419 |
Decrement
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 |
--------------------------------------------------------------------------------