Skip to content
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

Prevent autosave when the post is locked #32475

Open
wants to merge 4 commits into
base: trunk
Choose a base branch
from
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
14 changes: 14 additions & 0 deletions packages/core-data/src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ export function receiveCurrentUser( currentUser ) {
};
}

/**
* Returns an action object used to lock the editor.
*
* @param {Object} currentUserSession Meta information about the login session for current user.
*
* @return {Object} Action object.
*/
export function __experimentalUpdateCurrentUserSession( currentUserSession ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This looks like local state. core-data was not really mean to store things like that. So can you explain more the reasoning here and what this is about?

Copy link
Member

Choose a reason for hiding this comment

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

I think this state could be very easily moved to the editor store, to the state.postLock object. That object already has plenty of fields (isLocked, isTakeover, user, ...), so why not add another isUserActive field there?

All other aspects of this user session check should be integrated into the post lock state, too. Why add another hook to heartbeat.tick, if we can just expand the existing receivePostLock function?

Copy link
Member

Choose a reason for hiding this comment

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

Moving this to the postLock object sounds confusing. Post lock state is coming from an entirely different API response and it's easy to accidentally override postLock.isUserActive (or vice-versa) in the process.

Copy link
Member

Choose a reason for hiding this comment

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

Then it could be renamed to state.heartbeat, with subfields for post lock, auth check and possibly other things. They are all requested together, and also used together.

return {
type: 'UPDATE_CURRENT_USER_SESSION',
currentUserSession,
};
}

/**
* Returns an action object used in adding new entities.
*
Expand Down
24 changes: 24 additions & 0 deletions packages/core-data/src/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,29 @@ export function currentUser( state = {}, action ) {
return state;
}

/**
* Reducer returning the login session for current user.
*
* The default state is { isActive: true }. We are just going to assume that by default the user session is active.
* If the block editor is already being loaded for user and if the data store is already being initialised for the user,
* it means user is already logged in.
* Eventually Heartbeat API can update this state if the auth cookie either expires or is deleted for the user.
*
* @param {Object} state Current state for login session.
* @param {Object} action Dispatched action.
*/
export function currentUserSession( state = { isActive: true }, action ) {
switch ( action.type ) {
case 'UPDATE_CURRENT_USER_SESSION':
return {
...state,
...action.currentUserSession,
};
}

return state;
}

/**
* Reducer managing taxonomies.
*
Expand Down Expand Up @@ -629,6 +652,7 @@ export default combineReducers( {
currentTheme,
currentGlobalStylesId,
currentUser,
currentUserSession,
themeGlobalStyleVariations,
themeBaseGlobalStyles,
themeGlobalStyleRevisions,
Expand Down
16 changes: 16 additions & 0 deletions packages/core-data/src/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface State {
currentGlobalStylesId: string;
currentTheme: string;
currentUser: ET.User< 'edit' >;
currentUserSession: { isActive: boolean };
embedPreviews: Record< string, { html: string } >;
entities: EntitiesState;
themeBaseGlobalStyles: Record< string, Object >;
Expand Down Expand Up @@ -185,6 +186,21 @@ export function getCurrentUser( state: State ): ET.User< 'edit' > {
return state.currentUser;
}

/**
*Returns a boolean flag for current user's active login session.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
*Returns a boolean flag for current user's active login session.
* Returns a boolean flag for current user's active login session.

*
* By default this returns true, because we assume that by default the user session is active.
* If the block editor is already being loaded for user and if the data store is already being initialised for the user,
* it means user is already logged in.
*
* @param {Object} state Current state data.
*
* @return {boolean} Whether the current user has active login maintained or not.
*/
export function __experimentalIsCurrentUserSessionActive( state: State ) {
return state.currentUserSession?.isActive ?? true;
}

