Commit 61b7e399 authored by Robert Knight's avatar Robert Knight

Simplify and improve type safety of `watch` utility

Simplify the `watch` utility by making it only support a single callback to get
the watched value, along with an optional comparison function to use to compare
the results. To watch multiple values the callback can return an array and use a
shallow-equality comparison.

In `FrameSyncService` the lengthy callback passed to `watch` was
extracted into a separate function to make the `watch` call more
readable.
parent 8aa3c21f
import debounce from 'lodash.debounce'; import debounce from 'lodash.debounce';
import shallowEqual from 'shallowequal';
import { ListenerCollection } from '../../shared/listener-collection'; import { ListenerCollection } from '../../shared/listener-collection';
import { PortFinder, PortRPC, isMessageEqual } from '../../shared/messaging'; import { PortFinder, PortRPC, isMessageEqual } from '../../shared/messaging';
...@@ -134,86 +135,99 @@ export class FrameSyncService { ...@@ -134,86 +135,99 @@ export class FrameSyncService {
_setupSyncToGuests() { _setupSyncToGuests() {
let prevPublicAnns = 0; let prevPublicAnns = 0;
watch( /**
this._store.subscribe, * Handle annotations or frames being added or removed in the store.
[() => this._store.allAnnotations(), () => this._store.frames()], *
/** * @param {Annotation[]} annotations
* @param {[Annotation[], Frame[]]} current * @param {Frame[]} frames
* @param {[Annotation[]]} previous * @param {Annotation[]} prevAnnotations
*/ */
([annotations, frames], [prevAnnotations]) => { const onStoreAnnotationsChanged = (
let publicAnns = 0; annotations,
/** @type {Set<string>} */ frames,
const inSidebar = new Set(); prevAnnotations
/** @type {Annotation[]} */ ) => {
const added = []; let publicAnns = 0;
/** @type {Set<string>} */
// Determine which annotations have been added or deleted in the sidebar. const inSidebar = new Set();
annotations.forEach(annot => { /** @type {Annotation[]} */
if (isReply(annot)) { const added = [];
// The frame does not need to know about replies
return; // Determine which annotations have been added or deleted in the sidebar.
} annotations.forEach(annot => {
if (isReply(annot)) {
// The frame does not need to know about replies
return;
}
if (isPublic(annot)) { if (isPublic(annot)) {
++publicAnns; ++publicAnns;
} }
inSidebar.add(annot.$tag); inSidebar.add(annot.$tag);
if (!this._inFrame.has(annot.$tag)) { if (!this._inFrame.has(annot.$tag)) {
added.push(annot); added.push(annot);
} }
}); });
const deleted = prevAnnotations.filter( const deleted = prevAnnotations.filter(
annot => !inSidebar.has(annot.$tag) annot => !inSidebar.has(annot.$tag)
); );
// Send added annotations to matching frame. // Send added annotations to matching frame.
if (added.length > 0) { if (added.length > 0) {
/** @type {Map<string|null, Annotation[]>} */ /** @type {Map<string|null, Annotation[]>} */
const addedByFrame = new Map(); const addedByFrame = new Map();
for (let annotation of added) { for (let annotation of added) {
const frame = frameForAnnotation(frames, annotation); const frame = frameForAnnotation(frames, annotation);
if (!frame) { if (!frame) {
continue; continue;
}
const anns = addedByFrame.get(frame.id) ?? [];
anns.push(annotation);
addedByFrame.set(frame.id, anns);
} }
const anns = addedByFrame.get(frame.id) ?? [];
anns.push(annotation);
addedByFrame.set(frame.id, anns);
}
for (let [frameId, anns] of addedByFrame) { for (let [frameId, anns] of addedByFrame) {
const rpc = this._guestRPC.get(frameId); const rpc = this._guestRPC.get(frameId);
if (rpc) { if (rpc) {
rpc.call('loadAnnotations', anns.map(formatAnnot)); rpc.call('loadAnnotations', anns.map(formatAnnot));
}
} }
added.forEach(annot => {
this._inFrame.add(annot.$tag);
});
} }
// Remove deleted annotations from frames. added.forEach(annot => {
deleted.forEach(annot => { this._inFrame.add(annot.$tag);
// Delete from all frames. If a guest is not displaying a particular
// annotation, it will just ignore the request.
this._guestRPC.forEach(rpc =>
rpc.call('deleteAnnotation', annot.$tag)
);
this._inFrame.delete(annot.$tag);
}); });
}
// Remove deleted annotations from frames.
deleted.forEach(annot => {
// Delete from all frames. If a guest is not displaying a particular
// annotation, it will just ignore the request.
this._guestRPC.forEach(rpc => rpc.call('deleteAnnotation', annot.$tag));
this._inFrame.delete(annot.$tag);
});
// Update elements in host page which display annotation counts. // Update elements in host page which display annotation counts.
if (frames.length > 0) { if (frames.length > 0) {
if (frames.every(frame => frame.isAnnotationFetchComplete)) { if (frames.every(frame => frame.isAnnotationFetchComplete)) {
if (publicAnns === 0 || publicAnns !== prevPublicAnns) { if (publicAnns === 0 || publicAnns !== prevPublicAnns) {
this._hostRPC.call('publicAnnotationCountChanged', publicAnns); this._hostRPC.call('publicAnnotationCountChanged', publicAnns);
prevPublicAnns = publicAnns; prevPublicAnns = publicAnns;
}
} }
} }
} }
};
watch(
this._store.subscribe,
() =>
/** @type {const} */ ([
this._store.allAnnotations(),
this._store.frames(),
]),
([annotations, frames], [prevAnnotations]) =>
onStoreAnnotationsChanged(annotations, frames, prevAnnotations),
shallowEqual
); );
} }
......
import shallowEqual from 'shallowequal';
import { serviceConfig } from '../config/service-config'; import { serviceConfig } from '../config/service-config';
import { isReply } from '../helpers/annotation-metadata'; import { isReply } from '../helpers/annotation-metadata';
import { combineGroups } from '../helpers/groups'; import { combineGroups } from '../helpers/groups';
...@@ -157,9 +159,7 @@ export class GroupsService { ...@@ -157,9 +159,7 @@ export class GroupsService {
watch( watch(
this._store.subscribe, this._store.subscribe,
() => this._mainURI(), () => this._mainURI(),
() => { () => this.load()
this.load();
}
); );
// Reload groups when user ID changes. This is a bit inefficient since it // Reload groups when user ID changes. This is a bit inefficient since it
...@@ -167,17 +167,19 @@ export class GroupsService { ...@@ -167,17 +167,19 @@ export class GroupsService {
// logging in or logging out. // logging in or logging out.
watch( watch(
this._store.subscribe, this._store.subscribe,
[ () =>
() => this._store.hasFetchedProfile(), /** @type {const} */ ([
() => this._store.profile().userid, this._store.hasFetchedProfile(),
], this._store.profile().userid,
]),
(_, [prevFetchedProfile]) => { (_, [prevFetchedProfile]) => {
if (!prevFetchedProfile) { if (!prevFetchedProfile) {
// Ignore the first time that the profile is loaded. // Ignore the first time that the profile is loaded.
return; return;
} }
this.load(); this.load();
} },
shallowEqual
); );
} }
......
...@@ -268,9 +268,7 @@ export class StreamerService { ...@@ -268,9 +268,7 @@ export class StreamerService {
watch( watch(
this._store.subscribe, this._store.subscribe,
() => this._store.profile().userid, () => this._store.profile().userid,
() => { () => this._reconnect()
this._reconnect();
}
); );
} }
......
...@@ -47,45 +47,42 @@ describe('sidebar/util/watch', () => { ...@@ -47,45 +47,42 @@ describe('sidebar/util/watch', () => {
assert.notCalled(callback); assert.notCalled(callback);
}); });
it('compares watched values using strict equality', () => { it('compares watched values using strict equality by default', () => {
const callback = sinon.stub(); const callback = sinon.stub();
const store = counterStore(); const store = counterStore();
const getValue = () => [store.getState().a];
const newEmptyObject = () => ({}); watch(store.subscribe, getValue, callback);
watch(store.subscribe, newEmptyObject, callback);
store.dispatch({ type: 'INCREMENT_A' }); store.dispatch({ type: 'INCREMENT_B' });
store.dispatch({ type: 'INCREMENT_A' }); store.dispatch({ type: 'INCREMENT_B' });
// This will trigger the callback because we're comparing values by // The callback is called twice even though `getValue` returns an array
// strict equality rather than by shallow equality. // with the same content each time, because a strict equality check is
// used.
assert.calledTwice(callback); assert.calledTwice(callback);
assert.calledWith(callback, {});
}); });
it('runs callback if any of multiple watched values changes', () => { it('compares watched values using custom equality check', () => {
const callback = sinon.stub(); const callback = sinon.stub();
const store = counterStore(); const store = counterStore();
const equals = sinon.stub().returns(true);
watch( watch(store.subscribe, () => store.getState().a, callback, equals);
store.subscribe,
[() => store.getState().a, () => store.getState().b],
callback
);
// Dispatch action that changes the first watched value.
store.dispatch({ type: 'INCREMENT_A' }); store.dispatch({ type: 'INCREMENT_A' });
assert.calledWith(callback, [1, 0], [0, 0]); store.dispatch({ type: 'INCREMENT_A' });
// Dispatch action that changes the second watched value.
callback.resetHistory();
store.dispatch({ type: 'INCREMENT_B' });
assert.calledWith(callback, [1, 1], [1, 0]);
// Dispatch action that doesn't change either watched value. assert.calledTwice(equals);
callback.resetHistory(); assert.calledWith(equals, 1, 0);
store.dispatch({ type: 'INCREMENT_C' }); assert.calledWith(equals, 2, 0);
assert.notCalled(callback); assert.notCalled(callback);
equals.returns(false);
store.dispatch({ type: 'INCREMENT_A' });
assert.calledWith(equals, 3, 0);
assert.calledWith(callback, 3, 0);
}); });
it('returns unsubscription function', () => { it('returns unsubscription function', () => {
......
import shallowEqual from 'shallowequal';
/** /**
* Watch for changes of computed values. * Watch a data source for changes to a subset of its data.
*
* `watch` subscribes to change notifications from a data source, computes
* values using the data in it, and runs a callback with the current and
* previous computed values each time the computed result changes.
* *
* This utility is a shorthand for a common pattern for reacting to changes in * @example
* some data source: * const unsubscribe = watch(
* store.subscribe,
* *
* ``` * // Extract some data of interest from the store.
* let prevValue = getCurrentValue(); * () => store.getValue(),
* subscribe(() => {
* const newValue = getCurrentValue();
* if (prevValue !== newValue) {
* // Respond to change of value.
* // ...
* *
* // Update previous value. * // Use strict comparison of values
* prevValue = new value; * null,
* }
* });
* ```
* *
* Where `getCurrentValue` calculates the value of interest and * // Callback that is invoked each time the extracted data changes.
* `subscribe` registers a callback to receive change notifications for * (currentValue, prevValue) => { ... }
* whatever data source (eg. a Redux store) is used by `getCurrentValue`. * );
* unsubcribe(); // Remove the subscription
* *
* With the `watch` utility this becomes: * To watch multiple values, make {@link getValue} return an array and set
* {@link compare} to a function that compares each element of the array:
* *
* ``` * @example
* watch(subscribe, getCurrentValue, (newValue, prevValue) => { * watch(
* // Respond to change of value * store.subscribe,
* }); * () => [store.getValueA(), store.getValueB()],
* ```
* *
* `watch` can watch a single value, if the second argument is a function, * // Compare each element of the result
* or many if the second argument is an array of functions. In the latter case * shallowEqual,
* the callback will be invoked whenever _any_ of the watched values changes.
* *
* Values are compared using strict equality (`===`). * ([currentValueA, currentValueB], [prevValueA, prevValueB]) => { ... }
* );
* *
* @param {(callback: () => void) => Function} subscribe - Function used to * @template T
* subscribe to notifications of _potential_ changes in the watched values. * @param {(callback: () => void) => VoidFunction} subscribe - Function to
* @param {Function|Array<Function>} watchFns - A function or array of functions * subscribe to changes from the data source.
* which return the current watched values * @param {() => T} getValue - Callback that extracts information of interest
* @param {(current: any, previous: any) => void} callback - * from the data source.
* A callback that is invoked when the watched values changed. It is passed * @param {(current: T, previous: T) => void} callback -
* the current and previous values respectively. If `watchFns` is an array, * A callback that receives the data extracted by `getValue`. It is called
* the `current` and `previous` arguments will be arrays of current and * each time the result of `getValue` changes.
* previous values. * @param {((current: T, previous: T) => boolean)} [compare] -
* @return {Function} - Return value of `subscribe`. Typically this is a * Comparison function that tests whether the results of two `getValue` calls
* function that removes the subscription. * are equal. If omitted, a strict equality check is used
* @return {VoidFunction} - Return value of `subscribe`
*/ */
export function watch(subscribe, watchFns, callback) { export function watch(subscribe, getValue, callback, compare) {
const isArray = Array.isArray(watchFns); let prevValue = getValue();
const getWatchedValues = () =>
isArray
? /** @type {Function[]} */ (watchFns).map(fn => fn())
: /** @type {Function} */ (watchFns)();
let prevValues = getWatchedValues();
const unsubscribe = subscribe(() => { const unsubscribe = subscribe(() => {
const values = getWatchedValues(); const currentValue = getValue();
if (
const equal = isArray compare ? compare(currentValue, prevValue) : currentValue === prevValue
? shallowEqual(values, prevValues) ) {
: values === prevValues;
if (equal) {
return; return;
} }
// Save and then update `prevValues` before invoking `callback` in case // Save and then update `prevValues` before invoking `callback` in case
// `callback` triggers another update. // `callback` triggers another update.
const savedPrevValues = prevValues; const savedPrevValue = prevValue;
prevValues = values; prevValue = currentValue;
callback(values, savedPrevValues); callback(currentValue, savedPrevValue);
}); });
return unsubscribe; return unsubscribe;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment