Skip to content
This repository was archived by the owner on Feb 23, 2024. It is now read-only.

Refactor useForcedLayout to subscribe to store changes and to batch block insertions #7879

Merged
merged 5 commits into from
Dec 14, 2022
Merged
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
248 changes: 140 additions & 108 deletions assets/js/blocks/cart-checkout-shared/use-forced-layout.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,103 @@
/**
* External dependencies
*/
import {
useLayoutEffect,
useRef,
useCallback,
useMemo,
} from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { useRef, useEffect } from '@wordpress/element';
import { useRegistry, dispatch } from '@wordpress/data';
import {
createBlock,
getBlockType,
createBlocksFromInnerBlocksTemplate,
BlockInstance,
} from '@wordpress/blocks';
import type { Block, AttributeSource, TemplateArray } from '@wordpress/blocks';
import type { Block, TemplateArray } from '@wordpress/blocks';
import { isEqual } from 'lodash';
import { MutableRefObject } from 'react';

interface LockableBlock extends Block {
attributes: {
lock?: {
type: 'object';
remove?: boolean;
move: boolean;
default?: {
remove?: boolean;
move?: boolean;
};
};
};
}
const isBlockLocked = ( {
attributes,
}: {
attributes: Record< string, AttributeSource.Attribute >;
attributes: LockableBlock[ 'attributes' ];
} ) => Boolean( attributes.lock?.remove || attributes.lock?.default?.remove );

/**
* useForcedLayout hook
* This hook is used to determine which blocks are missing from a block. Given the list of inner blocks of a block, we
* can check for any registered blocks that:
* a) Are locked,
* b) Have the parent set as the current block, and
* c) Are not present in the list of inner blocks.
*/
const getMissingBlocks = (
innerBlocks: BlockInstance[],
registeredBlockTypes: ( LockableBlock | undefined )[]
) => {
const lockedBlockTypes = registeredBlockTypes.filter(
( block: LockableBlock | undefined ) => block && isBlockLocked( block )
);
const missingBlocks: LockableBlock[] = [];
lockedBlockTypes.forEach( ( lockedBlock ) => {
if ( typeof lockedBlock === 'undefined' ) {
return;
}
const existingBlock = innerBlocks.find(
( block ) => block.name === lockedBlock.name
);

if ( ! existingBlock ) {
missingBlocks.push( lockedBlock );
}
} );
return missingBlocks;
};

/**
* This hook is used to determine the position that a missing block should be inserted at.
*
* Responsible for ensuring FORCED blocks exist in the inner block layout. Forced blocks cannot be removed.
* @return The index to insert the missing block at.
*/
const findBlockPosition = ( {
defaultTemplatePosition,
innerBlocks,
currentDefaultTemplate,
}: {
defaultTemplatePosition: number;
innerBlocks: BlockInstance[];
currentDefaultTemplate: MutableRefObject< TemplateArray >;
} ) => {
switch ( defaultTemplatePosition ) {
case -1:
// The block is not part of the default template, so we append it to the current layout.
return innerBlocks.length;
// defaultTemplatePosition defaults to 0, so if this happens we can just return, this is because the block was
// the first block in the default layout, so we can prepend it to the current layout.
case 0:
return 0;
default:
// The new layout may have extra blocks compared to the default template, so rather than insert
// at the default position, we should append it after another default block.
const adjacentBlock =
currentDefaultTemplate.current[ defaultTemplatePosition - 1 ];
const position = innerBlocks.findIndex(
( { name: blockName } ) => blockName === adjacentBlock[ 0 ]
);
return position === -1 ? defaultTemplatePosition : position + 1;
}
};

