@geogirafe/lib-geoportal
Version:
GeoGirafe is a flexible application to build online geoportals.
487 lines (486 loc) • 20.2 kB
JavaScript
// SPDX-License-Identifier: Apache-2.0
import areEqual from './equality.js';
import { deepClone, deepFreeze, isConstructor, isFunction, isIgnoredProperty, isIterator, isPrimitive, isTypeSupported, isVirtualProperty } from './tools.js';
export default class Brain {
proxyCount = 0;
initialState;
stateProxy;
externalCallback;
proxyIds = new WeakMap();
targetToProxy = new WeakMap();
proxyToTarget = new WeakMap();
proxyChildren = new WeakMap();
proxyParents = new WeakMap();
proxyToFullPaths = new WeakMap();
mergeMinimalPathsCache = new Map();
delayed = false;
delayedCallbacks = new Map();
constructor(initialState, callback) {
this.initialState = initialState;
this.stateProxy = this.createProxy(this.initialState, '');
this.externalCallback = callback;
}
callback(path, oldValue, newValue, parents) {
if (this.delayed) {
// Remember all callback infos
const infos = this.delayedCallbacks.get(path);
if (infos) {
// Just set the new value
infos.newValue = newValue;
}
else {
this.delayedCallbacks.set(path, { oldValue: oldValue, newValue: newValue, parents: parents });
}
}
else {
// Immediate Callback
this.externalCallback(path, oldValue, newValue, parents);
}
}
/**
*
* Creates a proxy for the given target object
* @param target The target object to create a proxy for
* @param prop The property name of this target in its parent
* @param parent The parent proxy (optional for root element)
* @returns The created proxy
*/
createProxy(target, prop, parent) {
const proxy = new Proxy(target, this.objectHandler());
this.proxyIds.set(proxy, ++this.proxyCount);
this.targetToProxy.set(target, proxy);
this.proxyToTarget.set(proxy, target);
this.proxyChildren.set(proxy, new Map());
if (parent) {
this.proxyParents.set(proxy, [parent]);
this.proxyChildren.get(parent).set(prop, proxy);
const paths = parent.__brainFullPaths.map((path) => this.getFullPath(path, prop));
this.proxyToFullPaths.set(proxy, paths);
}
else {
// Root element
this.proxyToFullPaths.set(proxy, ['']);
}
return proxy;
}
/**
* Ensure we keep only minimal unique paths for a proxy.
* And ensure that the selectd paths have the same prefix than the parents path
* Example:
* existing: ["group"]
* candidates: ["group.children.0.parent"]
* => keep only ["group"]
* Other example if parentsPaths is defined:
* existing: ["group.inner"]
* candidates: ["group.inner", "group2.inner"]
* parrents: ["group2"]
* => keep only ["group2.inner"] (Because it is the only one still linked to the right parent)
*/
mergeMinimalPaths(proxyId, existingPaths, candidatePaths, parents) {
const cacheKey = JSON.stringify({
proxyId,
existingPaths,
candidatePaths,
parents: parents.map((p) => p.__brainId).join('|')
});
if (this.mergeMinimalPathsCache.has(cacheKey)) {
return this.mergeMinimalPathsCache.get(cacheKey);
}
const minimalPaths = new Set();
const parentsPaths = [];
for (const p of parents) {
parentsPaths.push(...p.__brainFullPaths);
}
for (const candidate of [...existingPaths, ...candidatePaths]) {
// Already present
if (minimalPaths.has(candidate)) {
continue;
}
// If the candidate hat a minimal path as prefix
if (Array.from(minimalPaths).some((path) => candidate.startsWith(`${path}.`))) {
continue;
}
// If not prefixed by any parent
if (!this.isRightParent(candidate, parentsPaths)) {
continue;
}
minimalPaths.add(candidate);
}
const returnValue = Array.from(minimalPaths);
this.mergeMinimalPathsCache.set(cacheKey, returnValue);
return returnValue;
}
isRightParent(candidate, parentsPaths) {
if (parentsPaths.length === 0 || parentsPaths[0].length === 0) {
// On the root
return true;
}
for (const parentPath of parentsPaths) {
if (parentPath.includes(`${candidate}.`)) {
// Circular reference
return true;
}
if (candidate.startsWith(`${parentPath}.`)) {
// Parent found
return true;
}
}
// Not a valid parent
return false;
}
getOrCreateProxyForValue(proxy, prop, value, childPaths) {
if (isPrimitive(value)) {
// No proxy for primitives
return undefined;
}
let valueProxy = this.targetToProxy.get(value);
if (valueProxy) {
// Proxy already exists
// Link parent to child
proxy.__brainChildren.set(prop, valueProxy);
// Add parent if not already present
const parents = valueProxy.__brainParents;
if (!parents.includes(proxy)) {
parents.push(proxy);
}
// Merge full paths (ensure minimal paths)
const merged = this.mergeMinimalPaths(valueProxy.__brainId, valueProxy.__brainFullPaths, childPaths, valueProxy.__brainParents);
const fullPaths = valueProxy.__brainFullPaths;
if (merged.length !== fullPaths.length || merged.some((p, i) => p !== fullPaths[i])) {
fullPaths.splice(0, fullPaths.length, ...merged);
this.updateChildPathsRecursively(valueProxy, fullPaths);
}
}
else {
// Create a new proxy
valueProxy = this.createProxy(value, prop, proxy);
const minimal = this.mergeMinimalPaths(valueProxy.__brainId, [], childPaths, valueProxy.__brainParents);
this.proxyToFullPaths.set(valueProxy, minimal);
this.updateChildPathsRecursively(valueProxy, minimal);
}
return valueProxy;
}
cleanProxyForValue(proxy, prop, value, childPaths) {
if (isPrimitive(value)) {
// Nothing to clean for primitives
return;
}
const valueProxy = this.targetToProxy.get(value);
if (!valueProxy) {
// The value was never proxied => Nothing to clean
return;
}
// Remove the child link for this property
proxy.__brainChildren.delete(prop);
// Remove only the paths that were coming from this proxy
const fullPaths = valueProxy.__brainFullPaths;
for (const childPath of childPaths) {
const index = fullPaths.indexOf(childPath);
if (index >= 0) {
fullPaths.splice(index, 1);
}
}
// Check if this proxy is still a parent of the child
// We only remove the parent if NO other child at this level points to it.
const parents = valueProxy.__brainParents;
const stillReferenced = Array.from(proxy.__brainChildren.values()).includes(valueProxy);
if (!stillReferenced) {
const index = parents.indexOf(proxy);
if (index >= 0) {
parents.splice(index, 1);
}
}
// Recursively update child paths for consistency
this.updateChildPathsRecursively(valueProxy, valueProxy.__brainFullPaths);
}
recalculateChildrenForArray(proxy, target, oldValue) {
if (!Array.isArray(target)) {
throw new TypeError('This method is only for arrays');
}
// Clean previous childs
proxy.__brainChildren.clear();
for (let i = 0; i < target.length; ++i) {
const childValue = target[i];
const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, i.toString()));
const childOldValue = oldValue.find((oldChild) => areEqual(oldChild, childValue));
if (childOldValue) {
const oldChildIndex = oldValue.indexOf(childOldValue);
const oldChildPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, oldChildIndex));
this.cleanProxyForValue(proxy, i.toString(), childValue, oldChildPaths);
}
const childProxy = this.getOrCreateProxyForValue(proxy, i.toString(), childValue, childPaths);
if (childProxy) {
proxy.__brainChildren.set(i.toString(), childProxy);
}
if (oldValue && oldValue.length > target.length) {
for (let i = target.length; i < oldValue.length; i++) {
const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, i.toString()));
this.cleanProxyForValue(proxy, i.toString(), oldValue[i], childPaths);
}
}
for (const [key, childProxy] of proxy.__brainChildren.entries()) {
const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, key));
this.proxyToFullPaths.set(childProxy, childPaths);
this.updateChildPathsRecursively(childProxy, childPaths);
}
}
}
updateChildPathsRecursively(proxy, parentPaths, visited = new Set()) {
if (visited.has(proxy)) {
return;
}
visited.add(proxy);
for (const [prop, childProxy] of proxy.__brainChildren.entries()) {
let fullPaths = this.proxyToFullPaths.get(childProxy);
if (!fullPaths) {
fullPaths = [];
this.proxyToFullPaths.set(childProxy, fullPaths);
}
const candidatePaths = parentPaths.map((path) => this.getFullPath(path, prop));
const merged = this.mergeMinimalPaths(childProxy.__brainId, fullPaths, candidatePaths, childProxy.__brainParents);
const changed = merged.length !== fullPaths.length || merged.some((p, i) => p !== fullPaths[i]);
if (changed) {
fullPaths.splice(0, fullPaths.length, ...merged);
this.updateChildPathsRecursively(childProxy, fullPaths, visited);
}
}
}
handleGetVirtualProperties(proxy, target, prop) {
switch (prop) {
case '__brainIsProxy':
return true;
case '__brainId':
return this.proxyIds.get(proxy);
case '__brainTarget':
return target;
case '__brainParents':
return this.proxyParents.get(proxy);
case '__brainChildren':
return this.proxyChildren.get(proxy);
case '__brainFullPaths':
return this.proxyToFullPaths.get(proxy) ?? [];
}
throw new Error(`Unknown virtual property: ${prop}`);
}
handleGetIgnored(target, prop) {
const value = target[prop];
if (isIgnoredProperty(target, prop)) {
return value;
}
throw new Error(`Unknown ignored property: ${prop}`);
}
handleGetFunctions(proxy, target, func) {
return (...args) => {
// args can contain a callback for func likes filter, map, reduce, forEach, ...
if (args.length === 1 && isFunction(args[0])) {
const originalCallback = args[0];
args[0] = (...cbArgs) => {
// We want to be able to use proxies in the callback
cbArgs = cbArgs.map((arg) => this.targetToProxy.get(arg) ?? arg);
return originalCallback(...cbArgs);
};
}
else {
// If args contains some proxies, we replace them with the target objects
args = args.map((arg) => (arg.__brainIsProxy ? arg.__brainTarget : arg));
}
const oldValue = deepClone(target);
deepFreeze(oldValue);
let result = target[func](...args);
if (result instanceof Promise) {
throw new Error('Promises are not supported yet.');
}
if (Array.isArray(result)) {
// Get proxied results
result = result.map((r) => this.targetToProxy.get(r) ?? r);
}
else if (isIterator(result)) {
// Iterator
const originalIterator = result;
result = {
[Symbol.iterator]() {
return {
next: () => {
const nextVal = originalIterator.next();
if (nextVal.done) {
return nextVal;
}
return {
done: false,
// Get proxied results
value: this.targetToProxy.get(nextVal.value) ?? nextVal.value
};
}
};
}
};
}
else {
// Unique result
result = this.targetToProxy.get(result) ?? result;
}
if (!areEqual(oldValue, target)) {
if (Array.isArray(target)) {
// The array has changed. We have to recalculate the children and paths for the elements
this.recalculateChildrenForArray(proxy, target, oldValue);
}
for (const path of proxy.__brainFullPaths) {
this.callback(path, oldValue, proxy, proxy.__brainParents);
}
}
return result;
};
}
getHandler(proxy, target, prop) {
if (isIgnoredProperty(target, prop)) {
return this.handleGetIgnored(target, prop);
}
if (isVirtualProperty(prop)) {
return this.handleGetVirtualProperties(proxy, target, prop);
}
if (isConstructor(prop)) {
return target.constructor;
}
let value = target[prop];
if (isPrimitive(value)) {
// No proxy for primitives
return value;
}
if (value.__brainIsProxy) {
// Proxy already exist.
// This can happen when a proxy was added to a not proxied object
// And then if the not proxied object is added to the state.
// In this case, brain should not recreate a proxy but simply reuse it.
value = value.__brainTarget;
}
if (!isTypeSupported(value)) {
throw new Error(`The type of the property ${prop} is not supported.`);
}
if (isFunction(value)) {
if (Array.isArray(target)) {
// Create proxies for all children
for (const i in target) {
const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, i));
this.getOrCreateProxyForValue(proxy, i, target[i], childPaths);
}
}
return this.handleGetFunctions(proxy, target, prop);
}
const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, prop));
const childProxy = this.getOrCreateProxyForValue(proxy, prop, value, childPaths);
return childProxy;
}
setHandler(proxy, target, prop, newValue) {
try {
let newValueProxy;
if (newValue?.__brainIsProxy) {
newValueProxy = newValue;
newValue = newValueProxy.__brainTarget;
}
if (isFunction(newValue)) {
// For functions, just set the property, there is nothing else to do.
target[prop] = newValue;
return true;
}
// Clean proxies
let oldValue = target[prop];
const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, prop));
this.cleanProxyForValue(proxy, prop, oldValue, childPaths);
if (isIgnoredProperty(target, prop)) {
// Ignored => No callback and no clone of the old version
// Just set the new value
target[prop] = newValue;
return true;
}
// Create frozen clone as oldValue
oldValue = deepClone(oldValue);
deepFreeze(oldValue);
target[prop] = newValue;
if (!isTypeSupported(newValue)) {
throw new Error(`The type of the property ${prop} is not supported.`);
}
if (areEqual(oldValue, newValue)) {
// No change => No callback
return true;
}
// Call callbacks
newValueProxy = this.getOrCreateProxyForValue(proxy, prop, newValue, childPaths);
for (const childPath of childPaths) {
this.callback(childPath, oldValue, newValueProxy ?? newValue, [proxy]);
}
return true;
}
catch (error) {
console.error(`Cannot set ${prop} on ${target} : ${error}`);
return false;
}
}
setArrayHandler(proxy, target, prop, newValue) {
try {
if (!Array.isArray(target)) {
throw new Error('This method should only be called on arrays');
}
if (newValue?.__brainIsProxy) {
newValue = newValue.__brainTarget;
}
if (isFunction(Reflect.get(target, prop)) || isFunction(newValue)) {
throw new Error('Setting functions is not supported.');
}
const oldTarget = deepClone(target);
deepFreeze(oldTarget);
Reflect.set(target, prop, newValue);
if (areEqual(oldTarget, target)) {
// No change => No callback
return true;
}
// Call callbacks
const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, prop));
this.getOrCreateProxyForValue(proxy, prop, newValue, childPaths);
for (const childPath of proxy.__brainFullPaths) {
this.callback(childPath, oldTarget, proxy, proxy.__brainParents);
}
return true;
}
catch (error) {
console.error(`Cannot set ${prop} on ${target} : ${error}`);
return false;
}
}
getFullPath(parentPath, prop) {
if (parentPath.trim().length > 0) {
return `${parentPath}.${prop}`;
}
return prop;
}
objectHandler = () => ({
get: (target, prop, _receiver) => {
const proxy = this.targetToProxy.get(target);
return this.getHandler(proxy, target, prop);
},
set: (target, prop, newValue, _receiver) => {
const proxy = this.targetToProxy.get(target);
if (Array.isArray(target)) {
return this.setArrayHandler(proxy, target, prop, newValue);
}
return this.setHandler(proxy, target, prop, newValue);
}
});
delay(statechanges) {
this.delayed = true;
try {
// Execute state changes
statechanges(this.stateProxy);
}
finally {
this.delayed = false;
// Call all callbacks
this.delayedCallbacks.forEach((infos, key) => {
this.externalCallback(key, infos.oldValue, infos.newValue, infos.parents);
});
this.delayedCallbacks.clear();
}
}
getState() {
return this.stateProxy;
}
}