redux-sessionstorage-simple
Version:
Save and load Redux state to and from SessionStorage.
535 lines (436 loc) • 16.2 kB
JavaScript
import merge from 'merge'
const MODULE_NAME = '[Redux-SessionStorage-Simple]'
const NAMESPACE_DEFAULT = 'redux_sessionstorage_simple'
const NAMESPACE_SEPARATOR_DEFAULT = '_'
const STATES_DEFAULT = []
const IGNORE_STATES_DEFAULT = []
const DEBOUNCE_DEFAULT = 0
const IMMUTABLEJS_DEFAULT = false
const DISABLE_WARNINGS_DEFAULT = false
let debounceTimeout = null
// ---------------------------------------------------
/* warn
DESCRIPTION
----------
Write a warning to the console if warnings are enabled
PARAMETERS
----------
@disableWarnings (Boolean) - If set to true then the warning is not written to the console
@warningMessage (String) - The message to write to the console
*/
function warnConsole (warningMessage) {
console.warn(MODULE_NAME, warningMessage)
}
function warnSilent (_warningMessage) {
// Empty
}
const warn = disableWarnings => (disableWarnings ? warnSilent : warnConsole)
// ---------------------------------------------------
/* lensPath
DESCRIPTION
----------
Gets inner data from an object based on a specified path
PARAMETERS
----------
@path (Array of Strings) - Path used to get an object's inner data
e.g. ['prop', 'innerProp']
@obj (Object) - Object to get inner data from
USAGE EXAMPLE
-------------
lensPath(
['prop', 'innerProp'],
{ prop: { innerProp: 123 } }
)
returns
123
*/
function lensPath (path, obj) {
if (obj === undefined) {
return null
} else if (path.length === 1) {
return obj[path[0]]
} else {
return lensPath(path.slice(1), obj[path[0]])
}
}
// ---------------------------------------------------
/* realiseObject
DESCRIPTION
----------
Create an object from a specified path, with
the innermost property set with an initial value
PARAMETERS
----------
@objectPath (String) - Object path e.g. 'myObj.prop1.prop2'
@objectInitialValue (Any, optional) - Value of the innermost property once object is created
USAGE EXAMPLE
-------------
realiseObject('myObj.prop1.prop2', 123)
returns
{
myObj: {
prop1: {
prop2: 123
}
}
}
*/
function realiseObject (objectPath, objectInitialValue = {}) {
function realiseObject_ (objectPathArr, objectInProgress) {
if (objectPathArr.length === 0) {
return objectInProgress
} else {
return realiseObject_(objectPathArr.slice(1), {[objectPathArr[0]]: objectInProgress})
}
}
return realiseObject_(objectPath.split('.').reverse(), objectInitialValue)
}
// ---------------------------------------------------
// SafeSessionStorage wrapper to handle the minefield of exceptions
// that sessionStorage can throw. JSON.parse() is handled here as well.
function SafeSessionStorage (warnFn) {
this.warnFn = warnFn || warnConsole
}
Object.defineProperty(SafeSessionStorage.prototype, 'length', {
get: function length () {
try {
return sessionStorage.length
} catch (err) {
this.warnFn(err)
}
return 0
},
configurable: true,
enumerable: true
});
SafeSessionStorage.prototype.key = function key (ind) {
try {
return sessionStorage.key(ind)
} catch (err) {
this.warnFn(err)
}
return null
}
SafeSessionStorage.prototype.setItem = function setItem (key, val) {
try {
sessionStorage.setItem(key, JSON.stringify(val))
} catch (err) {
this.warnFn(err)
}
}
SafeSessionStorage.prototype.getItem = function getItem (key) {
try {
return JSON.parse(sessionStorage.getItem(key))
} catch (err) {
this.warnFn(err)
}
return null
}
SafeSessionStorage.prototype.removeItem = function removeItem (key) {
try {
sessionStorage.removeItem(key)
} catch (err) {
this.warnFn(err)
}
}
// ---------------------------------------------------
/**
Saves specified parts of the Redux state tree into sessionstorage
Note: this is Redux middleware. Read this for an explanation:
http://redux.js.org/docs/advanced/Middleware.html
PARAMETERS
----------
@config (Object) - Contains configuration options (leave blank to save entire state tree to sessionstorage)
Properties:
states (Array of Strings, optional) - States to save e.g. ['user', 'products']
namespace (String, optional) - Namespace to add before your SessionStorage items
debounce (Number, optional) - Debouncing period (in milliseconds) to wait before saving to SessionStorage
Use this as a performance optimization if you feel you are saving
to SessionStorage too often. Recommended value: 500 - 1000 milliseconds
USAGE EXAMPLES
-------------
// save entire state tree - EASIEST OPTION
save()
// save specific parts of the state tree
save({
states: ['user', 'products']
})
// save the entire state tree under the namespace 'my_cool_app'. The key 'my_cool_app' will appear in SessionStorage
save({
namespace: 'my_cool_app'
})
// save the entire state tree only after a debouncing period of 500 milliseconds has elapsed
save({
debounce: 500
})
// save specific parts of the state tree with the namespace 'my_cool_app'. The keys 'my_cool_app_user' and 'my_cool_app_products' will appear in SessionStorage
save({
states: ['user', 'products'],
namespace: 'my_cool_app',
debounce: 500
})
*/
export function save ({
states = STATES_DEFAULT,
ignoreStates = IGNORE_STATES_DEFAULT,
namespace = NAMESPACE_DEFAULT,
namespaceSeparator = NAMESPACE_SEPARATOR_DEFAULT,
debounce = DEBOUNCE_DEFAULT,
disableWarnings = DISABLE_WARNINGS_DEFAULT
} = {}) {
return store => next => action => {
// Bake disableWarnings into the warn function
const warn_ = warn(disableWarnings)
const returnValue = next(action)
let state_
// Validate 'states' parameter
if (!isArray(states)) {
console.error(MODULE_NAME, "'states' parameter in 'save()' method was passed a non-array value. Setting default value instead. Check your 'save()' method.")
states = STATES_DEFAULT
}
// Validate 'ignoreStates' parameter
if (!isArray(ignoreStates)) {
console.error(MODULE_NAME, "'ignoreStates' parameter in 'save()' method was passed a non-array value. Setting default value instead. Check your 'save()' method.")
ignoreStates = IGNORE_STATES_DEFAULT
}
// Validate individual entries in'ignoreStates' parameter
if (ignoreStates.length > 0) {
ignoreStates = ignoreStates.filter(function (ignoreState) {
if (!isString(ignoreState)) {
console.error(MODULE_NAME, "'ignoreStates' array contains a non-string value. Ignoring this value. Check your 'ignoreStates' array.")
} else {
return ignoreState
}
})
}
// Validate 'namespace' parameter
if (!isString(namespace)) {
console.error(MODULE_NAME, "'namespace' parameter in 'save()' method was passed a non-string value. Setting default value instead. Check your 'save()' method.")
namespace = NAMESPACE_DEFAULT
}
// Validate 'namespaceSeparator' parameter
if (!isString(namespaceSeparator)) {
console.error(MODULE_NAME, "'namespaceSeparator' parameter in 'save()' method was passed a non-string value. Setting default value instead. Check your 'save()' method.")
namespaceSeparator = NAMESPACE_SEPARATOR_DEFAULT
}
// Validate 'debounce' parameter
if (!isInteger(debounce)) {
console.error(MODULE_NAME, "'debounce' parameter in 'save()' method was passed a non-integer value. Setting default value instead. Check your 'save()' method.")
debounce = DEBOUNCE_DEFAULT
}
// Check if there are states to ignore
if (ignoreStates.length > 0) {
state_= handleIgnoreStates(ignoreStates, store.getState())
} else {
state_= store.getState()
}
const storage = new SafeSessionStorage(warn_)
// Check to see whether to debounce SessionStorage saving
if (debounce) {
// Clear the debounce timeout if it was previously set
if (debounceTimeout) {
clearTimeout(debounceTimeout)
}
// Save to SessionStorage after the debounce period has elapsed
debounceTimeout = setTimeout(function () {
_save(states, namespace)
}, debounce)
// No debouncing necessary so save to SessionStorage right now
} else {
_save(states, namespace)
}
// Digs into rootState for the data to put in SessionStorage
function getStateForSessionStorage (state, rootState) {
const delimiter = '.'
if (state.split(delimiter).length > 1) {
return lensPath(state.split(delimiter), rootState)
} else {
return lensPath([state], rootState)
}
}
// Local function to avoid duplication of code above
function _save () {
if (states.length === 0) {
storage.setItem(namespace, state_)
} else {
states.forEach(state => {
const key = namespace + namespaceSeparator + state
const stateForSessionStorage = getStateForSessionStorage(state, state_)
if (stateForSessionStorage) {
storage.setItem(key, stateForSessionStorage)
} else {
// Make sure nothing is ever saved for this incorrect state
storage.removeItem(key)
}
})
}
}
return returnValue
}
}
/**
Loads specified states from sessionstorage into the Redux state tree.
PARAMETERS
----------
@config (Object) - Contains configuration options (leave blank to load entire state tree, if it was saved previously that is)
Properties:
states (Array of Strings, optional) - Parts of state tree to load e.g. ['user', 'products']
namespace (String, optional) - Namespace required to retrieve your SessionStorage items, if any
Usage examples:
// load entire state tree - EASIEST OPTION
load()
// load specific parts of the state tree
load({
states: ['user', 'products']
})
// load the entire state tree which was previously saved with the namespace "my_cool_app"
load({
namespace: 'my_cool_app'
})
// load specific parts of the state tree which was previously saved with the namespace "my_cool_app"
load({
states: ['user', 'products'],
namespace: 'my_cool_app'
})
*/
export function load ({
states = STATES_DEFAULT,
immutablejs = IMMUTABLEJS_DEFAULT,
namespace = NAMESPACE_DEFAULT,
namespaceSeparator = NAMESPACE_SEPARATOR_DEFAULT,
preloadedState = {},
disableWarnings = DISABLE_WARNINGS_DEFAULT
} = {}) {
// Bake disableWarnings into the warn function
const warn_ = warn(disableWarnings)
// Validate 'states' parameter
if (!isArray(states)) {
console.error(MODULE_NAME, "'states' parameter in 'load()' method was passed a non-array value. Setting default value instead. Check your 'load()' method.")
states = STATES_DEFAULT
}
// Validate 'namespace' parameter
if (!isString(namespace)) {
console.error(MODULE_NAME, "'namespace' parameter in 'load()' method was passed a non-string value. Setting default value instead. Check your 'load()' method.")
namespace = NAMESPACE_DEFAULT
}
// Validate 'namespaceSeparator' parameter
if (!isString(namespaceSeparator)) {
console.error(MODULE_NAME, "'namespaceSeparator' parameter in 'load()' method was passed a non-string value. Setting default value instead. Check your 'load()' method.")
namespaceSeparator = NAMESPACE_SEPARATOR_DEFAULT
}
// Display immmutablejs deprecation notice if developer tries to utilise it
if (immutablejs === true) {
warn_('Support for Immutable.js data structures has been deprecated as of version 2.0.0. Please use version 1.4.0 if you require this functionality.')
}
const storage = new SafeSessionStorage(warn_)
let loadedState = preloadedState
// Load all of the namespaced Redux data from SessionStorage into local Redux state tree
if (states.length === 0) {
const val = storage.getItem(namespace)
if (val) {
loadedState = val
}
} else { // Load only specified states into the local Redux state tree
states.forEach(function (state) {
const key = namespace + namespaceSeparator + state
const val = storage.getItem(key)
if (val) {
loadedState = merge.recursive(loadedState, realiseObject(state, val))
} else {
warn_("Invalid load '" + key + "' provided. Check your 'states' in 'load()'. If this is your first time running this app you may see this message. To disable it in future use the 'disableWarnings' flag, see documentation.")
}
})
}
return loadedState
}
/**
Combines multiple 'load' method calls to return a single state for use in Redux's createStore method.
Use this when parts of the loading process need to be handled differently e.g. some parts of your state tree use different namespaces
PARAMETERS
----------
@loads - 'load' method calls passed into this method as normal arguments
Usage example:
// Load parts of the state tree saved with different namespaces
combineLoads(
load({ states: ['user'], namespace: 'account_stuff' }),
load({ states: ['products', 'categories'], namespace: 'site_stuff' )
)
*/
export function combineLoads (...loads) {
let combinedLoad = {}
loads.forEach(load => {
// Make sure current 'load' is an object
if (!isObject(load)) {
console.error(MODULE_NAME, "One or more loads provided to 'combineLoads()' is not a valid object. Ignoring the invalid load/s. Check your 'combineLoads()' method.")
load = {}
}
for (let state in load) {
combinedLoad[state] = load[state]
}
})
return combinedLoad
}
/**
Clears all Redux state tree data from SessionStorage
Remember to provide a namespace if you used one during the save process
PARAMETERS
----------
@config (Object) -Contains configuration options (leave blank to clear entire state tree from SessionStorage, if it was saved without a namespace)
Properties:
namespace (String, optional) - Namespace that you used during the save process
Usage example:
// clear all Redux state tree data saved without a namespace
clear()
// clear Redux state tree data saved with a namespace
clear({
namespace: 'my_cool_app'
})
*/
export function clear ({
namespace = NAMESPACE_DEFAULT,
disableWarnings = DISABLE_WARNINGS_DEFAULT
} = {}) {
// Bake disableWarnings into the warn function
const warn_ = warn(disableWarnings)
// Validate 'namespace' parameter
if (!isString(namespace)) {
console.error(MODULE_NAME, "'namespace' parameter in 'clear()' method was passed a non-string value. Setting default value instead. Check your 'clear()' method.")
namespace = NAMESPACE_DEFAULT
}
const storage = new SafeSessionStorage(warn_)
const len = storage.length
for (let ind = 0; ind < len; ind++) {
const key = storage.key(ind)
// key starts with namespace
if (key && key.slice(0, namespace.length) === namespace) {
storage.removeItem(key)
}
}
}
// ---------------------------------------------------
// Utility functions
function isArray (value) {
return Object.prototype.toString.call(value) === '[object Array]'
}
function isString (value) {
return typeof value === 'string'
}
function isInteger (value) {
return typeof value === 'number' &&
isFinite(value) &&
Math.floor(value) === value
}
function isObject (value) {
return value !== null && typeof value === 'object'
}
// Removes ignored states from the main state object
function handleIgnoreStates (ignoreStates, stateFull) {
let stateFullMinusIgnoreStates = Object.entries(stateFull).reduce(function (acc, [key, value]) {
if (ignoreStates.indexOf(key) === -1) {
acc[key] = stateFull[key]
}
return acc
}, {})
return stateFullMinusIgnoreStates
}