Skip to content

Commit

Permalink
Data: Fix persistence initial state merging behavior (#13951)
Browse files Browse the repository at this point in the history
* Data: Fix inaccurate persistence plugin documentation

* Data: Leave unpersisted keys intact in initial persisted state

* Data: Add initialState option for namespace stores

* Data: Use initialState option for persistence restore

* Data: Deeply merge into persistence default value

* Data: Remove outdated code comment for effecting initialState

* Data: Persistence: Defer to default initial state in object-like mismatch

* Data: Persistence: Revert to persisted value as preferred

Merge if possible when both it and the default initial state are objects
  • Loading branch information
aduth authored and youknowriad committed Mar 6, 2019
1 parent afe1611 commit 0cbe3ca
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 49 deletions.
11 changes: 11 additions & 0 deletions packages/data/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
## 4.3.0 (Unreleased)

### Enhancements

- The `registerStore` function now accepts an optional `initialState` option value.

### Bug Fix

- Resolves issue in the persistence plugin where passing `persist` as an array of reducer keys would wrongly replace state values for the unpersisted reducer keys.
- Restores a behavior in the persistence plugin where a default state provided as an object will be deeply merged as a base for the persisted value. This allows for a developer to include additional new keys in a persisted value default in future iterations of their store.

## 4.2.0 (2019-01-03)

### Enhancements
Expand Down
4 changes: 4 additions & 0 deletions packages/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ The `controls` option should be passed as an object where each key is the name o

Refer to the [documentation of `@wordpress/redux-routine`](/packages/redux-routine/README.md) for more information.

### `initialState`

An optional preloaded initial state for the store. You may use this to restore some serialized state value or a state generated server-side.

## Data Access and Manipulation

It is very rare that you should access store methods directly. Instead, the following suite of functions and higher-order components is provided for the most common data access and manipulation needs.
Expand Down
18 changes: 10 additions & 8 deletions packages/data/src/namespace-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import createResolversCacheMiddleware from './resolvers-cache-middleware';
*/
export default function createNamespace( key, options, registry ) {
const reducer = options.reducer;
const store = createReduxStore( reducer, key, registry );
const store = createReduxStore( key, options, registry );

let selectors, actions, resolvers;
if ( options.actions ) {
Expand Down Expand Up @@ -76,21 +76,23 @@ export default function createNamespace( key, options, registry ) {
/**
* Creates a redux store for a namespace.
*
* @param {Function} reducer Root reducer for redux store.
* @param {string} key Part of the state shape to register the
* selectors for.
* @param {Object} registry Registry reference, for resolver enhancer support.
* @return {Object} Newly created redux store.
* @param {string} key Part of the state shape to register the
* selectors for.
* @param {Object} options Registered store options.
* @param {Object} registry Registry reference, for resolver enhancer support.
*
* @return {Object} Newly created redux store.
*/
function createReduxStore( reducer, key, registry ) {
function createReduxStore( key, options, registry ) {
const enhancers = [
applyMiddleware( createResolversCacheMiddleware( registry, key ), promise ),
];
if ( typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__ ) {
enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__( { name: key, instanceId: key } ) );
}

return createStore( reducer, flowRight( enhancers ) );
const { reducer, initialState } = options;
return createStore( reducer, initialState, flowRight( enhancers ) );
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/data/src/plugins/persistence/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Persistence Plugin

The persistence plugin enhances a registry to enable registered stores to opt in to persistent storage.

By default, persistence occurs by [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). This can be changed using the [`setStorage` function](#api) defined on the plugin. Unless set otherwise, state will be persisted on the `WP_DATA` key in storage.
By default, persistence occurs by [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). In environments where `localStorage` is not available, it will gracefully fall back to an in-memory object storage which will not persist between sessions. You can provide your own storage implementation by providing the [`storage` option](#options). Unless set otherwise, state will be persisted on the `WP_DATA` key in storage.

## Usage

Expand Down
42 changes: 22 additions & 20 deletions packages/data/src/plugins/persistence/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { flow } from 'lodash';
import { flow, merge, isPlainObject } from 'lodash';

/**
* Internal dependencies
Expand Down Expand Up @@ -34,20 +34,6 @@ const DEFAULT_STORAGE = defaultStorage;
*/
const DEFAULT_STORAGE_KEY = 'WP_DATA';

/**
* Higher-order reducer to provides an initial value when state is undefined.
*
* @param {Function} reducer Original reducer.
* @param {*} initialState Value to use as initial state.
*
* @return {Function} Enhanced reducer.
*/
export function withInitialState( reducer, initialState ) {
return ( state = initialState, action ) => {
return reducer( state, action );
};
}

/**
* Higher-order reducer which invokes the original reducer only if state is
* inequal from that of the action's `nextState` property, otherwise returning
Expand Down Expand Up @@ -178,12 +164,28 @@ export default function( registry, pluginOptions ) {
return registry.registerStore( reducerKey, options );
}

const initialState = persistence.get()[ reducerKey ];
// Load from persistence to use as initial state.
const persistedState = persistence.get()[ reducerKey ];
if ( persistedState !== undefined ) {
let initialState = options.reducer( undefined, {
type: '@@WP/PERSISTENCE_RESTORE',
} );

if ( isPlainObject( initialState ) && isPlainObject( persistedState ) ) {
// If state is an object, ensure that:
// - Other keys are left intact when persisting only a
// subset of keys.
// - New keys in what would otherwise be used as initial
// state are deeply merged as base for persisted value.
initialState = merge( {}, initialState, persistedState );
} else {
// If there is a mismatch in object-likeness of default
// initial or persisted state, defer to persisted value.
initialState = persistedState;
}

options = {
...options,
reducer: withInitialState( options.reducer, initialState ),
};
options = { ...options, initialState };
}

const store = registry.registerStore( reducerKey, options );

Expand Down
155 changes: 136 additions & 19 deletions packages/data/src/plugins/persistence/test/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';

/**
* Internal dependencies
*/
import plugin, {
createPersistenceInterface,
withInitialState,
withLazySameState,
} from '../';
import objectStorage from '../storage/object';
Expand Down Expand Up @@ -37,6 +41,137 @@ describe( 'persistence', () => {
registry.registerStore( 'test', options );
} );

it( 'should load a persisted value as initialState', () => {
registry = createRegistry().use( plugin, {
storage: {
getItem: () => JSON.stringify( { test: { a: 1 } } ),
setItem() {},
},
} );

registry.registerStore( 'test', {
persist: true,
reducer: ( state = {} ) => state,
selectors: {
getState: ( state ) => state,
},
} );

expect( registry.select( 'test' ).getState() ).toEqual( { a: 1 } );
} );

it( 'should load a persisted subset value as initialState', () => {
const DEFAULT_STATE = { a: null, b: null };

registry = createRegistry().use( plugin, {
storage: {
getItem: () => JSON.stringify( { test: { a: 1 } } ),
setItem() {},
},
} );

registry.registerStore( 'test', {
persist: [ 'a' ],
reducer: ( state = DEFAULT_STATE ) => state,
selectors: {
getState: ( state ) => state,
},
} );

expect( registry.select( 'test' ).getState() ).toEqual( { a: 1, b: null } );
} );

it( 'should merge persisted value with default if object-like', () => {
const DEFAULT_STATE = deepFreeze( { preferences: { useFoo: true, useBar: true } } );

registry = createRegistry().use( plugin, {
storage: {
getItem: () => JSON.stringify( {
test: {
preferences: {
useFoo: false,
},
},
} ),
setItem() {},
},
} );

registry.registerStore( 'test', {
persist: [ 'preferences' ],
reducer: ( state = DEFAULT_STATE ) => state,
selectors: {
getState: ( state ) => state,
},
} );

expect( registry.select( 'test' ).getState() ).toEqual( {
preferences: {
useFoo: false,
useBar: true,
},
} );
} );

it( 'should defer to persisted state if mismatch of object-like (persisted object-like)', () => {
registry = createRegistry().use( plugin, {
storage: {
getItem: () => JSON.stringify( { test: { persisted: true } } ),
setItem() {},
},
} );

registry.registerStore( 'test', {
persist: true,
reducer: ( state = null ) => state,
selectors: {
getState: ( state ) => state,
},
} );

expect( registry.select( 'test' ).getState() ).toEqual( { persisted: true } );
} );

it( 'should defer to persisted state if mismatch of object-like (initial object-like)', () => {
registry = createRegistry().use( plugin, {
storage: {
getItem: () => JSON.stringify( { test: null } ),
setItem() {},
},
} );

registry.registerStore( 'test', {
persist: true,
reducer: ( state = {} ) => state,
selectors: {
getState: ( state ) => state,
},
} );

expect( registry.select( 'test' ).getState() ).toBe( null );
} );

it( 'should be reasonably tolerant to a non-object persisted state', () => {
registry = createRegistry().use( plugin, {
storage: {
getItem: () => JSON.stringify( {
test: 1,
} ),
setItem() {},
},
} );

registry.registerStore( 'test', {
persist: true,
reducer: ( state = null ) => state,
selectors: {
getState: ( state ) => state,
},
} );

expect( registry.select( 'test' ).getState() ).toBe( 1 );
} );

it( 'override values passed to registerStore', () => {
const options = { persist: true, reducer() {} };

Expand All @@ -46,8 +181,6 @@ describe( 'persistence', () => {
persist: true,
reducer: expect.any( Function ),
} );
// Replaced reducer:
expect( originalRegisterStore.mock.calls[ 0 ][ 1 ].reducer ).not.toBe( options.reducer );
} );

it( 'should not persist if option not passed', () => {
Expand Down Expand Up @@ -208,22 +341,6 @@ describe( 'persistence', () => {
} );
} );

describe( 'withInitialState', () => {
it( 'should return a reducer function', () => {
const reducer = ( state = 1 ) => state;
const enhanced = withInitialState( reducer );

expect( enhanced() ).toBe( 1 );
} );

it( 'should assign a default state by argument', () => {
const reducer = ( state = 1 ) => state;
const enhanced = withInitialState( reducer, 2 );

expect( enhanced() ).toBe( 2 );
} );
} );

describe( 'withLazySameState', () => {
it( 'should call the original reducer if action.nextState differs from state', () => {
const reducer = jest.fn().mockImplementation( ( state, action ) => action.nextState );
Expand Down
3 changes: 2 additions & 1 deletion packages/data/src/test/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ describe( 'createRegistry', () => {
describe( 'registerStore', () => {
it( 'should be shorthand for reducer, actions, selectors registration', () => {
const store = registry.registerStore( 'butcher', {
reducer( state = { ribs: 6, chicken: 4 }, action ) {
reducer( state = {}, action ) {
switch ( action.type ) {
case 'sale':
return {
Expand All @@ -162,6 +162,7 @@ describe( 'createRegistry', () => {

return state;
},
initialState: { ribs: 6, chicken: 4 },
selectors: {
getPrice: ( state, meat ) => state[ meat ],
},
Expand Down

0 comments on commit 0cbe3ca

Please sign in to comment.