diff --git a/packages/frptools/README.md b/packages/frptools/README.md index 1be7fba..17f4ce7 100644 --- a/packages/frptools/README.md +++ b/packages/frptools/README.md @@ -108,3 +108,50 @@ inViewport(false); // showDialog result recomputed and `false` is written to the shouldShow(false); // showDialog result recomputed, console.log is not called. showDialog(); // showDialog does not recompute, console.log is not called. `false` is returned. ``` + +# [bundle](./src/bundle.js) + +`bundle` is a wrapper around a group of `observables` for the purpose of applying changes to all of +them at once without having to trigger a subscription that may depend on more than observable in the +group. + +Another way to think of a `bundle` is an `observable` that takes an object and exposes the +properties as individual observables. + +## Behavior + +A `bundle` wraps observables to intercept dependency hooks in such a way that updating all +observables can happen at once before any downstream `computeds` are evaluated. A bundle returns a +function that can be called with an object to set values for the mapped member observables. + +## Usage + +### Creation + +```js +const layoutEventBundle = bundle({ + width: observable(1), + height: observable(2) +}); +const ratio = computed((a, b) => a / b, [layoutEventBundle.width, layoutEventBundle.height]); +ratio.subscribe(render); +``` + +### Change Member Observables 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 Observables individually + +```js +layoutEventBundle.width(640); +layoutEventBundle.height(480); +``` + +The observables exposed by the bundle can also be updated apart from their grouping. diff --git a/packages/frptools/spec/bundle.spec.js b/packages/frptools/spec/bundle.spec.js new file mode 100644 index 0000000..db7d39e --- /dev/null +++ b/packages/frptools/spec/bundle.spec.js @@ -0,0 +1,114 @@ +const { observable, 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 observable changes together', () => { + const a = bundle({ + a: observable(0), + b: observable(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 = observable(0); + const _b = observable(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: observable(0), + b: observable(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/src/bundle.js b/packages/frptools/src/bundle.js new file mode 100644 index 0000000..3169467 --- /dev/null +++ b/packages/frptools/src/bundle.js @@ -0,0 +1,43 @@ +export function bundle(observables) { + const activeSubscribers = new Set(); + let activeUpdate = false; + + const accessor = function _bundle(values) { + const result = {}; + activeUpdate = true; + Object.keys(values) + .filter(k => typeof observables[k] === 'function') + .forEach(k => { + result[k] = observables[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(observables).forEach(k => { + const obs = observables[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 5e396cc..f479db4 100644 --- a/packages/frptools/src/computed.js +++ b/packages/frptools/src/computed.js @@ -4,13 +4,13 @@ export function computed(fn, dependencies = []) { let isDirty = true; let val; - function _computedDirtyReporter() { + function _computedDirtyReporter(_, skipPropagation) { if (!isDirty) { isDirty = true; + dependents.forEach(d => d(_, skipPropagation)); } - dependents.forEach(runParam); - if (subscribers.size) { + if (subscribers.size && !skipPropagation) { accessor(); } } diff --git a/packages/frptools/src/index.js b/packages/frptools/src/index.js index 240f46c..089f3ba 100644 --- a/packages/frptools/src/index.js +++ b/packages/frptools/src/index.js @@ -1,2 +1,3 @@ export { observable } from './observable'; export { computed } from './computed'; +export { bundle } from './bundle';