diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 3406da99f91d7..d8fb777a9cb59 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -13,6 +13,7 @@ import { parse, synchronizeBlocksWithTemplate, } from '@wordpress/blocks'; +import isShallowEqual from '@wordpress/is-shallow-equal'; /** * Internal dependencies @@ -32,8 +33,61 @@ import { getNotificationArgumentsForSaveFail, getNotificationArgumentsForTrashFail, } from './utils/notice-builder'; +import { awaitNextStateChange } from './controls'; import * as sources from './block-sources'; +/** + * Given a blocks array, returns a blocks array with sourced attribute values + * applied. The reference will remain consistent with the original argument if + * no attribute values must be overridden. If sourced values are applied, the + * return value will be a modified copy of the original array. + * + * @param {WPBlock[]} blocks Original blocks array. + * + * @return {WPBlock[]} Blocks array with sourced values applied. + */ +function* getBlocksWithSourcedAttributes( blocks ) { + let workingBlocks = blocks; + for ( let i = 0; i < blocks.length; i++ ) { + const block = blocks[ i ]; + const blockType = yield select( 'core/blocks', 'getBlockType', block.name ); + + for ( const [ attributeName, schema ] of Object.entries( blockType.attributes ) ) { + if ( ! sources[ schema.source ] || ! sources[ schema.source ].apply ) { + continue; + } + + // TODO: This should ideally leverage the `subscribeSources` cache, + // or at worst at least be cached here one-per-source. + const dependencies = yield* sources[ schema.source ].getDependencies(); + + const sourcedAttributeValue = sources[ schema.source ].apply( schema, dependencies ); + + // It's only necessary to apply the value if it differs from the + // block's locally-assigned value, to avoid needlessly resetting + // the block editor. + if ( sourcedAttributeValue === block.attributes[ attributeName ] ) { + continue; + } + + // Create a shallow clone to mutate, leaving the original intact. + if ( workingBlocks === blocks ) { + workingBlocks = [ ...workingBlocks ]; + } + + workingBlocks.splice( i, 1, { + ...block, + attributes: { + ...block.attributes, + [ attributeName ]: sourcedAttributeValue, + }, + } ); + } + } + + return workingBlocks; +} + /** * Returns an action generator used in signalling that editor has initialized with * the specified post object and editor settings. @@ -70,6 +124,7 @@ export function* setupEditor( post, edits, template ) { yield setupEditorState( post ); yield resetEditorBlocks( blocks ); + yield* subscribeSources(); } /** @@ -82,6 +137,50 @@ export function tearDownEditor() { return { type: 'TEAR_DOWN_EDITOR' }; } +/** + * Returns an action generator which loops to await the next state change, + * calling to reset blocks when a block source dependencies change. + * + * @yield {Object} Action object. + */ +export const subscribeSources = ( () => { + const lastDependencies = new WeakMap(); + + return function* () { + while ( true ) { + yield awaitNextStateChange(); + + // The bailout case: If the editor becomes unmounted, it will flag + // itself as non-ready. Effectively unsubscribes from the registry. + const isStillReady = yield select( 'core/editor', '__unstableIsEditorReady' ); + if ( ! isStillReady ) { + break; + } + + let reset = false; + for ( const source of Object.values( sources ) ) { + if ( ! source.getDependencies ) { + continue; + } + + const dependencies = yield* source.getDependencies(); + if ( ! isShallowEqual( dependencies, lastDependencies.get( source ) ) ) { + // Allow the loop to continue in order to assign latest + // dependencies values, but mark for reset. Avoid reset + // from the first assignment. + reset = reset || lastDependencies.has( source ); + + lastDependencies.set( source, dependencies ); + } + } + + if ( reset ) { + yield resetEditorBlocks( yield select( 'core/editor', 'getEditorBlocks' ) ); + } + } + }; +} )(); + /** * Action generator function used in signalling that sources are to be updated * in response to a single block attributes update. @@ -762,21 +861,9 @@ export function unlockPostSaving( lockName ) { * @return {Object} Action object */ export function* resetEditorBlocks( blocks, options = {} ) { - for ( const source in Object.values( sources ) ) { - if ( typeof source.applyAll === 'function' ) { - blocks = yield* source.applyAll( blocks ); - } - - if ( typeof source.apply === 'function' ) { - for ( let i = 0; i < blocks.length; i++ ) { - blocks[ i ] = yield* source.apply( blocks[ i ] ); - } - } - } - return { type: 'RESET_EDITOR_BLOCKS', - blocks, + blocks: yield* getBlocksWithSourcedAttributes( blocks ), shouldCreateUndoLevel: options.__unstableShouldCreateUndoLevel !== false, }; } diff --git a/packages/editor/src/store/block-sources/meta.js b/packages/editor/src/store/block-sources/meta.js index 15f6c9bcd13c5..4ae9434a1d3aa 100644 --- a/packages/editor/src/store/block-sources/meta.js +++ b/packages/editor/src/store/block-sources/meta.js @@ -9,28 +9,31 @@ import { select } from '@wordpress/data-controls'; import { editPost } from '../actions'; /** - * Store control which, given an array of blocks, modifies block entries which - * source from meta properties to assign the attribute values. + * Store control invoked upon a state changes, responsible for returning an + * object of dependencies. When a change in dependencies occurs (by shallow + * equality of the returned object), blocks are reset to apply the new sourced + * value. * - * @param {WPBlock[]} blocks Blocks array. - * - * @yield {Object} Yielded action objects or store controls. - * @return {WPBlock[]} Modified blocks array. + * @yield {Object} Optional yielded controls. + * @return {Object} Dependencies as object. */ -export function* applyAll( blocks ) { - const meta = yield select( 'core/editor', 'getEditedPostAttribute', 'meta' ); - - for ( let i = 0; i < blocks.length; i++ ) { - const block = blocks[ i ]; - const blockType = yield select( 'core/blocks', 'getBlockType', block.name ); - for ( const [ attributeName, schema ] of Object.entries( blockType.attributes ) ) { - if ( schema.source === 'meta' ) { - blocks[ i ].attributes[ attributeName ] = meta[ schema.meta ]; - } - } - } +export function* getDependencies() { + return { + meta: yield select( 'core/editor', 'getEditedPostAttribute', 'meta' ), + }; +} - return blocks; +/** + * Given an attribute schema and dependencies data, returns a source value. + * + * @param {Object} schema Block type attribute schema. + * @param {Object} dependencies Source dependencies. + * @param {Object} dependencies.meta Post meta. + * + * @return {Object} Block attribute value. + */ +export function apply( schema, { meta } ) { + return meta[ schema.meta ]; } /** diff --git a/packages/editor/src/store/controls.js b/packages/editor/src/store/controls.js new file mode 100644 index 0000000000000..726f214684bb1 --- /dev/null +++ b/packages/editor/src/store/controls.js @@ -0,0 +1,27 @@ +/** + * WordPress dependencies + */ +import { createRegistryControl } from '@wordpress/data'; + +/** + * Returns an control descriptor signalling to subscribe to the registry and + * resolve the control promise only when the next state change occurs. + * + * @return {Object} Control descriptor. + */ +export function awaitNextStateChange() { + return { type: 'AWAIT_NEXT_STATE_CHANGE' }; +} + +const controls = { + AWAIT_NEXT_STATE_CHANGE: createRegistryControl( + ( registry ) => () => new Promise( ( resolve ) => { + const unsubscribe = registry.subscribe( () => { + unsubscribe(); + resolve(); + } ); + } ) + ), +}; + +export default controls; diff --git a/packages/editor/src/store/index.js b/packages/editor/src/store/index.js index 33c5686396097..8f377151e08d4 100644 --- a/packages/editor/src/store/index.js +++ b/packages/editor/src/store/index.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { registerStore } from '@wordpress/data'; -import { controls } from '@wordpress/data-controls'; +import { controls as dataControls } from '@wordpress/data-controls'; /** * Internal dependencies @@ -11,6 +11,7 @@ import reducer from './reducer'; import applyMiddlewares from './middlewares'; import * as selectors from './selectors'; import * as actions from './actions'; +import controls from './controls'; import { STORE_KEY } from './constants'; /** @@ -24,7 +25,10 @@ export const storeConfig = { reducer, selectors, actions, - controls, + controls: { + ...dataControls, + ...controls, + }, }; const store = registerStore( STORE_KEY, {