Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ Example:

Monitors the changes made to the edited post and triggers autosave if necessary.

The post is checked every `interval` seconds and autosaved when there is something new to save.
The post is checked every `interval` seconds and autosaved when there is something new to save. Renders nothing when the post type doesn't support autosaves.

_Usage_

Expand Down Expand Up @@ -461,7 +461,7 @@ Monitors local autosaves of a post in the editor. It uses several hooks and func
- `useAutosaveNotice` hook: Manages the creation of a notice prompting the user to restore a local autosave, if one exists.
- `useAutosavePurge` hook: Ejects a local autosave after a successful save occurs.
- `hasSessionStorageSupport` function: Checks if the current environment supports browser sessionStorage.
- `LocalAutosaveMonitor` component: Uses the `AutosaveMonitor` component to perform autosaves at a specified interval.
- `LocalAutosaveMonitor` component: Saves a sessionStorage backup of the post at the `localAutosaveInterval`.

The module also checks for sessionStorage support and conditionally exports the `LocalAutosaveMonitor` component based on that.

Expand Down
44 changes: 44 additions & 0 deletions packages/editor/src/components/autosave-monitor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# AutosaveMonitor

`AutosaveMonitor` monitors the changes made to the edited post and triggers an autosave when there is something new to save. The post is checked on an interval, which defaults to the editor's `autosaveInterval` setting and saves to the server via the editor store's `autosave` action. Both the interval and the save callback can be overridden with props.

It also creates the `autosave-exists` warning notice when the server already holds an autosave more recent than the loaded post. This notice therefore only appears where `AutosaveMonitor` is rendered (and where the post type supports `autosave`). An editor that receives `settings.autosave` but does not render `AutosaveMonitor` will not surface this warning.

## Example

```js
const MyLayout = () => (
<main>
<AutosaveMonitor interval={ 30 } />
<MyEditor />
</main>
);
```

# LocalAutosaveMonitor

`LocalAutosaveMonitor` ensures that a local copy of the current post is regularly saved in `sessionStorage`. It runs its own timer, sharing only the `useInterval` helper with `AutosaveMonitor`. Additionally, it will:

- attempt to clear the local copy if a copy is successfully saved on the server;
- warn the user upon loading a post that there is a local copy that can be loaded;
- suppress that restore notice when the server already holds a more recent autosave.

`LocalAutosaveMonitor` observes a saving interval defined specifically for local autosaves, in contrast with remote (server-side) autosaving.

The interval used for the local autosave can be modified by updating the editor settings
```js
wp.data.dispatch( 'core/editor' ).updateEditorSettings( {
localAutosaveInterval: 45,
} );
```

## Example

```js
const MyLayout = () => (
<main>
<LocalAutosaveMonitor />
<MyEditor />
</main>
);
```
108 changes: 61 additions & 47 deletions packages/editor/src/components/autosave-monitor/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,63 +4,28 @@
import { useEffect, useRef } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { store as noticesStore } from '@wordpress/notices';
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import { store as editorStore } from '../../store';
import PostTypeSupportCheck from '../post-type-support-check';
import useInterval from './use-interval';

/**
* Calls `callback` every `intervalInSeconds`. The latest `callback` is always
* invoked without resetting the timer.
*
* @param {Function} callback Function to call on each tick.
* @param {number} intervalInSeconds Seconds between ticks.
*/
function useInterval( callback, intervalInSeconds ) {
const callbackRef = useRef( callback );

useEffect( () => {
callbackRef.current = callback;
}, [ callback ] );

useEffect( () => {
// Interval can be undefined before editor settings are populated.
if ( ! intervalInSeconds ) {
return;
}

const id = setInterval(
() => callbackRef.current(),
intervalInSeconds * 1000
);
return () => clearInterval( id );
}, [ intervalInSeconds ] );
}

