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

Block Editor: Add local change detection. #19741

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -828,13 +828,14 @@ _Returns_

<a name="isLastBlockChangePersistent" href="#isLastBlockChangePersistent">#</a> **isLastBlockChangePersistent**

Returns true if the most recent block change is be considered persistent, or
false otherwise. A persistent change is one committed by BlockEditorProvider
via its `onChange` callback, in addition to `onInput`.
Returns true if the most recent block change is to be considered persistent with
regards to the provided root client ID, or false otherwise. A persistent
change is one committed by `BlockEditorProvider` via its `onChange` callback, in addition to `onInput`.

_Parameters_

- _state_ `Object`: Block editor state.
- _rootClientId_ `[string]`: Block root client ID.

_Returns_

Expand Down Expand Up @@ -1364,10 +1365,16 @@ _Returns_
Returns an action object used in signalling that the block attributes with
the specified client ID has been updated.

You may specify a root client ID to scope change detection to a section of
the block tree. I.e. so the edit doesn't dirty the entire block tree. This
is used for edits made to blocks that are saved somewhere other than the main
post by their root.

_Parameters_

- _clientId_ `string`: Block client ID.
- _attributes_ `Object`: Block attributes to be merged.
- _rootClientId_ `[string]`: Block root client ID.

_Returns_

