@nx-js/observer-util
Version:
Simple transparent reactivity with 100% language coverage. Made with ES6 Proxies.
453 lines (408 loc) • 16.2 kB
JavaScript
const connectionStore = new WeakMap();
const ITERATION_KEY = Symbol('iteration key');
function storeObservable(obj) {
// this will be used to save (obj.key -> reaction) connections later
connectionStore.set(obj, new Map());
}
function registerReactionForOperation(reaction, { target, key, type }) {
if (type === 'iterate') {
key = ITERATION_KEY;
}
const reactionsForObj = connectionStore.get(target);
let reactionsForKey = reactionsForObj.get(key);
if (!reactionsForKey) {
reactionsForKey = new Set();
reactionsForObj.set(key, reactionsForKey);
}
// save the fact that the key is used by the reaction during its current run
if (!reactionsForKey.has(reaction)) {
reactionsForKey.add(reaction);
reaction.cleaners.push(reactionsForKey);
}
}
function getReactionsForOperation({ target, key, type }) {
const reactionsForTarget = connectionStore.get(target);
const reactionsForKey = new Set();
if (type === 'clear') {
reactionsForTarget.forEach((_, key) => {
addReactionsForKey(reactionsForKey, reactionsForTarget, key);
});
} else {
addReactionsForKey(reactionsForKey, reactionsForTarget, key);
}
if (type === 'add' || type === 'delete' || type === 'clear') {
const iterationKey = Array.isArray(target) ? 'length' : ITERATION_KEY;
addReactionsForKey(reactionsForKey, reactionsForTarget, iterationKey);
}
return reactionsForKey;
}
function addReactionsForKey(reactionsForKey, reactionsForTarget, key) {
const reactions = reactionsForTarget.get(key);
reactions && reactions.forEach(reactionsForKey.add, reactionsForKey);
}
function releaseReaction(reaction) {
if (reaction.cleaners) {
reaction.cleaners.forEach(releaseReactionKeyConnection, reaction);
}
reaction.cleaners = [];
}
function releaseReactionKeyConnection(reactionsForKey) {
reactionsForKey.delete(this);
}
// reactions can call each other and form a call stack
const reactionStack = [];
let isDebugging = false;
function runAsReaction(reaction, fn, context, args) {
// do not build reactive relations, if the reaction is unobserved
if (reaction.unobserved) {
return Reflect.apply(fn, context, args);
}
// only run the reaction if it is not already in the reaction stack
// TODO: improve this to allow explicitly recursive reactions
if (reactionStack.indexOf(reaction) === -1) {
// release the (obj -> key -> reactions) connections
// and reset the cleaner connections
releaseReaction(reaction);
try {
// set the reaction as the currently running one
// this is required so that we can create (observable.prop -> reaction) pairs in the get trap
reactionStack.push(reaction);
return Reflect.apply(fn, context, args);
} finally {
// always remove the currently running flag from the reaction when it stops execution
reactionStack.pop();
}
}
}
// register the currently running reaction to be queued again on obj.key mutations
function registerRunningReactionForOperation(operation) {
// get the current reaction from the top of the stack
const runningReaction = reactionStack[reactionStack.length - 1];
if (runningReaction) {
debugOperation(runningReaction, operation);
registerReactionForOperation(runningReaction, operation);
}
}
function queueReactionsForOperation(operation) {
// iterate and queue every reaction, which is triggered by obj.key mutation
getReactionsForOperation(operation).forEach(queueReaction, operation);
}
function queueReaction(reaction) {
debugOperation(reaction, this);
// queue the reaction for later execution or run it immediately
if (typeof reaction.scheduler === 'function') {
reaction.scheduler(reaction);
} else if (typeof reaction.scheduler === 'object') {
reaction.scheduler.add(reaction);
} else {
reaction();
}
}
function debugOperation(reaction, operation) {
if (reaction.debugger && !isDebugging) {
try {
isDebugging = true;
reaction.debugger(operation);
} finally {
isDebugging = false;
}
}
}
function hasRunningReaction() {
return reactionStack.length > 0;
}
const IS_REACTION = Symbol('is reaction');
function observe(fn, options = {}) {
// wrap the passed function in a reaction, if it is not already one
const reaction = fn[IS_REACTION] ? fn : function reaction() {
return runAsReaction(reaction, fn, this, arguments);
};
// save the scheduler and debugger on the reaction
reaction.scheduler = options.scheduler;
reaction.debugger = options.debugger;
// save the fact that this is a reaction
reaction[IS_REACTION] = true;
// run the reaction once if it is not a lazy one
if (!options.lazy) {
reaction();
}
return reaction;
}
function unobserve(reaction) {
// do nothing, if the reaction is already unobserved
if (!reaction.unobserved) {
// indicate that the reaction should not be triggered any more
reaction.unobserved = true;
// release (obj -> key -> reaction) connections
releaseReaction(reaction);
}
// unschedule the reaction, if it is scheduled
if (typeof reaction.scheduler === 'object') {
reaction.scheduler.delete(reaction);
}
}
const proxyToRaw = new WeakMap();
const rawToProxy = new WeakMap();
const hasOwnProperty = Object.prototype.hasOwnProperty;
function findObservable(obj) {
const observableObj = rawToProxy.get(obj);
if (hasRunningReaction() && typeof obj === 'object' && obj !== null) {
if (observableObj) {
return observableObj;
}
return observable(obj);
}
return observableObj || obj;
}
function patchIterator(iterator, isEntries) {
const originalNext = iterator.next;
iterator.next = () => {
let { done, value } = originalNext.call(iterator);
if (!done) {
if (isEntries) {
value[1] = findObservable(value[1]);
} else {
value = findObservable(value);
}
}
return { done, value };
};
return iterator;
}
const instrumentations = {
has(key) {
const target = proxyToRaw.get(this);
const proto = Reflect.getPrototypeOf(this);
registerRunningReactionForOperation({ target, key, type: 'has' });
return proto.has.apply(target, arguments);
},
get(key) {
const target = proxyToRaw.get(this);
const proto = Reflect.getPrototypeOf(this);
registerRunningReactionForOperation({ target, key, type: 'get' });
return findObservable(proto.get.apply(target, arguments));
},
add(key) {
const target = proxyToRaw.get(this);
const proto = Reflect.getPrototypeOf(this);
const hadKey = proto.has.call(target, key);
// forward the operation before queueing reactions
const result = proto.add.apply(target, arguments);
if (!hadKey) {
queueReactionsForOperation({ target, key, value: key, type: 'add' });
}
return result;
},
set(key, value) {
const target = proxyToRaw.get(this);
const proto = Reflect.getPrototypeOf(this);
const hadKey = proto.has.call(target, key);
const oldValue = proto.get.call(target, key);
// forward the operation before queueing reactions
const result = proto.set.apply(target, arguments);
if (!hadKey) {
queueReactionsForOperation({ target, key, value, type: 'add' });
} else if (value !== oldValue) {
queueReactionsForOperation({ target, key, value, oldValue, type: 'set' });
}
return result;
},
delete(key) {
const target = proxyToRaw.get(this);
const proto = Reflect.getPrototypeOf(this);
const hadKey = proto.has.call(target, key);
const oldValue = proto.get ? proto.get.call(target, key) : undefined;
// forward the operation before queueing reactions
const result = proto.delete.apply(target, arguments);
if (hadKey) {
queueReactionsForOperation({ target, key, oldValue, type: 'delete' });
}
return result;
},
clear() {
const target = proxyToRaw.get(this);
const proto = Reflect.getPrototypeOf(this);
const hadItems = target.size !== 0;
const oldTarget = target instanceof Map ? new Map(target) : new Set(target);
// forward the operation before queueing reactions
const result = proto.clear.apply(target, arguments);
if (hadItems) {
queueReactionsForOperation({ target, oldTarget, type: 'clear' });
}
return result;
},
forEach(cb, ...args) {
const target = proxyToRaw.get(this);
const proto = Reflect.getPrototypeOf(this);
registerRunningReactionForOperation({ target, type: 'iterate' });
// swap out the raw values with their observable pairs
// before passing them to the callback
const wrappedCb = (value, ...rest) => cb(findObservable(value), ...rest);
return proto.forEach.call(target, wrappedCb, ...args);
},
keys() {
const target = proxyToRaw.get(this);
const proto = Reflect.getPrototypeOf(this);
registerRunningReactionForOperation({ target, type: 'iterate' });
return proto.keys.apply(target, arguments);
},
values() {
const target = proxyToRaw.get(this);
const proto = Reflect.getPrototypeOf(this);
registerRunningReactionForOperation({ target, type: 'iterate' });
const iterator = proto.values.apply(target, arguments);
return patchIterator(iterator, false);
},
entries() {
const target = proxyToRaw.get(this);
const proto = Reflect.getPrototypeOf(this);
registerRunningReactionForOperation({ target, type: 'iterate' });
const iterator = proto.entries.apply(target, arguments);
return patchIterator(iterator, true);
},
[Symbol.iterator]() {
const target = proxyToRaw.get(this);
const proto = Reflect.getPrototypeOf(this);
registerRunningReactionForOperation({ target, type: 'iterate' });
const iterator = proto[Symbol.iterator].apply(target, arguments);
return patchIterator(iterator, target instanceof Map);
},
get size() {
const target = proxyToRaw.get(this);
const proto = Reflect.getPrototypeOf(this);
registerRunningReactionForOperation({ target, type: 'iterate' });
return Reflect.get(proto, 'size', target);
}
};
var collectionHandlers = {
get(target, key, receiver) {
// instrument methods and property accessors to be reactive
target = hasOwnProperty.call(instrumentations, key) ? instrumentations : target;
return Reflect.get(target, key, receiver);
}
};
// eslint-disable-next-line
const globalObj = typeof window === 'object' ? window : Function('return this')();
// built-in object can not be wrapped by Proxies
// their methods expect the object instance as the 'this' instead of the Proxy wrapper
// complex objects are wrapped with a Proxy of instrumented methods
// which switch the proxy to the raw object and to add reactive wiring
const handlers = new Map([[Map, collectionHandlers], [Set, collectionHandlers], [WeakMap, collectionHandlers], [WeakSet, collectionHandlers], [Object, false], [Array, false], [Int8Array, false], [Uint8Array, false], [Uint8ClampedArray, false], [Int16Array, false], [Uint16Array, false], [Int32Array, false], [Uint32Array, false], [Float32Array, false], [Float64Array, false]]);
function shouldInstrument({ constructor }) {
const isBuiltIn = typeof constructor === 'function' && constructor.name in globalObj && globalObj[constructor.name] === constructor;
return !isBuiltIn || handlers.has(constructor);
}
function getHandlers(obj) {
return handlers.get(obj.constructor);
}
const hasOwnProperty$1 = Object.prototype.hasOwnProperty;
const wellKnownSymbols = new Set(Object.getOwnPropertyNames(Symbol).map(key => Symbol[key]).filter(value => typeof value === 'symbol'));
// intercept get operations on observables to know which reaction uses their properties
function get(target, key, receiver) {
const result = Reflect.get(target, key, receiver);
// do not register (observable.prop -> reaction) pairs for well known symbols
// these symbols are frequently retrieved in low level JavaScript under the hood
if (typeof key === 'symbol' && wellKnownSymbols.has(key)) {
return result;
}
// register and save (observable.prop -> runningReaction)
registerRunningReactionForOperation({ target, key, receiver, type: 'get' });
// if we are inside a reaction and observable.prop is an object wrap it in an observable too
// this is needed to intercept property access on that object too (dynamic observable tree)
const observableResult = rawToProxy.get(result);
if (hasRunningReaction() && typeof result === 'object' && result !== null) {
if (observableResult) {
return observableResult;
}
// do not violate the none-configurable none-writable prop get handler invariant
// fall back to none reactive mode in this case, instead of letting the Proxy throw a TypeError
const descriptor = Reflect.getOwnPropertyDescriptor(target, key);
if (!descriptor || !(descriptor.writable === false && descriptor.configurable === false)) {
return observable(result);
}
}
// otherwise return the observable wrapper if it is already created and cached or the raw object
return observableResult || result;
}
function has(target, key) {
const result = Reflect.has(target, key);
// register and save (observable.prop -> runningReaction)
registerRunningReactionForOperation({ target, key, type: 'has' });
return result;
}
function ownKeys(target) {
registerRunningReactionForOperation({ target, type: 'iterate' });
return Reflect.ownKeys(target);
}
// intercept set operations on observables to know when to trigger reactions
function set(target, key, value, receiver) {
// make sure to do not pollute the raw object with observables
if (typeof value === 'object' && value !== null) {
value = proxyToRaw.get(value) || value;
}
// save if the object had a descriptor for this key
const hadKey = hasOwnProperty$1.call(target, key);
// save if the value changed because of this set operation
const oldValue = target[key];
// execute the set operation before running any reaction
const result = Reflect.set(target, key, value, receiver);
// do not queue reactions if the target of the operation is not the raw receiver
// (possible because of prototypal inheritance)
if (target !== proxyToRaw.get(receiver)) {
return result;
}
// queue a reaction if it's a new property or its value changed
if (!hadKey) {
queueReactionsForOperation({ target, key, value, receiver, type: 'add' });
} else if (value !== oldValue) {
queueReactionsForOperation({
target,
key,
value,
oldValue,
receiver,
type: 'set'
});
}
return result;
}
function deleteProperty(target, key) {
// save if the object had the key
const hadKey = hasOwnProperty$1.call(target, key);
const oldValue = target[key];
// execute the delete operation before running any reaction
const result = Reflect.deleteProperty(target, key);
// only queue reactions for delete operations which resulted in an actual change
if (hadKey) {
queueReactionsForOperation({ target, key, oldValue, type: 'delete' });
}
return result;
}
var baseHandlers = { get, has, ownKeys, set, deleteProperty };
function observable(obj = {}) {
// if it is already an observable or it should not be wrapped, return it
if (proxyToRaw.has(obj) || !shouldInstrument(obj)) {
return obj;
}
// if it already has a cached observable wrapper, return it
// otherwise create a new observable
return rawToProxy.get(obj) || createObservable(obj);
}
function createObservable(obj) {
// if it is a complex built-in object or a normal object, wrap it
const handlers = getHandlers(obj) || baseHandlers;
const observable = new Proxy(obj, handlers);
// save these to switch between the raw object and the wrapped object with ease later
rawToProxy.set(obj, observable);
proxyToRaw.set(observable, obj);
// init basic data structures to save and cleanup later (observable.prop -> reaction) connections
storeObservable(obj);
return observable;
}
function isObservable(obj) {
return proxyToRaw.has(obj);
}
function raw(obj) {
return proxyToRaw.get(obj) || obj;
}
export { observe, unobserve, observable, isObservable, raw };