-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Editor: Refactor AutosaveMonitor to a function component #79043
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4c64c84
2f83381
40e8194
5b9bc06
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 ] ); | ||
|
|
||
| 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 | ||
|
Mamaduka marked this conversation as resolved.
|
||
| ); | ||
|
Comment on lines
+33
to
+36
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I also checked legacy core 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
To me, that looks quite intentional. I wonder if @adamziel or @youknowriad will recall. It's been 6 years 😬
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Then the
The old version switched between 1s interval and 60s interval depending on To make it even more convoluted, the I think we'll have to reformulate the requirements for autosave behavior, basically from scratch, to make any sense out of this.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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(); | ||
| } | ||
|
Mamaduka marked this conversation as resolved.
|
||
| }, autosaveInterval ); | ||
|
|
||
| return null; | ||
| } | ||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could be
useEffectEventin the future.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It could immediately be
useEventfromwp-compose, we already have this helper available, it assigns the callback to an internal ref and returns a stable wrapped callback.There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see,
useEffectEventreturn values are treated as a special exception by theexhaustive-depsrule, 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
useCallbackoruseDispatchreturn values.We'll have to wait for a potentially long time until we can use React 19-only APIs, so using
useEventin 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.