/**
* Monitors the changes made to the edited post and triggers autosave if necessary.
*
* The post is checked every `interval` seconds and autosaved when there is something new to save.
*
* @param {Object} props The component props.
* @param {number} [props.interval] Time in seconds between checks. Defaults to the editor's
* `autosaveInterval` setting.
* @param {Function} [props.autosave] Function to call when changes need to be saved. Defaults to the
* editor store's `autosave` action.
*
* @example
* ```jsx
* <AutosaveMonitor interval={ 30 } />
* ```
*/
export default function AutosaveMonitor( { interval, autosave } ) {
function AutosaveMonitorInner( { interval, autosave } ) {
const { autosave: autosaveAction } = useDispatch( editorStore );
const { createWarningNotice } = useDispatch( noticesStore );
const triggerAutosave = autosave ?? autosaveAction;

const { getReferenceByDistinctEdits } = useSelect( coreStore );
const { isEditedPostDirty, isEditedPostAutosaveable, isAutosavingPost } =
useSelect( editorStore );
const {
isEditedPostDirty,
isEditedPostAutosaveable,
isAutosavingPost,
getEditorSettings,
} = useSelect( editorStore );

const autosaveInterval = useSelect(
( select ) => {
Expand All @@ -73,6 +38,30 @@ export default function AutosaveMonitor( { interval, autosave } ) {
[ interval ]
);

// Warn the user once when the server already holds an autosave that is more
// recent than the loaded post.
useEffect( () => {
const { autosave: existingAutosave } = getEditorSettings();
if ( ! existingAutosave ) {
return;
}

createWarningNotice(
Comment thread
Mamaduka marked this conversation as resolved.
Resolved
__(
'There is an autosave of this post that is more recent than the version below.'
),
{
id: 'autosave-exists',
actions: [
{
label: __( 'View the autosave' ),
url: existingAutosave.editLink,
},
],
}
);
}, [ getEditorSettings, createWarningNotice ] );

// Reference of the edits last considered for autosaving. Mutable state that
// must not trigger a re-render, hence a ref.
const lastEditsReferenceRef = useRef();
Expand All @@ -96,3 +85,28 @@ export default function AutosaveMonitor( { interval, autosave } ) {

return null;
}

/**
* Monitors the changes made to the edited post and triggers autosave if necessary.
*
* The post is checked every `interval` seconds and autosaved when there is something new to save.
* Renders nothing when the post type doesn't support autosaves.
*
* @param {Object} props The component props.
* @param {number} [props.interval] Time in seconds between checks. Defaults to the editor's
* `autosaveInterval` setting.
* @param {Function} [props.autosave] Function to call when changes need to be saved. Defaults to the
* editor store's `autosave` action.
*
* @example
* ```jsx
* <AutosaveMonitor interval={ 30 } />
* ```
*/
export default function AutosaveMonitor( props ) {
return (
<PostTypeSupportCheck supportKeys="autosave">
<AutosaveMonitorInner { ...props } />
</PostTypeSupportCheck>
);
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
/**
* WordPress dependencies
*/
import { useCallback, useEffect, useRef } from '@wordpress/element';
import { useEffect, useRef } from '@wordpress/element';
import { ifCondition, usePrevious } from '@wordpress/compose';
import { useSelect, useDispatch, useRegistry } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { __ } from '@wordpress/i18n';
import { parse } from '@wordpress/blocks';
import { store as noticesStore } from '@wordpress/notices';

/**
* Internal dependencies
*/
import AutosaveMonitor from '../autosave-monitor';
import {
localAutosaveGet,
localAutosaveClear,
} from '../../store/local-autosave';
import { store as editorStore } from '../../store';
import PostTypeSupportCheck from '../post-type-support-check';
import useInterval from './use-interval';

const requestIdleCallback = window.requestIdleCallback
? window.requestIdleCallback
Expand Down Expand Up @@ -63,21 +65,11 @@ function useAutosaveNotice() {
);

useEffect( () => {
let localAutosave = localAutosaveGet( postId, isEditedPostNew );
if ( ! localAutosave ) {
const edits = localAutosaveGet( postId, isEditedPostNew );
if ( ! edits ) {
return;
}

try {
localAutosave = JSON.parse( localAutosave );
} catch {
// Not usable if it can't be parsed.
return;
}

const { post_title: title, content, excerpt } = localAutosave;
const edits = { title, content, excerpt };

const { getEditedPostAttribute, getEditorSettings } =
registry.select( editorStore );

Expand Down Expand Up @@ -172,11 +164,18 @@ function useAutosavePurge() {
}, [ isEditedPostNew, postId ] );
}

function LocalAutosaveMonitor() {
function LocalAutosaveMonitorInner() {
const { autosave } = useDispatch( editorStore );
const deferredAutosave = useCallback( () => {
requestIdleCallback( () => autosave( { local: true } ) );
}, [] );
const {
getCurrentPostId,
isEditedPostNew,
isEditedPostEmpty,
isEditedPostDirty,
isPostAutosavingLocked,
getEditedPostAttribute,
} = useSelect( editorStore );
const { getReferenceByDistinctEdits } = useSelect( coreStore );

useAutosaveNotice();
useAutosavePurge();

Expand All @@ -186,11 +185,56 @@ function LocalAutosaveMonitor() {
[]
);

// Reference of the edits last considered for autosaving. Mutable state that
// must not trigger a re-render, hence a ref.
const lastEditsReferenceRef = useRef();
Comment thread
Mamaduka marked this conversation as resolved.
Resolved

useInterval( () => {
// A sessionStorage backup only needs saveable content, checked inline
// because `isEditedPostSaveable()` short-circuits to false during any
// save, including a remote autosave, which must not block the backup.
const hasSaveableContent =
!! getEditedPostAttribute( 'title' ) ||
!! getEditedPostAttribute( 'excerpt' ) ||
! isEditedPostEmpty();
if ( ! hasSaveableContent || isPostAutosavingLocked() ) {
return;
}

const editsReference = getReferenceByDistinctEdits();
const hasNewEdits = editsReference !== lastEditsReferenceRef.current;
if ( ! hasNewEdits || ! isEditedPostDirty() ) {
return;
}

// Skip the backup when the edits already match it.
const backup = localAutosaveGet(
getCurrentPostId(),
isEditedPostNew()
);
const isBackupCurrent =
backup &&
Object.keys( backup ).every(
( key ) => backup[ key ] === getEditedPostAttribute( key )
);
if ( isBackupCurrent ) {
return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will never update the lastEditsReferenceRef ref, is that expected? Should we still set it to editsReference as done below?

And if yes, we might want to add test coverage for this scenario.

}

// Only consume the edits reference when we save the backup, so edits
// made in the meantime aren't skipped.
lastEditsReferenceRef.current = editsReference;
requestIdleCallback( () => autosave( { local: true } ) );
}, localAutosaveInterval );

return null;
}

function LocalAutosaveMonitor() {
return (
<AutosaveMonitor
interval={ localAutosaveInterval }
autosave={ deferredAutosave }
/>
<PostTypeSupportCheck supportKeys="autosave">
<LocalAutosaveMonitorInner />
</PostTypeSupportCheck>
);
}

Expand All @@ -200,7 +244,7 @@ function LocalAutosaveMonitor() {
* - `useAutosaveNotice` hook: Manages the creation of a notice prompting the user to restore a local autosave, if one exists.
* - `useAutosavePurge` hook: Ejects a local autosave after a successful save occurs.
* - `hasSessionStorageSupport` function: Checks if the current environment supports browser sessionStorage.
* - `LocalAutosaveMonitor` component: Uses the `AutosaveMonitor` component to perform autosaves at a specified interval.
* - `LocalAutosaveMonitor` component: Saves a sessionStorage backup of the post at the `localAutosaveInterval`.
*
* The module also checks for sessionStorage support and conditionally exports the `LocalAutosaveMonitor` component based on that.
*
Expand Down
Loading
Loading