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

Fix issues with inserting the same inner block controller more than once #24180

Closed
wants to merge 7 commits into from

Conversation

noahtallen
Copy link
Member

@noahtallen noahtallen commented Jul 24, 2020

Description

Resolves #22639. You can now add more than one template parts of the same type to the editor. (or anything which shares the same data source as an inner block controller.)

I wrote a description for this approach in this comment. TLDR is that in cases where two different inner block controllers share the same entityId (and therefore data source), we map the blocks in all duplicated instances to different local clientIds. This way, there are no duplicated clientIds stored in the block editor.

Current issues:

  • When typing in a duplicated instance, the focus is constantly stolen away. (probably need to map selection state back to the correct clientIds)
  • Changes in one duplicated template part don't appear to update other instances.
  • Once or twice, I have still seen one of the duplicated instances disappear from the editor. (possibly an edge case)
  • Improve verbosity of some of the code
  • This approach might be bad for performance. in big-O, we're looking at an extra O(N) (where N is the total number of blocks in a duplicated template part) whenever an incoming and/or outgoing update is dispatched.
  • Add unit tests

How has this been tested?

Locally, in edit site.

Screenshots

TBD.

Types of changes

Bug fix

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code follows the accessibility standards.
  • My code has proper inline documentation.
  • I've included developer documentation if appropriate.
  • I've updated all React Native files affected by any refactorings/renamings in this PR.

@github-actions
Copy link

github-actions bot commented Jul 24, 2020

Size Change: +743 B (0%)

Total Size: 1.2 MB

Filename Size Change
build/annotations/index.js 3.52 kB -1 B
build/block-directory/index.js 8.41 kB +1 B
build/block-editor/index.js 129 kB +764 B (0%)
build/block-library/index.js 135 kB +10 B (0%)
build/blocks/index.js 47.5 kB +3 B (0%)
build/components/index.js 202 kB -52 B (0%)
build/data/index.js 8.43 kB +1 B
build/edit-navigation/index.js 10.4 kB +4 B (0%)
build/edit-post/index.js 306 kB +3 B (0%)
build/edit-site/index.js 19.6 kB +4 B (0%)
build/edit-widgets/index.js 17.6 kB +1 B
build/editor/index.js 45.3 kB +4 B (0%)
build/format-library/index.js 7.49 kB +1 B
ℹ️ View Unchanged
Filename Size Change
build/a11y/index.js 1.14 kB 0 B
build/api-fetch/index.js 3.34 kB 0 B
build/autop/index.js 2.72 kB 0 B
build/blob/index.js 620 B 0 B
build/block-directory/style-rtl.css 943 B 0 B
build/block-directory/style.css 942 B 0 B
build/block-editor/style-rtl.css 11.1 kB 0 B
build/block-editor/style.css 11.1 kB 0 B
build/block-library/editor-rtl.css 8.59 kB 0 B
build/block-library/editor.css 8.59 kB 0 B
build/block-library/style-rtl.css 7.6 kB 0 B
build/block-library/style.css 7.59 kB 0 B
build/block-library/theme-rtl.css 741 B 0 B
build/block-library/theme.css 741 B 0 B
build/block-serialization-default-parser/index.js 1.77 kB 0 B
build/block-serialization-spec-parser/index.js 3.1 kB 0 B
build/components/style-rtl.css 15.5 kB 0 B
build/components/style.css 15.5 kB 0 B
build/compose/index.js 9.42 kB 0 B
build/core-data/index.js 12 kB 0 B
build/data-controls/index.js 1.27 kB 0 B
build/date/index.js 31.9 kB 0 B
build/deprecated/index.js 772 B 0 B
build/dom-ready/index.js 568 B 0 B
build/dom/index.js 4.42 kB 0 B
build/edit-navigation/style-rtl.css 868 B 0 B
build/edit-navigation/style.css 871 B 0 B
build/edit-post/style-rtl.css 6.24 kB 0 B
build/edit-post/style.css 6.22 kB 0 B
build/edit-site/style-rtl.css 3.3 kB 0 B
build/edit-site/style.css 3.3 kB 0 B
build/edit-widgets/style-rtl.css 2.79 kB 0 B
build/edit-widgets/style.css 2.79 kB 0 B
build/editor/editor-styles-rtl.css 492 B 0 B
build/editor/editor-styles.css 493 B 0 B
build/editor/style-rtl.css 3.8 kB 0 B
build/editor/style.css 3.8 kB 0 B
build/element/index.js 4.45 kB 0 B
build/escape-html/index.js 733 B 0 B
build/format-library/style-rtl.css 547 B 0 B
build/format-library/style.css 548 B 0 B
build/hooks/index.js 1.74 kB 0 B
build/html-entities/index.js 622 B 0 B
build/i18n/index.js 3.54 kB 0 B
build/is-shallow-equal/index.js 711 B 0 B
build/keyboard-shortcuts/index.js 2.39 kB 0 B
build/keycodes/index.js 1.85 kB 0 B
build/list-reusable-blocks/index.js 3.02 kB 0 B
build/list-reusable-blocks/style-rtl.css 476 B 0 B
build/list-reusable-blocks/style.css 476 B 0 B
build/media-utils/index.js 5.12 kB 0 B
build/notices/index.js 1.69 kB 0 B
build/nux/index.js 3.27 kB 0 B
build/nux/style-rtl.css 671 B 0 B
build/nux/style.css 668 B 0 B
build/plugins/index.js 2.44 kB 0 B
build/primitives/index.js 1.34 kB 0 B
build/priority-queue/index.js 789 B 0 B
build/redux-routine/index.js 2.85 kB 0 B
build/rich-text/index.js 13.7 kB 0 B
build/server-side-render/index.js 2.6 kB 0 B
build/shortcode/index.js 1.7 kB 0 B
build/token-list/index.js 1.24 kB 0 B
build/url/index.js 4.06 kB 0 B
build/viewport/index.js 1.74 kB 0 B
build/warning/index.js 1.13 kB 0 B
build/wordcount/index.js 1.17 kB 0 B

