Break subscription management out to properly handle unsubscribing

This commit is contained in:
Timothy Farrell 2019-07-28 14:17:28 -05:00
parent 52d8c44a22
commit 278ec30827
9 changed files with 96 additions and 55 deletions

View File

@ -2,13 +2,13 @@
Reactimal provides a set of primitives that can be used to express your code as a logic graph using
reactive programming methods. Reactive programming inverts the dependency relationship between
functions and their parameters. No longer do callers need to know the signature of the functions
functions and their inputs. No longer do callers need to know the signature of the functions
they call. Instead computed values subscribe to scalar properties or other computed values. The
reactive method improves code readability and reduces the tendency toward spaghetti code.
Reactimal provides the following reactive programming primitives. Each type of primitive is
[subscribable](./docs/subscribable.md) and provides a single output value, but differ in how they
receive their input.
[subscribable](./docs/subscribable.md) and provides a single output value (or promise), but differ
in how they receive their input.
## Input Primitives

View File

@ -1,7 +1,8 @@
import { id, registerFire, registerSubscriptions, call } from './util.js';
import { id, call } from './util.js';
import { subscribable } from './subscribable.js';
export const hashableComputed = hash => (fn, dependencies = []) => {
let subscribers = [];
let isDirty = true;
let val;
let oldId;
@ -25,29 +26,23 @@ export const hashableComputed = hash => (fn, dependencies = []) => {
};
// Add child nodes to the logic graph (value-based)
accessor.subscribe = registerSubscriptions(subscribers);
accessor._fire = registerFire(subscribers);
Object.assign(accessor, subscribable());
// 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());
accessor._forEachSubscription(s => s._setDirty && s._setDirty());
}
return subscribers.length && accessor;
return accessor._hasSubscribers() && accessor;
};
// Remove this node from the logic graph completely
accessor.detach = () => {
subscribers = [];
accessor.unsubscribeAll();
dependentSubscriptions.forEach(call);
};
// Remove child nodes from the logic graph
accessor.unsubscribeAll = () => {
subscribers = [];
};
const dependentSubscriptions = dependencies.map(d => d.subscribe(accessor._setDirty));
return accessor;

View File

@ -1,16 +1,10 @@
import { registerSubscriptions, registerFire } from './util.js';
import { subscribable } from './subscribable.js';
export const hashableContainer = hash => store => {
let subscribers = [];
let id = hash && hash(store);
let lockCount = 0;
const containerMethods = {
subscribe: registerSubscriptions(subscribers),
_fire: registerFire(subscribers),
unsubscribeAll: () => {
subscribers = [];
},
_lock: () => {
lockCount += 1;
return p;
@ -19,7 +13,8 @@ export const hashableContainer = hash => store => {
if (lockCount && --lockCount === 0) {
checkUpdate(store);
}
}
},
...subscribable()
};
function checkUpdate(target) {

View File

@ -137,4 +137,21 @@ describe('A container', () => {
a.add(1);
expect(order).toEqual('abc');
});
it('unsubscribeAll clears subscriptions', () => {
let order = '';
const a = containedSet(new Set());
a.subscribe(() => (order += 'a'));
a.subscribe(() => (order += 'b'));
a.subscribe(() => (order += 'c'));
a.unsubscribeAll()
a.add(1);
expect(order).toEqual('');
});
it('protects method properties.', () => {
const a = containedSet(new Set());
expect(() => a.subscribe = 'test').toThrow(new ReferenceError('Cannot set subscribe in [object Set]'));
});
});

View File

@ -1,4 +1,6 @@
import { id, registerSubscriptions, registerFire } from './util.js';
import { id } from './util.js';
import { subscribable } from './subscribable.js';
export const hashableProperty = hash => store => {
let subscribers = [];
@ -16,9 +18,8 @@ export const hashableProperty = hash => store => {
}
return store;
};
accessor.subscribe = registerSubscriptions(subscribers);
accessor.unsubscribeAll = () => (subscribers = []);
accessor._fire = registerFire(subscribers);
// Add child nodes to the logic graph (value-based)
Object.assign(accessor, subscribable());
accessor._lock = () => {
lockCount += 1;
return accessor();

View File

@ -1,4 +1,6 @@
import { id, registerFire, registerSubscriptions, call } from './util.js';
import { id, call } from './util.js';
import { subscribable } from './subscribable.js';
export const hashableStream = hash => (fn, dependencies = []) => {
let subscribers = [];
@ -37,29 +39,23 @@ export const hashableStream = hash => (fn, dependencies = []) => {
};
// Add child nodes to the logic graph (value-based)
accessor.subscribe = registerSubscriptions(subscribers);
accessor._fire = registerFire(subscribers);
Object.assign(accessor, subscribable());
// 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());
accessor._forEachSubscription(s => s._setDirty && s._setDirty());
}
return subscribers.length && accessor;
return accessor._hasSubscribers() && accessor;
};
// Remove this node from the logic graph completely
accessor.detach = () => {
subscribers = [];
accessor.unsubscribeAll();
dependentSubscriptions.forEach(call);
};
// Remove child nodes from the logic graph
accessor.unsubscribeAll = () => {
subscribers = [];
};
const dependentSubscriptions = dependencies.map(d => d.subscribe(accessor._setDirty));
return accessor;

View File

@ -155,4 +155,28 @@ describe('A stream', () => {
await aDep();
expect(aCount).toBe(2);
});
it('detach breaks the logic chain', async () => {
const a = prop(0);
const b = computed(square, [a]);
const c = stream(delaySquare, [a]);
const d = stream(delayAdd, [a, c]);
const e = stream(delaySquare, [b]);
expect(await c()).toEqual(0);
expect(await d()).toEqual(0);
expect(await e()).toEqual(0);
a(1);
expect(await c()).toEqual(1);
expect(await d()).toEqual(2);
expect(await e()).toEqual(1);
c.detach();
a(2);
expect(await c()).toEqual(1);
expect(await d()).toEqual(3);
expect(await e()).toEqual(16);
});
});

30
src/subscribable.js Normal file
View File

@ -0,0 +1,30 @@
import { call } from './util.js';
export const subscribable = () => {
const subscriptions = [];
return {
subscribe(fn) {
subscriptions.push(fn);
return () => {
const idx = subscriptions.indexOf(fn);
if (idx !== -1) {
subscriptions.splice(idx, 1);
}
return subscriptions.length;
};
},
unsubscribeAll() {
subscriptions.splice(0, Infinity);
},
_fire(val) {
return subscriptions.map(s => s(val)).forEach(call);
},
_forEachSubscription(fn) {
subscriptions.forEach(fn);
},
_hasSubscribers() {
return subscriptions.length > 0;
}
};
}

View File

@ -3,20 +3,3 @@ export const id = a => a;
export const pick = (id, def) => doc => (doc && doc.hasOwnProperty(id) ? doc[id] : def);
export const call = a => (typeof a === 'function' ? a() : a);
// internal utilities
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 registerFire = subscriptionsArray => val => {
subscriptionsArray.map(s => s(val)).forEach(call);
};