/**
* Hook to ensure FORCED blocks are rendered in the correct place.
*/
export const useForcedLayout = ( {
clientId,
Expand All @@ -38,114 +110,74 @@ export const useForcedLayout = ( {
registeredBlocks: Array< string >;
// The default template for the inner blocks in this layout.
defaultTemplate: TemplateArray;
} ): void => {
} ) => {
const currentRegisteredBlocks = useRef( registeredBlocks );
const currentDefaultTemplate = useRef( defaultTemplate );

const { insertBlock, replaceInnerBlocks } =
useDispatch( 'core/block-editor' );
const registry = useRegistry();
useEffect( () => {
const { replaceInnerBlocks } = dispatch( 'core/block-editor' );
return registry.subscribe( () => {
const innerBlocks = registry
.select( 'core/block-editor' )
.getBlocks( clientId );

const { innerBlocks, registeredBlockTypes } = useSelect(
( select ) => {
return {
innerBlocks:
select( 'core/block-editor' ).getBlocks( clientId ),
registeredBlockTypes: currentRegisteredBlocks.current.map(
( blockName ) => getBlockType( blockName )
),
};
},
[ clientId, currentRegisteredBlocks.current ]
);

const appendBlock = useCallback(
( block, position ) => {
const newBlock = createBlock( block.name );
insertBlock( newBlock, position, clientId, false );
},
// We need to skip insertBlock here due to a cache issue in wordpress.com that causes an inifinite loop, see https://github.com/Automattic/wp-calypso/issues/66092 for an expanded doc.
// eslint-disable-next-line react-hooks/exhaustive-deps
[ clientId ]
);

const lockedBlockTypes = useMemo(
() =>
registeredBlockTypes.filter(
( block: Block | undefined ) => block && isBlockLocked( block )
),
[ registeredBlockTypes ]
) as Block[];
// If there are NO inner blocks, sync with the given template.
if (
innerBlocks.length === 0 &&
currentDefaultTemplate.current.length > 0
) {
const nextBlocks = createBlocksFromInnerBlocksTemplate(
currentDefaultTemplate.current
);
if ( ! isEqual( nextBlocks, innerBlocks ) ) {
replaceInnerBlocks( clientId, nextBlocks );
return;
}
}

/**
* If the current inner blocks differ from the registered blocks, push the differences.
*/
useLayoutEffect( () => {
if ( ! clientId ) {
return;
}
const registeredBlockTypes = currentRegisteredBlocks.current.map(
( blockName: string ) => getBlockType( blockName )
);

// If there are NO inner blocks, sync with the given template.
if (
innerBlocks.length === 0 &&
currentDefaultTemplate.current.length > 0
) {
const nextBlocks = createBlocksFromInnerBlocksTemplate(
currentDefaultTemplate.current
const missingBlocks = getMissingBlocks(
innerBlocks,
registeredBlockTypes
);
if ( ! isEqual( nextBlocks, innerBlocks ) ) {
replaceInnerBlocks( clientId, nextBlocks );
return;
}
}

// Find registered locked blocks missing from Inner Blocks and append them.
lockedBlockTypes.forEach( ( block ) => {
// If the locked block type is already in the layout, we can skip this one.
if (
innerBlocks.find(
( { name }: { name: string } ) => name === block.name
)
) {
if ( missingBlocks.length === 0 ) {
return;
}

// Is the forced block part of the default template, find it's original position.
const defaultTemplatePosition =
currentDefaultTemplate.current.findIndex(
( [ blockName ] ) => blockName === block.name
);

switch ( defaultTemplatePosition ) {
case -1:
// The block is not part of the default template so we append it to the current layout.
appendBlock( block, innerBlocks.length );
break;
case 0:
// The block was the first block in the default layout, so prepend it to the current layout.
appendBlock( block, 0 );
break;
default:
// The new layout may have extra blocks compared to the default template, so rather than insert
// at the default position, we should append it after another default block.
const adjacentBlock =
currentDefaultTemplate.current[
defaultTemplatePosition - 1
];
const position = innerBlocks.findIndex(
( { name: blockName } ) =>
blockName === adjacentBlock[ 0 ]
// Initially set as -1, so we can skip checking the position multiple times. Later on in the map callback,
// we check where the forced blocks should be inserted. This gets set to >= 0 if we find a missing block,
// so we know we can skip calculating it.
let insertAtPosition = -1;
const blockConfig = missingBlocks.map( ( block ) => {
const defaultTemplatePosition =
currentDefaultTemplate.current.findIndex(
( [ blockName ] ) => blockName === block.name
);
appendBlock(
block,
position === -1 ? defaultTemplatePosition : position + 1
);
break;
}
const createdBlock = createBlock( block.name );

// As mentioned above, if this is not -1, this is the first time we're calculating the position, if it's
// already been calculated we can skip doing so.
if ( insertAtPosition === -1 ) {
insertAtPosition = findBlockPosition( {
defaultTemplatePosition,
innerBlocks,
currentDefaultTemplate,
} );
}

return createdBlock;
} );

registry.batch( () => {
registry
.dispatch( 'core/block-editor' )
.insertBlocks( blockConfig, insertAtPosition, clientId );
} );
} );
/*
We need to skip replaceInnerBlocks here due to a cache issue in wordpress.com that causes an inifinite loop, see https://github.com/Automattic/wp-calypso/issues/66092 for an expanded doc.
@todo Add replaceInnerBlocks and insertBlock after fixing https://github.com/Automattic/wp-calypso/issues/66092
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ clientId, innerBlocks, lockedBlockTypes, appendBlock ] );
}, [ clientId, registry ] );
};