compressed-size-action

@noahtallen
Copy link
Member Author

My current guess for the selection issue is that it has something to do with the onFocus event firing in RichText, but there are two RichText components for the same thing, and they both fight for control of the selection which ends up getting set to 0. At any rate, there seems to be about 10 dispatches of set selection state when selecting into the component. Or something like that.

At any rate, I've confirmed that it is not related to useBlockSync. None of the methods in block sync which could change selection state are called when I select into the component.

@epiqueras
Copy link
Contributor

Nice work!

@ZebulanStanphill ZebulanStanphill added the Needs Technical Feedback Needs testing from a developer perspective. label Jul 24, 2020
@MichaelArestad
Copy link
Contributor

I ran into this earlier. Glad you're working on a fix.

@noahtallen
Copy link
Member Author

I've been thinking about alternate approaches here which would map incoming blocks from an entity to fresh block IDs for use within the block editor. I'll try to post a more detailed dive into my thoughts so far tomorrow. I think mapping the blocks to fresh IDs when there are > 1 instance of the same inner block controller will fix the issues we have, but it will be difficult to handle incoming/outgoing states without race conditions.

@noahtallen
Copy link
Member Author

To help us understand the situation more (and to help me think about the problem), I created a flowchart sort of thing which you can view here: https://whimsical.com/7jzmmDXxDDHVeidLthhmhZ. Screenshots and descriptions below. I hope this is helpful to anyone who might not understand the issue yet and wants to help out :)

Description of block sync mechanism with template parts:

This flowchart describes how the template part entity is parsed from the CPT, added to an entity provider, and synced to any template part block which references the given template part entity. To the left is a description of when changes are synced to/from the different components at each level:

image

An example with blocks

This chart describes a situation where you have a template part entity with the parsed block client IDs [a, b, c], and a template which contains three template part blocks (tp2, tp2, and tp3) each referencing that same template part entity:
image