/**
* Returns all the users returned by a query ID.
*
Expand Down
78 changes: 74 additions & 4 deletions packages/editor/src/components/post-locked-modal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import {
} from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import { addQueryArgs } from '@wordpress/url';
import { useEffect, createInterpolateElement } from '@wordpress/element';
import {
useEffect,
useState,
createInterpolateElement,
} from '@wordpress/element';
import { addAction, removeAction } from '@wordpress/hooks';
import { useInstanceId } from '@wordpress/compose';
import { store as coreStore } from '@wordpress/core-data';
Expand All @@ -21,10 +25,14 @@ import { store as coreStore } from '@wordpress/core-data';
import { store as editorStore } from '../../store';

export default function PostLockedModal() {
const [ heartbeatTickTriggered, triggerHeartbeatTick ] = useState( false );
const instanceId = useInstanceId( PostLockedModal );
const hookName = 'core/editor/post-locked-modal-' + instanceId;
const { autosave, updatePostLock } = useDispatch( editorStore );
const { __experimentalUpdateCurrentUserSession: updateCurrentUserSession } =
useDispatch( coreStore );
const {
isDraft,
isLocked,
isTakeover,
user,
Expand All @@ -33,6 +41,7 @@ export default function PostLockedModal() {
activePostLock,
postType,
previewLink,
isCurrentUserSessionActive,
Copy link
Member Author

Choose a reason for hiding this comment

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

ℹ️ For now, this data (isCurrentUserSessionActive) is only being used in this component and could well be a local state instead of being in the data store. But I can think of few use cases where we would want to check for current user sessions. I myself thought about using the same in AutosaveMonitor component. (This was when I was exploring potential fix in this PR. Eventually the need did not arise. So I removed that code before committing.) But the point is, there is a use case and can be a need in future.

} = useSelect( ( select ) => {
const {
isPostLocked,
Expand All @@ -44,8 +53,12 @@ export default function PostLockedModal() {
getEditedPostPreviewLink,
getEditorSettings,
} = select( editorStore );
const { getPostType } = select( coreStore );
const { getPostType, __experimentalIsCurrentUserSessionActive } =
select( coreStore );
return {
isDraft: [ 'draft', 'auto-draft' ].includes(
getEditedPostAttribute( 'status' )
),
isLocked: isPostLocked(),
isTakeover: isPostLockTakeover(),
user: getPostLockUser(),
Expand All @@ -54,9 +67,61 @@ export default function PostLockedModal() {
activePostLock: getActivePostLock(),
postType: getPostType( getEditedPostAttribute( 'type' ) ),
previewLink: getEditedPostPreviewLink(),
isCurrentUserSessionActive:
__experimentalIsCurrentUserSessionActive(),
};
}, [] );

useEffect( () => {
const checkUserLoginHookName = `core/editor/check-user-login/post-locked-modal-${ instanceId }`;

/**
* Binds to the Heartbeat Tick event and checks if the user is logged in or not.
*
* @param {Object} data Data received in the heartbeat request
*/
function checkUserLogin( data ) {
// Bail early, if the heartbeat has neither auth check data attribute nor nonce expiry attribute.
if (
! [ 'wp-auth-check', 'nonces_expired' ].some( ( attr ) =>
Object.prototype.hasOwnProperty.call( data, attr )
)
) {
return;
}
Comment on lines +84 to +91
Copy link
Member

Choose a reason for hiding this comment

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

auth-check.js in core only looks at wp-auth-check, not nonces_expired, see https://github.com/WordPress/wordpress-develop/blob/7b24083859c37d8ac75b9549f458bc389610eaef/src/js/_enqueues/lib/auth-check.js#L162-L168

So I'd remove the nonces_expired check here.

And then simplify to:

Suggested change
// Bail early, if the heartbeat has neither auth check data attribute nor nonce expiry attribute.
if (
! [ 'wp-auth-check', 'nonces_expired' ].some( ( attr ) =>
Object.prototype.hasOwnProperty.call( data, attr )
)
) {
return;
}
if ( ! 'wp-auth-check' in data ) {
return;
}

Copy link
Member

Choose a reason for hiding this comment

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

If we used apiFetch for the heartbeat request, then we wouldn't need to care about nonces at all, because apiFetch handles them internally (nonce middleware). Automatically requesting a new one when the old one expires etc.

I'm not sure if admin-ajax.php is compatible with apiFetch or whether we'd need to create a REST endpoint first.

Copy link
Member

Choose a reason for hiding this comment

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

No that would require a REST endpoint. See the ither comments about that.

This comment here is still relevant for admin-ajax


if ( ! data[ 'wp-auth-check' ] && isCurrentUserSessionActive ) {
// Update login state to false,
// if server does not respond with auth check
// and current login state is true.
updateCurrentUserSession( { isActive: false } );
} else if ( ! isCurrentUserSessionActive && data.nonces_expired ) {
// If current login state is false and nonce is renewed,
// trigger a heartbeat tick to check for latest post lock.
// Because first heartbeat will only renew the nonce.
// It will not return any errors if the post is already locked by other user or not.
if ( window.wp?.heartbeat?.connectNow ) {
triggerHeartbeatTick( true );
window.wp.heartbeat.connectNow();
}
} else if (
! isCurrentUserSessionActive &&
heartbeatTickTriggered &&
data[ 'wp-auth-check' ]
) {
// If current login state is false and server responds with auth check for previously triggered heartbeat,
// update login state to true.
updateCurrentUserSession( { isActive: true } );
}
}
Copy link
Member Author

