easy-peasy
Version:
Vegetarian friendly state for React
236 lines (209 loc) • 6.17 kB
JavaScript
import React from 'react';
import { Immer, isDraft } from 'immer';
/**
* We create our own immer instance to avoid potential issues with autoFreeze
* becoming default enabled everywhere. We want to disable autofreeze as it
* does not suit the design of Easy Peasy.
* https://github.com/immerjs/immer/issues/681#issuecomment-705581111
*/
let easyPeasyImmer;
export function isPlainObject(obj) {
if (typeof obj !== 'object' || obj === null) return false;
let proto = obj;
while (Object.getPrototypeOf(proto) !== null) {
proto = Object.getPrototypeOf(proto);
}
return Object.getPrototypeOf(obj) === proto;
}
export function clone(source) {
function recursiveClone(current) {
const next = Object.keys(current).reduce((acc, key) => {
if (Object.getOwnPropertyDescriptor(current, key).get == null) {
acc[key] = current[key];
}
return acc;
}, {});
Object.keys(next).forEach((key) => {
if (isPlainObject(next[key])) {
next[key] = recursiveClone(next[key]);
}
});
return next;
}
return recursiveClone(source);
}
export function isPromise(x) {
return x != null && typeof x === 'object' && typeof x.then === 'function';
}
export function get(path, target) {
return path.reduce(
(acc, cur) => (isPlainObject(acc) ? acc[cur] : undefined),
target,
);
}
export function newify(currentPath, currentState, finalValue) {
if (currentPath.length === 0) {
return finalValue;
}
const newState = { ...currentState };
const key = currentPath[0];
if (currentPath.length === 1) {
newState[key] = finalValue;
} else {
newState[key] = newify(currentPath.slice(1), newState[key], finalValue);
}
return newState;
}
export function set(path, target, value) {
if (path.length === 0) {
if (typeof value === 'object') {
Object.keys(target).forEach((key) => {
delete target[key];
});
Object.keys(value).forEach((key) => {
target[key] = value[key];
});
}
return;
}
path.reduce((acc, cur, idx) => {
if (idx + 1 === path.length) {
acc[cur] = value;
} else {
acc[cur] = acc[cur] || {};
}
return acc[cur];
}, target);
}
export function createSimpleProduce(disableImmer = false) {
return function simpleProduce(path, state, fn, config) {
if ((config && 'immer' in config) ? config?.immer === false : disableImmer) {
const current = get(path, state);
const next = fn(current);
if (current !== next) {
return newify(path, state, next);
}
return state;
}
if (!easyPeasyImmer) {
easyPeasyImmer = new Immer({
// We need to ensure that we disable proxies if they aren't available
// on the environment. Users need to ensure that they use the enableES5
// feature of immer.
useProxies:
typeof Proxy !== 'undefined' &&
typeof Proxy.revocable !== 'undefined' &&
typeof Reflect !== 'undefined',
// Autofreezing breaks easy-peasy, we need a mixed version of immutability
// and mutability in order to apply updates to our computed properties
autoFreeze: false,
});
}
if (path.length === 0) {
const draft = easyPeasyImmer.createDraft(state);
const result = fn(draft);
if (result) {
return isDraft(result) ? easyPeasyImmer.finishDraft(result) : result;
}
return easyPeasyImmer.finishDraft(draft);
}
const parentPath = path.slice(0, path.length - 1);
const draft = easyPeasyImmer.createDraft(state);
const parent = get(parentPath, state);
const current = get(path, draft);
const result = fn(current);
if (result) {
parent[path[path.length - 1]] = result;
}
return easyPeasyImmer.finishDraft(draft);
};
}
const pReduce = (iterable, reducer, initialValue) =>
new Promise((resolve, reject) => {
const iterator = iterable[Symbol.iterator]();
let index = 0;
const next = (total) => {
const element = iterator.next();
if (element.done) {
resolve(total);
return;
}
Promise.all([total, element.value])
.then((value) =>
// eslint-disable-next-line no-plusplus
next(reducer(value[0], value[1], index++)),
)
.catch((err) => reject(err));
};
next(initialValue);
});
export const pSeries = (tasks) => {
const results = [];
return pReduce(tasks, (_, task) =>
task().then((value) => {
results.push(value);
}),
).then(() => results);
};
export function areInputsEqual(newInputs, lastInputs) {
if (newInputs.length !== lastInputs.length) {
return false;
}
for (let i = 0; i < newInputs.length; i += 1) {
if (newInputs[i] !== lastInputs[i]) {
return false;
}
}
return true;
}
export function useMemoOne(
// getResult changes on every call,
getResult,
// the inputs array changes on every call
inputs,
) {
// using useState to generate initial value as it is lazy
const initial = React.useState(() => ({
inputs,
result: getResult(),
}))[0];
const committed = React.useRef(initial);
// persist any uncommitted changes after they have been committed
const isInputMatch = Boolean(
inputs &&
committed.current.inputs &&
areInputsEqual(inputs, committed.current.inputs),
);
// create a new cache if required
const cache = isInputMatch
? committed.current
: {
inputs,
result: getResult(),
};
// commit the cache
React.useEffect(() => {
committed.current = cache;
}, [cache]);
return cache.result;
}
const logEventListenerError = (type, err) => {
// eslint-disable-next-line no-console
console.log(`Error in ${type}`);
// eslint-disable-next-line no-console
console.log(err);
};
export const handleEventDispatchErrors =
(type, dispatcher) =>
(...args) => {
try {
const result = dispatcher(...args);
if (isPromise(result)) {
result.catch((err) => {
logEventListenerError(type, err);
});
}
} catch (err) {
logEventListenerError(type, err);
}
};