Expand Down
19 changes: 18 additions & 1 deletion packages/block-editor/src/components/block-list/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
isReusableBlock,
isUnmodifiedDefaultBlock,
getUnregisteredTypeHandlerName,
DEFAULT_BLOCK_TYPE_SETTINGS,
} from '@wordpress/blocks';
import { withFilters } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
Expand Down Expand Up @@ -427,7 +428,23 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => {
return {
setAttributes( newAttributes ) {
const { clientId } = ownProps;
updateBlockAttributes( clientId, newAttributes );
const { getBlockRootClientId, getBlockName } = select(
'core/block-editor'
);
let isLocalChange = false;
const rootClientId = getBlockRootClientId( clientId );
if ( rootClientId ) {
// The default save outputs null.
// We can have more nuanced heuristics in the future.
isLocalChange =
select( 'core/blocks' ).getBlockType( getBlockName( rootClientId ) )
Copy link
Member

Choose a reason for hiding this comment

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

If a block implements its own save that returns null, this logic will fail, right?
I also wonder about back compatibility: Imagining a current block that uses InnerBlocks without any save and listens to save actions (e.g: executes when saved state changes) to save its InnerBlocks using a rest API. Would a block like that break with this change because if its InnerBlocks are changed the post would not become dirty? I think the odds of a block like that existing are low, but at least we would need to create a dev note.

As an alternative solution, what if we use a supports flag to indicate this type of blocks?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If a block implements its own save that returns null, this logic will fail, right?
I also wonder about back compatibility: Imagining a current block that uses InnerBlocks without any save and listens to save actions (e.g: executes when saved state changes) to save its InnerBlocks using a rest API. Would a block like that break with this change because if its InnerBlocks are changed the post would not become dirty?

Yes, for both, I don't see value in supporting either, though.

As an alternative solution, what if we use a supports flag to indicate this type of blocks?

It would work, but it would be adding more cruft.

I am not sure. Thoughts @youknowriad?

Copy link
Contributor

Choose a reason for hiding this comment

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

If i understand properly where we're coming from here. i believe all these issues are happening because we mix the blocks of the sub-entity into the blocks of the top entity.

I think we discussed this previously, IMO, we should find a way to stop doing this as it doesn't make sense to mix content from entity A into an edit of entity B.

One potential idea I shared previously is to have a unique block list in the "editor" store and each entity has its own "blocks" edits in the "core" store.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think the issue is a bit more general than that.

We save serialized content yet detect changes on block objects. Any edit to a block will be dirtying even if its serialization doesn't change.

One potential idea I shared previously is to have a unique block list in the "editor" store and each entity has its own "blocks" edits in the "core" store.

So like reusable blocks, but resolved into a single blocklist? What do you see as the benefits of that approach over this one?

Copy link
Contributor

Choose a reason for hiding this comment

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

So like reusable blocks, but resolved into a single blocklist? What do you see as the benefits of that approach over this one?

The benefits is that an entity is dirty only if its block list is dirty (A template block list won't include the blocks from the template area)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The same is true in this PR.

So for your suggestion, we would need a new property in block objects that potentially contain a selector that gets a block list for their inner blocks. The editor store would then dynamically resolve a complete block list. However, when the block editor makes an edit, it will mutate the full block list, how would the editor know to not dirty the top-level entity. It seems like we are trading some complexity for another.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed no silver bullet here. I think "onChange" of the "controlled" inner blocks might be the place where we dispatch the change to two block lists. Conceptually speaking, I feel like having in an entity of core-data, things that are saved in another entity will just bite us with this kind of hidden bugs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think "onChange" of the "controlled" inner blocks might be the place where we dispatch the change to two block lists.

We'll still have the same issues when we nest one of these blocks inside another.

Conceptually speaking, I feel like having in an entity of core-data, things that are saved in another entity will just bite us with this kind of hidden bugs.

I don't see a conceptual issue with it. They are object references, it's not like the data is duplicated. That's why this PR was so simple to do.

.save === DEFAULT_BLOCK_TYPE_SETTINGS.save;
}
updateBlockAttributes(
clientId,
newAttributes,
isLocalChange ? rootClientId : undefined
);
},
onInsertBlocks( blocks, index ) {
const { rootClientId } = ownProps;
Expand Down
2 changes: 1 addition & 1 deletion packages/block-editor/src/components/inner-blocks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ InnerBlocks = compose( [
! hasSelectedInnerBlock( clientId, true ),
parentLock: getTemplateLock( rootClientId ),
enableClickThrough: isNavigationMode() || isSmallScreen,
isLastBlockChangePersistent: isLastBlockChangePersistent(),
isLastBlockChangePersistent: isLastBlockChangePersistent( clientId ),
};
} ),
withDispatch( ( dispatch, ownProps ) => {
Expand Down
13 changes: 10 additions & 3 deletions packages/block-editor/src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,23 @@ export function receiveBlocks( blocks ) {
* Returns an action object used in signalling that the block attributes with
* the specified client ID has been updated.
*
* @param {string} clientId Block client ID.
* @param {Object} attributes Block attributes to be merged.
* You may specify a root client ID to scope change detection to a section of
* the block tree. I.e. so the edit doesn't dirty the entire block tree. This
* is used for edits made to blocks that are saved somewhere other than the main
* post by their root.
*
* @param {string} clientId Block client ID.
* @param {Object} attributes Block attributes to be merged.
* @param {string} [rootClientId] Block root client ID.
*
* @return {Object} Action object.
*/
export function updateBlockAttributes( clientId, attributes ) {
export function updateBlockAttributes( clientId, attributes, rootClientId ) {
return {
type: 'UPDATE_BLOCK_ATTRIBUTES',
clientId,
attributes,
rootClientId,
};
}

Expand Down
24 changes: 22 additions & 2 deletions packages/block-editor/src/store/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import {
identity,
difference,
omitBy,
pickBy,
} from 'lodash';

/**
* WordPress dependencies
*/
import isShallowEqual from '@wordpress/is-shallow-equal';
import { combineReducers } from '@wordpress/data';
import { isReusableBlock } from '@wordpress/blocks';
/**
Expand Down Expand Up @@ -373,6 +375,8 @@ function withPersistentBlockChange( reducer ) {

return ( state, action ) => {
let nextState = reducer( state, action );
nextState.persistentChangeRootClientId =
action.type === 'UPDATE_BLOCK_ATTRIBUTES' && action.rootClientId;

const isExplicitPersistentChange =
action.type === 'MARK_LAST_CHANGE_AS_PERSISTENT' ||
Expand Down Expand Up @@ -405,6 +409,12 @@ function withPersistentBlockChange( reducer ) {
? ! markNextChangeAsNotPersistent
: ! isUpdatingSameBlockAttribute( action, lastAction ),
};
if (
action.type === 'UPDATE_BLOCK_ATTRIBUTES' &&
action.rootClientId
) {
nextState.persistentChangeRootClientId = action.rootClientId;
}

// In comparing against the previous action, consider only those which
// would have qualified as one which would have been ignored or not
Expand Down Expand Up @@ -525,8 +535,18 @@ const withBlockReset = ( reducer ) => ( state, action ) => {
...mapBlockParents( action.blocks ),
},
cache: {
...omit( state.cache, visibleClientIds ),
...mapValues( flattenBlocks( action.blocks ), () => ( {} ) ),
...state.cache,
Comment on lines -528 to +534
Copy link
Contributor

@Addison-Stavlo Addison-Stavlo Feb 24, 2020

Choose a reason for hiding this comment

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

Just curious why these were being omit-ed from everything in this reducer in the first place? Now that we are no longer omitting them in the cache, could this cause any potential issues?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it was just to keep it simpler.

Do you see an issue with this @aduth, @youknowriad?

Copy link
Member

Choose a reason for hiding this comment

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

This has changed quite a bit since last I recall being familiar with it, but my concerns would be one of (a) are we risking to leave orphaned child blocks or (b) are we not cleaning up blocks which are no longer part of state.

Reading the documentation for the function, it seems like ideally we would want to avoid a merge altogether, and that the only reason we have it is to account for reusable blocks. I'm not familiar if we're to the point with recent changes like #14367 to be able to remove this and let RESET_BLOCKS entirely take over the state (no merge). But if not, then I think we probably still want the omit ? Since otherwise, we may leave some state for blocks which are no longer relevant for the next state, right? When is the cache ever cleaned up?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not familiar if we're to the point with recent changes like #14367 to be able to remove this and let RESET_BLOCKS entirely take over the state (no merge).

That would be ideal, who would be a good person to ask?

...mapValues(
pickBy(
flattenBlocks( action.blocks ),
( block ) =>
! isShallowEqual( block, {
...state.byClientId[ block.clientId ],
attributes: state.attributes[ block.clientId ],
} )
),
() => ( {} )
),
},
};
}
Expand Down
17 changes: 11 additions & 6 deletions packages/block-editor/src/store/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1502,16 +1502,21 @@ export function getSettings( state ) {
}

/**
* Returns true if the most recent block change is be considered persistent, or
* false otherwise. A persistent change is one committed by BlockEditorProvider
* via its `onChange` callback, in addition to `onInput`.
* Returns true if the most recent block change is to be considered persistent with
* regards to the provided root client ID, or false otherwise. A persistent
* change is one committed by `BlockEditorProvider` via its `onChange` callback, in addition to `onInput`.
*
* @param {Object} state Block editor state.
* @param {Object} state Block editor state.
* @param {string} [rootClientId] Block root client ID.
*
* @return {boolean} Whether the most recent block change was persistent.
*/
export function isLastBlockChangePersistent( state ) {
return state.blocks.isPersistentChange;
export function isLastBlockChangePersistent( state, rootClientId ) {
return (
state.blocks.isPersistentChange &&
( ! state.blocks.persistentChangeRootClientId ||
state.blocks.persistentChangeRootClientId === rootClientId )
);
}

/**
Expand Down
8 changes: 8 additions & 0 deletions packages/blocks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@ _Returns_

- `Object`: Block object.

<a name="DEFAULT_BLOCK_TYPE_SETTINGS" href="#DEFAULT_BLOCK_TYPE_SETTINGS">#</a> **DEFAULT_BLOCK_TYPE_SETTINGS**

Default values to assign for omitted optional block type settings.

_Type_

- `Object`

<a name="doBlocksMatchTemplate" href="#doBlocksMatchTemplate">#</a> **doBlocksMatchTemplate**

Checks whether a list of blocks matches a template by comparing the block names.
Expand Down
1 change: 1 addition & 0 deletions packages/blocks/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export {
export { isValidBlockContent } from './validation';
export { getCategories, setCategories, updateCategory } from './categories';
export {
DEFAULT_BLOCK_TYPE_SETTINGS,
registerBlockType,
registerBlockCollection,
unregisterBlockType,
Expand Down