@nerdwallet/epic-state-subscriptions
Version:
An epic state subscription library using Redux-Observable
187 lines (181 loc) • 7.14 kB
JavaScript
import _ from "lodash";
import { stateSubscriptionPathCache } from "./cache";
/**
* A basic shallow equal comparison utility
* @param {Object?} - The first object to be shallow compared
* @param {Object?} - The second object to be shallow compared
* @return {Boolean} Whether the two objects are shallow equal, defaults to true for === equality
* of objects and primitives
*/
export const shallowEqual = (obj1, obj2) => {
if (!obj1 || !obj2) {
return obj1 === obj2;
}
return _.every(
[...Object.keys(obj1), ...Object.keys(obj2)],
key => obj1[key] === obj2[key]
);
};
/**
* Determines if there are dirty state subscription paths for a nested path pattern where nested is defined as a
* `*` property such as `state.x.*`. It traverses each top level key off of `state.x` and aggregates dirty paths.
*
* @param {String} stateSubscriptionKey - The state subscription identifier key
* @param {Array} pathComponents - The state subscription path delimited into an array by `.`
* @param {Object} state - The current state of the Redux store
* @param {Number} pathIndex - The path component index currently being traversed
* @param {Function} callback - The dirty subscription callback to be invoked on each transformed nested path
* @return {Array} The dirty paths in the redux store for the provided state subscription path
*/
const findNestedDirtySubscriptionPaths = (
stateSubscriptionKey,
pathPattern,
pathComponents,
state,
pathIndex,
callback
) => {
const stringPath = pathComponents.slice(0, pathIndex).join(".");
const stateSlice = pathIndex === 0 ? state : _.get(state, stringPath);
// If the state of the redux store is an object, we want to traverse each key under the object
// to determine if the top-level keys are dirty.
if (_.isPlainObject(stateSlice)) {
return Object.keys(stateSlice).reduce((acc, slicePath) => {
// For each traversed top-level key, check if it is a dirty path by creating its path components
// array by taking the preceding components, its own key name, then the path components after it.
// Consider the case where `state.x.y.z` exists and we are recursing on the state subscription `state.x.*.z`
// Here it would be looping over all the keys for `state.x`, discover y and then recurse with the path `state.x.y.z`
const newPathComponents = [
...pathComponents.slice(0, pathIndex),
slicePath,
...pathComponents.slice(pathIndex + 1, pathComponents.length)
];
return [
...acc,
...callback(
stateSubscriptionKey,
pathPattern,
newPathComponents,
state,
pathIndex
)
];
}, []);
}
return [];
};
/**
* Determines if there is a dirty state subscription path for the given path pattern such as `state.x`.
*
* @param {String} stateSubscriptionKey - The state subscription identifier key
* @param {String} pathPattern - The original state subscription path pattern
* @param {Array} pathComponents - The state subscription path delimited into an array by `.`
* @param {Object} state - The current state of the Redux store
* @param {Number} pathIndex=0 - The path component index currently being traversed
* @param {Array} foundPaths=[] - The current dirty state subscription paths found
* @return {Array} The dirty paths in the redux store for the provided state subscription path
*/
const findDirtySubscriptionPaths = (
stateSubscriptionKey,
pathPattern,
pathComponents,
state,
pathIndex = 0,
foundPaths = []
) => {
const pathComponent = pathComponents[pathIndex];
// Nested Recursive case:
// If the current state subscription path component being traversed is a `*`, we want to check all paths
// under the current state, so we call the helper method `findNestedDirtySubscriptionPaths` to check
// the nested paths under the current state subscription path.
if (pathComponent === "*") {
return [
...foundPaths,
...findNestedDirtySubscriptionPaths(
stateSubscriptionKey,
pathPattern,
pathComponents,
state,
pathIndex,
findDirtySubscriptionPaths
)
];
// Base case:
// If we are traversing the path components and have reached the final one, we want to check the state
// at the full string path re-created by joining the preceding and current state subscription path components with
// the state subscription cache for that path.
} else if (pathIndex === pathComponents.length - 1) {
const stringPath = pathComponents.join(".");
// The cached paths are retrieved from {subscriptionKey}{pathPattern}{cacheHitKey}
// For example, a top level key of `group1` with a path pattern of `test.*` and 2 keys `foo` and
// `bar` under the test slice of the redux store would store the cached entry for each at paths
// `group1.test.*.foo and `group1.test.*.bar` in the cache
const stateSubscriptionPathCacheForPattern = _.get(
stateSubscriptionPathCache[stateSubscriptionKey],
pathPattern
);
const cachedPathState = _.get(
stateSubscriptionPathCacheForPattern,
stringPath
);
const currentPathState = _.get(state, stringPath);
if (!shallowEqual(currentPathState, cachedPathState)) {
// If the state subscription path is dirty, update the state subscription cache for that path.
// The cached paths are stored under the pattern {subscriptionKey}.{pathPattern}.{cacheHitKey}
_.set(
stateSubscriptionPathCache,
[stateSubscriptionKey, pathPattern, stringPath],
currentPathState
);
return [
...foundPaths,
{
nextState: currentPathState,
prevState: cachedPathState,
pathPattern,
path: stringPath
}
];
}
return foundPaths;
}
// Recursive case:
// Only a partial path has been traversed so far so it continues the recursion at the next path index
return findDirtySubscriptionPaths(
stateSubscriptionKey,
pathPattern,
pathComponents,
state,
pathIndex + 1,
foundPaths
);
};
/**
* Checks the given state subscription key to determine if any of its watched paths
* are dirty in the Redux store.
*
* @param {String} stateSubscriptionKey - The state subscription identifier key
* @param {String} stateSubscriptionPathCache - The subscription cache used to compare the current redux state with the cached value
* @param {Array} paths - The paths that the individual state subscription config is subscribed to
* @param {Object} state - The current Redux state
* @return {Array} - The dirty state subscription paths
*/
// eslint-disable-next-line import/prefer-default-export
export const findUpdatedStateSubscriptionPaths = (
stateSubscriptionKey,
pathPatterns,
state
) =>
// Aggregate all the paths that have changed together
pathPatterns.reduce(
(acc, pathPattern) => [
...acc,
...findDirtySubscriptionPaths(
stateSubscriptionKey,
pathPattern,
pathPattern.split("."),
state
)
],
[]
);