Skip to content
Merged
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
18 changes: 5 additions & 13 deletions packages/editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,27 +130,19 @@ Example:

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

The logic is straightforward: a check is performed every `props.interval` seconds. If any changes are detected, `props.autosave()` is called. The time between the change and the autosave varies but is no larger than `props.interval` seconds. Refer to the code below for more details, such as the specific way of detecting changes.

There are two caveats:

- If `props.isAutosaveable` happens to be false at a time of checking for changes, the check is retried every second.
- The timer may be disabled by setting `props.disableIntervalChecks` to `true`. In that mode, any change will immediately trigger `props.autosave()`.
The post is checked every `interval` seconds and autosaved when there is something new to save.

_Usage_

```jsx
<AutosaveMonitor interval={ 30000 } />
<AutosaveMonitor interval={ 30 } />
```

_Parameters_

- _props_ `Object`: - The properties passed to the component.
- _props.autosave_ `Function`: - The function to call when changes need to be saved.
- _props.interval_ `number`: - The maximum time in seconds between an unsaved change and an autosave.
- _props.isAutosaveable_ `boolean`: - If false, the check for changes is retried every second.
- _props.disableIntervalChecks_ `boolean`: - If true, disables the timer and any change will immediately trigger `props.autosave()`.
- _props.isDirty_ `boolean`: - Indicates if there are unsaved changes.
- _props_ `Object`: The component props.
- _props.interval_ `[number]`: Time in seconds between checks. Defaults to the editor's `autosaveInterval` setting.
- _props.autosave_ `[Function]`: Function to call when changes need to be saved. Defaults to the editor store's `autosave` action.

### BlockAlignmentToolbar

Expand Down
173 changes: 69 additions & 104 deletions packages/editor/src/components/autosave-monitor/index.js
Original file line number Diff line number Diff line change
@@ -1,133 +1,98 @@
/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { withSelect, withDispatch } from '@wordpress/data';
import { useEffect, useRef } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';

/**
* Internal dependencies
*/
import { store as editorStore } from '../../store';