The problem with having duplicated blocks is evident when looking at the blocks.parents state, which maps a single lock client ID to its parent block client ID. In the example above, that state would look like this:

const parents = {
  a: 'tp1',
  b: 'tp2',
  c: 'tp3',
};

As you can see, I can only say that the block with clientId a has a single parent, tp1. But as you know from the chart above, this block clientID is actually a child of three different parent blocks.

There are several other bits of the block editor store which behave in similar ways, where having unique block client IDs is assumed. In some places, this is actually helpful, because on screen, the block with clientID a needs to behave the same way after it is already inside of the template part block (e.g. it has the same children everywhere, the same attributes, same selection, etc). The main problem is just hierarchy, positioning, and race conditions.

An example chain of actions: editing a template part's inner blocks

This diagram traces what happens when you make a modification to, say, the block with clientId a. The setup with three template part blocks referencing the same entity here is same as in the above sections.

Note: start at the "START HERE" button at the bottom left.

image

As you can see from the diagram, the ultimate effect of the replaceInnerBlocks action is that the template part entity is wiped completely of all changes. This is the specific cause of #22639.

Solutions

  1. Fix replaceInnerBlocks action such that it does not cause the other template part blocks to dispatch an update when the blocks are removed in the first part of the action. This was the first thought in this PR, and implementation basically tricks useBlockSync into thinking that no changes happened during the first part of replaceInnerBlocks.
  2. When we have multiple template part blocks referencing the same entity blocks, useBlockSync should detect duplicates, and then map the entity clientIDs to new clientIDs for use only in the block editor store. This means that template parts tp1, tp2, and tp3 referencing the same template part entity each have children with unique IDs, like a-tp1, b-tp1, c-tp1. So there would be no issues dealing with blocks which have the same clientID. The only challenge would be reliably mapping the blocks to clientIDs and making sure that sync works properly among everything.
  3. Feel free to suggest another solution!

So far, it seems like number 2 is the best option, and I've been exploring ways to accomplish this. I should be able to post more about that soon.

@noahtallen
Copy link
Member Author

Thinking about solution two for a bit, I think we can make it work. This solution would basically map the blocks from the entity to new block clientIDs at the useBlockSync level. This diagram explains why solution two would not have the same problems. (Using the same structure as my diagrams above.)

image

Thinking about implementation details, we would have a global data structure to track which entities are already being tracked:

// tracks which entities are in the block editor
const entityTracker = {
  entityIdentifier: string[] // an array of strings identifying useBlockSync instances
};

// In useBlockSync
useEffect( () => {
  // on component mount, make sure we register ourselves as something tracking the entity. 
  entityTracker[ entityIdentifier ].push( selfId );
  return () => entityTracker[ entityIdentifier ].remove( selfId );
}, [ entityIdentifier ] );

// When the incoming block value from the entity changes:

// If there is more than one thing listening to this entity, we need to map the blocks
if ( entityTracker[ entityIdentifier ].length > 0 ) {
  blocks = mapBlocksToLocalIds( blocks );
  // then send the blocks to the local block editor.
}

// When the block editor updates with new blocks:
if ( entityTracker[ entityIdentifier ].length > 0 ) {
  blocks = mapBlocksToEntityIds( blocks );
  // then send the mapped blocks to onChange.
}

Then we would need a local data structure to match which blocks have been mapped:

const entitiesToLocal = {};
const localToEntities = {};

function mapBlocksToLocalIds( blocks ) {
  // Note: actual implementation would have to account for nested innerBlocks.
  return blocks.map( ( block ) => {
    if ( ! entitiesToLocal[ block.id ] ) {
      const localId = new UID(); // Just has to be a unique string of some sort.
      entitiesToLocal[ block.id ] = localId;
      localToEntities[ localId ] = block.id;
    }
    return {
      ...block,
      id: entitiesToLocal[ block.id ],
    };
  } );
}

