From 1c8d52253db8fbfb504d48f3cfcda0570f4f4f9f Mon Sep 17 00:00:00 2001 From: Timothy Farrell Date: Fri, 15 Dec 2017 22:58:03 -0600 Subject: [PATCH] 3.0.0 refactor Bundle is no longer necessary since all tools have the "set all subscribers dirty before updating" behavior. Added additional guarantees around subscription call order. tool.fire() can be called externally --- packages/frptools/README.md | 114 ++++++++++------------- packages/frptools/package.json | 2 +- packages/frptools/spec/bundle.spec.js | 114 ----------------------- packages/frptools/spec/computed.spec.js | 33 ++++++- packages/frptools/spec/container.spec.js | 26 +++++- packages/frptools/spec/property.spec.js | 26 +++++- packages/frptools/src/bundle.js | 43 --------- packages/frptools/src/computed.js | 51 ++++------ packages/frptools/src/container.js | 20 ++-- packages/frptools/src/index.js | 1 - packages/frptools/src/property.js | 19 ++-- packages/frptools/src/testUtil.js | 45 +++++++++ packages/frptools/src/util.js | 25 +++-- 13 files changed, 226 insertions(+), 293 deletions(-) delete mode 100644 packages/frptools/spec/bundle.spec.js delete mode 100644 packages/frptools/src/bundle.js create mode 100644 packages/frptools/src/testUtil.js diff --git a/packages/frptools/README.md b/packages/frptools/README.md index 515557c..98b11c2 100644 --- a/packages/frptools/README.md +++ b/packages/frptools/README.md @@ -1,6 +1,7 @@ # FRP tools -Property and Computed value stores designed to work together for storing real and derived state. +Property, Container and Computed value stores designed to work together for storing discrete and +derived state. # [property](./src/property.js) @@ -38,19 +39,6 @@ and re-evaluated the next time they are read from. inViewport(false); ``` -### Subscribe to changes - -Call the `subscribe` method with a callback that will be called when the property value changes. The -returned function can be called to unsubscribe from the property. When called it will provide the -count of remaining subscriptions. - -```js -const unsubscribe = inViewport.subscribe(console.log.bind(console)); -const remainingSubscriptionCount = unsubscribe(); - -inViewport.unsubscribeAll(); // Call unsubscribeAll to remove child property/computed subscriptions. -``` - ### Provide a hash function for complex types When storing a type that is not determined to be equal with simple equality (===), provide a hash @@ -165,57 +153,9 @@ const b = prop(new Set([2, 3]), hashSet); const intersection = computed(_intersection, [a, b], hashSet); ``` -# [bundle](./src/bundle.js) - -`bundle` is a wrapper around a group of properties for the purpose of applying changes to all of -them at once without having to trigger a subscription that may depend on more than property in the -group. - -Another way to think of a `bundle` is a `property` that takes an object and exposes the object's -properties as individual `property` instances. - -## Behavior - -A `bundle` wraps properties to intercept dependency hooks in such a way that updating all `property` -instances can happen at once before any downstream `computed` instances are evaluated. A bundle -returns a function that can be called with an object to set values for the mapped member `property` -instances. - -## Usage - -### Creation - -```js -const layoutEventBundle = bundle({ - width: prop(1), - height: prop(2) -}); -const ratio = computed((a, b) => a / b, [layoutEventBundle.width, layoutEventBundle.height]); -ratio.subscribe(render); -``` - -### Change Member Properties atomically - -```js -layoutEventBundle({ width: 640, height: 480 }); -``` - -`ratio` would normally be evaluated twice and `render` would be called after each intermediate -change. But bundle allows both values to change and `ratio` will only be evaluated once and `render` -called once. - -### Change Member Properties individually - -```js -layoutEventBundle.width(640); -layoutEventBundle.height(480); -``` - -The properties exposed by the bundle can also be updated apart from their grouping. - # [container](./src/container.js) -`container` is a wrapper around any container type (object, Set, Map, or Array) while monitoring +`container` is a wrapper around any container type (Object, Set, Map, or Array) while monitoring changes to the container. A container can be subscribed to and `computed` instances can depend on them. @@ -223,7 +163,7 @@ them. Anytime a property is set or a method is gotten and called, the container will check for an updated state and trigger subscribers if it is updated. An hash function must be applied to determine -updated status otherwise subscribers will be called on any potential update. +updated status. ## Usage @@ -242,3 +182,49 @@ monkeys.push('Bill'); ``` _firstMonkey_ would be computed and "Bill" would be logged to the console. + +### Access the contained object directly + +Reference the `_` (underscore) property to access the contained object directly. + +```js +monkeys._.push('Bill'); +``` + +The array in _monkeys_ would get a new value without _firstMonkey_ being notified of the change. + +# Common Behaviors + +All frptools types have the following methods available: + +## `.subscribe(fn)` - Subscribe to changes + +Call the `subscribe` method with a callback that will be called when the property value changes. The +returned function can be called to unsubscribe from the property. When called it will provide the +count of remaining subscriptions. + +```js +const unsubscribe = inViewport.subscribe(console.log.bind(console)); +const remainingSubscriptionCount = unsubscribe(); +``` + +## `.unsubscribeAll()` - Remove child subscriptions + +Call the `unsubscribeAll` method to remove all child-node subscriptions. + +```js +const unsubscribe = inViewport.subscribe(console.log.bind(console)); +const remainingSubscriptionCount = unsubscribe(); + +inViewport.unsubscribeAll(); // console.log will no longer be called. +``` + +## `.fire(val)` - Send a value to the node's subscribers + +Call the `fire` method to send a value to each of the node's subscribers. This is designed for the +node to use to propagate updates but firing other values could have some uses. + +```js +const unsubscribe = inViewport.subscribe(console.log.bind(console)); +inViewport.fire(false); // "false" logged to console. +``` diff --git a/packages/frptools/package.json b/packages/frptools/package.json index 43ce239..b438747 100644 --- a/packages/frptools/package.json +++ b/packages/frptools/package.json @@ -1,6 +1,6 @@ { "name": "frptools", - "version": "2.2.1", + "version": "3.0.0", "description": "Observable Property and Computed data streams", "main": "lib/index.js", "jsnext:main": "src/index.js", diff --git a/packages/frptools/spec/bundle.spec.js b/packages/frptools/spec/bundle.spec.js deleted file mode 100644 index 12a5a13..0000000 --- a/packages/frptools/spec/bundle.spec.js +++ /dev/null @@ -1,114 +0,0 @@ -const { prop, computed, bundle } = require('../lib/index.js'); - -describe('bundle', () => { - const methods = { - add: (a, b) => a + b, - square: a => a * a, - getVal: val => {} - }; - - beforeEach(() => { - spyOn(methods, 'add').and.callThrough(); - spyOn(methods, 'square').and.callThrough(); - spyOn(methods, 'getVal').and.callThrough(); - }); - - it('bundles property changes together', () => { - const a = bundle({ - a: prop(0), - b: prop(10) - }); - const b = computed(methods.square, [a.a]); - const c = computed(methods.add, [a.a, a.b]); - - b.subscribe(methods.getVal); - c.subscribe(methods.getVal); - expect(methods.getVal).toHaveBeenCalledTimes(0); - - expect(b()).toEqual(0); - expect(methods.getVal).toHaveBeenCalledTimes(1); - b(); - expect(methods.getVal).toHaveBeenCalledTimes(1); - expect(c()).toEqual(10); - c(); - expect(methods.getVal).toHaveBeenCalledTimes(2); - expect(methods.add).toHaveBeenCalledTimes(1); - expect(methods.square).toHaveBeenCalledTimes(1); - - a({ a: 2, b: 20 }); - expect(methods.add).toHaveBeenCalledTimes(2); - expect(methods.square).toHaveBeenCalledTimes(2); - expect(methods.getVal).toHaveBeenCalledTimes(4); - expect(b()).toEqual(4); - expect(c()).toEqual(22); - expect(methods.add).toHaveBeenCalledTimes(2); - expect(methods.square).toHaveBeenCalledTimes(2); - expect(methods.getVal).toHaveBeenCalledTimes(4); - }); - - it('unbundled changes are less efficient', () => { - const a = prop(0); - const _b = prop(10); - const b = computed(methods.square, [a]); - const c = computed(methods.add, [a, _b]); - - b.subscribe(methods.getVal); - c.subscribe(methods.getVal); - expect(methods.getVal).toHaveBeenCalledTimes(0); - - expect(b()).toEqual(0); - expect(methods.getVal).toHaveBeenCalledTimes(1); - b(); - expect(methods.getVal).toHaveBeenCalledTimes(1); - expect(c()).toEqual(10); - c(); - expect(methods.getVal).toHaveBeenCalledTimes(2); - expect(methods.add).toHaveBeenCalledTimes(1); - expect(methods.square).toHaveBeenCalledTimes(1); - - a(2); - _b(20); - expect(methods.add).toHaveBeenCalledTimes(3); - expect(methods.square).toHaveBeenCalledTimes(2); - expect(methods.getVal).toHaveBeenCalledTimes(5); - expect(b()).toEqual(4); - expect(c()).toEqual(22); - expect(methods.add).toHaveBeenCalledTimes(3); - expect(methods.square).toHaveBeenCalledTimes(2); - expect(methods.getVal).toHaveBeenCalledTimes(5); - }); - - it('allows individual members to be updated', () => { - const a = bundle({ - a: prop(0), - b: prop(10) - }); - const b = computed(methods.square, [a.a]); - const c = computed(methods.add, [a.a, a.b]); - - b.subscribe(methods.getVal); - c.subscribe(methods.getVal); - expect(methods.getVal).toHaveBeenCalledTimes(0); - - expect(b()).toEqual(0); - expect(methods.getVal).toHaveBeenCalledTimes(1); - b(); - expect(methods.getVal).toHaveBeenCalledTimes(1); - expect(c()).toEqual(10); - c(); - expect(methods.getVal).toHaveBeenCalledTimes(2); - expect(methods.add).toHaveBeenCalledTimes(1); - expect(methods.square).toHaveBeenCalledTimes(1); - - a.a(2); - a.b(20); - expect(methods.add).toHaveBeenCalledTimes(3); - expect(methods.square).toHaveBeenCalledTimes(2); - expect(methods.getVal).toHaveBeenCalledTimes(5); - expect(b()).toEqual(4); - expect(c()).toEqual(22); - expect(methods.add).toHaveBeenCalledTimes(3); - expect(methods.square).toHaveBeenCalledTimes(2); - expect(methods.getVal).toHaveBeenCalledTimes(5); - }); -}); diff --git a/packages/frptools/spec/computed.spec.js b/packages/frptools/spec/computed.spec.js index 8b69bde..1e28e87 100644 --- a/packages/frptools/spec/computed.spec.js +++ b/packages/frptools/spec/computed.spec.js @@ -1,5 +1,5 @@ const { prop, computed } = require('../lib/index.js'); -const { hashSet } = require('../lib/util.js'); +const { dirtyMock, hashSet } = require('../lib/testUtil.js'); describe('computed', () => { const add = (a, b) => a + b; @@ -180,4 +180,35 @@ describe('computed', () => { expect([...ABintersection()]).toEqual([1, 2]); expect(runCount).toEqual(2); }); + + it('flags all subscribers as dirty before propagating change', () => { + function intersection(a, b) { + return new Set([...a].filter(x => b.has(x))); + } + + const a = prop(new Set([1, 2]), hashSet); + const b = prop(new Set([2, 3]), hashSet); + const ABintersection = computed(intersection, [a, b], hashSet); + + const [dirtyA, dirtyB, checker] = dirtyMock(2); + + ABintersection.subscribe(dirtyA.setDirty); + ABintersection.subscribe(dirtyB.setDirty); + + a(new Set([3, 4])); + + expect(checker()).toBe(true); + }); + + it('calls subscriptions in order', () => { + let order = ''; + + const a = prop(null); + const b = computed(a => a, [a]); + b.subscribe(() => (order += 'a')); + b.subscribe(() => (order += 'b')); + b.subscribe(() => (order += 'c')); + a(1); + expect(order).toEqual('abc'); + }); }); diff --git a/packages/frptools/spec/container.spec.js b/packages/frptools/spec/container.spec.js index 31d7ef4..c761f12 100644 --- a/packages/frptools/spec/container.spec.js +++ b/packages/frptools/spec/container.spec.js @@ -1,5 +1,5 @@ const { container, computed } = require('../lib/index.js'); -const { hashSet } = require('../lib/util.js'); +const { dirtyMock, hashSet } = require('../lib/testUtil.js'); describe('A container', () => { it('notifies dependents of updates', () => { @@ -58,4 +58,28 @@ describe('A container', () => { a._.add(3); expect(runCount).toEqual(2); }); + + it('flags all subscribers as dirty before propagating change', () => { + const a = container(new Set(), hashSet); + + const [dirtyA, dirtyB, checker] = dirtyMock(2); + + a.subscribe(dirtyA.setDirty); + a.subscribe(dirtyB.setDirty); + + a.add(1); + + expect(checker()).toBe(true); + }); + + it('calls subscriptions in order', () => { + let order = ''; + + const a = container(new Set(), hashSet); + a.subscribe(() => (order += 'a')); + a.subscribe(() => (order += 'b')); + a.subscribe(() => (order += 'c')); + a.add(1); + expect(order).toEqual('abc'); + }); }); diff --git a/packages/frptools/spec/property.spec.js b/packages/frptools/spec/property.spec.js index f42d60d..0b46a95 100644 --- a/packages/frptools/spec/property.spec.js +++ b/packages/frptools/spec/property.spec.js @@ -1,5 +1,5 @@ const { prop } = require('../lib/index.js'); -const { hashSet } = require('../lib/util.js'); +const { dirtyMock, hashSet } = require('../lib/testUtil.js'); describe('A property', () => { it('returns its initialized value', () => { @@ -75,4 +75,28 @@ describe('A property', () => { expect([...a(new Set([3, 2, 1]))]).toEqual([3, 2, 1]); expect(runCount).toEqual(1); }); + + it('flags all subscribers as dirty before propagating change', () => { + const a = prop(true); + + const [dirtyA, dirtyB, checker] = dirtyMock(2); + + a.subscribe(dirtyA.setDirty); + a.subscribe(dirtyB.setDirty); + + a(false); + + expect(checker()).toBe(true); + }); + + it('calls subscriptions in order', () => { + let order = ''; + + const a = prop(0); + a.subscribe(() => (order += 'a')); + a.subscribe(() => (order += 'b')); + a.subscribe(() => (order += 'c')); + a(1); + expect(order).toEqual('abc'); + }); }); diff --git a/packages/frptools/src/bundle.js b/packages/frptools/src/bundle.js deleted file mode 100644 index 6e482dc..0000000 --- a/packages/frptools/src/bundle.js +++ /dev/null @@ -1,43 +0,0 @@ -export function bundle(props) { - const activeSubscribers = new Set(); - let activeUpdate = false; - - const accessor = function _bundle(values) { - const result = {}; - activeUpdate = true; - Object.keys(values) - .filter(k => typeof props[k] === 'function') - .forEach(k => { - result[k] = props[k](values[k]); - }); - - const subscribers = Array.from(activeSubscribers); - // Set them dirty but don't propagate. - subscribers.forEach(s => s(result, true)); - // Now propagate. - subscribers.forEach(s => s(result)); - activeSubscribers.clear(); - activeUpdate = false; - return result; - }; - - const subscriptionFactory = obsFn => fn => { - return obsFn(v => { - if (activeUpdate) { - activeSubscribers.add(fn); - } else { - fn(v); - } - return v; - }); - }; - - Object.keys(props).forEach(k => { - const obs = props[k]; - - accessor[k] = obs; - obs._d = subscriptionFactory(obs._d); - }); - - return accessor; -} diff --git a/packages/frptools/src/computed.js b/packages/frptools/src/computed.js index bd808b6..af24102 100644 --- a/packages/frptools/src/computed.js +++ b/packages/frptools/src/computed.js @@ -1,26 +1,11 @@ -import { id } from './util.js'; +import { id, registerFire, registerSubscriptions, call } from './util.js'; export function computed(fn, dependencies = [], hash = id) { - const subscribers = new Set(); - const dependents = new Set(); + let subscribers = []; let isDirty = true; let val; let id; - // Receive dirty flag from parent logic node (dependency). Pass it down. - function _computedDirtyReporter(_, skipPropagation) { - if (!isDirty) { - isDirty = true; - dependents.forEach(d => d(_, skipPropagation)); - } - - if (subscribers.size && !skipPropagation) { - accessor(); - } - } - - const dependentSubscriptions = Array.from(dependencies).map(d => d._d(_computedDirtyReporter)); - // Compute new value, call subscribers if changed. const accessor = function _computed() { if (isDirty) { @@ -30,40 +15,38 @@ export function computed(fn, dependencies = [], hash = id) { if (id !== newId) { id = newId; val = newVal; - subscribers.forEach(s => s(val)); + accessor.fire(val); } } return val; }; // Add child nodes to the logic graph (value-based) - accessor.subscribe = fn => { - subscribers.add(fn); - return () => { - subscribers.delete(fn); - return subscribers.size; - }; - }; + accessor.subscribe = registerSubscriptions(subscribers); + accessor.fire = registerFire(subscribers); - // Add child nodes to the logic graph (dirty-based) - accessor._d = fn => { - dependents.add(fn); - return () => dependents.delete(fn); + // Receive dirty flag from parent logic node (dependency). Pass it down. + accessor.setDirty = function setDirty() { + if (!isDirty) { + isDirty = true; + subscribers.forEach(s => s.setDirty && s.setDirty()); + } + return subscribers.length && accessor; }; // Remove this node from the logic graph completely accessor.detach = () => { - subscribers.clear(); - dependents.clear(); - dependentSubscriptions.forEach(runParam); + subscribers = []; + dependentSubscriptions.forEach(call); }; // Remove child nodes from the logic graph accessor.unsubscribeAll = () => { - subscribers.clear(); - dependents.clear(); + subscribers = []; }; + const dependentSubscriptions = dependencies.map(d => d.subscribe(accessor.setDirty)); + return accessor; } diff --git a/packages/frptools/src/container.js b/packages/frptools/src/container.js index 7f12944..127750b 100644 --- a/packages/frptools/src/container.js +++ b/packages/frptools/src/container.js @@ -1,24 +1,22 @@ +import { id, registerSubscriptions, registerFire } from './util.js'; + export function container(store, hash) { - const subscribers = new Set(); + let subscribers = []; let id = hash(store); const containerMethods = { - subscribe: fn => { - subscribers.add(fn); - return () => { - subscribers.delete(fn); - return subscribers.size; - }; - }, - unsubscribeAll: () => subscribers.clear() + subscribe: registerSubscriptions(subscribers), + fire: registerFire(subscribers), + unsubscribeAll: () => { + subscribers = []; + } }; - containerMethods._d = containerMethods.subscribe; function checkUpdate(target) { const newId = hash(target); if (id !== newId) { id = newId; - subscribers.forEach(s => s(target)); + containerMethods.fire(target); } } diff --git a/packages/frptools/src/index.js b/packages/frptools/src/index.js index 30681ef..74dc444 100644 --- a/packages/frptools/src/index.js +++ b/packages/frptools/src/index.js @@ -1,4 +1,3 @@ export { prop } from './property'; export { computed } from './computed'; -export { bundle } from './bundle'; export { container } from './container'; diff --git a/packages/frptools/src/property.js b/packages/frptools/src/property.js index 3f431c1..491c039 100644 --- a/packages/frptools/src/property.js +++ b/packages/frptools/src/property.js @@ -1,7 +1,7 @@ -import { id } from './util.js'; +import { id, registerSubscriptions, registerFire } from './util.js'; export function prop(store, hash = id) { - const subscribers = new Set(); + let subscribers = []; let id = hash(store); const accessor = function _prop(newVal) { @@ -9,20 +9,13 @@ export function prop(store, hash = id) { if (newVal !== undefined && id !== newId) { id = newId; store = newVal; - subscribers.forEach(s => s(store)); + accessor.fire(store); } return store; }; - - accessor.subscribe = accessor._d = fn => { - subscribers.add(fn); - return () => { - subscribers.delete(fn); - return subscribers.size; - }; - }; - - accessor.unsubscribeAll = () => subscribers.clear(); + accessor.subscribe = registerSubscriptions(subscribers); + accessor.fire = registerFire(subscribers); + accessor.unsubscribeAll = () => (subscribers = []); return accessor; } diff --git a/packages/frptools/src/testUtil.js b/packages/frptools/src/testUtil.js new file mode 100644 index 0000000..9e72123 --- /dev/null +++ b/packages/frptools/src/testUtil.js @@ -0,0 +1,45 @@ +export function dirtyMock(count) { + const result = {}; + let state = 'init'; + + const fakeProp = function(i) { + const a = val => { + if (state === 'dirty') { + expect(Object.keys(result).length).toEqual(count); + state = 'cleaning'; + } + if (val === undefined) { + if (result[i] === false) { + delete result[i]; + } else { + result[i] = true; + } + } + if (Object.keys(result).length === 0) { + state = 'clean'; + } + }; + a.setDirty = () => { + state = 'dirty'; + result[i] = false; + return a; + }; + return a; + }; + const output = []; + for (let i = 0; i < count; ++i) { + output.push(fakeProp(i)); + } + output.push(() => Object.keys(result).length === 0 && state === 'clean'); + return output; +} + +export function hashSet(_a) { + if (_a instanceof Set) { + return Array.from(_a.keys()) + .sort() + .map(k => `${(typeof k).substr(0, 1)}:${encodeURIComponent(k)}/`) + .join('?'); + } + return _a; +} diff --git a/packages/frptools/src/util.js b/packages/frptools/src/util.js index 251a894..6222d3a 100644 --- a/packages/frptools/src/util.js +++ b/packages/frptools/src/util.js @@ -1,11 +1,18 @@ export const id = a => a; -export function hashSet(_a) { - if (_a instanceof Set) { - return Array.from(_a.keys()) - .sort() - .map(k => `${(typeof k).substr(0, 1)}:${encodeURIComponent(k)}/`) - .join('?'); - } - return _a; -} +export const registerSubscriptions = subscriptionsArray => fn => { + subscriptionsArray.push(fn); + return () => { + const idx = subscriptionsArray.indexOf(fn); + if (idx !== -1) { + subscriptionsArray.splice(idx, 1); + } + return subscriptionsArray.length; + }; +}; + +export const call = a => (typeof a === 'function' ? a() : a); + +export const registerFire = subscriptionsArray => val => { + subscriptionsArray.map(s => s(val)).forEach(call); +};