From e99c21244741cba21b9dfab1cc90951b7ed2524f Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Mon, 16 Sep 2019 15:23:18 -0400 Subject: [PATCH 1/6] Editor: Add sessionStorage autosave mechanism (#16490) --- .../developers/data/data-core-editor.md | 21 -- .../developers/data/data-core.md | 21 ++ packages/core-data/README.md | 21 ++ packages/core-data/src/selectors.js | 23 +++ packages/core-data/src/test/selectors.js | 43 ++++ packages/e2e-tests/specs/autosave.test.js | 107 ++++++++++ .../edit-post/src/components/layout/index.js | 2 + packages/edit-post/src/editor.js | 7 +- packages/edit-post/src/store/actions.js | 7 + packages/edit-post/src/store/defaults.js | 1 + packages/edit-post/src/store/reducer.js | 8 + .../src/components/autosave-monitor/index.js | 42 +++- packages/editor/src/components/index.js | 1 + .../local-autosave-monitor/index.js | 188 ++++++++++++++++++ packages/editor/src/store/selectors.js | 23 --- packages/editor/src/store/test/selectors.js | 16 -- 16 files changed, 459 insertions(+), 72 deletions(-) create mode 100644 packages/e2e-tests/specs/autosave.test.js create mode 100644 packages/editor/src/components/local-autosave-monitor/index.js diff --git a/docs/designers-developers/developers/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md index cc5f0a89928a7..98fdd462dadee 100644 --- a/docs/designers-developers/developers/data/data-core-editor.md +++ b/docs/designers-developers/developers/data/data-core-editor.md @@ -481,27 +481,6 @@ _Related_ - getPreviousBlockClientId in core/block-editor store. -# **getReferenceByDistinctEdits** - -Returns a new reference when edited values have changed. This is useful in -inferring where an edit has been made between states by comparison of the -return values using strict equality. - -_Usage_ - - const hasEditOccurred = ( - getReferenceByDistinctEdits( beforeState ) !== - getReferenceByDistinctEdits( afterState ) - ); - -_Parameters_ - -- _state_ `Object`: Editor state. - -_Returns_ - -- `*`: A value whose reference will change only when an edit occurs. - # **getSelectedBlock** _Related_ diff --git a/docs/designers-developers/developers/data/data-core.md b/docs/designers-developers/developers/data/data-core.md index cc724c5d26000..ce0cda7d3542a 100644 --- a/docs/designers-developers/developers/data/data-core.md +++ b/docs/designers-developers/developers/data/data-core.md @@ -246,6 +246,27 @@ _Returns_ - `?Object`: The edit. +# **getReferenceByDistinctEdits** + +Returns a new reference when edited values have changed. This is useful in +inferring where an edit has been made between states by comparison of the +return values using strict equality. + +_Usage_ + + const hasEditOccurred = ( + getReferenceByDistinctEdits( beforeState ) !== + getReferenceByDistinctEdits( afterState ) + ); + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `*`: A value whose reference will change only when an edit occurs. + # **getThemeSupports** Return theme supports data in the index. diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 27d3e812f4648..892a6e572a99b 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -459,6 +459,27 @@ _Returns_ - `?Object`: The edit. +# **getReferenceByDistinctEdits** + +Returns a new reference when edited values have changed. This is useful in +inferring where an edit has been made between states by comparison of the +return values using strict equality. + +_Usage_ + + const hasEditOccurred = ( + getReferenceByDistinctEdits( beforeState ) !== + getReferenceByDistinctEdits( afterState ) + ); + +_Parameters_ + +- _state_ `Object`: Editor state. + +_Returns_ + +- `*`: A value whose reference will change only when an edit occurs. + # **getThemeSupports** Return theme supports data in the index. diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index 317b758748720..5ce0941c33414 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -478,3 +478,26 @@ export function getAutosave( state, postType, postId, authorId ) { export const hasFetchedAutosaves = createRegistrySelector( ( select ) => ( state, postType, postId ) => { return select( REDUCER_KEY ).hasFinishedResolution( 'getAutosaves', [ postType, postId ] ); } ); + +/** + * Returns a new reference when edited values have changed. This is useful in + * inferring where an edit has been made between states by comparison of the + * return values using strict equality. + * + * @example + * + * ``` + * const hasEditOccurred = ( + * getReferenceByDistinctEdits( beforeState ) !== + * getReferenceByDistinctEdits( afterState ) + * ); + * ``` + * + * @param {Object} state Editor state. + * + * @return {*} A value whose reference will change only when an edit occurs. + */ +export const getReferenceByDistinctEdits = createSelector( + () => [], + ( state ) => [ state.undo.length, state.undo.offset ], +); diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index eb488c040e8fb..a52c13d99c7d1 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -15,6 +15,7 @@ import { getAutosave, getAutosaves, getCurrentUser, + getReferenceByDistinctEdits, } from '../selectors'; describe( 'getEntityRecord', () => { @@ -275,3 +276,45 @@ describe( 'getCurrentUser', () => { expect( getCurrentUser( state ) ).toEqual( currentUser ); } ); } ); + +describe( 'getReferenceByDistinctEdits', () => { + it( 'should return referentially equal values across empty states', () => { + const state = { undo: [] }; + expect( getReferenceByDistinctEdits( state ) ).toBe( getReferenceByDistinctEdits( state ) ); + + const beforeState = { undo: [] }; + const afterState = { undo: [] }; + expect( getReferenceByDistinctEdits( beforeState ) ).toBe( getReferenceByDistinctEdits( afterState ) ); + } ); + + it( 'should return referentially equal values across unchanging non-empty state', () => { + const undoStates = [ {} ]; + const state = { undo: undoStates }; + expect( getReferenceByDistinctEdits( state ) ).toBe( getReferenceByDistinctEdits( state ) ); + + const beforeState = { undo: undoStates }; + const afterState = { undo: undoStates }; + expect( getReferenceByDistinctEdits( beforeState ) ).toBe( getReferenceByDistinctEdits( afterState ) ); + } ); + + describe( 'when adding edits', () => { + it( 'should return referentially different values across changing states', () => { + const beforeState = { undo: [ {} ] }; + beforeState.undo.offset = 0; + const afterState = { undo: [ {}, {} ] }; + afterState.undo.offset = 1; + expect( getReferenceByDistinctEdits( beforeState ) ).not.toBe( getReferenceByDistinctEdits( afterState ) ); + } ); + } ); + + describe( 'when using undo', () => { + it( 'should return referentially different values across changing states', () => { + const beforeState = { undo: [ {}, {} ] }; + beforeState.undo.offset = 1; + const afterState = { undo: [ {}, {} ] }; + afterState.undo.offset = 0; + expect( getReferenceByDistinctEdits( beforeState ) ).not.toBe( getReferenceByDistinctEdits( afterState ) ); + } ); + } ); +} ); + diff --git a/packages/e2e-tests/specs/autosave.test.js b/packages/e2e-tests/specs/autosave.test.js new file mode 100644 index 0000000000000..6220a53090cbf --- /dev/null +++ b/packages/e2e-tests/specs/autosave.test.js @@ -0,0 +1,107 @@ +/** + * WordPress dependencies + */ +import { + clickBlockAppender, + createNewPost, + getEditedPostContent, + pressKeyWithModifier, +} from '@wordpress/e2e-test-utils'; + +// Constant to override editor preference +const AUTOSAVE_INTERVAL_SECONDS = 5; + +async function saveDraftWithKeyboard() { + return pressKeyWithModifier( 'primary', 's' ); +} + +async function sleep( durationInSeconds ) { + return new Promise( ( resolve ) => + setTimeout( resolve, durationInSeconds * 1000 ) ); +} + +async function clearSessionStorage() { + await page.evaluate( () => window.sessionStorage.clear() ); +} + +async function readSessionStorageAutosave( postId ) { + return page.evaluate( + ( key ) => window.sessionStorage.getItem( key ), + `wp-autosave-block-editor-post-${ postId }` + ); +} + +async function getCurrentPostId() { + return page.evaluate( + () => window.wp.data.select( 'core/editor' ).getCurrentPostId() + ); +} + +async function setLocalAutosaveInterval( value ) { + return page.evaluate( ( _value ) => { + window.wp.data.dispatch( 'core/edit-post' ) + .__experimentalUpdateLocalAutosaveInterval( _value ); + }, value ); +} + +function wrapParagraph( text ) { + return ` +

${ text }

+`; +} + +describe( 'autosave', () => { + beforeEach( async () => { + await clearSessionStorage(); + await createNewPost(); + await setLocalAutosaveInterval( AUTOSAVE_INTERVAL_SECONDS ); + } ); + + it( 'should save to sessionStorage', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'before save' ); + await saveDraftWithKeyboard(); + await page.keyboard.type( ' after save' ); + + // Wait long enough for local autosave to kick in + await sleep( AUTOSAVE_INTERVAL_SECONDS + 1 ); + + const id = await getCurrentPostId(); + const autosave = await readSessionStorageAutosave( id ); + const { content } = JSON.parse( autosave ); + expect( content ).toBe( wrapParagraph( 'before save after save' ) ); + + // Test throttling by scattering typing + await page.keyboard.type( ' 1' ); + await sleep( AUTOSAVE_INTERVAL_SECONDS - 4 ); + await page.keyboard.type( '2' ); + await sleep( 2 ); + await page.keyboard.type( '3' ); + await sleep( 2 ); + + const newAutosave = await readSessionStorageAutosave( id ); + expect( JSON.parse( newAutosave ).content ).toBe( wrapParagraph( 'before save after save 123' ) ); + } ); + + it( 'should recover from sessionStorage', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'before save' ); + await saveDraftWithKeyboard(); + await page.keyboard.type( ' after save' ); + + // Reload without saving on the server + await sleep( AUTOSAVE_INTERVAL_SECONDS + 1 ); + await page.reload(); + + const notice = await page.$eval( '.components-notice__content', ( element ) => element.innerText ); + expect( notice ).toContain( 'The backup of this post in your browser is different from the version below.' ); + + expect( await getEditedPostContent() ).toEqual( wrapParagraph( 'before save' ) ); + await page.click( '.components-notice__action' ); + expect( await getEditedPostContent() ).toEqual( wrapParagraph( 'before save after save' ) ); + } ); + + afterAll( async () => { + await clearSessionStorage(); + } ); +} ); diff --git a/packages/edit-post/src/components/layout/index.js b/packages/edit-post/src/components/layout/index.js index 4ae2c2a9a71e9..5e8802e406ba3 100644 --- a/packages/edit-post/src/components/layout/index.js +++ b/packages/edit-post/src/components/layout/index.js @@ -17,6 +17,7 @@ import { __ } from '@wordpress/i18n'; import { PreserveScrollInReorder } from '@wordpress/block-editor'; import { AutosaveMonitor, + LocalAutosaveMonitor, UnsavedChangesWarning, EditorNotices, PostPublishPanel, @@ -77,6 +78,7 @@ function Layout( { +