function mapBlocksToEntityIds( blocks ) {
  // Note: actual implementation would have to account for nested innerBlocks.
  return blocks.map( ( block ) => {
    if ( ! localToEntities[ block.id ] ) {
      // This entityId could be more tricky when inserting new blocks.
      // Would need to verify how clientIds for new blocks are generated,
      // and if that would be compatible with change here.
      const entityId = new UID(); 
      localToEntities[ block.id ] = entityId;
      entitiesToLocal[ entityId ] = block.id;
    }
    return {
      ...block,
      id: localToEntities[ block.id ],
    };
  } );
}

I think the possible rough edges here are the following:

  • Making sure that selection state is updated correctly
  • Making sure that the add/remove block cases work correctly with block generation.

@youknowriad
Copy link
Contributor

@noahtallen some variations of these charts and explanations should go into the "architecture" docs of the repository to explain how the full site editor works :)

@noahtallen
Copy link
Member Author

noahtallen commented Sep 1, 2020

I'm testing this out on this branch: https://github.com/WordPress/gutenberg/compare/try/fix-multiple-tempalte-parts. I forgot to put the code on this branch, so I'll probably do that tomorrow :) (This branch should now have the latest code.)

Currently, it fixes the problem where duplicated instances are deleted immediately when you insert them. There are still several issues though. I haven't spent any time looking into them yet, so far was just able to implement the rough code we have now.

  • When typing in a duplicated instance, the focus is constantly stolen away. (probably need to map selection state back to the correct clientIds)
  • Changes in one duplicated template part don't appear to update other instances.
  • Once or twice, I have still seen one of the duplicated instances disappear from the editor. (possibly an edge case)
  • The code is fairly messy. I just wanted to get it working to see what the limitations might be :)
  • This approach might be bad for performance. in big-O, we're looking at an extra O(N) (where N is the total number of blocks in a duplicated template part) whenever an incoming and/or outgoing update is dispatched.

@noahtallen noahtallen force-pushed the try/fix-inner-blocks-insertion branch from 172a606 to 306add6 Compare September 1, 2020 17:28
@@ -67,7 +67,8 @@
"refx": "^3.0.0",
"rememo": "^3.0.0",
"tinycolor2": "^1.4.1",
"traverse": "^0.6.6"
"traverse": "^0.6.6",
"uuid": "^7.0.2"
Copy link
Member Author

Choose a reason for hiding this comment

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

Note: this is used elsewhere in gutenberg

@@ -26,7 +26,7 @@ import getBlockContext from './get-block-context';
import BlockList from '../block-list';
import { BlockContextProvider } from '../block-context';
import { useBlockEditContext } from '../block-edit/context';
import useBlockSync from '../provider/use-block-sync';
import useBlockSync from '../use-block-sync';
Copy link
Member Author

Choose a reason for hiding this comment

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