@desaiuditd desaiuditd Jun 10, 2021

Choose a reason for hiding this comment

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

ℹ️ This whole function (which is managing the current user session in data store) can be questionable and there could be better way to handle this. So I'm happy to take any advise of improvement here.


addAction( 'heartbeat.tick', checkUserLoginHookName, checkUserLogin );
Copy link
Contributor

Choose a reason for hiding this comment

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

We already subscribe to this action in the hook down here. Was it a deliberate choice to separate these two logics. The reasoning is not clear to me.


return () => {
removeAction( 'heartbeat.tick', checkUserLoginHookName );
};
}, [ heartbeatTickTriggered, isCurrentUserSessionActive ] );

useEffect( () => {
/**
* Keep the lock refreshed.
Expand Down Expand Up @@ -90,7 +155,12 @@ export default function PostLockedModal() {
const received = data[ 'wp-refresh-post-lock' ];
if ( received.lock_error ) {
// Auto save and display the takeover modal.
autosave();
// Meanwhile, other user may have opened the post and started to make new changes.
// Post may have been published or may have been switched back to draft.
// In either case, we don't want to accidentally loose the changes made by other user.
// If we perform server side autosave, and the post is in draft, previous changes will be overwritten in the database.
// Hence, we will only perform local autosave.
autosave( { local: true } );
Copy link
Member Author

Choose a reason for hiding this comment

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

ℹ️ Ideally, isPostLocked check (which is part of isEditedPostSaveable check which in turn is part of isEditedPostAutosaveable) should be able to prevent this server autosave request. But somehow it seems like the data store at point of time in code execution is not properly updated. Hence, I'm still observing the network request for the autosave in the browser. Hence, I decided to put local: true to prevent the server autosave.

Copy link
Member

Choose a reason for hiding this comment

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

If we perform server side autosave, and the post is in draft, previous changes will be overwritten in the database.

I don't think that's true. It creates a new autosave, not overriding any of the other user's changes. The original post and the other user's autosave both still exist.

So this change would break that.

FWIW it's possible to write e2e tests around post locking and autosaves... Sounds like now would be a good time to do that :-)

updatePostLock( {
isLocked: true,
isTakeover: true,
Expand Down Expand Up @@ -141,7 +211,7 @@ export default function PostLockedModal() {
removeAction( 'heartbeat.tick', hookName );
window.removeEventListener( 'beforeunload', releasePostLock );
};
}, [] );
}, [ isDraft, isLocked ] );
Copy link
Member

Choose a reason for hiding this comment

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

Is this intentional? More dependencies means the effect is triggered more often. I think an empty dependencies array was deliberately chosen here.


if ( ! isLocked ) {
return null;
Expand Down
10 changes: 5 additions & 5 deletions packages/editor/src/components/post-preview-button/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ export default function PostPreviewButton( {
role,
onPreview,
} ) {
const { postId, currentPostLink, previewLink, isSaveable, isViewable } =
useSelect( ( select ) => {
const { postId, currentPostLink, previewLink, isViewable } = useSelect(
Copy link
Member

Choose a reason for hiding this comment

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

Why remove the isSaveable check in this component?

isEditedPostSaveable returns false if the post is considered empty and it doesn't make sense to preview an empty post.

( select ) => {
const editor = select( editorStore );
const core = select( coreStore );

Expand All @@ -119,10 +119,11 @@ export default function PostPreviewButton( {
postId: editor.getCurrentPostId(),
currentPostLink: editor.getCurrentPostAttribute( 'link' ),
previewLink: editor.getEditedPostPreviewLink(),
isSaveable: editor.isEditedPostSaveable(),
isViewable: postType?.viewable ?? false,
};
}, [] );
},
[]
);

const { __unstableSaveForPreview } = useDispatch( editorStore );

Expand Down Expand Up @@ -168,7 +169,6 @@ export default function PostPreviewButton( {
className={ className || 'editor-post-preview' }
href={ href }
target={ targetId }
disabled={ ! isSaveable }
onClick={ openPreviewWindow }
role={ role }
>
Expand Down
16 changes: 0 additions & 16 deletions packages/editor/src/components/post-preview-button/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,22 +139,6 @@ describe( 'PostPreviewButton', () => {
).toBeInTheDocument();
} );

it( 'should be disabled if post is not saveable.', () => {
Copy link
Member

Choose a reason for hiding this comment

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

As per my other comment, it doesn't seem right to remove this functionality.

mockUseSelect( { isEditedPostSaveable: () => false } );

render( <PostPreviewButton /> );

expect( screen.getByRole( 'button' ) ).toBeDisabled();
} );

it( 'should not be disabled if post is saveable.', () => {
mockUseSelect( { isEditedPostSaveable: () => true } );

render( <PostPreviewButton /> );

expect( screen.getByRole( 'button' ) ).toBeEnabled();
} );

it( 'should set `href` to edited post preview link if specified.', () => {
const url = 'https://wordpress.org';
mockUseSelect( {
Expand Down
6 changes: 5 additions & 1 deletion packages/editor/src/store/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,10 @@ export function isEditedPostSaveable( state ) {
return false;
}

if ( isPostLocked( state ) ) {
return false;
}

// TODO: Post should not be saveable if not dirty. Cannot be added here at
// this time since posts where meta boxes are present can be saved even if
// the post is not dirty. Currently this restriction is imposed at UI, but
Expand Down Expand Up @@ -1031,7 +1035,7 @@ export function getPermalinkParts( state ) {
* @return {boolean} Is locked.
*/
export function isPostLocked( state ) {
return state.postLock.isLocked;
return state.postLock?.isLocked ?? false;
Copy link
Member Author

@desaiuditd desaiuditd Jun 10, 2021

Choose a reason for hiding this comment

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

ℹ️ This is irrelevant change to the issue. Without this certain unit tests started to fail complaining about postLock being undefined in the state. This will just make sure that it has valid fallback.

Copy link
Member

Choose a reason for hiding this comment

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

I think in the test file we usually just made sure a default is set. So for consistency probably best to update those tests too.

}

/**
Expand Down
21 changes: 21 additions & 0 deletions packages/editor/src/store/test/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1421,6 +1421,27 @@ describe( 'selectors', () => {

expect( isEditedPostSaveable( state ) ).toBe( true );
} );

it( 'should return false if the post is locked', () => {
const state = {
editor: {
present: {
blocks: {
value: [],
},
edits: {},
},
},
initialEdits: {},
currentPost: {},
saving: {},
postLock: {
isLocked: true,
},
};

expect( isEditedPostSaveable( state ) ).toBe( false );
} );
} );

describe( 'isEditedPostAutosaveable', () => {
Expand Down
Loading