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

[WIP]: handle duplicated blocks from entities with aliases and entity management #27084

Closed
wants to merge 6 commits into from
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -1433,6 +1433,16 @@ _Returns_

- `Object`: Action object.

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

Returns an action object that sets the block tree underneath the given target
to be the same as the one under the source using block aliases.

_Parameters_

- _sourceTree_ `string`: The client ID of the source for the sync.
- _targetTree_ `string`: The client ID of the target for the sync.

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

Returns an action object synchronize the template with the list of blocks
Expand Down
3 changes: 2 additions & 1 deletion packages/block-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@
"redux-multi": "^0.1.12",
"rememo": "^3.0.0",
"tinycolor2": "^1.4.1",
"traverse": "^0.6.6"
"traverse": "^0.6.6",
"uuid": "^8.3.1"
},
"publishConfig": {
"access": "public"
Expand Down
42 changes: 40 additions & 2 deletions packages/block-editor/src/components/provider/use-block-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { last, noop } from 'lodash';
import { useEffect, useRef } from '@wordpress/element';
import { useRegistry } from '@wordpress/data';

/**
* Internal dependencies
*/
import useEntityManager from './use-entity-manager';

/**
* A function to call when the block value has been updated in the block-editor
* store.
Expand Down Expand Up @@ -45,6 +50,10 @@ import { useRegistry } from '@wordpress/data';
* If none is passed, then it is assumed to be a
* root controller rather than an inner block
* controller.
* @param {string} props.entityId An ID which should identify the "thing" to which
* the blocks belong. This is used to keep track
* of times when two different components need to
* sync the same blocks.
* @param {Object[]} props.value The control value for the blocks. This value
* is used to initalize the block-editor store
* and for resetting the blocks to incoming
Expand All @@ -65,6 +74,7 @@ import { useRegistry } from '@wordpress/data';
*/
export default function useBlockSync( {
clientId = null,
entityId = null,
value: controlledBlocks,
selectionStart: controlledSelectionStart,
selectionEnd: controlledSelectionEnd,
Expand All @@ -79,10 +89,15 @@ export default function useBlockSync( {
replaceInnerBlocks,
setHasControlledInnerBlocks,
__unstableMarkNextChangeAsNotPersistent,
synchronizeBlockSubTrees,
} = registry.dispatch( 'core/block-editor' );
const { getBlockName, getBlocks } = registry.select( 'core/block-editor' );

const pendingChanges = useRef( { incoming: null, outgoing: [] } );
const { isPrimaryManager, primaryManagerId } = useEntityManager(
entityId,
clientId
);

const setControlledBlocks = () => {
if ( ! controlledBlocks ) {
Expand All @@ -102,6 +117,20 @@ export default function useBlockSync( {
}
};

useEffect( () => {
if ( ! isPrimaryManager ) {
// Since we are not the primary, we want to synchronize our inner
// blocks to be the same as the ones under the actual primary.
synchronizeBlockSubTrees( primaryManagerId, clientId );
// this needs to run after the primary template part exists. Unsure
// if that will be problematic -- to be fair, if we are not the main
// tracker for the entity, then the main tracker has already been
// set up, at least partially.
}
// Note: we do NOT want the effect to run again when the primary ID changes.
// It would introduce unecessary calculations so it is not a dependency.
}, [ isPrimaryManager ] );

// Add a subscription to the block-editor registry to detect when changes
// have been made. This lets us inform the data source of changes. This
// is an effect so that the subscriber can run synchronously without
Expand All @@ -114,6 +143,11 @@ export default function useBlockSync( {
}, [ onInput, onChange ] );

useEffect( () => {
// Do not listen for changes if we are not the entity tracker.
if ( ! isPrimaryManager ) {
return;
}

const {
getSelectionStart,
getSelectionEnd,
Expand Down Expand Up @@ -183,10 +217,14 @@ export default function useBlockSync( {
} );

return () => unsubscribe();
}, [ registry, clientId ] );
}, [ registry, clientId, isPrimaryManager ] );

// Determine if blocks need to be reset when they change.
useEffect( () => {
// Do not listen for changes if the entity is already tracked.
if ( ! isPrimaryManager ) {
return;
}
if ( pendingChanges.current.outgoing.includes( controlledBlocks ) ) {
// Skip block reset if the value matches expected outbound sync
// triggered by this component by a preceding change detection.
Expand Down Expand Up @@ -215,5 +253,5 @@ export default function useBlockSync( {
);
}
}
}, [ controlledBlocks, clientId ] );
}, [ controlledBlocks, clientId, isPrimaryManager ] );
}
173 changes: 173 additions & 0 deletions packages/block-editor/src/components/provider/use-entity-manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* WordPress dependencies
*/
import { useEffect, useRef, useState } from '@wordpress/element';

/**
* A function to call to inform the entity manager that it is now in charge.
*
* @callback setAsPrimary
* @param {boolean} isPrimary If true, set the entity manager is the primary.
*/

/**
* A manager is something which needs to keep track of changes in the block editor
* which might need to be synced back to an entity, or vice-versa.
*
* @typedef WPEntityManager
* @property {string} id The id of the manager. Typically the block client
* ID of an inner block controller.
* @property {setAsPrimary} setAsPrimary A function to call to set the manager
* as the primary manager for the entity.
*/

/**
* This object maintains a list of the entity managers we have. Each entity ID
* is a key of the object which maps to an array of managers. The first entry in
* each array is considered the primary manager for the given entity. The primary
* manager is the only manager which is allowed to sync changes in and out of the
* block editor from the entity. This way, we do not have multiple managers trying
* to put identical blocks into the editor in different locations.
*
* @type {Object.<string, WPEntityManager[]>}
*/
const _ENTITY_MANAGERS = {};

/**
* Adds an entity manager to the list under the given entity. A manager is only
* added to the list once. The first manager to be added is considered the primary
* manager.
*
* @param {string} entityId The ID of the entity we want to watch.
* @param {WPEntityManager} manager The manager to attache to the given entity.
* @return {boolean} True if the added entity manager is the primary manager.
*/
function addEntityManager( entityId, manager ) {
if ( ! entityId ) {
return true;
}

let isPrimary = false;
if ( ! Array.isArray( _ENTITY_MANAGERS[ entityId ] ) ) {
_ENTITY_MANAGERS[ entityId ] = [];
}

/**
* This basically handles two cases:
* 1. There is no entity tracking setup for the entityId yet. As a result,
* this is the first one to be added, so it is primary.
* 2. There has been entity tracking setup in the past, but there are no
* trackers currently assigned to the entity. As a result, this is the only
* manager which currently exists, so it is primary.
*/
if ( _ENTITY_MANAGERS[ entityId ].length === 0 ) {
isPrimary = true;
}

if (
! _ENTITY_MANAGERS[ entityId ].some( ( { id } ) => id === manager.id )
) {
_ENTITY_MANAGERS[ entityId ].push( manager );
}
return isPrimary;
}

/**
* Removes a manager from the list. Does nothing if the manager does not exist.
* Once a manager is removed, the first manager in the list is informed that it
* is the manager via the `setAsPrimary` callback. Note that this callback is
* only called if the removed manager was the primary manager. It is not called
* if the old primary manager is still the same.
*
* @param {string} entityId The ID of the entity the manager is attached to.
* @param {string} managerId The ID of the manager to remove.
*/
function removeEntityManager( entityId, managerId ) {
// Do nothing if no trackers have been set up.
if ( ! Array.isArray( _ENTITY_MANAGERS[ entityId ] ) ) {
return;
}
const itemIndex = _ENTITY_MANAGERS[ entityId ].findIndex(
( { id } ) => id === managerId
);
if ( itemIndex > -1 ) {
// Remove the manager.
_ENTITY_MANAGERS[ entityId ].splice( itemIndex, 1 );

// Tell the new primary manager that it is now in charge.
if ( itemIndex === 0 ) {
return _ENTITY_MANAGERS[ entityId ][ 0 ]?.setAsPrimary( true );
}
}
}

/**
* A hook which keeps track of whether we are the primary manager for a given
* entity. When multiple components are trying to manage the same entity via this
* hook, the hook keeps everything organized. Only one component is allowed to
* manager an entity. This hook returns true when the component (identified by
* `managerId`) is the primary manager for the entity.
*
* In practice, if this returns true, it means that the component has permission
* to update and sync changes in/out of the block editor to the entity via the
* inner block controller mechanism.
*
* If no entity ID is passed, it's assumed that there is no need to keep track
* of multiple entitys per manager. For example, the root block-editor would not
* pass a clientId because there is no need for it to share management with
* multiple different components.
*
* However, a template part may need to share management of a single entity with
* multiple components when there are multiple template part blocks which each
* reference the same template part entity. In that case, it is important that
* only one component is in charge of handling syncs from the entity to the block
* editor.
*
* @param {string=} [entityId] A unique identifier for the entity to manage. If
* no entity ID is given, it is assumed that we are
* always the primary manager.
* @param {string} managerId The ID to use for the entity manager.
*/
export default function useEntityManager( entityId, managerId ) {
/**
* This method sets that we are now the tracker. It is called when a different
* primary manager has been removed to let this manager know that it is now
* in charge.
*
* @type {setAsPrimary}
*/
const setAsPrimary = ( isPrimary ) => setIsPrimary( isPrimary );

const [ isPrimaryManager, setIsPrimary ] = useState( () =>
addEntityManager( entityId, {
id: managerId,
setAsPrimary,
} )
);

const oldEntityId = useRef( entityId );
const oldManagerId = useRef( managerId );

useEffect( () => {
const didEntityChange = oldEntityId.current !== entityId;
const didClientIdChange = oldManagerId.current !== managerId;
// If the info changed, we need to reset the tracker information.
if ( didEntityChange || didClientIdChange ) {
removeEntityManager( oldEntityId.current, oldManagerId.current );
const isNewPrimary = addEntityManager( entityId, {
id: managerId,
setAsPrimary,
} );
setIsPrimary( isNewPrimary );
oldEntityId.current = entityId;
oldManagerId.current = managerId;
}
// Remove the manager on component unmount.
return () => removeEntityManager( entityId, managerId );
}, [ entityId, managerId ] );

return {
isPrimaryManager,
primaryManagerId: _ENTITY_MANAGERS[ entityId ]?.[ 0 ]?.id,
};
}
15 changes: 15 additions & 0 deletions packages/block-editor/src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -1348,3 +1348,18 @@ export function setHasControlledInnerBlocks(
clientId,
};
}

/**
* Returns an action object that sets the block tree underneath the given target
* to be the same as the one under the source using block aliases.
*
* @param {string} sourceTree The client ID of the source for the sync.
* @param {string} targetTree The client ID of the target for the sync.
*/
export function synchronizeBlockSubTrees( sourceTree, targetTree ) {
return {
type: 'SYNCHRONIZE_BLOCK_SUB_TREES',
sourceTree,
targetTree,
};
}
Loading