Moved use block sync to its own directory

} ) {
const registry = useRegistry();
const instanceId = useRef( uuid() ).current;
Copy link
Member Author

Choose a reason for hiding this comment

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

The withInstanceId/useInstanceId won't work here because they rely on mapping an object (e.g. component) to a new ID. This approach doesn't work with custom hooks directly. (it might be useful to move this logic to that package so that it does, though)

@@ -84,6 +102,14 @@ export default function useBlockSync( {

const pendingChanges = useRef( { incoming: null, outgoing: [] } );

useEffect( () => {
Copy link
Member Author

Choose a reason for hiding this comment

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

could possibly move this to its own custom hook

@@ -199,6 +246,7 @@ export default function useBlockSync( {
) {
pendingChanges.current.outgoing = [];
}
// TODO: problematic if getBlocks has alternate client IDs
} else if ( getBlocks( clientId ) !== controlledBlocks ) {
Copy link
Member Author

Choose a reason for hiding this comment

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

Need to fix this check. Theoretically, if blocks are being mapped to local IDs, then getBlocks will return one array with local IDs, but the controlledBlocks will use the IDs which come from the entity. As a result, the references will always be different.

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think this is causing any bugs, though. It's probably just here for performance

@youknowriad
Copy link
Contributor

The mapping already only happens when onChange would be called, in the outgoing case. We need to be able to update the clientIds here so that when the "entity" receives the onChange call, it can update its blocks appropriately. Unfortunately, this happens on every keyboard stroke.. :/

Redux state and selectors in general rely a lot on memoization, what I'm saying is that when onChange is called, we only generate new objects for the blocks that did change, If the block didn't, we can use the existing object (the external or internal version of the object depending on the direction of the change)

@noahtallen
Copy link
Member Author

we only generate new objects for the blocks that did change, If the block didn't, we can use the existing object (the external or internal version of the object depending on the direction of the change)

Hm. I'm following a little better. But to clarify, do you mean that we would stop generating new objects during the mapping process? Every single block needs to receive a new clientID regardless of whether it is different. Every client ID needs to be unique locally. In this scenario, do we simply mutate the objects instead of creating new ones?

But would that work? How about this scenario:

entity: listOfBlocks

templatePartA: uses listOfBlocks

templatePartB: uses listOfBlocks

listOfBlocks has a specific reference, as does the blocks. If we mutate those objects, aren't we mutating a reference that is shared between entity, templatePartA, and templatePartB? In which case, wouldn't they all ultimately continue to have the same clientIDs after the mapping process is complete?

@youknowriad
Copy link
Contributor

Every single block needs to receive a new clientID regardless of whether it is different. Every client ID needs to be unique locally. In this scenario, do we simply mutate the objects instead of creating new ones?

No, that's not what i meant, I mean we continue generating new objects but we keep two lists of objects (one external and one internal) and when doing the sync, if the previous version of a block is strictly equal to the new version, it means that particular block didn't change, so instead of creating a new instance for the mapped version, pick the previously used one from the other list.

const externalBlockA = {};
const externalBlockB = {};
const externalValue = [ externalBlockA, externalBlockB ];

// initial mapping
const internalValue = map( externalValue );

// Call onChange one block
// ex: setAttributes( internalValue[ 0 ].clientId, { something } )
// We get a new variable here newInternalValue
// This means the sync need to happen to the outside 

const newExternalValue = map( newInternalValue );

expect( newExternalValue[ 0 ] ).not.toBe( externalValue[ 0 ] ); // This is different because that block changed
expect( newExternalValue[ 1 ] ).not.toBe( externalValue[ 1 ] ); // This is not different because that block was not updated

This optimization is useful to avoid extra re-rendering because our usage of React and redux rely on memoization. That said, I'm not sure how complex it is to implement.

@noahtallen
Copy link
Member Author

noahtallen commented Sep 15, 2020

Oh that's really interesting! It could help performance, not by reducing the cost of the array iteration, but by improving memoization ability across the editor UI.

@noahtallen noahtallen force-pushed the try/fix-inner-blocks-insertion branch from 02d43ac to 78d170c Compare September 22, 2020 00:32
@noahtallen
Copy link
Member Author

c976304 attempts to persist the object references if the block has not changed. I'm pretty sure that the logic is correct, but I'm noticing a big performance improvements. The editor becomes noticeably sluggish after a point. 🤔

@noahtallen
Copy link
Member Author

One thought is that we might not be realizing all of the performance gains we could be with this approach. Let's assume that everything is working correctly as outlined in @youknowriad's comment above. Even if this is correct, the local useBlockSync is going to call replaceInnerBlocks every time anything changes.

i.e.:

const block1 = {};
const block2 = {};

const localBlocks = [ block1, block 2 ];

Then, say an update happens to block1 in a different inner block controller. So now we need to update the local block list:

const mappedBlocks = mapBlocksToLocal( externalBlocks );


// These statements are true:
mappedBlocks[ 0 ] !== block1;
mappedBlocks[ 0 ] === block2;

All as expected, right?

But now we do this:

replaceInnerBlocks( mappedBlocks );

And the replace inner blocks action is going to first remove all existing blocks, and then add all of mappedBlocks back in. Firstly, this is a lot of work in and of itself, but I imagine it also invalidates block cache and also changes block references...

Comment on lines +93 to +112
// TODO: useState vs useRef here? Unsure if a change in the manager should
// re-run useBlockSync, or if pointing at the ref works fine.
const [ manager, updateManager ] = useState(
createBlockClientIdManager( assocateWithEntity( entityId ) )
);

const entityIdRef = useRef( entityId );

// This side effect creates a new block client ID manager if and only if the
// component starts using blocks from a new entity. Since consumers require
// the manager before this effect would be run, we initalize the manager
// above and then avoid initalizing a new one the first time the effect runs.
useEffect( () => {
if ( entityIdRef.current !== entityId ) {
updateManager(
createBlockClientIdManager( assocateWithEntity( entityId ) )
);
entityIdRef.current = entityId;
}
}, [ entityId ] );
Copy link
Member

Choose a reason for hiding this comment

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

I might not know what I'm talking about, but couldn't this whole thing just be replaced with:

	const manager = useMemo(
		() => createBlockClientIdManager( assocateWithEntity( entityId ) ),
		[ entityId ]
	);

@youknowriad
Copy link
Contributor

And the replace inner blocks action is going to first remove all existing blocks, and then add all of mappedBlocks back in. Firstly, this is a lot of work in and of itself, but I imagine it also invalidates block cache and also changes block references...

The reasoning sounds correct, so if it's true maybe the optimization we did is not the best one or the right one. I guess ideally we should find a way to avoid generating a new cache key in these cases right?

@noahtallen
Copy link
Member Author

Yeah, totally. it sounds like this sync behavior should be happening at some level inside the block-editor store. Like, when I do updateAttribute( blockA ), that change should just be copied to the copies of that block under the template part in that same dispatch. It'd be way more efficient than syncing out then syncing back in to each copy. But also more tricky to handle correctly :/

@youknowriad
Copy link
Contributor

Hey, @noahtallen I just remembered this PR :) It seems we need to find a path forward here (even an imperfect one), what's your current thoughts?

@noahtallen
Copy link
Member Author

Hey! My goal for this week is to push this PR over the finish line :D

My current thought is to do the syncing inside the block-editor redux code. Basically, something like the following:

  1. when inserting a duplicate template part, somehow mark that it's a duplicate in the block-editor store.
  2. When an edit operation happens to any one of the duplicate blocks, sync that edit to all of the duplicated blocks inside a reducer.
  3. Make sure that useBlockSync does not try to dispatch outgoing updates for all three blocks.

The idea here is to move the "sync edits across all duplicated blocks" part of the problem into the reducer, rather than relying on an outbound sync to the entity store, and then back into the block-editor store again. This would really cut down on the amount of state updates, which would probably work very well for performance.

@noahtallen
Copy link
Member Author

I spent some time thinking about this recently. It's a tricky.

The Problem:

To summarize the solution which is currently implemented in this PR: Instead of inserting duplicated client IDs into the block tree in different places, we instead map all synced blocks to new IDs whenever we put the blocks into the block editor. Then, when we make an edit, we sync the blocks back to its source ID before telling the entity about the update. When that happens, the entity can inform all the other listeners of the new blocks, which then sync to new IDs, and are then inserted.

So the sync process in short is:

  1. Make edit to $block inside of $tp1.
  2. For each inner block of $tp1, map to the "source" client ID.
  3. Send that tree of blocks now with source IDs to the entity provider.
  4. For each duplicated template part, see that new changes have occured.
  5. Map the incoming source blocks to local client IDs for each duplicated template part.
  6. Delete all of the blocks under the local template part.
  7. Insert all of the new blocks under the local template part. (these last two items are the replaceInnerBlocks action.)

As you can tell, this approach has us recursing through the entire slice of the block tree under a certain template part multiple times. First on the outgoing edit, and then again up to N more times. Not to mention some amount of latency between getting the update into the entity store, and then back into the block editor store. On top of that, we have to delete the entire subtree N times, and then re-insert it N times.

This is unfortunate, because the sync happens on every edit.... So, on every keystroke, you are iterating through every single nested block of the local template part and doing a fair amount of work.

Basically, it's a performance problem. Though this approach works to solve the bugs we currently encounter, it's pretty heavy-handed!

One thing I was thinking: do we have to use replaceInnerBlocks? Basically, yes. We only know that the tree of blocks has a different reference, so we don't know what granular change occurred without parsing and comparing the whole thing anyways.

A Path Forward

What's the real problem we're solving here? It's essentially how do we show one block multiple times in the block tree?

My concept to solve this problem would involve a way to alias another block. Think of a function like this:

synchronizeBlockTree( source, alias )

This could be used to make the trees under each root are identical. Basically, the first time you call this function, it would recurse through the tree under source using this pseudocode:

const aliasTree = [];

// ignoring nested stuff for simplicity
for sourceBlock in sourceBlockTree {
  const aliasId = uuid();
  const alias = {
    ...sourceBlock,
    clientId: aliasId,
    aliasOf: sourceBlock.clientId
  }
 aliasTree.push( alias ); // Track the block the alias references
 sourceBlock.aliases.push( aliasId ); // Track each alias referencing the source block
}

// Update the slice of the tree under the "duplicated template part block" to be an alias of the source (e.g. alias of the first inserted template part block)
replaceInnerBlocks( alias, aliasTree );

Conceptually, the idea would be to maintain a single source of truth for a block. Each "alias" just references that source of truth. It would not be possible in this approach to both be an alias and a source.

Essentially, the first template part would be inserted as normal. But when we go to insert another template part block referencing the same entity, we could have it run synchronizeBlockTree to setup all of its children blocks in the correct way.

  1. The duplicate block detects that there is already something syncing blocks for this entity. (From the original template part block.)
  2. In that case, run the sync function to set up.

Then, in the reducer, we can handle CRUD actions across blocks and their aliases on a granular level.

For example, if I want to "update" a block, the new logic would go like this:

updateBlock( clientId, updatedData ) {
  const block = getBlock(clientId);

  // If this is a source block, update each alias.
  if ( block.aliases) {
    block.aliases.forEach( aliasId => doUpdate( alisId, updatedData );
    doUpdate( clientId, updatedData );
  }

  // If this is an alias, just pass the operation back to the source block.
  // The source block will handle updating itself, and all aliases (including the current block)
  if ( block.aliasOf ) {
    updateBlock( block.aliasOf, updatedData );
  }
}

For something like creating a block, we would just have to check if the parent block has aliases, and then attach it as a child to each of the aliased blocks.

This would basically allow everything to propagate correctly inside of the block-editor store, but what about syncing back to the entity?

Since we are no longer syncing in/out for duplicated blocks referencing the same entity, we only have one thing to worry about, which is the source block. Considering that all of the source blocks are updated properly using the above mechanism, the outgoing mechanism will work the same as it currently does, with no need to map to different clientIds.

That leaves future "incoming" syncs (such as the UNDO operation). This is a bit tricky, but I think an incoming sync would require:

  1. replaceInnerBlocks on the source block tree. (I don't think this would fall into the normal CRUD operations above.)
  2. Run synchronizeBlockTree again for each of the duplicated template parts to make sure it exactly matches the incoming blocks.

I don't think we can implement a special case for replaceInnerBlocks in the reducer in that case, because we simply can't account for all the blocks. We can only know details (like aliases) of the blocks which already exist in the block store, so it just wouldn't be possible to infer granular operations here. I think it would basically be "wipe, replace, and re-sync aliases".


Thanks for reading! Please let me know what you think of this new idea. I think it's a bit more elegant and probably much better for performance since we are not changing the number of syncs in/out.

@youknowriad
Copy link
Contributor

@noahtallen This makes sense to me, so you're saying that basically we add a "block aliases" state to the block-editor store right?

I was also thinking about this PR while working on #26866 and it seems having the state being made of atoms, might make this easier as well. (not that it should wait for the other PR to land)

@noahtallen
Copy link
Member Author

noahtallen commented Nov 18, 2020

This makes sense to me, so you're saying that basically we add a "block aliases" state to the block-editor store right?

Yep, basically! And then account for that in the various block operations. I still need to figure out where exactly the data would go, but the model is something like:

// keep track of source -> aliases. This is a 1:many relationship. A source can have many aliases.
{
  [ sourceBlockClientId ]: [
    blockA1,
    blockA2,
    blockA3,
  ]
}

// keep track of alias -> source. This is a 1:1 relationship. An alias can have only one source.
{
  [ blockA1 ]: sourceBlockClientId,
  [ blockA2 ]: sourceBlockClientId,
  [ blockA3 ]: sourceBlockClientId,
}

I imagine these props don't necessarily need their own object, but could be added as block properties elsewhere.

I was also thinking about this PR while working on #26866 and it seems having the state being made of atoms

I was also thinking about that! I'm not exactly sure how it fits in (I haven't looked at it closely), but it does seem like better atomicity in the stores would be helpful. For example, the replaceInnerBlocks action: when the first half of the operation is finished (remove blocks), I'm pretty sure the subscription callback updates with no blocks. If the subscription was only called after the entire operation (one action triggering a few other actions) finishes, then the problem would be different!

edit: though, we would still need the alias state, because the current approach in master just inserts duplicate clientIds everywhere, which doesn't work with parts of the state. For example the block.parents state. If I insert the same clientId into multiple locations, I can only keep track of one of its parents since block.parents is a 1:1 mapping. Many parts of the block state work like that, so maintaining unique clientIDs even across duplicated blocks is still important.

@noahtallen
Copy link
Member Author

One challenge with this new approach is "what happens to the aliases when the sources are removed"? For example, say you insert a template part block. This becomes the source. Then you insert another template part block referencing the same entity. These child blocks become "aliases" of the source child blocks.

Now, what if I remove the first template part block? I'm not actually deleting the blocks from the template part entity, I'm just removing the template part block from the template. In this case, we need to handle a couple of extra things:

  1. Removing the blocks from the tree doesn't translate to removing all of the aliases. I.e. it's not a normal delete operation.
  2. The alias references would all need to be updated to point to a new source.

@noahtallen
Copy link
Member Author

Now, what if I remove the first template part block? I'm not actually deleting the blocks from the template part entity, I'm just removing the template part block from the template. In this case, we need to handle a couple of extra things:

  1. Removing the blocks from the tree doesn't translate to removing all of the aliases. I.e. it's not a normal delete operation.
  2. The alias references would all need to be updated to point to a new source.

Thought about this a bit more. I think we can basically do this:

  1. Keep a list of clientIds for each template part block which is duplicated.
  2. The first clientId of the list (e.g. the first template part block) is considered the manager.
  3. When removing that block, we pop it from the list.
  4. Then, we re-set the manager to be the new first client ID.
  5. This reset operation can basically reset the blocks under the next template part client ID to the "source" blocks.

This way, all of the aliases continue working exactly as before, and we still have something to sync edits in and out.

@noahtallen
Copy link
Member Author

I've started to write the approach this PR: #27084

@youknowriad
Copy link
Contributor

superseded by #27885 Thanks for you work on these issues @noahtallen

@youknowriad youknowriad deleted the try/fix-inner-blocks-insertion branch December 24, 2020 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Technical Feedback Needs testing from a developer perspective. [Package] Block editor /packages/block-editor [Type] Bug An existing feature does not function as intended
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Adding duplicate Template Part removes both from page, can trigger clearing inner blocks in dirty state.
6 participants