From 4e70d07f71ba3f2e50c3f5979c0038df6f6a1edc Mon Sep 17 00:00:00 2001 From: Timothy Farrell Date: Wed, 18 Jan 2017 08:26:11 -0600 Subject: [PATCH] Add frptools --- .gitignore | 3 ++ package.json | 37 +++++++++++++ rollup.config.js | 19 +++++++ spec/computed.spec.js | 68 ++++++++++++++++++++++++ spec/observable.spec.js | 55 +++++++++++++++++++ spec/support/jasmine.json | 7 +++ src/index.js | 109 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 298 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 rollup.config.js create mode 100644 spec/computed.spec.js create mode 100644 spec/observable.spec.js create mode 100644 spec/support/jasmine.json create mode 100644 src/index.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..468bb89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +lib diff --git a/package.json b/package.json new file mode 100644 index 0000000..c994655 --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "frptools", + "version": "1.0.0", + "description": "Observable and Computed data streams", + "main": "lib/index.js", + "jsnext:main": "src/index.js", + "files": ["dist", "lib", "src"], + "keywords": ["reactive"], + "author": "Timothy Farrell (https://github.com/explorigin)", + "license": "MIT", + "devDependencies": { + "babel-cli": "6.18.0", + "babel-core": "6.21.0", + "babel-preset-es2015": "6.18.0", + "babel-preset-es2015-rollup": "3.0.0", + "babel-preset-stage-0": "6.16.0", + "eslint": "3.12.2", + "eslint-plugin-flowtype": "2.29.1", + "jasmine": "^2.5.3", + "rimraf": "2.5.4", + "rollup-plugin-babel": "^2.7.1", + "rollup-plugin-json": "2.1.0" + }, + "scripts": { + "clean": "rimraf dist lib", + "build:lib": "NODE_ENV=production babel src --presets=\"stage-0,es2015\" --out-dir lib", + "build:umd": "npm run build:lib && NODE_ENV=production rollup -c", + "build:umd:min": + "npm run build:umd && uglifyjs -m --screw-ie8 -c -o dist/frptools.min.js dist/frptools.js", + "build:umd:gzip": + "npm run build:umd:min && gzip -c9 dist/frptools.min.js > dist/frptools.min.js.gz", + "build": "npm run build:umd:gzip && ls -l dist/", + "prepublish": "npm run clean && npm run build", + "test": "npm run build:lib && jasmine", + "uglifyjs": "2.4.10" + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..5e5978e --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,19 @@ +import json from 'rollup-plugin-json'; +import babel from 'rollup-plugin-babel'; + +const babelConfig = { + env: { + es6: true, + browser: true + }, + plugins: [], + presets: ['es2015-rollup'] +}; + +export default { + entry: 'src/index.js', + format: 'umd', + moduleName: 'frptools', + plugins: [json(), babel(babelConfig)], + dest: 'dist/frptools.js' +}; diff --git a/spec/computed.spec.js b/spec/computed.spec.js new file mode 100644 index 0000000..1c7dfd6 --- /dev/null +++ b/spec/computed.spec.js @@ -0,0 +1,68 @@ +const { observable, computed } = require('../lib/index.js'); + +describe('computed', () => { + const add = (a, b) => a + b; + const square = a => a * a; + + it('returns the value computed from its dependencies', () => { + const a = observable(0); + const b = computed(square, [a]); + const c = computed(add, [a, b]); + + expect(b()).toEqual(0); + expect(c()).toEqual(0); + + a(1); + expect(b()).toEqual(1); + expect(c()).toEqual(2); + + a(2); + expect(b()).toEqual(4); + expect(c()).toEqual(6); + + a(3); + expect(b()).toEqual(9); + expect(c()).toEqual(12); + }); + + it('only computes when called', () => { + let runCount = 0; + let currentValue = 1; + const a = observable(0); + const b = computed( + val => { + runCount += 1; + expect(val).toEqual(currentValue); + return val * val; + }, + [a] + ); + + a(1); + expect(runCount).toEqual(0); + expect(b()).toEqual(1); + expect(runCount).toEqual(1); + expect(b()).toEqual(1); + expect(runCount).toEqual(1); + currentValue = 3; + a(3); + expect(runCount).toEqual(1); + expect(b()).toEqual(9); + expect(runCount).toEqual(2); + }); + + it('can be detached', () => { + const a = observable(2); + const b = computed(square, [a]); + const c = computed(add, [a, b]); + + expect(b()).toEqual(4); + expect(c()).toEqual(6); + + b.detach(); + + a(3); + expect(b()).toEqual(4); + expect(c()).toEqual(7); + }); +}); diff --git a/spec/observable.spec.js b/spec/observable.spec.js new file mode 100644 index 0000000..b789382 --- /dev/null +++ b/spec/observable.spec.js @@ -0,0 +1,55 @@ +const { observable } = require('../lib/index.js'); + +describe('observable', () => { + it('returns its initialized value', () => { + const a = observable(true); + expect(a()).toEqual(true); + }); + + it('returns its set value', () => { + const a = observable(); + expect(a()).toEqual(undefined); + expect(a(true)).toEqual(true); + }); + + it('returns notifies dependents of updates', () => { + let runCount = 0; + let currentValue = 1; + const a = observable(); + a.subscribe(val => { + runCount += 1; + expect(val).toEqual(currentValue); + }); + expect(a(1)).toEqual(1); + expect(runCount).toEqual(1); + expect(a(1)).toEqual(1); + expect(runCount).toEqual(1); + currentValue = 2; + expect(a(2)).toEqual(2); + expect(runCount).toEqual(2); + expect(a(2)).toEqual(2); + expect(runCount).toEqual(2); + currentValue = 1; + expect(a(1)).toEqual(1); + expect(runCount).toEqual(3); + expect(a(1)).toEqual(1); + expect(runCount).toEqual(3); + }); + + it('honors cancelled subscriptions', () => { + let runCount = 0; + let currentValue = 1; + const a = observable(); + const cancelSubscription = a.subscribe(val => { + runCount += 1; + expect(val).toEqual(currentValue); + }); + expect(a(1)).toEqual(1); + expect(runCount).toEqual(1); + expect(a(1)).toEqual(1); + expect(runCount).toEqual(1); + cancelSubscription(); + expect(a(3)).toEqual(3); + expect(runCount).toEqual(1); + }); +}); diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json new file mode 100644 index 0000000..b52c9e6 --- /dev/null +++ b/spec/support/jasmine.json @@ -0,0 +1,7 @@ +{ + "spec_dir": "spec", + "spec_files": ["**/*[sS]pec.js"], + "helpers": ["helpers/**/*.js"], + "stopSpecOnExpectationFailure": false, + "random": false +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..38666a8 --- /dev/null +++ b/src/index.js @@ -0,0 +1,109 @@ +// observable is a simple value store that can report when its value changes. +// It is good for wrapping external props passed into a component so compute +// types can dependent on them. +// Usage: +// +// Creation: +// `const inViewport = observable(true);` +// Creates and sets initial value to `true` +// +// Read: +// `if (inViewport()) { }` +// Call it to receive the stored value. +// +// Change: +// `inViewport(false);` +// Call it passing the new value. If any computed stores depend on this value +// they will be marked dirty and re-evaluated the next time they are read from. +// +// Subscribe to changes: +// `inViewport.subscribe(console.log.bind(console))` +// Call the subscribe method with a callback that will be called when the +// observable is changed to a different value. + +export function observable(store) { + const subscribers = new Set(); + + const accessor = function _observable(newVal) { + if (newVal !== undefined && store !== newVal) { + store = newVal; + subscribers.forEach(s => s(store)); + } + return store; + }; + + accessor.subscribe = accessor._d = fn => { + subscribers.add(fn); + return () => subscribers.delete(fn); + }; + + accessor.unsubscribeAll = () => subscribers.clear(); + + return accessor; +} + +// computed is a functional store that depends on the values of observables or other computeds. They cannot be set directly. +// +// Behavior: +// computed will subscribe to its dependencies in such a way that it will be marked as "dirty" when any dependency changes. +// +// Usage: +// +// Creation: +// const showDialog = computed((inVP, shouldShow) => (inVP && shouldShow), [inViewport, shouldShow]); +// +// Read: +// `if (showDialog()) { alert("Hi"); }` +// Call it to receive the stored value. + +export function computed(fn, dependencies = []) { + const subscribers = new Set(); + const dependents = new Set(); + let val = undefined; + let isDirty = true; + + function _computedDirtyReporter() { + if (!isDirty) { + isDirty = true; + } + dependents.forEach(runParam); + + if (subscribers.size) { + accessor(); + } + } + + const dependentSubscriptions = Array.from(dependencies).map(d => d._d(_computedDirtyReporter)); + + const accessor = function _computed() { + if (isDirty) { + const newVal = fn.apply(null, dependencies.map(runParam)); + isDirty = false; + if (newVal !== val) { + val = newVal; + subscribers.forEach(s => s(val)); + } + } + return val; + }; + + accessor.subscribe = fn => { + subscribers.add(fn); + return () => subscribers.delete(fn); + }; + + accessor._d = fn => { + dependents.add(fn); + return () => dependents.delete(fn); + }; + + accessor.detach = () => { + subscribers.clear(); + dependents.clear(); + dependentSubscriptions.forEach(runParam); + }; + + return accessor; +} + +const runParam = a => a();