From 652b049503bae5ec2cbc9ba6d2b6300ca33a6249 Mon Sep 17 00:00:00 2001 From: GravlLift Date: Thu, 30 Jul 2020 15:10:14 -0400 Subject: [PATCH] feat: allow infinite depth configuration --- .editorconfig | 10 + README.md | 21 +- package-lock.json | 6 +- package.json | 2 +- spec/index_spec.ts | 178 +++++++++------ src/lib/index.ts | 537 +++++++++++++++++++++++---------------------- 6 files changed, 416 insertions(+), 338 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..450ff41 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +max_line_length = 120 \ No newline at end of file diff --git a/README.md b/README.md index 5c332e3..bc74fff 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ An interface defining the configuration attributes to bootstrap `localStorageSyn * `object[]`: Array of objects where for each object the key represents the state key and the value represents custom serialize/deserialize options. This can be one of the following: - * An array of properties which should be synced. This allows for the partial state sync (e.g. `localStorageSync({keys: [{todos: ['name', 'status'] }, ... ]})`). Note: this config cannot go any deeper. So you cannot specify another object inside of the `todos` array for example. + * An array of properties which should be synced. This allows for the partial state sync (e.g. `localStorageSync({keys: [{todos: ['name', 'status'] }, ... ]})`). * A reviver function as specified in the [JSON.parse documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse). @@ -96,7 +96,24 @@ An interface defining the configuration attributes to bootstrap `localStorageSyn * `checkStorageAvailability` \(*boolean? = false*): Specify if the storage availability checking is expected, i.e. for server side rendering / Universal. * `mergeReducer` (optional) `(state: any, rehydratedState: any, action: any) => any`: Defines the reducer to use to merge the rehydrated state from storage with the state from the ngrx store. If unspecified, defaults to performing a full deepmerge on an `INIT_ACTION` or an `UPDATE_ACTION`. -Usage: `localStorageSync({keys: ['todos', 'visibilityFilter'], storageKeySerializer: (key) => 'cool_' + key, ... })`. In this example `Storage` will use keys `cool_todos` and `cool_visibilityFilter` keys to store `todos` and `visibilityFilter` slices of state). The key itself is used by default - `(key) => key`. +### Usage +#### Key Prefix +```ts +localStorageSync({keys: ['todos', 'visibilityFilter'], storageKeySerializer: (key) => 'cool_' + key, ... }); +``` +In above example `Storage` will use keys `cool_todos` and `cool_visibilityFilter` keys to store `todos` and `visibilityFilter` slices of state). The key itself is used by default - `(key) => key`. + +#### Target Depth Configuration + +```ts +localStorageSync({ + keys: [ + { feature1: [{ slice11: ['slice11_1'], slice14: ['slice14_2'] }] }, + { feature2: ['slice21'] } + ], +}); +``` +In this example, `feature1.slice11.slice11_1`, `feature1.slice14.slice14_2`, and `feature2.slice21` will be synced to `localStorage.feature1` and `localStorage.feature2`. ## Release Notes / Changelog diff --git a/package-lock.json b/package-lock.json index f36e920..bd7affe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10435,9 +10435,9 @@ } }, "typescript": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.6.tgz", - "integrity": "sha512-Pspx3oKAPJtjNwE92YS05HQoY7z2SFyOpHo9MqJor3BXAGNaPUs83CuVp9VISFkSjyRfiTpmKuAYGJB7S7hOxw==", + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.7.tgz", + "integrity": "sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw==", "dev": true }, "uglify-js": { diff --git a/package.json b/package.json index f323306..8886981 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "semantic-release": "^17.1.1", "ts-node": "^8.8.2", "tslint": "^6.1.3", - "typescript": "^3.9.2", + "typescript": "^3.9.7", "zone.js": "^0.7.7" }, "ngPackage": { diff --git a/spec/index_spec.ts b/spec/index_spec.ts index a397f54..561c9a9 100644 --- a/spec/index_spec.ts +++ b/spec/index_spec.ts @@ -9,7 +9,7 @@ const INIT_ACTION = '@ngrx/store/init'; // Very simple classes to test serialization options. They cover string, number, date, and nested classes // The top level class has static functions to help test reviver, replacer, serialize and deserialize class TypeB { - constructor(public afield: string) { } + constructor(public afield: string) {} } class TypeA { @@ -17,8 +17,7 @@ class TypeA { if (typeof value === 'object') { if (value.afield) { return new TypeB(value.afield); - } - else { + } else { return new TypeA(value.astring, value.anumber, value.adate, value.aclass); } } @@ -45,7 +44,7 @@ class TypeA { public anumber: number = undefined, public adate: Date = undefined, public aclass: TypeB = undefined - ) { } + ) {} } class TypeC extends TypeA { @@ -58,20 +57,24 @@ class TypeC extends TypeA { static decrypt(message: string) { let decoded = CryptoJS.AES.decrypt(message, TypeC.key); - decoded = decoded.toString(CryptoJS.enc.Utf8); - - return decoded; + return decoded.toString(CryptoJS.enc.Utf8); } } class MockStorage implements Storage { public length: number; - public clear(): void { throw 'Not Implemented'; } + public clear(): void { + throw 'Not Implemented'; + } public getItem(key: string): string | null { return this[key] ? this[key] : null; } - key(index: number): string | null { throw 'Not Implemented'; } - removeItem(key: string): void { this[key] = undefined; } + key(index: number): string | null { + throw 'Not Implemented'; + } + removeItem(key: string): void { + this[key] = undefined; + } setItem(key: string, data: string): void { this[key] = data; } @@ -79,23 +82,16 @@ class MockStorage implements Storage { [index: number]: string; } -function mockStorageKeySerializer(key) { return key; } - +function mockStorageKeySerializer(key) { + return key; +} describe('ngrxLocalStorage', () => { - let t1 = new TypeA( - 'Testing', - 3.14159, - new Date('1968-11-16T12:30:00Z'), - new TypeB('Nested Class')); + let t1 = new TypeA('Testing', 3.14159, new Date('1968-11-16T12:30:00Z'), new TypeB('Nested Class')); let t1Json = JSON.stringify(t1); - let t1Filtered = new TypeA( - 'Testing', - undefined, - undefined, - new TypeB('Nested Class')); + let t1Filtered = new TypeA('Testing', undefined, undefined, new TypeB('Nested Class')); let t1FilteredJson = JSON.stringify(t1Filtered); @@ -112,6 +108,10 @@ describe('ngrxLocalStorage', () => { const primitiveStr = 'string is not an object'; const initialStatePrimitiveStr = { state: primitiveStr }; + beforeEach(() => { + localStorage.clear(); + }); + it('simple', () => { // This tests a very simple state object syncing to mock Storage // Since we're not specifiying anything for rehydration, the roundtrip @@ -180,10 +180,13 @@ describe('ngrxLocalStorage', () => { }; // test selective write to storage - syncStateUpdate(nestedState, [ - { 'feature1': ['slice11', 'slice12'] }, - { 'feature2': ['slice21', 'slice22'] }, - ], s, skr, false); + syncStateUpdate( + nestedState, + [{ feature1: ['slice11', 'slice12'] }, { feature2: ['slice21', 'slice22'] }], + s, + skr, + false + ); const raw1 = s.getItem('feature1'); expect(raw1).toEqual(jasmine.arrayContaining(['slice11', 'slice12'])); @@ -279,11 +282,15 @@ describe('ngrxLocalStorage', () => { // We want to validate the space parameter, but don't want to trip up on OS specific newlines, so filter the newlines out and // compare against the literal string. let raw = s.getItem('replacer'); - expect(raw.replace(/\r?\n|\r/g, '')).toEqual('{ "astring": "Testing", "adate": "1968-11-16T12:30:00.000Z", "anumber": 3.14159\}'); + expect(raw.replace(/\r?\n|\r/g, '')).toEqual( + '{ "astring": "Testing", "adate": "1968-11-16T12:30:00.000Z", "anumber": 3.14159}' + ); let finalState: any = rehydrateApplicationState(keys, s, skr, true); - expect(JSON.stringify(finalState)).toEqual('{"replacer":{"astring":"Testing","adate":"1968-11-16T12:30:00.000Z","anumber":3.14159}}'); + expect(JSON.stringify(finalState)).toEqual( + '{"replacer":{"astring":"Testing","adate":"1968-11-16T12:30:00.000Z","anumber":3.14159}}' + ); expect(t1 instanceof TypeA).toBeTruthy(); expect(finalState.replacer instanceof TypeA).toBeFalsy(); @@ -342,7 +349,7 @@ describe('ngrxLocalStorage', () => { let s = new MockStorage(); let skr = mockStorageKeySerializer; - const initalState = {state: t1Simple}; + const initalState = { state: t1Simple }; syncStateUpdate(initalState, ['state'], s, skr, false); @@ -446,12 +453,12 @@ describe('ngrxLocalStorage', () => { it('merge initial state and rehydrated state', () => { // localStorage starts out in a "bad" state. This could happen if our application state schema // changes. End users may have the old schema and a software update has the new schema. - localStorage.setItem('state', JSON.stringify({oldstring: 'foo'})); + localStorage.setItem('state', JSON.stringify({ oldstring: 'foo' })); // Set up reducers const reducer = (state = initialState, action) => state; - const metaReducer = localStorageSync({keys: ['state'], rehydrate: true}); - const action = {type: INIT_ACTION}; + const metaReducer = localStorageSync({ keys: ['state'], rehydrate: true }); + const action = { type: INIT_ACTION }; // Resultant state should merge the oldstring state and our initual state const finalState = metaReducer(reducer)(initialState, action); @@ -460,9 +467,9 @@ describe('ngrxLocalStorage', () => { it('should merge selectively saved state and rehydrated state', () => { const initialState = { - app: { app1: false, app2: [], app3: {} }, - feature1: { slice11: false, slice12: [], slice13: {} }, - feature2: { slice21: false, slice22: [], slice23: {} }, + app: { app1: false, app2: [], app3: {} }, + feature1: { slice11: false, slice12: [], slice13: {} }, + feature2: { slice21: false, slice22: [], slice23: {} }, }; // A legit case where state is saved in chunks rather than as a single object @@ -471,19 +478,19 @@ describe('ngrxLocalStorage', () => { // Set up reducers const reducer = (state = initialState, action) => state; - const metaReducer = localStorageSync({keys: [ - {'feature1': ['slice11', 'slice12']}, - {'feature2': ['slice21', 'slice22']}, - ], rehydrate: true}); + const metaReducer = localStorageSync({ + keys: [{ feature1: ['slice11', 'slice12'] }, { feature2: ['slice21', 'slice22'] }], + rehydrate: true, + }); - const action = {type: INIT_ACTION}; + const action = { type: INIT_ACTION }; // Resultant state should merge the rehydrated partial state and our initial state const finalState = metaReducer(reducer)(initialState, action); expect(finalState).toEqual({ - app: { app1: false, app2: [], app3: {} }, - feature1: { slice11: true, slice12: [1, 2], slice13: {} }, - feature2: { slice21: true, slice22: [1, 2], slice23: {} }, + app: { app1: false, app2: [], app3: {} }, + feature1: { slice11: true, slice12: [1, 2], slice13: {} }, + feature2: { slice21: true, slice22: [1, 2], slice23: {} }, }); }); @@ -492,39 +499,72 @@ describe('ngrxLocalStorage', () => { app: { app1: false, app2: [], app3: {} }, feature1: { slice11: false, slice12: [], slice13: {} }, feature2: { slice21: false, slice22: [], slice23: {} }, - }; - - // A legit case where state is saved in chunks rather than as a single object - localStorage.setItem('feature1', JSON.stringify({ slice11: true, slice12: [1, 2] })); - localStorage.setItem('feature2', JSON.stringify({ slice21: true, slice22: [1, 2] })); - - // Set up reducers - const reducer = (state = initialState, action) => state; - const mergeReducer = (state, rehydratedState, action) => { + }; + + // A legit case where state is saved in chunks rather than as a single object + localStorage.setItem('feature1', JSON.stringify({ slice11: true, slice12: [1, 2] })); + localStorage.setItem('feature2', JSON.stringify({ slice21: true, slice22: [1, 2] })); + + // Set up reducers + const reducer = (state = initialState, action) => state; + const mergeReducer = (state, rehydratedState, action) => { // Perform a merge where we only want a single property from feature1 // but a deepmerge with feature2 - return { + return { ...state, feature1: { - slice11: rehydratedState.feature1.slice11 + slice11: rehydratedState.feature1.slice11, }, - feature2: deepmerge(state.feature2, rehydratedState.feature2) - } - } - const metaReducer = localStorageSync({keys: [ - {'feature1': ['slice11', 'slice12']}, - {'feature2': ['slice21', 'slice22']}, - ], rehydrate: true, mergeReducer}); - - const action = {type: INIT_ACTION}; - - // Resultant state should merge the rehydrated partial state and our initial state - const finalState = metaReducer(reducer)(initialState, action); - expect(finalState).toEqual({ + feature2: deepmerge(state.feature2, rehydratedState.feature2), + }; + }; + const metaReducer = localStorageSync({ + keys: [{ feature1: ['slice11', 'slice12'] }, { feature2: ['slice21', 'slice22'] }], + rehydrate: true, + mergeReducer, + }); + + const action = { type: INIT_ACTION }; + + // Resultant state should merge the rehydrated partial state and our initial state + const finalState = metaReducer(reducer)(initialState, action); + expect(finalState).toEqual({ app: { app1: false, app2: [], app3: {} }, feature1: { slice11: true }, feature2: { slice21: true, slice22: [1, 2], slice23: {} }, - }); - }); + }); + }); + + it('should save targeted infinite depth to localStorage', () => { + // Configure to only save feature1.slice11.slice11_1 and feature2.slice12, + // ignore all other properties + const metaReducer = localStorageSync({ + keys: [{ feature1: [{ slice11: ['slice11_1'], slice14: ['slice14_2'] }] }, { feature2: ['slice21'] }], + }); + + // Excute action + metaReducer((state: any, _action: any) => state)( + // Initial state with lots of unrelated properties + { + feature1: { + slice11: { slice11_1: 'good_value', slice11_2: 'bad_value' }, + slice12: [], + slice13: false, + slice14: { slice14_1: true, slice14_2: 'other_good_value' }, + }, + feature2: { + slice21: 'third_good_value', + }, + }, + { type: 'SomeAction' } + ); + + // Local storage should match expect values + expect(JSON.parse(localStorage['feature1'])).toEqual({ + slice11: { slice11_1: 'good_value' }, + slice14: { slice14_2: 'other_good_value' }, + }); + expect(JSON.parse(localStorage['feature2'])).toEqual({ slice21: 'third_good_value' }); + }); }); diff --git a/src/lib/index.ts b/src/lib/index.ts index b968ee2..fa6c5ba 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -5,309 +5,320 @@ const UPDATE_ACTION = '@ngrx/store/update-reducers'; const detectDate = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/; // correctly parse dates from local storage -export const dateReviver = (key: string, value: any) => { - if (typeof value === 'string' && detectDate.test(value)) { - return new Date(value); - } - return value; +export const dateReviver = (_key: string, value: any) => { + if (typeof value === 'string' && detectDate.test(value)) { + return new Date(value); + } + return value; }; -const dummyReviver = (key: string, value: any) => value; +const dummyReviver = (_key: string, value: any) => value; const checkIsBrowserEnv = () => { - return typeof window !== 'undefined'; + return typeof window !== 'undefined'; }; -const validateStateKeys = (keys: any[]) => { - return keys.map(key => { - let attr = key; +const validateStateKeys = (keys: Keys) => { + return (keys as any[]).map((key) => { + let attr = key; - if (typeof key === 'object') { - attr = Object.keys(key)[0]; - } + if (typeof key === 'object') { + attr = Object.keys(key)[0]; + } - if (typeof attr !== 'string') { - throw new TypeError( - `localStorageSync Unknown Parameter Type: ` + - `Expected type of string, got ${typeof attr}` - ); - } - return key; - }); + if (typeof attr !== 'string') { + throw new TypeError( + `localStorageSync Unknown Parameter Type: ` + `Expected type of string, got ${typeof attr}` + ); + } + return key; + }); }; export const rehydrateApplicationState = ( - keys: any[], - storage: Storage, - storageKeySerializer: (key: string) => string, - restoreDates: boolean + keys: Keys, + storage: Storage, + storageKeySerializer: (key: string) => string, + restoreDates: boolean ) => { - return keys.reduce((acc, curr) => { - let key = curr; - let reviver = restoreDates ? dateReviver : dummyReviver; - let deserialize; - let decrypt; - - if (typeof key === 'object') { - key = Object.keys(key)[0]; - // use the custom reviver function - if (typeof curr[key] === 'function') { - reviver = curr[key]; - } else { - // use custom reviver function if available - if (curr[key].reviver) { - reviver = curr[key].reviver; - } - // use custom serialize function if available - if (curr[key].deserialize) { - deserialize = curr[key].deserialize; - } - } - - // Ensure that encrypt and decrypt functions are both present - if (curr[key].encrypt && curr[key].decrypt) { - if ( - typeof curr[key].encrypt === 'function' && - typeof curr[key].decrypt === 'function' - ) { - decrypt = curr[key].decrypt; - } else { - console.error( - `Either encrypt or decrypt is not a function on '${ - curr[key] - }' key object.` - ); - } - } else if (curr[key].encrypt || curr[key].decrypt) { - // Let know that one of the encryption functions is not provided - console.error( - `Either encrypt or decrypt function is not present on '${ - curr[key] - }' key object.` - ); - } - } - if (storage !== undefined) { - let stateSlice = storage.getItem(storageKeySerializer(key)); - if (stateSlice) { - // Use provided decrypt function - if (decrypt) { - stateSlice = decrypt(stateSlice); - } - - const isObjectRegex = new RegExp('{|\\['); - let raw = stateSlice; + return (keys as any[]).reduce((acc, curr) => { + let key = curr; + let reviver = restoreDates ? dateReviver : dummyReviver; + let deserialize: (arg0: string) => any; + let decrypt: (arg0: string) => string; + + if (typeof key === 'object') { + key = Object.keys(key)[0]; + // use the custom reviver function + if (typeof curr[key] === 'function') { + reviver = curr[key]; + } else { + // use custom reviver function if available + if (curr[key].reviver) { + reviver = curr[key].reviver; + } + // use custom serialize function if available + if (curr[key].deserialize) { + deserialize = curr[key].deserialize; + } + } - if (stateSlice === 'null' || isObjectRegex.test(stateSlice.charAt(0))) { - raw = JSON.parse(stateSlice, reviver); + // Ensure that encrypt and decrypt functions are both present + if (curr[key].encrypt && curr[key].decrypt) { + if (typeof curr[key].encrypt === 'function' && typeof curr[key].decrypt === 'function') { + decrypt = curr[key].decrypt; + } else { + console.error(`Either encrypt or decrypt is not a function on '${curr[key]}' key object.`); + } + } else if (curr[key].encrypt || curr[key].decrypt) { + // Let know that one of the encryption functions is not provided + console.error(`Either encrypt or decrypt function is not present on '${curr[key]}' key object.`); + } } - - return Object.assign({}, acc, { - [key]: deserialize ? deserialize(raw) : raw - }); - } - } - return acc; - }, {}); + if (storage !== undefined) { + let stateSlice = storage.getItem(storageKeySerializer(key)); + if (stateSlice) { + // Use provided decrypt function + if (decrypt) { + stateSlice = decrypt(stateSlice); + } + + const isObjectRegex = new RegExp('{|\\['); + let raw = stateSlice; + + if (stateSlice === 'null' || isObjectRegex.test(stateSlice.charAt(0))) { + raw = JSON.parse(stateSlice, reviver); + } + + return Object.assign({}, acc, { + [key]: deserialize ? deserialize(raw) : raw, + }); + } + } + return acc; + }, {}); }; +// Recursively traverse all properties of the existing slice as defined by the `filter` argument, +// and output the new object with extraneous properties removed. +function createStateSlice(existingSlice: any, filter: (string | number | KeyConfiguration | Options)[]) { + return filter.reduce( + (memo: { [x: string]: any; [x: number]: any }, attr: string | number | KeyConfiguration | Options) => { + if (typeof attr === 'string' || typeof attr === 'number') { + const value = existingSlice?.[attr]; + if (value !== undefined) { + memo[attr] = value; + } + } else { + for (const key in attr) { + if (Object.prototype.hasOwnProperty.call(attr, key)) { + const element = attr[key]; + memo[key] = createStateSlice(existingSlice[key], element); + } + } + } + return memo; + }, + {} + ); +} + export const syncStateUpdate = ( - state: any, - keys: any[], - storage: Storage, - storageKeySerializer: (key: string) => string, - removeOnUndefined: boolean, - syncCondition?: (state: any) => any + state: any, + keys: Keys, + storage: Storage, + storageKeySerializer: (key: string | number) => string, + removeOnUndefined: boolean, + syncCondition?: (state: any) => any ) => { - if (syncCondition) { - try { - if (syncCondition(state) !== true) { - return; - } - } catch (e) { - // Treat TypeError as do not sync - if (e instanceof TypeError) { - return; - } - throw e; - } - } - keys.forEach(key => { - let stateSlice = state[key]; - let replacer; - let space; - let encrypt; - - if (typeof key === 'object') { - let name = Object.keys(key)[0]; - stateSlice = state[name]; - - if (typeof stateSlice !== 'undefined' && key[name]) { - // use serialize function if specified. - if (key[name].serialize) { - stateSlice = key[name].serialize(stateSlice); - } else { - // if serialize function is not specified filter on fields if an array has been provided. - let filter; - if (key[name].reduce) { - filter = key[name]; - } else if (key[name].filter) { - filter = key[name].filter; - } - if (filter) { - stateSlice = filter.reduce((memo, attr) => { - memo[attr] = stateSlice[attr]; - return memo; - }, {}); - } - - // Check if encrypt and decrypt are present, also checked at this#rehydrateApplicationState() - if (key[name].encrypt && key[name].decrypt) { - if (typeof key[name].encrypt === 'function') { - encrypt = key[name].encrypt; + if (syncCondition) { + try { + if (syncCondition(state) !== true) { + return; } - } else if (key[name].encrypt || key[name].decrypt) { - // If one of those is not present, then let know that one is missing - console.error( - `Either encrypt or decrypt function is not present on '${ - key[name] - }' key object.` - ); - } + } catch (e) { + // Treat TypeError as do not sync + if (e instanceof TypeError) { + return; + } + throw e; } + } - /* + keys.forEach((key: string | KeyConfiguration | Options | ((key: string, value: any) => any)): void => { + let stateSlice = state[key as string]; + let replacer; + let space: string | number; + let encrypt; + + if (typeof key === 'object') { + let name = Object.keys(key)[0]; + stateSlice = state[name]; + + if (typeof stateSlice !== 'undefined' && key[name]) { + // use serialize function if specified. + if (key[name].serialize) { + stateSlice = key[name].serialize(stateSlice); + } else { + // if serialize function is not specified filter on fields if an array has been provided. + let filter: KeyConfiguration[]; + if (key[name].reduce) { + filter = key[name]; + } else if (key[name].filter) { + filter = key[name].filter; + } + if (filter) { + stateSlice = createStateSlice(stateSlice, filter); + } + + // Check if encrypt and decrypt are present, also checked at this#rehydrateApplicationState() + if (key[name].encrypt && key[name].decrypt) { + if (typeof key[name].encrypt === 'function') { + encrypt = key[name].encrypt; + } + } else if (key[name].encrypt || key[name].decrypt) { + // If one of those is not present, then let know that one is missing + console.error( + `Either encrypt or decrypt function is not present on '${key[name]}' key object.` + ); + } + } + + /* Replacer and space arguments to pass to JSON.stringify. If these fields don't exist, undefined will be passed. */ - replacer = key[name].replacer; - space = key[name].space; - } + replacer = key[name].replacer; + space = key[name].space; + } - key = name; - } + key = name; + } - if (typeof stateSlice !== 'undefined' && storage !== undefined) { - try { - if (encrypt) { - // ensure that a string message is passed - stateSlice = encrypt( - typeof stateSlice === 'string' - ? stateSlice - : JSON.stringify(stateSlice, replacer, space) - ); + if (typeof stateSlice !== 'undefined' && storage !== undefined) { + try { + if (encrypt) { + // ensure that a string message is passed + stateSlice = encrypt( + typeof stateSlice === 'string' ? stateSlice : JSON.stringify(stateSlice, replacer, space) + ); + } + storage.setItem( + storageKeySerializer(key as string), + typeof stateSlice === 'string' ? stateSlice : JSON.stringify(stateSlice, replacer, space) + ); + } catch (e) { + console.warn('Unable to save state to localStorage:', e); + } + } else if (typeof stateSlice === 'undefined' && removeOnUndefined) { + try { + storage.removeItem(storageKeySerializer(key as string)); + } catch (e) { + console.warn(`Exception on removing/cleaning undefined '${key}' state`, e); + } } - storage.setItem( - storageKeySerializer(key), - typeof stateSlice === 'string' - ? stateSlice - : JSON.stringify(stateSlice, replacer, space) - ); - } catch (e) { - console.warn('Unable to save state to localStorage:', e); - } - } else if (typeof stateSlice === 'undefined' && removeOnUndefined) { - try { - storage.removeItem(storageKeySerializer(key)); - } catch (e) { - console.warn( - `Exception on removing/cleaning undefined '${key}' state`, - e - ); - } - } - }); + }); }; // Default merge strategy is a full deep merge. export const defaultMergeReducer = (state: any, rehydratedState: any, action: any) => { + if ((action.type === INIT_ACTION || action.type === UPDATE_ACTION) && rehydratedState) { + const overwriteMerge = (destinationArray: any, sourceArray: any, options: any) => sourceArray; + const options: deepmerge.Options = { + arrayMerge: overwriteMerge, + }; - if ((action.type === INIT_ACTION || action.type === UPDATE_ACTION) && rehydratedState) { - const overwriteMerge = (destinationArray, sourceArray, options) => sourceArray; - const options: deepmerge.Options = { - arrayMerge: overwriteMerge - }; - - state = deepmerge(state, rehydratedState, options); - } + state = deepmerge(state, rehydratedState, options); + } - return state; + return state; }; -export const localStorageSync = (config: LocalStorageConfig) => ( - reducer: any -) => { - if (config.storage === undefined && - !config.checkStorageAvailability || (config.checkStorageAvailability && checkIsBrowserEnv()) - ) { - config.storage = localStorage || window.localStorage; - } - - if (config.storageKeySerializer === undefined) { - config.storageKeySerializer = key => key; - } - - if (config.restoreDates === undefined) { - config.restoreDates = true; - } - - // Use default merge reducer. - let mergeReducer = config.mergeReducer; - - if (mergeReducer === undefined || typeof (mergeReducer) !== 'function') { - mergeReducer = defaultMergeReducer; - } - - const stateKeys = validateStateKeys(config.keys); - const rehydratedState = config.rehydrate - ? rehydrateApplicationState( - stateKeys, - config.storage, - config.storageKeySerializer, - config.restoreDates - ) - : undefined; - - return function (state, action: any) { - let nextState; - - // If state arrives undefined, we need to let it through the supplied reducer - // in order to get a complete state as defined by user - if ((action.type === INIT_ACTION) && !state) { - nextState = reducer(state, action); - } else { - nextState = { ...state }; +export const localStorageSync = (config: LocalStorageConfig) => (reducer: any) => { + if ( + (config.storage === undefined && !config.checkStorageAvailability) || + (config.checkStorageAvailability && checkIsBrowserEnv()) + ) { + config.storage = localStorage || window.localStorage; } - // Merge the store state with the rehydrated state using - // either a user-defined reducer or the default. - nextState = mergeReducer(nextState, rehydratedState, action); - - nextState = reducer(nextState, action); - - if (action.type !== INIT_ACTION) { - syncStateUpdate( - nextState, - stateKeys, - config.storage, - config.storageKeySerializer, - config.removeOnUndefined, - config.syncCondition, - ); + if (config.storageKeySerializer === undefined) { + config.storageKeySerializer = (key) => key; } - return nextState; - }; + if (config.restoreDates === undefined) { + config.restoreDates = true; + } + + // Use default merge reducer. + let mergeReducer = config.mergeReducer; + + if (mergeReducer === undefined || typeof mergeReducer !== 'function') { + mergeReducer = defaultMergeReducer; + } + + const stateKeys = validateStateKeys(config.keys); + const rehydratedState = config.rehydrate + ? rehydrateApplicationState(stateKeys, config.storage, config.storageKeySerializer, config.restoreDates) + : undefined; + + return function (state: any, action: any) { + let nextState: any; + + // If state arrives undefined, we need to let it through the supplied reducer + // in order to get a complete state as defined by user + if (action.type === INIT_ACTION && !state) { + nextState = reducer(state, action); + } else { + nextState = { ...state }; + } + + // Merge the store state with the rehydrated state using + // either a user-defined reducer or the default. + nextState = mergeReducer(nextState, rehydratedState, action); + + nextState = reducer(nextState, action); + + if (action.type !== INIT_ACTION) { + syncStateUpdate( + nextState, + stateKeys, + config.storage, + config.storageKeySerializer, + config.removeOnUndefined, + config.syncCondition + ); + } + + return nextState; + }; }; export interface LocalStorageConfig { - keys: any[]; - rehydrate?: boolean; - storage?: Storage; - removeOnUndefined?: boolean; - restoreDates?: boolean; - storageKeySerializer?: (key: string) => string; - syncCondition?: (state: any) => any; - checkStorageAvailability?: boolean; - mergeReducer?: (state: any, rehydratedState: any, action: any) => any; + keys: Keys; + rehydrate?: boolean; + storage?: Storage; + removeOnUndefined?: boolean; + restoreDates?: boolean; + storageKeySerializer?: (key: string) => string; + syncCondition?: (state: any) => any; + checkStorageAvailability?: boolean; + mergeReducer?: (state: any, rehydratedState: any, action: any) => any; +} + +interface KeyConfiguration { + [key: string]: string[] | number[] | KeyConfiguration[] | Options | ((key: string, value: any) => any); } + +interface Options { + serialize?: (state: any) => any; + deserialize?: (state: any) => any; + reviver?: (key: string, value: any) => any; + replacer?: ((key: string, value: any) => any) | string[]; + encrypt?: (message: string) => string; + decrypt?: (message: string) => string; + filter?: string[]; + space?: string | number; +} + +export type Keys = string[] | (KeyConfiguration | Options)[];