From c1c4c7ed80f0f29f60adb44f2fd0cb2f8660045d Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 3 Aug 2018 14:41:00 -0400 Subject: [PATCH] Data: Add persistence via data plugin interface (#8341) * Data: Add persistence via data plugin interface * Data: Integrate persistence as first-party plugin * Data: Restore deprecated persistence behaviors * Data: Return default persisted by caught error * Data: Initialize persistence by plugin options * Data: Remove string form variation on use * Data: Refactor persistence as dispatch enhancer * Data: Remove middlewares / enhancers support for store * Data: Remove unused variable * Data: Improve objectStorage spec compliancy Should (a) return null if not set, (b) assign value as string * Data: Fix persistence handling of null value * Data: Fix deprecation key to persist 1. Wrong property accessed used 2. Reducer is already wrapped at point registerStore is called. Detect from plugin instead. * Data: Add tests for WPDataRegistry#use * Data: Remove _ prefix from local variable Not conventional * Data: Update plugins documentation to avoid mention of string usage --- edit-post/index.js | 9 +- edit-post/store/index.js | 10 +- lib/client-assets.php | 16 ++ packages/data/src/deprecated.js | 55 ++++- packages/data/src/index.js | 12 +- packages/data/src/persist.js | 83 -------- packages/data/src/plugins/README.md | 17 ++ packages/data/src/plugins/index.js | 1 + .../data/src/plugins/persistence/README.md | 36 ++++ .../data/src/plugins/persistence/index.js | 181 ++++++++++++++++ .../plugins/persistence/storage/default.js | 19 ++ .../src/plugins/persistence/storage/object.js | 23 ++ .../persistence/storage/test/object.js | 41 ++++ .../src/plugins/persistence/test/index.js | 196 ++++++++++++++++++ packages/data/src/registry.js | 157 ++++++++------ packages/data/src/test/persist.js | 151 -------------- packages/data/src/test/registry.js | 37 +++- packages/editor/src/store/index.js | 9 +- packages/nux/src/store/index.js | 8 +- 19 files changed, 731 insertions(+), 330 deletions(-) delete mode 100644 packages/data/src/persist.js create mode 100644 packages/data/src/plugins/README.md create mode 100644 packages/data/src/plugins/index.js create mode 100644 packages/data/src/plugins/persistence/README.md create mode 100644 packages/data/src/plugins/persistence/index.js create mode 100644 packages/data/src/plugins/persistence/storage/default.js create mode 100644 packages/data/src/plugins/persistence/storage/object.js create mode 100644 packages/data/src/plugins/persistence/storage/test/object.js create mode 100644 packages/data/src/plugins/persistence/test/index.js delete mode 100644 packages/data/src/test/persist.js diff --git a/edit-post/index.js b/edit-post/index.js index be2b7131b4f7cc..88c7580e16643d 100644 --- a/edit-post/index.js +++ b/edit-post/index.js @@ -3,7 +3,7 @@ */ import { registerCoreBlocks } from '@wordpress/core-blocks'; import { render, unmountComponentAtNode } from '@wordpress/element'; -import { dispatch, setupPersistence } from '@wordpress/data'; +import { dispatch } from '@wordpress/data'; import deprecated from '@wordpress/deprecated'; /** @@ -15,11 +15,6 @@ import store from './store'; import { initializeMetaBoxState } from './store/actions'; import Editor from './editor'; -/** - * Module Constants - */ -const STORAGE_KEY = `WP_EDIT_POST_DATA_${ window.userSettings.uid }`; - /** * Reinitializes the editor after the user chooses to reboot the editor after * an unhandled error occurs, replacing previously mounted editor element using @@ -93,5 +88,3 @@ export { default as PluginPostStatusInfo } from './components/sidebar/plugin-pos export { default as PluginPrePublishPanel } from './components/sidebar/plugin-pre-publish-panel'; export { default as PluginSidebar } from './components/sidebar/plugin-sidebar'; export { default as PluginSidebarMoreMenuItem } from './components/header/plugin-sidebar-more-menu-item'; - -setupPersistence( STORAGE_KEY ); diff --git a/edit-post/store/index.js b/edit-post/store/index.js index a7160cdf806c03..7158916711a775 100644 --- a/edit-post/store/index.js +++ b/edit-post/store/index.js @@ -1,10 +1,7 @@ /** * WordPress Dependencies */ -import { - registerStore, - restrictPersistence, -} from '@wordpress/data'; +import { registerStore } from '@wordpress/data'; /** * Internal dependencies @@ -13,11 +10,12 @@ import reducer from './reducer'; import applyMiddlewares from './middlewares'; import * as actions from './actions'; import * as selectors from './selectors'; + const store = registerStore( 'core/edit-post', { - reducer: restrictPersistence( reducer, 'preferences' ), + reducer, actions, selectors, - persist: true, + persist: [ 'preferences' ], } ); applyMiddlewares( store ); diff --git a/lib/client-assets.php b/lib/client-assets.php index f0db4d64af876c..38a4c77cf1784b 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -210,6 +210,22 @@ function gutenberg_register_scripts_and_styles() { filemtime( gutenberg_dir_path() . 'build/data/index.js' ), true ); + wp_add_inline_script( + 'wp-data', + implode( "\n", array( + // TODO: Transferring old storage should be removed at v3.7. + '( function() {', + ' var userId = window.userSettings.uid;', + ' var oldStorageKey = "WP_EDIT_POST_DATA_" + userId;', + ' var storageKey = "WP_DATA_USER_" + userId;', + ' if ( localStorage[ oldStorageKey ] ) {', + ' localStorage[ storageKey ] = localStorage[ oldStorageKey ];', + ' delete localStorage[ oldStorageKey ];', + ' }', + ' wp.data.use( wp.data.plugins.persistence, { storageKey: storageKey } );', + '} )()', + ) ) + ); wp_register_script( 'wp-core-data', gutenberg_url( 'build/core-data/index.js' ), diff --git a/packages/data/src/deprecated.js b/packages/data/src/deprecated.js index 5bfdb1f0d58d26..4aae1d74968255 100644 --- a/packages/data/src/deprecated.js +++ b/packages/data/src/deprecated.js @@ -11,7 +11,7 @@ import deprecated from '@wordpress/deprecated'; /** * Internal dependencies */ -import { getPersistenceStorage } from './persist'; +import * as persistence from './plugins/persistence'; /** * Adds the rehydration behavior to redux reducers. @@ -63,12 +63,14 @@ export function loadAndPersist( store, reducer, reducerKey, storageKey ) { hint: 'See https://github.com/WordPress/gutenberg/pull/8146 for more details', } ); + const persist = persistence.createPersistenceInterface( { storageKey } ); + // Load initially persisted value - const persistedString = getPersistenceStorage().getItem( storageKey ); - if ( persistedString ) { + const persisted = persist.get(); + if ( persisted ) { const persistedState = { ...get( reducer( undefined, { type: '@@gutenberg/init' } ), reducerKey ), - ...JSON.parse( persistedString ), + ...JSON.parse( persisted ), }; store.dispatch( { @@ -85,7 +87,50 @@ export function loadAndPersist( store, reducer, reducerKey, storageKey ) { if ( newStateValue !== currentStateValue ) { currentStateValue = newStateValue; const stateToSave = get( reducer( store.getState(), { type: 'SERIALIZE' } ), reducerKey ); - getPersistenceStorage().setItem( storageKey, JSON.stringify( stateToSave ) ); + persist.set( stateToSave ); } } ); } + +/** + * Higher-order reducer used to persist just one key from the reducer state. + * + * @param {function} reducer Reducer function. + * @param {string} keyToPersist The reducer key to persist. + * + * @return {function} Updated reducer. + */ +export function restrictPersistence( reducer, keyToPersist ) { + deprecated( 'wp.data.restrictPersistence', { + alternative: 'registerStore persist option with persistence plugin', + version: '3.7', + plugin: 'Gutenberg', + hint: 'See https://github.com/WordPress/gutenberg/pull/8341 for more details', + } ); + + reducer.__deprecatedKeyToPersist = keyToPersist; + + return reducer; +} + +/** + * Sets a different persistence storage. + * + * @param {Object} storage Persistence storage. + */ +export function setPersistenceStorage( storage ) { + deprecated( 'wp.data.setPersistenceStorage', { + alternative: 'persistence plugin with storage option', + version: '3.7', + plugin: 'Gutenberg', + hint: 'See https://github.com/WordPress/gutenberg/pull/8341 for more details', + } ); + + const originalCreatePersistenceInterface = persistence.createPersistenceInterface; + persistence.createPersistenceInterface = ( options ) => { + originalCreatePersistenceInterface( { + storage, + ...options, + } ); + }; +} diff --git a/packages/data/src/index.js b/packages/data/src/index.js index 3cbd9aa941052a..308298c5064096 100644 --- a/packages/data/src/index.js +++ b/packages/data/src/index.js @@ -7,12 +7,19 @@ import { combineReducers } from 'redux'; * Internal dependencies */ import defaultRegistry from './default-registry'; -export { restrictPersistence, setPersistenceStorage } from './persist'; +import * as plugins from './plugins'; + export { default as withSelect } from './components/with-select'; export { default as withDispatch } from './components/with-dispatch'; export { default as RegistryProvider } from './components/registry-provider'; export { createRegistry } from './registry'; -export { withRehydration, loadAndPersist } from './deprecated'; +export { + withRehydration, + loadAndPersist, + restrictPersistence, + setPersistenceStorage, +} from './deprecated'; +export { plugins }; /** * The combineReducers helper function turns an object whose values are different @@ -35,3 +42,4 @@ export const registerActions = defaultRegistry.registerActions; export const registerSelectors = defaultRegistry.registerSelectors; export const registerResolvers = defaultRegistry.registerResolvers; export const setupPersistence = defaultRegistry.setupPersistence; +export const use = defaultRegistry.use; diff --git a/packages/data/src/persist.js b/packages/data/src/persist.js deleted file mode 100644 index 9ec3b029b4c537..00000000000000 --- a/packages/data/src/persist.js +++ /dev/null @@ -1,83 +0,0 @@ -// Defaults to the local storage. -let persistenceStorage; - -/** - * Sets a different persistence storage. - * - * @param {Object} storage Persistence storage. - */ -export function setPersistenceStorage( storage ) { - persistenceStorage = storage; -} - -/** - * Get the persistence storage handler. - * - * @return {Object} Persistence storage. - */ -export function getPersistenceStorage() { - return persistenceStorage || window.localStorage; -} - -/** - * Adds the rehydration behavior to redux reducers. - * - * @param {Function} reducer The reducer to enhance. - * @param {string} storageKey The storage key to use. - * - * @return {Function} Enhanced reducer. - */ -export function withRehydration( reducer ) { - // EnhancedReducer with auto-rehydration - const enhancedReducer = ( state, action ) => { - if ( action.type === 'REDUX_REHYDRATE' ) { - return reducer( action.payload, { - ...action, - previousState: state, - } ); - } - - return reducer( state, action ); - }; - - return enhancedReducer; -} - -/** - * Higher-order reducer used to persist just one key from the reducer state. - * - * @param {function} reducer Reducer function. - * @param {string} keyToPersist The reducer key to persist. - * - * @return {function} Updated reducer. - */ -export function restrictPersistence( reducer, keyToPersist ) { - return ( state, action ) => { - const nextState = reducer( state, action ); - - if ( action.type === 'SERIALIZE' ) { - // Returning the same instance if the state is kept identical avoids reserializing again - if ( - action.previousState && - action.previousState[ keyToPersist ] === nextState[ keyToPersist ] - ) { - return action.previousState; - } - - return { [ keyToPersist ]: nextState[ keyToPersist ] }; - } - - if ( action.type === 'REDUX_REHYDRATE' ) { - return { - ...action.previousState, - ...state, - [ keyToPersist ]: { - ...action.previousState[ keyToPersist ], - ...state[ keyToPersist ], - }, - }; - } - - return nextState; - }; -} diff --git a/packages/data/src/plugins/README.md b/packages/data/src/plugins/README.md new file mode 100644 index 00000000000000..42e48d1813777c --- /dev/null +++ b/packages/data/src/plugins/README.md @@ -0,0 +1,17 @@ +Data Plugins +============ + +Included here are a set of default plugin integrations for the WordPress data module. + +## Usage + +For any of the plugins included here as directories, call the `use` method to include its behaviors in the registry. + +```js +// npm Usage +import { plugins, use } from '@wordpress/data'; +use( plugins.persistence ); + +// WordPress Globals Usage +wp.data.use( wp.data.plugins.persistence ); +``` diff --git a/packages/data/src/plugins/index.js b/packages/data/src/plugins/index.js new file mode 100644 index 00000000000000..30050ad77fa62e --- /dev/null +++ b/packages/data/src/plugins/index.js @@ -0,0 +1 @@ +export { default as persistence } from './persistence'; diff --git a/packages/data/src/plugins/persistence/README.md b/packages/data/src/plugins/persistence/README.md new file mode 100644 index 00000000000000..ed21472ee595de --- /dev/null +++ b/packages/data/src/plugins/persistence/README.md @@ -0,0 +1,36 @@ +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. + +## Usage + +Call the `use` method on the default or your own registry to include the persistence plugin: + +```js +wp.data.use( wp.data.plugins.persistence, { storageKey: 'example' } ); +``` + +Then, when registering a store, set a `persist` property as `true` (persist all state) or an array of state keys to persist. + +```js +wp.data.registerStore( 'my-plugin', { + // ... + + persist: [ 'preferences' ], +} ); +``` + +## Options + +### `storage` + +Persistent storage implementation. This must at least implement `getItem` and `setItem` of the Web Storage API. + +See: https://developer.mozilla.org/en-US/docs/Web/API/Storage + +### `storageKey` + +The key on which to set in persistent storage. diff --git a/packages/data/src/plugins/persistence/index.js b/packages/data/src/plugins/persistence/index.js new file mode 100644 index 00000000000000..f31484c5b5098b --- /dev/null +++ b/packages/data/src/plugins/persistence/index.js @@ -0,0 +1,181 @@ +/** + * External dependencies + */ +import { pick, flow } from 'lodash'; + +/** + * Internal dependencies + */ +import defaultStorage from './storage/default'; + +/** + * Persistence plugin options. + * + * @property {Storage} storage Persistent storage implementation. This must + * at least implement `getItem` and `setItem` of + * the Web Storage API. + * @property {string} storageKey Key on which to set in persistent storage. + * + * @typedef {WPDataPersistencePluginOptions} + */ + +/** + * Default plugin storage. + * + * @type {Storage} + */ +const DEFAULT_STORAGE = defaultStorage; + +/** + * Default plugin storage key. + * + * @type {string} + */ +const DEFAULT_STORAGE_KEY = 'WP_DATA'; + +/** + * Higher-order reducer to provides an initial value when state is undefined. + * + * @param {Functigon} 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 ); + }; +} + +/** + * Creates a persistence interface, exposing getter and setter methods (`get` + * and `set` respectively). + * + * @param {WPDataPersistencePluginOptions} options Plugin options. + * + * @return {Object} Persistence interface. + */ +export function createPersistenceInterface( options ) { + const { + storage = DEFAULT_STORAGE, + storageKey = DEFAULT_STORAGE_KEY, + } = options; + + let data; + + /** + * Returns the persisted data as an object, defaulting to an empty object. + * + * @return {Object} Persisted data. + */ + function get() { + if ( data === undefined ) { + // If unset, getItem is expected to return null. Fall back to + // empty object. + const persisted = storage.getItem( storageKey ); + if ( persisted === null ) { + data = {}; + } else { + try { + data = JSON.parse( persisted ); + } catch ( error ) { + // Similarly, should any error be thrown during parse of + // the string (malformed JSON), fall back to empty object. + data = {}; + } + } + } + + return data; + } + + /** + * Merges an updated reducer state into the persisted data. + * + * @param {string} key Key to update. + * @param {*} value Updated value. + */ + function set( key, value ) { + data = { ...data, [ key ]: value }; + storage.setItem( storageKey, JSON.stringify( data ) ); + } + + return { get, set }; +} + +/** + * Data plugin to persist store state into a single storage key. + * + * @param {WPDataRegistry} registry Data registry. + * @param {?WPDataPersistencePluginOptions} pluginOptions Plugin options. + * + * @return {WPDataPlugin} Data plugin. + */ +export default function( registry, pluginOptions ) { + const persistence = createPersistenceInterface( pluginOptions ); + + /** + * Creates an enhanced store dispatch function, triggering the state of the + * given reducer key to be persisted when changed. + * + * @param {Function} getState Function which returns current state. + * @param {string} reducerKey Reducer key. + * @param {?Array} keys Optional subset of keys to save. + * + * @return {Function} Enhanced dispatch function. + */ + function createPersistOnChange( getState, reducerKey, keys ) { + let lastState = getState(); + + return ( result ) => { + let state = getState(); + if ( state !== lastState ) { + if ( Array.isArray( keys ) ) { + state = pick( state, keys ); + } + + persistence.set( reducerKey, state ); + lastState = state; + } + + return result; + }; + } + + return { + registerStore( reducerKey, options ) { + // REMOVEME: Deprecation: v3.7 + if ( options.reducer.__deprecatedKeyToPersist ) { + options = { + ...options, + persist: [ options.reducer.__deprecatedKeyToPersist ], + }; + delete options.reducer.__deprecatedKeyToPersist; + } + + if ( ! options.persist ) { + return registry.registerStore( reducerKey, options ); + } + + const initialState = persistence.get()[ reducerKey ]; + + options = { + ...options, + reducer: withInitialState( options.reducer, initialState ), + }; + + const store = registry.registerStore( reducerKey, options ); + + store.dispatch = flow( [ + store.dispatch, + createPersistOnChange( + store.getState, + reducerKey, + options.persist + ), + ] ); + + return store; + }, + }; +} diff --git a/packages/data/src/plugins/persistence/storage/default.js b/packages/data/src/plugins/persistence/storage/default.js new file mode 100644 index 00000000000000..ba5f8052ee8935 --- /dev/null +++ b/packages/data/src/plugins/persistence/storage/default.js @@ -0,0 +1,19 @@ +/** + * Internal dependencies + */ +import objectStorage from './object'; + +let storage; + +try { + // Private Browsing in Safari 10 and earlier will throw an error when + // attempting to set into localStorage. The test here is intentional in + // causing a thrown error as condition for using fallback object storage. + storage = window.localStorage; + storage.setItem( '__wpDataTestLocalStorage', '' ); + storage.removeItem( '__wpDataTestLocalStorage' ); +} catch ( error ) { + storage = objectStorage; +} + +export default storage; diff --git a/packages/data/src/plugins/persistence/storage/object.js b/packages/data/src/plugins/persistence/storage/object.js new file mode 100644 index 00000000000000..ae9b120b2f4b2d --- /dev/null +++ b/packages/data/src/plugins/persistence/storage/object.js @@ -0,0 +1,23 @@ +let objectStorage; + +const storage = { + getItem( key ) { + if ( ! objectStorage || ! objectStorage[ key ] ) { + return null; + } + + return objectStorage[ key ]; + }, + setItem( key, value ) { + if ( ! objectStorage ) { + storage.clear(); + } + + objectStorage[ key ] = String( value ); + }, + clear() { + objectStorage = Object.create( null ); + }, +}; + +export default storage; diff --git a/packages/data/src/plugins/persistence/storage/test/object.js b/packages/data/src/plugins/persistence/storage/test/object.js new file mode 100644 index 00000000000000..ac44dbfbc7fb89 --- /dev/null +++ b/packages/data/src/plugins/persistence/storage/test/object.js @@ -0,0 +1,41 @@ +/** + * Internal dependencies + */ +import objectStorage from '../object'; + +describe( 'objectStorage', () => { + beforeEach( () => { + objectStorage.clear(); + } ); + + describe( 'getItem', () => { + it( 'should return null if there is no item with key', () => { + const result = objectStorage.getItem( 'foo' ); + + expect( result ).toBe( null ); + } ); + + it( 'should return the value of assigned item', () => { + objectStorage.setItem( 'foo', 'bar' ); + const result = objectStorage.getItem( 'foo' ); + + expect( result ).toBe( 'bar' ); + } ); + } ); + + describe( 'setItem', () => { + it( 'should set item by key for future retrieval', () => { + objectStorage.setItem( 'foo', 'bar' ); + const result = objectStorage.getItem( 'foo' ); + + expect( result ).toBe( 'bar' ); + } ); + + it( 'should assign as string', () => { + objectStorage.setItem( 'foo', null ); + const result = objectStorage.getItem( 'foo' ); + + expect( result ).toBe( 'null' ); + } ); + } ); +} ); diff --git a/packages/data/src/plugins/persistence/test/index.js b/packages/data/src/plugins/persistence/test/index.js new file mode 100644 index 00000000000000..c614e624d2191b --- /dev/null +++ b/packages/data/src/plugins/persistence/test/index.js @@ -0,0 +1,196 @@ +/** + * Internal dependencies + */ +import plugin, { + createPersistenceInterface, + withInitialState, +} from '../'; +import objectStorage from '../storage/object'; +import { createRegistry } from '../../../'; + +describe( 'persistence', () => { + let registry, originalRegisterStore; + + beforeAll( () => { + jest.spyOn( objectStorage, 'setItem' ); + } ); + + beforeEach( () => { + objectStorage.clear(); + objectStorage.setItem.mockClear(); + + // Since the exposed `registerStore` is a proxying function, mimic + // intercept of original call by adding an initial plugin. + registry = createRegistry() + .use( ( originalRegistry ) => { + originalRegisterStore = jest.spyOn( originalRegistry, 'registerStore' ); + return {}; + } ) + .use( plugin, { storage: objectStorage } ); + } ); + + it( 'should not mutate options', () => { + const options = Object.freeze( { persist: true, reducer() {} } ); + + registry.registerStore( 'test', options ); + } ); + + it( 'override values passed to registerStore', () => { + const options = { persist: true, reducer() {} }; + + registry.registerStore( 'test', options ); + + expect( originalRegisterStore ).toHaveBeenCalledWith( 'test', { + 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', () => { + const initialState = { foo: 'bar', baz: 'qux' }; + function reducer( state = initialState, action ) { + return action.nextState || state; + } + + registry.registerStore( 'test', { + reducer, + actions: { + setState( nextState ) { + return { type: 'SET_STATE', nextState }; + }, + }, + } ); + + registry.dispatch( 'test' ).setState( { ok: true } ); + + expect( objectStorage.setItem ).not.toHaveBeenCalled(); + } ); + + it( 'should not persist when state matches initial', () => { + // Caveat: State is compared by strict equality. This doesn't work for + // object types in rehydrating from persistence, since: + // JSON.parse( {} ) !== JSON.parse( {} ) + // It's more important for runtime to check equal-ness, which is + // expected to be reflected even for object types by reducer. + const state = 1; + const reducer = () => state; + + objectStorage.setItem( 'WP_DATA', JSON.stringify( { test: state } ) ); + objectStorage.setItem.mockClear(); + + registry.registerStore( 'test', { + reducer, + persist: true, + actions: { + doNothing() { + return { type: 'NOTHING' }; + }, + }, + } ); + + registry.dispatch( 'test' ).doNothing(); + + expect( objectStorage.setItem ).not.toHaveBeenCalled(); + } ); + + it( 'should persist when state changes', () => { + const initialState = { foo: 'bar', baz: 'qux' }; + function reducer( state = initialState, action ) { + return action.nextState || state; + } + + registry.registerStore( 'test', { + reducer, + persist: true, + actions: { + setState( nextState ) { + return { type: 'SET_STATE', nextState }; + }, + }, + } ); + + registry.dispatch( 'test' ).setState( { ok: true } ); + + expect( objectStorage.setItem ).toHaveBeenCalledWith( 'WP_DATA', '{"test":{"ok":true}}' ); + } ); + + it( 'should persist a subset of keys', () => { + const initialState = { foo: 'bar', baz: 'qux' }; + function reducer( state = initialState, action ) { + return action.nextState || state; + } + + registry.registerStore( 'test', { + reducer, + persist: [ 'foo' ], + actions: { + setState( nextState ) { + return { type: 'SET_STATE', nextState }; + }, + }, + } ); + + registry.dispatch( 'test' ).setState( { foo: 1, baz: 2 } ); + + expect( objectStorage.setItem ).toHaveBeenCalledWith( 'WP_DATA', '{"test":{"foo":1}}' ); + } ); + + describe( 'createPersistenceInterface', () => { + const storage = objectStorage; + const storageKey = 'FOO'; + + let get, set; + beforeEach( () => { + ( { get, set } = createPersistenceInterface( { storage, storageKey } ) ); + } ); + + describe( 'get', () => { + it( 'returns an empty object if not set', () => { + const data = get(); + + expect( data ).toEqual( {} ); + } ); + + it( 'returns the current value', () => { + objectStorage.setItem( storageKey, '{"test":{}}' ); + const data = get(); + + expect( data ).toEqual( { test: {} } ); + } ); + } ); + + describe( 'set', () => { + it( 'sets JSON by object', () => { + set( 'test', {} ); + + expect( objectStorage.setItem ).toHaveBeenCalledWith( storageKey, '{"test":{}}' ); + } ); + + it( 'merges to existing', () => { + set( 'test1', {} ); + set( 'test2', {} ); + + expect( objectStorage.setItem ).toHaveBeenCalledWith( storageKey, '{"test1":{}}' ); + expect( objectStorage.setItem ).toHaveBeenCalledWith( storageKey, '{"test1":{},"test2":{}}' ); + } ); + } ); + } ); + + 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 ); + } ); + } ); +} ); diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js index 641e419ba629c6..e34ac42a08103d 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -2,18 +2,46 @@ * External dependencies */ import { createStore } from 'redux'; -import { flowRight, without, mapValues, overEvery, get } from 'lodash'; +import { + flowRight, + without, + mapValues, + overEvery, + get, +} from 'lodash'; /** * WordPress dependencies */ -import isShallowEqual from '@wordpress/is-shallow-equal'; +import deprecated from '@wordpress/deprecated'; + +/** + * An isolated orchestrator of store registrations. + * + * @typedef {WPDataRegistry} + * + * @property {Function} registerReducer + * @property {Function} registerSelectors + * @property {Function} registerResolvers + * @property {Function} registerActions + * @property {Function} registerStore + * @property {Function} subscribe + * @property {Function} select + * @property {Function} dispatch + * @property {Function} use + */ + +/** + * An object of registry function overrides. + * + * @typedef {WPDataPlugin} + */ /** * Internal dependencies */ import dataStore from './store'; -import { withRehydration, getPersistenceStorage } from './persist'; +import { persistence } from './plugins'; /** * Returns true if the given argument appears to be a dispatchable action. @@ -87,6 +115,14 @@ export function toAsyncIterable( object ) { }() ); } +/** + * Creates a new store registry, given an optional object of initial store + * configurations. + * + * @param {Object} storeConfigs Initial store configurations. + * + * @return {WPDataRegistry} Data registry. + */ export function createRegistry( storeConfigs = {} ) { const namespaces = {}; let listeners = []; @@ -99,22 +135,21 @@ export function createRegistry( storeConfigs = {} ) { } /** - * Registers a new sub-reducer to the global state and returns a Redux-like store object. + * Registers a new sub-reducer to the global state and returns a Redux-like + * store object. * - * @param {string} reducerKey Reducer key. - * @param {Object} reducer Reducer function. - * @param {boolean} persist Should the reducer be persisted. + * @param {string} reducerKey Reducer key. + * @param {Object} reducer Reducer function. * * @return {Object} Store Object. */ - function registerReducer( reducerKey, reducer, persist = false ) { + function registerReducer( reducerKey, reducer ) { const enhancers = []; if ( window.__REDUX_DEVTOOLS_EXTENSION__ ) { enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__( { name: reducerKey, instanceId: reducerKey } ) ); } - reducer = persist ? withRehydration( reducer ) : reducer; const store = createStore( reducer, flowRight( enhancers ) ); - namespaces[ reducerKey ] = { store, reducer, persist }; + namespaces[ reducerKey ] = { store, reducer }; // Customize subscribe behavior to call listeners only on effective change, // not on every dispatch. @@ -251,7 +286,7 @@ export function createRegistry( storeConfigs = {} ) { throw new TypeError( 'Must specify store reducer' ); } - const store = registerReducer( reducerKey, options.reducer, options.persist ); + const store = registerReducer( reducerKey, options.reducer ); if ( options.actions ) { registerActions( reducerKey, options.actions ); @@ -313,62 +348,32 @@ export function createRegistry( storeConfigs = {} ) { * @param {string} storageKey The storage key. */ function setupPersistence( storageKey ) { - const persistenceStorage = getPersistenceStorage(); - - // Load initially persisted value - let previousValue = null; - const persistedString = persistenceStorage.getItem( storageKey ); - if ( persistedString ) { - const persistedData = JSON.parse( persistedString ); - Object.entries( namespaces ).forEach( ( [ reducerKey, { store, persist } ] ) => { - if ( ! persist ) { - return; - } - - const persistedState = { - ...store.getState(), - ...get( persistedData, reducerKey ), - }; - - store.dispatch( { - type: 'REDUX_REHYDRATE', - payload: persistedState, - } ); - } ); - - // Avoid initial save. - previousValue = persistedData; - } - - const triggerPersist = () => { - const newValue = Object.entries( namespaces ) - .filter( ( [ , { persist } ] ) => persist ) - .reduce( ( memo, [ reducerKey, { reducer, store } ] ) => { - memo[ reducerKey ] = reducer( store.getState(), { - type: 'SERIALIZE', - previousState: get( previousValue, reducerKey ), - } ); - return memo; - }, {} ); - - if ( ! isShallowEqual( newValue, previousValue ) ) { - persistenceStorage.setItem( storageKey, JSON.stringify( newValue ) ); - } - - previousValue = newValue; - }; + deprecated( 'data registry setupPersistence', { + alternative: 'persistence plugin', + version: '3.7', + plugin: 'Gutenberg', + hint: 'See https://github.com/WordPress/gutenberg/pull/8341 for more details', + } ); - // Persist updated preferences - subscribe( triggerPersist ); - triggerPersist(); + registry.use( persistence, { storageKey } ); } - Object.entries( { - 'core/data': dataStore, - ...storeConfigs, - } ).map( ( [ name, config ] ) => registerStore( name, config ) ); + /** + * Maps an object of function values to proxy invocation through to the + * current internal representation of the registry, which may be enhanced + * by plugins. + * + * @param {Object} fns Object of function values. + * + * @return {Object} Object enhanced with plugin proxying. + */ + function withPlugins( fns ) { + return mapValues( fns, ( fn, key ) => function() { + return registry[ key ].apply( null, arguments ); + } ); + } - return { + let registry = { registerReducer, registerSelectors, registerResolvers, @@ -378,5 +383,31 @@ export function createRegistry( storeConfigs = {} ) { select, dispatch, setupPersistence, + use, }; + + /** + * Enhances the registry with the prescribed set of overrides. Returns the + * enhanced registry to enable plugin chaining. + * + * @param {WPDataPlugin} plugin Plugin by which to enhance. + * @param {?Object} options Optional options to pass to plugin. + * + * @return {WPDataRegistry} Enhanced registry. + */ + function use( plugin, options ) { + registry = { + ...registry, + ...plugin( registry, options ), + }; + + return registry; + } + + Object.entries( { + 'core/data': dataStore, + ...storeConfigs, + } ).map( ( [ name, config ] ) => registerStore( name, config ) ); + + return withPlugins( registry ); } diff --git a/packages/data/src/test/persist.js b/packages/data/src/test/persist.js deleted file mode 100644 index 7039545ae43c9b..00000000000000 --- a/packages/data/src/test/persist.js +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Internal dependencies - */ -import { getPersistenceStorage, setPersistenceStorage, restrictPersistence } from '../persist'; -import { createRegistry } from '../registry'; - -describe( 'persiss registry', () => { - let registry; - beforeEach( () => { - registry = createRegistry(); - setPersistenceStorage( window.localStorage ); - } ); - - it( 'should load the initial value from the local storage integrating it into reducer default value.', () => { - const storageKey = 'dumbStorageKey'; - const store = registry.registerStore( 'storeKey', { - reducer: ( state = { ribs: true } ) => { - return state; - }, - persist: true, - } ); - - getPersistenceStorage().setItem( storageKey, JSON.stringify( { - storeKey: { - chicken: true, - }, - } ) ); - - registry.setupPersistence( storageKey ); - - expect( store.getState() ).toEqual( { chicken: true, ribs: true } ); - } ); - - it( 'should persist to local storage once the state value changes', () => { - const storageKey = 'dumbStorageKey2'; - const reducer = ( state, action ) => { - if ( action.type === 'SERIALIZE' ) { - return state; - } - - if ( action.type === 'UPDATE' ) { - return { chicken: true }; - } - - return { ribs: true }; - }; - const store = registry.registerStore( 'storeKey', { - reducer, - persist: true, - } ); - - registry.setupPersistence( storageKey ); - - store.dispatch( { type: 'UPDATE' } ); - expect( JSON.parse( getPersistenceStorage().getItem( storageKey ) ) ) - .toEqual( { storeKey: { chicken: true } } ); - } ); - - it( 'should not trigger persistence if the value doesn’t change', () => { - const storageKey = 'dumbStorageKey2'; - let countCalls = 0; - const storage = { - getItem() { - return this.item; - }, - setItem( key, value ) { - countCalls++; - this.item = value; - }, - }; - setPersistenceStorage( storage ); - const reducer = ( state = { ribs: true }, action ) => { - if ( action.type === 'UPDATE' ) { - return { chicken: true }; - } - - return state; - }; - registry.registerStore( 'store1', { - reducer, - persist: true, - actions: { - update: () => ( { type: 'UPDATE' } ), - }, - } ); - registry.registerStore( 'store2', { - reducer, - actions: { - update: () => ( { type: 'UPDATE' } ), - }, - } ); - registry.setupPersistence( storageKey ); - - expect( countCalls ).toBe( 1 ); // Setup trigger initial persistence. - - registry.dispatch( 'store1' ).update(); - - expect( countCalls ).toBe( 2 ); // Updating state trigger persistence. - - registry.dispatch( 'store2' ).update(); - - expect( countCalls ).toBe( 2 ); // If the persisted state doesn't change, don't persist. - } ); -} ); - -describe( 'restrictPersistence', () => { - it( 'should only serialize a sub reducer state', () => { - const reducer = restrictPersistence( () => { - return { - preferences: { - chicken: 'ribs', - }, - - a: 'b', - }; - }, 'preferences' ); - - expect( reducer( undefined, { type: 'SERIALIZE' } ) ).toEqual( { - preferences: { - chicken: 'ribs', - }, - } ); - } ); - - it( 'should merge the substate with the default value', () => { - const reducer = restrictPersistence( () => { - return { - preferences: { - chicken: true, - }, - - a: 'b', - }; - }, 'preferences' ); - const state = reducer( undefined, { type: '@@init' } ); - expect( reducer( { - preferences: { - ribs: true, - }, - }, { - type: 'REDUX_REHYDRATE', - previousState: state, - } ) ).toEqual( { - a: 'b', - preferences: { - chicken: true, - ribs: true, - }, - } ); - } ); -} ); diff --git a/packages/data/src/test/registry.js b/packages/data/src/test/registry.js index acc26dc607978e..2e4223ca52afed 100644 --- a/packages/data/src/test/registry.js +++ b/packages/data/src/test/registry.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { castArray } from 'lodash'; +import { castArray, mapValues } from 'lodash'; /** * Internal dependencies @@ -472,6 +472,41 @@ describe( 'createRegistry', () => { expect( store.getState() ).toBe( 5 ); } ); } ); + + describe( 'use', () => { + it( 'should pass through options object to plugin', () => { + const expectedOptions = {}; + let actualOptions; + + function plugin( _registry, options ) { + // The registry passed to a plugin is not the same as the one + // returned by createRegistry, as the former uses the internal + // representation of the object, the latter applying its + // function proxying. + expect( _registry ).toMatchObject( + mapValues( registry, () => expect.any( Function ) ) + ); + + actualOptions = options; + + return {}; + } + + registry.use( plugin, expectedOptions ); + + expect( actualOptions ).toBe( expectedOptions ); + } ); + + it( 'should override base method', () => { + function plugin( _registry, options ) { + return { select: () => options.value }; + } + + registry.use( plugin, { value: 10 } ); + + expect( registry.select() ).toBe( 10 ); + } ); + } ); } ); describe( 'isActionLike', () => { diff --git a/packages/editor/src/store/index.js b/packages/editor/src/store/index.js index 90a1ec42ccf884..542ab0a908b56d 100644 --- a/packages/editor/src/store/index.js +++ b/packages/editor/src/store/index.js @@ -6,10 +6,7 @@ import { forOwn } from 'lodash'; /** * WordPress Dependencies */ -import { - registerStore, - restrictPersistence, -} from '@wordpress/data'; +import { registerStore } from '@wordpress/data'; /** * Internal dependencies @@ -27,10 +24,10 @@ import { validateTokenSettings } from '../components/rich-text/tokens'; const MODULE_KEY = 'core/editor'; const store = registerStore( MODULE_KEY, { - reducer: restrictPersistence( reducer, 'preferences' ), + reducer, selectors, actions, - persist: true, + persist: [ 'preferences' ], } ); applyMiddlewares( store ); diff --git a/packages/nux/src/store/index.js b/packages/nux/src/store/index.js index 6ad84580738875..8f3e8af338dab2 100644 --- a/packages/nux/src/store/index.js +++ b/packages/nux/src/store/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { registerStore, restrictPersistence } from '@wordpress/data'; +import { registerStore } from '@wordpress/data'; /** * Internal dependencies @@ -10,13 +10,11 @@ import reducer from './reducer'; import * as actions from './actions'; import * as selectors from './selectors'; -const REDUCER_KEY = 'preferences'; - const store = registerStore( 'core/nux', { - reducer: restrictPersistence( reducer, REDUCER_KEY ), + reducer, actions, selectors, - persist: true, + persist: [ 'preferences' ], } ); export default store;