bd-core
Version:
a JavaScript library for building browser-hosted user interfaces
843 lines (778 loc) • 28.8 kB
JavaScript
import {Destroyable} from './Destroyable.js';
import {STAR} from './symbols.js';
const eqlComparators = new Map();
function eql(refValue, otherValue) {
if (!refValue) {
return otherValue === refValue;
}
if (refValue instanceof Object) {
const comparator = eqlComparators.get(refValue.constructor);
if (comparator) {
return comparator(refValue, otherValue);
}
}
if (otherValue instanceof Object) {
const comparator = eqlComparators.get(otherValue.constructor);
if (comparator) {
return comparator(otherValue, refValue);
}
}
return refValue === otherValue;
}
const watcherCatalog = new WeakMap();
const OWNER = Symbol('bd-owner');
const OWNER_NULL = Symbol('bd-owner-null');
const PROP = Symbol('bd-prop');
const UNKNOWN_OLD_VALUE = {
value: 'UNKNOWN_OLD_VALUE'
};
const pWatchableWatchers = Symbol('bd-pWatchableWatchers');
const pWatchableHandles = Symbol('bd-pWatchableHandles');
const pWatchableSetup = Symbol('bd-pWatchableSetup');
class WatchableRef {
constructor(referenceObject, prop, formatter) {
if (typeof prop === 'function') {
// no prop,...star watcher
formatter = prop;
prop = STAR;
}
Object.defineProperty(this, 'value', {
enumerable: true,
// eslint-disable-next-line func-names
get: ((function () {
if (formatter) {
if (prop === STAR) {
return () => formatter(referenceObject);
} else {
return () => formatter(referenceObject[prop]);
}
} else if (prop === STAR) {
return () => referenceObject;
} else {
return () => referenceObject[prop];
}
})())
});
// if (referenceObject[OWNER] && prop === STAR), then we cValue===newValue===referenceObject...
// therefore can't detect internal mutations to referenceObject, so don't try
const cannotDetectMutations = prop === STAR && referenceObject[OWNER];
this[pWatchableWatchers] = [];
let cValue;
const callback = (newValue, oldValue, target, referenceProp) => {
if (formatter) {
oldValue = oldValue === UNKNOWN_OLD_VALUE ? oldValue : formatter(oldValue);
newValue = formatter(newValue);
}
if (cannotDetectMutations || oldValue === UNKNOWN_OLD_VALUE || !eql(cValue, newValue)) {
this[pWatchableWatchers].slice().forEach(
destroyable => destroyable.proc((cValue = newValue), oldValue, target, referenceProp)
);
}
};
this[pWatchableSetup] = () => {
cValue = this.value;
if (referenceObject[OWNER]) {
this[pWatchableHandles] = [watch(referenceObject, prop, (newValue, oldValue, receiver, _prop) => {
if (prop === STAR) {
callback(referenceObject, UNKNOWN_OLD_VALUE, referenceObject, _prop);
} else {
callback(newValue, oldValue, referenceObject, _prop);
}
})];
} else if (referenceObject.watch) {
this[pWatchableHandles] = [
referenceObject.watch(prop, (newValue, oldValue, target) => {
callback(newValue, oldValue, target, prop);
if (this[pWatchableHandles].length === 2) {
this[pWatchableHandles].pop().destroy();
}
if (newValue && newValue[OWNER]) {
// value is a watchable
// eslint-disable-next-line no-shadow
this[pWatchableHandles].push(watch(newValue, (newValue, oldValue, receiver, referenceProp) => {
callback(receiver, UNKNOWN_OLD_VALUE, referenceObject, referenceProp);
}));
}
})
];
const value = referenceObject[prop];
if (value && value[OWNER]) {
// value is a watchable
this[pWatchableHandles].push(watch(value, (newValue, oldValue, receiver, referenceProp) => {
callback(receiver, UNKNOWN_OLD_VALUE, referenceObject, referenceProp);
}));
}
referenceObject.own && referenceObject.own(this);
} else {
throw new Error("don't know how to watch referenceObject");
}
};
}
destroy() {
Destroyable.destroyAll(this[pWatchableWatchers]);
}
watch(watcher) {
this[pWatchableHandles] || this[pWatchableSetup]();
return new Destroyable(watcher, this[pWatchableWatchers], () => {
Destroyable.destroyAll(this[pWatchableHandles]);
delete this[pWatchableHandles];
});
}
}
WatchableRef.pWatchableWatchers = pWatchableWatchers;
WatchableRef.pWatchableHandles = pWatchableHandles;
WatchableRef.pWatchableSetup = pWatchableSetup;
WatchableRef.UNKNOWN_OLD_VALUE = UNKNOWN_OLD_VALUE;
WatchableRef.STAR = STAR;
function getWatchableRef(referenceObject, referenceProp, formatter) {
// (referenceObject, referenceProp, formatter)
// (referenceObject, referenceProp)
// (referenceObject, formatter) => (referenceObject, STAR, formatter)
// (referenceObject) => (referenceObject, STAR)
if (typeof referenceProp === 'function') {
// no referenceProp,...star watcher
formatter = referenceProp;
referenceProp = STAR;
}
return new WatchableRef(referenceObject, referenceProp || STAR, formatter);
}
function watch(watchable, name, watcher) {
if (typeof name === 'function') {
watcher = name;
name = STAR;
}
let variables = watcherCatalog.get(watchable);
if (!variables) {
watcherCatalog.set(watchable, (variables = {}));
}
// eslint-disable-next-line no-shadow
const insWatcher = (name, watcher) => new Destroyable(watcher, variables[name] || (variables[name] = []));
if (!watcher) {
const hash = name;
// eslint-disable-next-line no-shadow
return Reflect.ownKeys(hash).map(name => insWatcher(name, hash[name]));
} else if (Array.isArray(name)) {
// eslint-disable-next-line no-shadow
return name.map(name => insWatcher(name, watcher));
} else {
return insWatcher(name, watcher);
}
}
let holdStarNotifications = false;
function applyWatchers(newValue, oldValue, receiver, name) {
const catalog = watcherCatalog.get(receiver);
if (catalog) {
if (name === STAR) {
const watchers = catalog[STAR];
watchers && watchers.slice().forEach(destroyable => destroyable.proc(receiver, oldValue, receiver, [STAR]));
} else {
const prop = name[0];
let watchers = catalog[prop];
watchers && watchers.slice().forEach(destroyable => destroyable.proc(receiver[prop], oldValue, receiver, name));
if (!holdStarNotifications) {
(watchers = catalog[STAR]) && watchers.slice().forEach(
destroyable => destroyable.proc(receiver, oldValue, receiver, name)
);
}
}
}
if (watch.log) {
// eslint-disable-next-line no-console
console.log(name, newValue);
}
if (!holdStarNotifications && receiver[OWNER] !== OWNER_NULL) {
name.unshift(receiver[PROP]);
applyWatchers(receiver, UNKNOWN_OLD_VALUE, receiver[OWNER], name);
}
}
let pauseWatchers = false;
const moving = false;
let _silentSet = false;
function set(target, prop, value, receiver) {
if (_silentSet) {
target[prop] = value;
return true;
} else {
const oldValue = target[prop];
if (value instanceof Object) {
const holdPauseWatchers = pauseWatchers;
try {
pauseWatchers = true;
value = moving ? value : createWatchable(value, receiver, prop);
pauseWatchers = holdPauseWatchers;
} catch (e) {
pauseWatchers = holdPauseWatchers;
throw e;
}
}
// we would like to set and applyWatchers iff target[prop] !== value. Unfortunately, sometimes target[prop] === value
// even though we haven't seen the mutation before, e.g., length in an Array instance
const result = Reflect.set(target, prop, value, receiver);
!pauseWatchers && applyWatchers(value, oldValue, receiver, [prop]);
return result;
}
}
const objectProxyHandler = {
set
};
const SWAP_OLD_LENGTH = Symbol('SWAP_OLD_LENGTH');
const OLD_LENGTH = Symbol('old-length');
const NO_CHANGE = Symbol('splice-no-change');
const QUICK_COPY = Symbol('slice-quick-copy');
const BEFORE_ADVICE = Symbol('BEFORE_ADVICE');
const noop = () => {
// do nothing
};
const arrayProxyHandler = {
set(target, prop, value, receiver) {
if (prop === 'length') {
const result = Reflect.set(target, prop, value, receiver);
const oldValue = target[SWAP_OLD_LENGTH](value);
!pauseWatchers && !_silentSet && applyWatchers(value, oldValue, receiver, ['length']);
return result;
} else {
return set(target, prop, value, receiver);
}
}
};
function getAdvice(owner, method) {
const advice = owner[BEFORE_ADVICE] && owner[BEFORE_ADVICE][method];
return advice && advice.map(f => f());
}
class WatchableArray extends Array {
// note: we can make all of these much more efficient, particularly shift and unshift.
// But, it's probably rare that it will matter, so we'll do it when the need arises
[SWAP_OLD_LENGTH](newLength) {
const result = this[OLD_LENGTH];
this[OLD_LENGTH] = newLength;
return result;
}
before(method, proc) {
let beforeAdvice = this[BEFORE_ADVICE];
if (!beforeAdvice) {
Object.defineProperty(this, BEFORE_ADVICE, {value: {}});
beforeAdvice = this[BEFORE_ADVICE];
}
const stack = beforeAdvice[method] || (beforeAdvice[method] = []);
stack.push(proc);
const handle = {
destroy() {
handle.destroy = noop;
const index = stack.indexOf(proc);
if (index !== -1) stack.splice(index, 1);
}
};
return handle;
}
_splice(...args) {
const oldValues = this.slice(QUICK_COPY);
const changeSet = [];
let result;
try {
_silentSet = true;
result = super.splice(...args);
for (let i = 0, end = Math.max(oldValues.length, this.length); i < end; i++) {
let value = this[i];
if (value instanceof Object) {
if (value[OWNER]) {
if (value[OWNER] !== this) {
// a new item that came from another watchable
value = this[i] = createWatchable(value, this, i);
changeSet.push(value);
// eslint-disable-next-line eqeqeq
} else if (value[PROP] != i) { // INTENTIONAL !=
// an item that was moved within this
value[PROP] = i;
changeSet.push(value);
} else {
changeSet.push(NO_CHANGE);
}
} else {
// a new item that is not already a watchable
value = this[i] = createWatchable(value, this, i);
changeSet.push(value);
}
} else if (value !== oldValues[i]) {
changeSet.push(value);
} else {
changeSet.push(NO_CHANGE);
}
}
_silentSet = false;
} catch (e) {
try {
oldValues.forEach((value, i) => (this[i] = value));
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
_silentSet = false;
throw e;
}
try {
holdStarNotifications = true;
let change = false;
changeSet.forEach((value, i) => {
if (value !== NO_CHANGE) {
change = true;
applyWatchers(value, oldValues[i], this, [i]);
}
});
if (this.length !== oldValues.length) {
change = true;
applyWatchers(this.length, oldValues.length, this, ['length']);
}
holdStarNotifications = false;
if (change) {
applyWatchers(this, UNKNOWN_OLD_VALUE, this, STAR);
}
} catch (e) {
holdStarNotifications = false;
throw e;
}
return result;
}
splice(...args) {
const advice = getAdvice(this, 'splice');
const result = this._splice(...args);
advice && advice.map(f => f && f(result));
return result;
}
pop() {
const advice = getAdvice(this, 'pop');
const result = fromWatchable(super.pop());
advice && advice.map(f => f && f(result));
return result;
}
shift() {
const advice = getAdvice(this, 'shift');
const result = fromWatchable(this._splice(0, 1)[0]);
advice && advice.map(f => f && f(result));
return result;
}
slice(...args) {
return args[0] === QUICK_COPY ? super.slice() : fromWatchable(super.slice(...args));
}
unshift(...args) {
const advice = getAdvice(this, 'unshift');
this._splice(0, 0, ...args);
advice && advice.map(f => f && f());
}
reverse() {
const advice = getAdvice(this, 'reverse');
const oldValues = this.slice(QUICK_COPY);
try {
_silentSet = true;
for (let i = 0, j = this.length - 1; i < j; i++, j--) {
const temp = this[i];
this[i] = this[j];
this[i][PROP] = i;
this[j] = temp;
temp[PROP] = j;
}
_silentSet = false;
} catch (e) {
_silentSet = false;
throw e;
}
try {
holdStarNotifications = true;
if (this.length % 2) {
for (let i = 0, end = Math.floor(this.length / 2); i < end; i++) {
applyWatchers(this[i], oldValues[i], this, [i]);
}
for (let i = Math.ceil(this.length / 2), end = this.length; i < end; i++) {
applyWatchers(this[i], oldValues[i], this, [i]);
}
} else {
this.forEach((value, i) => applyWatchers(value, oldValues[i], this, [i]));
}
holdStarNotifications = false;
if (this.length > 1) {
applyWatchers(this, UNKNOWN_OLD_VALUE, this, STAR);
}
} catch (e) {
holdStarNotifications = false;
throw e;
}
advice && advice.map(f => f && f(this));
return this;
}
_reorder(proc) {
const oldValues = this.slice(QUICK_COPY);
const changeSet = Array(this.length).fill(false);
let changes = false;
try {
_silentSet = true;
proc(this);
this.forEach((value, i) => {
// eslint-disable-next-line eqeqeq
if (value[PROP] != i) { // INTENTIONAL !=
value[PROP] = i;
changeSet[i] = true;
changes = true;
}
});
_silentSet = false;
} catch (e) {
_silentSet = false;
throw e;
}
try {
holdStarNotifications = true;
changeSet.forEach((value, i) => {
if (value) {
applyWatchers(this[i], oldValues[i], this, [i]);
}
});
holdStarNotifications = false;
if (changes) {
applyWatchers(this, UNKNOWN_OLD_VALUE, this, STAR);
}
} catch (e) {
holdStarNotifications = false;
throw e;
}
}
reorder(proc) {
const advice = getAdvice(this, 'reorder');
this._reorder(proc);
advice && advice.map(f => f && f(this));
return this;
}
sort(...args) {
const advice = getAdvice(this, 'sort');
this._reorder(theArray => super.sort.apply(theArray, args));
advice && advice.map(f => f && f(this));
return this;
}
}
function silentSet(watchable, prop, value, enumerable, configurable) {
try {
_silentSet = true;
if (value === undefined) {
delete watchable[prop];
} else if (enumerable !== undefined && !enumerable && !watchable.hasOwnProperty(prop)) {
Object.defineProperty(watchable, prop, {
writable: true,
configurable: configurable !== undefined ? configurable : true,
value
});
} else {
watchable[prop] = value;
}
_silentSet = false;
} catch (e) {
_silentSet = false;
throw e;
}
}
function createWatchable(src, owner, prop) {
const keys = Reflect.ownKeys(src);
const isArray = Array.isArray(src);
const result = isArray ? new Proxy(new WatchableArray(), arrayProxyHandler) : new Proxy({}, objectProxyHandler);
if (isArray) {
keys.forEach(k => k !== 'length' && (result[k] = src[k]));
Object.defineProperty(result, OLD_LENGTH, {writable: true, value: result.length});
} else {
keys.forEach(k => (result[k] = src[k]));
}
const silentHold = _silentSet;
_silentSet = true;
Object.defineProperty(result, OWNER, {writable: true, value: owner});
prop !== undefined && Object.defineProperty(result, PROP, {writable: true, value: prop});
_silentSet = silentHold;
return result;
}
function toWatchable(data) {
if (!(data instanceof Object)) {
throw new Error('scalar values are not watchable');
}
try {
pauseWatchers = true;
data = createWatchable(data, OWNER_NULL, false);
pauseWatchers = false;
return data;
} catch (e) {
pauseWatchers = false;
throw e;
}
}
function fromWatchable(data) {
if (data instanceof Object) {
if (!data) {
return data;
}
if (!data[OWNER]) {
return data;
}
const result = Array.isArray(data) ? Array(data.length) : {};
Reflect.ownKeys(data).forEach(k => {
if (k !== OWNER && k !== PROP && k !== OLD_LENGTH) {
result[k] = fromWatchable(data[k]);
}
});
return result;
} else {
return data;
}
}
const onMutateBeforeNames = {};
const onMutateNames = {};
function mutate(owner, name, privateName, newValue) {
const oldValue = owner[privateName];
if (eql(oldValue, newValue)) {
return false;
} else {
let onMutateBeforeName,
onMutateName;
if (typeof name !== 'symbol') {
onMutateBeforeName = onMutateBeforeNames[name];
if (!onMutateBeforeName) {
const suffix = name.substring(0, 1).toUpperCase() + name.substring(1);
onMutateBeforeName = onMutateBeforeNames[name] = `onMutateBefore${suffix}`;
onMutateName = onMutateNames[name] = `onMutate${suffix}`;
} else {
onMutateName = onMutateNames[name];
}
}
if (onMutateBeforeName && owner[onMutateBeforeName]) {
if (owner[onMutateBeforeName](newValue, oldValue) === false) {
// the proposed mutation is illegal
return false;
}
}
if (owner.hasOwnProperty(privateName)) {
owner[privateName] = newValue;
} else {
// not enumerable or configurable
Object.defineProperty(owner, privateName, {writable: true, value: newValue});
}
onMutateName && owner[onMutateName] && owner[onMutateName](newValue, oldValue);
return [name, newValue, oldValue];
}
}
function getWatcher(owner, watcher) {
return typeof watcher === 'function' ? watcher : owner[watcher].bind(owner);
}
function watchHub(superClass) {
return class extends (superClass || class {
}) {
// protected interface...
bdMutateNotify(name, newValue, oldValue) {
const variables = watcherCatalog.get(this);
if (!variables) {
return;
}
if (Array.isArray(name)) {
// each element in name is either a triple ([name, oldValue, newValue]) or false
let doStar = false;
name.forEach(p => {
if (p) {
doStar = true;
const watchers = variables[p[0]];
if (watchers) {
newValue = p[1];
oldValue = p[2];
watchers.slice().forEach(destroyable => destroyable.proc(newValue, oldValue, this, name));
}
}
});
if (doStar) {
const watchers = variables[STAR];
if (watchers) {
watchers.slice().forEach(destroyable => destroyable.proc(this, oldValue, this, name));
}
}
} else {
let watchers = variables[name];
if (watchers) {
watchers.slice().forEach(destroyable => destroyable.proc(newValue, oldValue, this, name));
}
watchers = variables[STAR];
if (watchers) {
watchers.slice().forEach(destroyable => destroyable.proc(newValue, oldValue, this, name));
}
}
}
bdMutate(name, privateName, newValue) {
if (arguments.length > 3) {
let i = 0;
const results = [];
let mutateOccurred = false;
while (i < arguments.length) {
// eslint-disable-next-line prefer-rest-params
const mutateResult = mutate(this, arguments[i++], arguments[i++], arguments[i++]);
mutateOccurred = mutateOccurred || mutateResult;
results.push(mutateResult);
}
if (mutateOccurred) {
this.bdMutateNotify(results);
return results;
}
return false;
} else {
const result = mutate(this, name, privateName, newValue);
if (result) {
this.bdMutateNotify(...result);
return result;
}
return false;
}
}
// public interface...
get isBdWatchHub() {
return true;
}
watch(...args) {
// possible sigs:
// 1: name, watcher
// 2: name[], watcher
// 3: hash: name -> watcher
// 4: watchable, name, watcher
// 5: watchable, name[], watcher
// 6: watchable, hash: name -> watcher
// 7: watchable, watcher // STAR watcher
if (arguments.length === 1) {
// sig 3
const hash = args[0];
return Reflect.ownKeys(hash).map(name => this.watch(name, hash[name]));
}
if (args[0][OWNER]) {
// sig 4-6
let result;
if (arguments.length === 2) {
if (typeof args[1] === 'object') {
// sig 6
const hash = args[1];
Reflect.ownKeys(hash).map(name => (hash[name] = getWatcher(this, hash[name])));
result = watch(args[0], hash);
} else {
// sig 7
result = watch(args[0], STAR, getWatcher(this, args[1]));
}
} else {
// sig 4 or 5
result = watch(args[0], args[1], getWatcher(this, args[2]));
}
this.own && this.own(result);
return result;
}
if (Array.isArray(args[0])) {
// sig 2
return args[0].map(name => this.watch(name, getWatcher(this, args[1])));
}
// sig 1
const name = args[0];
const watcher = getWatcher(this, args[1]);
let variables = watcherCatalog.get(this);
if (!variables) {
watcherCatalog.set(this, (variables = {}));
}
const result = new Destroyable(watcher, variables[name] || (variables[name] = []));
this.own && this.own(result);
return result;
}
destroyWatch(name) {
const variables = watcherCatalog.get(this);
function destroyList(list) {
if (list) {
while (list.length) list.shift().destroy();
}
}
if (variables) {
if (name) {
destroyList(variables[name]);
delete variables[name];
} else {
Reflect.ownKeys(variables).forEach(k => destroyList(variables[k]));
watcherCatalog.delete(this);
}
}
}
getWatchableRef(name, formatter) {
const result = new WatchableRef(this, name, formatter);
this.own && this.own(result);
return result;
}
};
}
const WatchHub = watchHub();
function isWatchable(target) {
return target && target[OWNER];
}
function withWatchables(superClass, ...args) {
const publicPropNames = [];
function def(name) {
let pname;
if (Array.isArray(name)) {
pname = name[1];
name = name[0];
} else {
pname = `_${name}`;
}
publicPropNames.push(name);
// eslint-disable-next-line no-use-before-define
Object.defineProperty(prototype, name, {
enumerable: true,
get() {
return this[pname];
},
set(value) {
this.bdMutate(name, pname, value);
}
});
}
function init(owner, kwargs) {
publicPropNames.forEach(name => {
if (kwargs.hasOwnProperty(name)) {
owner[name] = kwargs[name];
}
});
}
const result = class extends superClass {
constructor(kwargs) {
kwargs = kwargs || {};
super(kwargs);
init(this, kwargs);
}
};
const prototype = result.prototype;
args.forEach(def);
result.watchables = publicPropNames.concat(superClass.watchables || []);
return result;
}
function bind(src, srcProp, dest, destProp) {
dest[destProp] = src[srcProp];
if (src.isBdWatchHub) {
return src.watch(srcProp, newValue => (dest[destProp] = newValue));
} else if (src[OWNER]) {
return watch(srcProp, newValue => (dest[destProp] = newValue));
} else {
throw new Error('src is not watchable');
}
}
function biBind(src1, prop1, src2, prop2) {
src2[prop2] = src1[prop1];
return [bind(src1, prop1, src2, prop2), bind(src2, prop2, src1, prop1)];
}
export {
eqlComparators,
eql,
UNKNOWN_OLD_VALUE,
OWNER,
OWNER_NULL,
PROP,
silentSet,
WatchableRef,
getWatchableRef,
watch,
toWatchable,
fromWatchable,
watchHub,
WatchHub,
isWatchable,
withWatchables,
bind,
biBind
};