@v4fire/core
Version:
V4Fire core library
918 lines (766 loc) • 20.6 kB
text/typescript
/* eslint-disable @typescript-eslint/unified-signatures */
/*!
* V4Fire Core
* https://github.com/V4Fire/Core
*
* Released under the MIT license
* https://github.com/V4Fire/Core/blob/master/LICENSE
*/
/**
* [[include:core/object/watch/README.md]]
* @packageDocumentation
*/
import watchEngine from 'core/object/watch/engines';
import { muteLabel, toOriginalObject, toRootObject, watchHandlers } from 'core/object/watch/const';
import { isValueCanBeArrayIndex } from 'core/object/watch/helpers';
import { unwrap } from 'core/object/watch/engines/helpers';
import type {
WatchPath,
WatchOptions,
WatchHandler,
RawWatchHandler,
MultipleWatchHandler,
Watcher,
WatchHandlersSet,
WatchEngine
} from 'core/object/watch/interface';
export * from 'core/object/watch/const';
export { unwrap, isProxy, getProxyType } from 'core/object/watch/engines/helpers';
export * from 'core/object/watch/interface';
export default watch;
/**
* Watches for changes of the specified object
*
* @param obj
* @param [handler] - callback that is invoked on every mutation hook
*/
function watch<T extends object>(obj: T, handler?: MultipleWatchHandler): Watcher<T>;
/**
* Watches for changes of the specified object
*
* @param obj
* @param opts - additional options
* @param [handler] - callback that is invoked on every mutation hook
*/
function watch<T extends object>(
obj: T,
opts: WatchOptions & {immediate: true},
handler?: WatchHandler
): Watcher<T>;
/**
* Watches for changes of the specified object
*
* @param obj
* @param opts - additional options
* @param [handler] - callback that is invoked on every mutation hook
*/
function watch<T extends object>(obj: T, opts: WatchOptions, handler?: MultipleWatchHandler): Watcher<T>;
/**
* Watches for changes of the specified object
*
* @param obj
* @param path - path to a property to watch
* @param [handler] - callback that is invoked on every mutation hook
*/
function watch<T extends object>(
obj: T,
path: WatchPath,
handler?: WatchHandler
): Watcher<T>;
/**
* Watches for changes of the specified object
*
* @param obj
* @param path - path to a property to watch
* @param opts - additional options
* @param [handler] - callback that is invoked on every mutation hook
*/
function watch<T extends object>(
obj: T,
path: WatchPath,
opts: WatchOptions & ({collapse: false}),
handler?: MultipleWatchHandler
): Watcher<T>;
/**
* Watches for changes of the specified object
*
* @param obj
* @param path - path to a property to watch
* @param opts - additional options
* @param [handler] - callback that is invoked on every mutation hook
*/
function watch<T extends object>(
obj: T,
path: WatchPath,
opts: WatchOptions,
handler?: MultipleWatchHandler
): Watcher<T>;
// eslint-disable-next-line max-lines-per-function
function watch<T extends object>(
obj: T,
pathOptsOrHandler?: WatchPath | WatchHandler | MultipleWatchHandler | WatchOptions,
handlerOrOpts?: WatchHandler | MultipleWatchHandler | WatchOptions,
optsOrHandler?: WatchOptions | WatchHandler | MultipleWatchHandler
): Watcher<T> {
const
isPathParsedFromString = Symbol('Is the path parsed from a string'),
unwrappedObj = unwrap(obj);
let
wrappedHandler: CanUndef<WatchHandler>,
handler: CanUndef<WatchHandler | MultipleWatchHandler>,
opts: CanUndef<WatchOptions>;
let
timer,
normalizedPath: CanUndef<unknown[]>;
// Support for overloads of the function
if (Object.isString(pathOptsOrHandler) || Object.isArray(pathOptsOrHandler)) {
if (Object.isArray(pathOptsOrHandler)) {
normalizedPath = pathOptsOrHandler;
} else {
normalizedPath = pathOptsOrHandler.split('.');
normalizedPath[isPathParsedFromString] = true;
}
if (Object.isFunction(handlerOrOpts)) {
handler = handlerOrOpts;
} else {
opts = handlerOrOpts;
if (Object.isFunction(optsOrHandler)) {
handler = optsOrHandler;
}
}
} else if (Object.isFunction(pathOptsOrHandler)) {
handler = pathOptsOrHandler;
} else {
opts = pathOptsOrHandler;
if (Object.isFunction(handlerOrOpts)) {
handler = handlerOrOpts;
}
}
opts ??= {};
opts.engine = opts.engine ?? watchEngine;
const
rawDeps = Object.size(opts.dependencies) > 0 ? opts.dependencies : undefined;
let
depsMap: CanUndef<Map<unknown[], unknown[][]>>,
localDeps: CanUndef<unknown[]>,
deps: CanUndef<unknown[][][]>;
// Normalize dependencies
if (rawDeps != null && unwrappedObj != null) {
const convert = (dep) => {
if (Object.isString(dep)) {
dep = dep.split('.');
dep[isPathParsedFromString] = true;
}
return dep;
};
if (Object.isArray(rawDeps)) {
localDeps = [];
if (normalizedPath != null) {
for (let i = 0; i < rawDeps.length; i++) {
localDeps.push(convert(rawDeps[i]));
}
}
} else {
deps = [];
depsMap = new Map();
Object.forEach(rawDeps, (dep, key) => {
if (!Object.isArray(dep)) {
throw new TypeError('Invalid format of dependencies');
}
let
localDeps;
if (Object.isArray(dep)) {
localDeps = dep.slice();
for (let i = 0; i < localDeps.length; i++) {
localDeps[i] = convert(localDeps[i]);
}
} else {
localDeps = [convert(dep)];
}
const
path = convert(key);
deps!.push([path, localDeps]);
Object.set(depsMap, path, localDeps);
});
if (depsMap.size > 0) {
const expandDeps = (deps) => {
for (let i = 0; i < deps.length; i++) {
const
dep = Object.get(depsMap, deps[i]);
if (dep != null) {
deps.splice(i, 1, ...expandDeps(dep));
}
}
return deps;
};
for (let i = 0; i < deps.length; i++) {
expandDeps(deps[i][1]);
}
if (normalizedPath != null) {
localDeps = Object.get(depsMap, normalizedPath);
}
}
}
}
opts.deep = normalizedPath != null && normalizedPath.length > 1 || opts.deep;
const
{deep, collapse} = opts;
const
pref = opts.prefixes,
post = opts.postfixes;
const {
immediate,
withProto,
tiedWith,
pathModifier,
eventFilter
} = opts;
// If we have a handler and valid object to watch,
// we need to wrap this handler to provide all features of watching
if (handler != null && unwrappedObj != null) {
let
dynamicValStore,
argsQueue: any[] = [];
wrappedHandler = (value, oldValue, info) => {
const
originalPath = info.path;
if (pathModifier != null) {
info = {...info, path: pathModifier(info.path)};
}
info.originalPath = originalPath;
if (
// We don't watch deep mutations
!deep && info.path.length > (Object.isDictionary(info.obj) ? 1 : 2) ||
// We don't watch prototype mutations
!withProto && info.fromProto ||
// The mutation is skipped by the filter
eventFilter != null && !Object.isTruly(eventFilter(value, oldValue, info))
) {
return;
}
let
cache;
const fireMutationEvent = (tiedPath?, needGetVal = false) => {
let
resolvedInfo = info;
// If we have a tied property with the property that have a mutation,
// we need to register it
if (tiedPath != null) {
cache ??= new Map();
if (Object.get(cache, tiedPath) === true) {
return;
}
Object.set(cache, tiedPath, true);
resolvedInfo = {
...info,
path: tiedPath.slice(),
parent: {
value,
oldValue,
info
}
};
}
// Returns a list of attributes to the mutation handler
const getArgs = () => {
if (needGetVal) {
const
dynamicVal = Object.get(unwrappedObj, collapse ? tiedPath[0] : tiedPath);
if (Object.size(handler) < 2) {
return [dynamicVal, undefined, resolvedInfo];
}
dynamicValStore ??= new Map();
const args = [
dynamicVal,
Object.get(dynamicValStore, resolvedInfo.path),
resolvedInfo
];
Object.set(dynamicValStore, resolvedInfo.path, dynamicVal);
return args;
}
if (collapse) {
const
isRoot = resolvedInfo.obj === resolvedInfo.root;
return [
isRoot ? value : resolvedInfo.top,
isRoot ? oldValue : resolvedInfo.top,
resolvedInfo
];
}
if (
collapse !== false &&
normalizedPath != null &&
normalizedPath.length < resolvedInfo.originalPath.length
) {
const val = Object.get(unwrappedObj, normalizedPath);
return [val, val, resolvedInfo];
}
return [value, oldValue, resolvedInfo];
};
if (immediate) {
// eslint-disable-next-line prefer-spread
handler!.apply(null, getArgs());
// Deferred events
} else {
const
needEventQueue = normalizedPath == null || collapse === false;
if (needEventQueue) {
argsQueue.push(getArgs());
} else {
argsQueue = getArgs();
}
if (timer == null) {
timer = setImmediate(() => {
timer = undefined;
try {
if (needEventQueue) {
(<MultipleWatchHandler>handler)(argsQueue);
} else {
// eslint-disable-next-line prefer-spread
(<WatchHandler>handler).apply(null, argsQueue);
}
} finally {
argsQueue = [];
}
});
}
}
};
// Takes a tied path and checks if it matches with the actual path
const checkTiedPath = (tiedPath: unknown[], deps: CanUndef<unknown[]>) => {
const
mutationPath = info.path,
path = mutationPath.length > tiedPath.length ? mutationPath.slice(0, tiedPath.length) : mutationPath,
tailPath = path.length !== tiedPath.length ? tiedPath.slice(path.length) : [];
// Sometimes, we can be caught in the situation when we watch by the path, like, foo.bar.bla,
// and the mutation occurs on foo.bar.
// We need to get a value by the tail (.bla) and check that it really was changed.
// const obj = {foo: {bar: {bla: 1}}}};
// obj.foo.bar = {bla: 1};
if (tailPath.length > 0) {
const
tailValue = Object.get(value, tailPath),
tailOldValue = Object.get(oldValue, tailPath);
if (tailValue === tailOldValue) {
return;
}
if (!collapse) {
value = tailValue;
oldValue = tailOldValue;
}
}
// The flag indicates that we need to get a real property value from the original object.
// It makes sense for getters.
let dynamic = false;
path: for (let i = 0; i < path.length; i++) {
const
pathVal = path[i],
tiedPathVal = tiedPath[i];
const needNormalizeVal =
Object.isNumber(pathVal) &&
tiedPath[isPathParsedFromString] === true &&
isValueCanBeArrayIndex(tiedPathVal);
const pathsAreSame = needNormalizeVal ?
Number(tiedPathVal) === Number(pathVal) :
tiedPathVal === pathVal;
if (pathsAreSame) {
continue;
}
if (Object.isString(pathVal)) {
const
normalizedTiedPathVal = String(tiedPathVal);
if (pref) {
for (let i = 0; i < pref.length; i++) {
if (pathVal === pref[i] + normalizedTiedPathVal) {
dynamic = true;
continue path;
}
}
}
if (post) {
for (let i = 0; i < post.length; i++) {
if (pathVal === normalizedTiedPathVal + post[i]) {
dynamic = true;
continue path;
}
}
}
}
if (deps != null) {
deps: for (let i = 0; i < deps.length; i++) {
const
depPath = deps[i];
if (!Object.isArray(depPath)) {
continue;
}
const
path = info.path.length > depPath.length ? info.path.slice(0, depPath.length) : info.path;
depsPath: for (let i = 0; i < path.length; i++) {
const
pathVal = path[i],
depPathVal = depPath[i];
const needNormalizeVal =
Object.isNumber(pathVal) &&
depPath[isPathParsedFromString] === true &&
isValueCanBeArrayIndex(depPathVal);
const pathsAreSame = needNormalizeVal ?
Number(depPathVal) === Number(pathVal) :
depPathVal === pathVal;
if (pathsAreSame) {
dynamic = true;
continue;
}
if (Object.isString(pathVal)) {
const
normalizedDepPathVal = String(depPathVal);
if (pref) {
for (let i = 0; i < pref.length; i++) {
if (pathVal === pref[i] + normalizedDepPathVal) {
dynamic = true;
continue depsPath;
}
}
}
if (post) {
for (let i = 0; i < post.length; i++) {
if (pathVal === normalizedDepPathVal + post[i]) {
dynamic = true;
continue depsPath;
}
}
}
}
continue deps;
}
break path;
}
}
// The path doesn't match with a tied path
return;
}
fireMutationEvent(tiedPath, dynamic);
};
// We watch only the one specified property
if (normalizedPath) {
checkTiedPath(normalizedPath, localDeps);
return;
}
fireMutationEvent();
// Check if the mutation matches by prefixes/postfixes with another properties
if (pref || post) {
const
tiedPath: unknown[] = [];
let
dynamic = false;
path: for (let i = 0; i < info.path.length; i++) {
const
pathVal = info.path[i];
if (Object.isString(pathVal)) {
if (pref) {
for (let i = 0; i < pref.length; i++) {
const
prefVal = pref[i];
if (pathVal.startsWith(prefVal)) {
dynamic = true;
tiedPath.push(pathVal.slice(prefVal.length));
continue path;
}
}
}
if (post) {
for (let i = 0; i < post.length; i++) {
const
postVal = post[i];
if (pathVal.endsWith(postVal)) {
dynamic = true;
tiedPath.push(pathVal.slice(0, -postVal.length));
continue path;
}
}
}
tiedPath.push(pathVal);
}
}
if (dynamic) {
fireMutationEvent(tiedPath, true);
}
}
// Check if the mutation matches by dependencies with another properties
if (deps) {
for (let i = 0; i < deps.length; i++) {
const dep = deps[i];
checkTiedPath(dep[0], dep[1]);
}
}
};
}
const
watcher = opts.engine.watch(obj, undefined, <RawWatchHandler>wrappedHandler, obj[watchHandlers] ?? new Set(), opts);
const
{proxy} = watcher;
if (tiedWith && Object.isSimpleObject(unwrappedObj)) {
tiedWith[watchHandlers] = proxy[watchHandlers];
tiedWith[toOriginalObject] = proxy[toOriginalObject];
for (let keys = Object.keys(proxy), i = 0; i < keys.length; i++) {
const
key = keys[i];
if (Object.hasOwnProperty(tiedWith, key)) {
continue;
}
Object.defineProperty(tiedWith, key, {
configurable: true,
enumerable: true,
get(): unknown {
return proxy[key];
},
set(val: unknown): void {
proxy[key] = val;
}
});
}
}
return watcher;
}
/**
* The function temporarily mutes all mutation events for the specified proxy object
*
* @param obj
* @example
* ```js
* const user = {
* name: 'Kobezzza',
* skills: {
* programming: 80,
* singing: 10
* }
* };
*
* const {proxy} = watch(user, {immediate: true, deep: true}, (value, oldValue, info) => {
* console.log(value, oldValue, info.path);
* });
*
* // 81 80 ['skills', 'programming']
* proxy.skills.programming++;
* mute(proxy);
*
* // This mutation won't invoke our callback
* proxy.skills.programming++;
* ```
*/
export function mute(obj: object): boolean {
const
root = unwrap(obj[toRootObject] ?? obj);
if (root) {
root[muteLabel] = true;
return true;
}
return false;
}
/**
* Wraps the specified object with unwatchable proxy, i.e. any mutations of this proxy can’t be watched
*
* @param obj
* @example
* ```js
* const obj = {
* a: 1,
* b: unwatchable({c: 2})
* };
*
* const {proxy} = watch(obj, {immediate: true}, (value, oldValue) => {
* console.log(value, oldValue);
* });
*
* // This mutation will be ignored by the watcher
* proxy.b.c = 3;
*
* // 1 2
* proxy.a = 2;
* ```
*/
export function unwatchable<T extends object>(obj: T): T {
const {proxy} = watch(obj);
mute(proxy);
return proxy;
}
/**
* The function unmutes all mutation events for the specified proxy object
*
* @param obj
* @example
* ```js
* const user = {
* name: 'Kobezzza',
* skills: {
* programming: 80,
* singing: 10
* }
* };
*
* const {proxy} = watch(user, {immediate: true, deep: true}, (value, oldValue, info) => {
* console.log(value, oldValue, info.path);
* });
*
* // 81 80 ['skills', 'programming']
* proxy.skills.programming++;
* mute(proxy);
*
* // This mutation won't invoke our callback
* proxy.skills.programming++;
* unmute(proxy);
*
* // 83 82 ['skills', 'programming']
* proxy.skills.programming++;
* ```
*/
export function unmute(obj: object): boolean {
const
root = unwrap(obj[toRootObject] ?? obj);
if (root) {
root[muteLabel] = false;
return true;
}
return false;
}
/**
* Sets a new watchable value for a proxy object by the specified path.
* The function is actual when using an engine based on accessors to add new properties to the watchable object.
* Or when you want to restore watching for a property after deleting it.
*
* @param obj
* @param path
* @param value
* @param [engine] - watch engine to use
*
* @example
* ```js
* const user = {
* name: 'Kobezzza',
* skills: {
* programming: 80,
* singing: 10
* }
* };
*
* const {proxy} = watch(user, {immediate: true, deep: true}, (value, oldValue, info) => {
* console.log(value, oldValue, info.path);
* });
*
* // This mutation will invoke our callback
* set(proxy, 'bla.foo', 1);
* ```
*/
export function set(
obj: object,
path: WatchPath,
value: unknown,
engine?: WatchEngine
): void;
/**
* Sets a new watchable value for a proxy object by the specified path.
* The function is actual when using an engine based on accessors to add new properties to the watchable object.
* Or when you want to restore watching for a property after deleting it.
*
* @param obj
* @param path
* @param value
* @param [handlers] - set of registered handlers
* @param [engine] - watch engine to use
*/
export function set(
obj: object,
path: WatchPath,
value: unknown,
handlers: WatchHandlersSet,
engine?: WatchEngine
): void;
export function set(
obj: object,
path: WatchPath,
value: unknown,
handlersOrEngine?: WatchHandlersSet | WatchEngine,
engine: WatchEngine = watchEngine
): void {
let
handlers;
if (Object.isSet(handlersOrEngine)) {
handlers = handlersOrEngine;
} else {
engine = handlersOrEngine ?? engine;
handlers = obj[watchHandlers];
}
engine.set(obj, path, value, handlers);
}
/**
* Deletes a watchable value from a proxy object by the specified path
*
* @param obj
* @param path
* @param [engine] - watch engine to use
*
* @example
* ```js
* const user = {
* name: 'Kobezzza',
* skills: {
* programming: 80,
* singing: 10
* }
* };
*
* const {proxy} = watch(user, {immediate: true, deep: true}, (value, oldValue, info) => {
* console.log(value, oldValue, info.path);
* });
*
* // This mutation will invoke our callback
* unset(proxy, 'skills.programming');
*
* console.log('programming' in proxy.skills === false);
*
* // This mutation won't invoke our callback
* proxy.skills.programming = 80;
*
* // Invoke set to register a property to watch.
* // This mutation will invoke our callback.
* set(proxy, 'skills.programming', 80)
*
* // This mutation will invoke our callback
* proxy.skills.programming++;
* ```
*/
export function unset(
obj: object,
path: WatchPath,
engine?: WatchEngine
): void;
/**
* Deletes a watchable value from a proxy object by the specified path.
* To restore watching for this property, use `set`.
*
* @param obj
* @param path
* @param [handlers] - set of registered handlers
* @param [engine] - watch engine to use
*/
export function unset(
obj: object,
path: WatchPath,
handlers: WatchHandlersSet,
engine?: WatchEngine
): void;
export function unset(
obj: object,
path: WatchPath,
handlersOrEngine?: WatchHandlersSet | WatchEngine,
engine: WatchEngine = watchEngine
): void {
let
handlers;
if (Object.isSet(handlersOrEngine)) {
handlers = handlersOrEngine;
} else {
engine = handlersOrEngine ?? engine;
handlers = obj[watchHandlers];
}
engine.unset(obj, path, handlers);
}