export class AutosaveMonitor extends Component {
constructor( props ) {
super( props );
this.needsAutosave = !! ( props.isDirty && props.isAutosaveable );
}

componentDidMount() {
if ( ! this.props.disableIntervalChecks ) {
this.setAutosaveTimer();
}
}

componentDidUpdate( prevProps ) {
if ( this.props.disableIntervalChecks ) {
if ( this.props.editsReference !== prevProps.editsReference ) {
this.props.autosave();
}
return;
}

if ( this.props.interval !== prevProps.interval ) {
clearTimeout( this.timerId );
this.setAutosaveTimer();
}

if ( ! this.props.isDirty ) {
this.needsAutosave = false;
return;
}

if ( this.props.isAutosaving && ! prevProps.isAutosaving ) {
this.needsAutosave = false;
return;
}

if ( this.props.editsReference !== prevProps.editsReference ) {
this.needsAutosave = true;
}
}

componentWillUnmount() {
clearTimeout( this.timerId );
}
/**
* 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 );

setAutosaveTimer( timeout = this.props.interval * 1000 ) {
this.timerId = setTimeout( () => {
this.autosaveTimerHandler();
}, timeout );
}
useEffect( () => {
callbackRef.current = callback;
}, [ callback ] );
Comment on lines +21 to +25

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This could be useEffectEvent in the future.

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.

It could immediately be useEvent from wp-compose, we already have this helper available, it assigns the callback to an internal ref and returns a stable wrapped callback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

ESLint will still think that the callback needs to be declared as a dependency, not a deal breaker, but it can get annoying really fast if we start using our version of the hook.

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.

I see, useEffectEvent return values are treated as a special exception by the exhaustive-deps rule, they don't need to be in the dependency array. I didn't know that before.

Still, it's not a big deal, we already have to declare essentially constant things like useCallback or useDispatch return values.

We'll have to wait for a potentially long time until we can use React 19-only APIs, so using useEvent in the meantime is a good idea.

It's totally not a blocker for this PR, it's more like discussing the best practice for mid-term future.


autosaveTimerHandler() {
if ( ! this.props.isAutosaveable ) {
this.setAutosaveTimer( 1000 );
useEffect( () => {
// Interval can be undefined before editor settings are populated.
if ( ! intervalInSeconds ) {
return;
}

if ( this.needsAutosave ) {
this.needsAutosave = false;
this.props.autosave();
}

this.setAutosaveTimer();
}

render() {
return null;
}
const id = setInterval(
() => callbackRef.current(),
intervalInSeconds * 1000
Comment thread
Mamaduka marked this conversation as resolved.
);
Comment on lines +33 to +36

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.

If isEditedPostAutosaveable() is false because the existing autosave is still loading, a pending edit can wait another full interval after autosaveability returns. The previous behavior always retried after 1 second.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Sorry, should've mentioned this in the description. It was an intentional call on my end, but I will check the git history if that's a behavior we should keep.

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.

Correct me if I'm wrong, but I believe the old code did setAutosaveTimer( 1000 ) when ! isAutosaveable, retrying every second. The new code just returns and waits for the next setInterval tick, up to interval seconds, which could be up to a minute. I'm not 100% convinced this is the desired behavior.

@Mamaduka Mamaduka Jun 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Claude and I did a little digging into the history. The 1s retry came from #23962 (the fixed-schedule rewrite back in 2020). As far as I can tell, it was just an incidental detail of that refactor. And isEditedPostAutosaveable() is only false in transient states (autosaves still loading on mount, or a temporary autosave lock), so in practice the only cost is a one-interval delay in a brief post-load window.

I also checked legacy core autosave.js - there's no 1s fast-retry there at all. It blocks for 10s when a save is in flight and otherwise runs on the heartbeat interval (~60s). So dropping the retry actually moves us closer to the core's behavior, not further.

Given that, I'd lean toward keeping the simpler fixed schedule. Reintroducing the retry means bringing back a variable timer, which is exactly what this refactor cleaned up.

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.

Hmm, I'm not sure it was an incidental detail / unintended. The PR description includes this as part of the intended mechanism:

If the handler is running at a time when isAutosaveable is false, keep retrying every 1 second.

To me, that looks quite intentional. I wonder if @adamziel or @youknowriad will recall. It's been 6 years 😬

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

IMO, I would prefer the behavior to be similar to core. Since it hasn't been changed in years, it's safe to assume it's working and that consumers don't care about special behavior.

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.

There's no easy way to find out if folks were really relying on this behavior without trying out. So I'm good to try, and being consistent with what's been there forever makes sense. Let's keep an eye out for reports.

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.

The autosave logic is rather chaotic and after staring at it for 40 minutes I still don't get it ☹️

The isEditedPostAutosaveable checks if the post is eligible for autosaving:

  1. It can't be completely empty, must have title or content or excerpt
  2. Its post type must support autosaving in the first place
  3. Saving must not be locked
  4. It must be sufficiently different from the last autosave

Then the useInterval callback does its own check:

  1. There has been some editEntityRecord, undo or redo since last check
  2. The post is dirty. I.e., different from last save (not last autosave!). How is "being dirty" different from "having a recent edit" from the previous condition? I don't know.
  3. The autosave is not already in progress.

The old version switched between 1s interval and 60s interval depending on isEditedPostAutosaveable. That makes sense for some parts of the condition: autosaving got just unlocked, autosaves finished fetching from REST. But what about the actual user-initiated changes? Why some of them wait for 60s and some only for 1s?

To make it even more convoluted, the <AutosaveMonitor> component is used also in <LocalAutosaveMonitor> with a custom interval (15s) and autosave callback (saves to local storage). Even though the local monitor saves to local storage, it still compares against the last server-side autosave, and cares about isAutosaving, i.e., whether server-side autosave is in progress.

I think we'll have to reformulate the requirements for autosave behavior, basically from scratch, to make any sense out of this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I agree that the logic is chaotic and all over the place, but the goal of this PR is to refactor away from the legacy class component and HoCs while preserving old behavior as much as possible.

That's also one of the reasons I didn't handle notice colocation here.

I think that a full logic rewrite is better done in a follow-up.

  • Audit what makes a post eligible for autosave. Conditions should be almost the same for both local and remote autosaves.
  • Colocate components and reuse parts instead of full components.
  • Once LocalAutosaveMonitor doesn't depend on AutosaveMonitor, move notice handling there.

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.

OK, the only major difference I see now between the old and new version is that the old version has a much faster heartbeat (1s) while isEditedPostAutosaveable is false. We might want to revisit that in a followup. It's hard to tell how important this is, with the overall logic being so chaotic.

return () => clearInterval( id );
}, [ intervalInSeconds ] );
}

/**
* Monitors the changes made to the edited post and triggers autosave if necessary.
*
* The logic is straightforward: a check is performed every `props.interval` seconds. If any changes are detected, `props.autosave()` is called.
* The time between the change and the autosave varies but is no larger than `props.interval` seconds. Refer to the code below for more details, such as
* the specific way of detecting changes.
* The post is checked every `interval` seconds and autosaved when there is something new to save.
*
* There are two caveats:
* * If `props.isAutosaveable` happens to be false at a time of checking for changes, the check is retried every second.
* * The timer may be disabled by setting `props.disableIntervalChecks` to `true`. In that mode, any change will immediately trigger `props.autosave()`.
*
* @param {Object} props - The properties passed to the component.
* @param {Function} props.autosave - The function to call when changes need to be saved.
* @param {number} props.interval - The maximum time in seconds between an unsaved change and an autosave.
* @param {boolean} props.isAutosaveable - If false, the check for changes is retried every second.
* @param {boolean} props.disableIntervalChecks - If true, disables the timer and any change will immediately trigger `props.autosave()`.
* @param {boolean} props.isDirty - Indicates if there are unsaved changes.
* @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={30000} />
* <AutosaveMonitor interval={ 30 } />
* ```
*/
export default compose( [
withSelect( ( select, ownProps ) => {
const { getReferenceByDistinctEdits } = select( coreStore );
export default function AutosaveMonitor( { interval, autosave } ) {
const { autosave: autosaveAction } = useDispatch( editorStore );
const triggerAutosave = autosave ?? autosaveAction;

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

const autosaveInterval = useSelect(
( select ) => {
if ( interval !== undefined ) {
return interval;
}

const {
isEditedPostDirty,
isEditedPostAutosaveable,
isAutosavingPost,
getEditorSettings,
} = select( editorStore );
return select( editorStore ).getEditorSettings().autosaveInterval;
},
[ interval ]
);

const { interval = getEditorSettings().autosaveInterval } = ownProps;
// Reference of the edits last considered for autosaving. Mutable state that
// must not trigger a re-render, hence a ref.
const lastEditsReferenceRef = useRef();

return {
editsReference: getReferenceByDistinctEdits(),
isDirty: isEditedPostDirty(),
isAutosaveable: isEditedPostAutosaveable(),
isAutosaving: isAutosavingPost(),
interval,
};
} ),
withDispatch( ( dispatch, ownProps ) => ( {
autosave() {
const { autosave = dispatch( editorStore ).autosave } = ownProps;
autosave();
},
} ) ),
] )( AutosaveMonitor );
useInterval( () => {
// The post can't be autosaved yet (e.g. its existing autosave is still
// loading). Keep any pending edits and try again on the next tick.
if ( ! isEditedPostAutosaveable() ) {
return;
}

const editsReference = getReferenceByDistinctEdits();
const hasNewEdits = editsReference !== lastEditsReferenceRef.current;
if ( hasNewEdits && isEditedPostDirty() && ! isAutosavingPost() ) {
// Only consume the edits reference when we autosave,
// so edits made during an in-flight autosave aren't skipped.
lastEditsReferenceRef.current = editsReference;
triggerAutosave();
}
Comment thread
Mamaduka marked this conversation as resolved.
}, autosaveInterval );

return null;
}
Loading
Loading