diff --git a/core/block_svg.ts b/core/block_svg.ts index b463c64c6..7851c1472 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -1611,10 +1611,13 @@ export class BlockSvg extends Block implements IASTNodeLocationSvg, /** * Triggers a rerender after a delay to allow for batching. * + * @returns A promise that resolves after the currently queued renders have + * been completed. Used for triggering other behavior that relies on + * updated size/position location for the block. * @internal */ - queueRender() { - queueRender(this); + queueRender(): Promise { + return queueRender(this); } /** diff --git a/core/blockly.ts b/core/blockly.ts index 03d4649d0..11d46c27c 100644 --- a/core/blockly.ts +++ b/core/blockly.ts @@ -121,6 +121,7 @@ import * as uiPosition from './positionable_helpers.js'; import * as Procedures from './procedures.js'; import * as registry from './registry.js'; import {RenderedConnection} from './rendered_connection.js'; +import * as renderManagement from './render_management.js'; import * as blockRendering from './renderers/common/block_rendering.js'; import * as constants from './constants.js'; import * as geras from './renderers/geras/geras.js'; @@ -711,6 +712,7 @@ export {Msg, setLocale}; export {Names}; export {Options}; export {RenderedConnection}; +export {renderManagement}; export {Scrollbar}; export {ScrollbarPair}; export {ShortcutRegistry}; diff --git a/core/insertion_marker_manager.ts b/core/insertion_marker_manager.ts index fc25c87fd..f86d4427a 100644 --- a/core/insertion_marker_manager.ts +++ b/core/insertion_marker_manager.ts @@ -12,6 +12,7 @@ import * as goog from '../closure/goog/goog.js'; goog.declareModuleId('Blockly.InsertionMarkerManager'); +import {finishQueuedRenders} from './render_management.js'; import * as blockAnimations from './block_animations.js'; import type {BlockSvg} from './block_svg.js'; import * as common from './common.js'; @@ -188,9 +189,9 @@ export class InsertionMarkerManager { const inferiorConnection = local.isSuperior() ? closest : local; const rootBlock = this.topBlock.getRootBlock(); - // bringToFront is incredibly expensive. Delay by at least a frame. - requestAnimationFrame(() => { + finishQueuedRenders().then(() => { blockAnimations.connectionUiEffect(inferiorConnection.getSourceBlock()); + // bringToFront is incredibly expensive. Delay until the next frame. setTimeout(() => { rootBlock.bringToFront(); }, 0); diff --git a/core/render_management.ts b/core/render_management.ts index 658f0b1d8..38c2ccc12 100644 --- a/core/render_management.ts +++ b/core/render_management.ts @@ -8,20 +8,51 @@ import {BlockSvg} from './block_svg.js'; import {Coordinate} from './utils/coordinate.js'; +/** The set of all blocks in need of rendering which don't have parents. */ const rootBlocks = new Set(); + +/** The set of all blocks in need of rendering. */ let dirtyBlocks = new WeakSet(); -let pid = 0; + +/** + * The promise which resolves after the current set of renders is completed. Or + * null if there are no queued renders. + * + * Stored so that we can return it from afterQueuedRenders. + */ +let afterRendersPromise: Promise|null = null; /** * Registers that the given block and all of its parents need to be rerendered, * and registers a callback to do so after a delay, to allowf or batching. * * @param block The block to rerender. + * @return A promise that resolves after the currently queued renders have been + * completed. Used for triggering other behavior that relies on updated + * size/position location for the block. * @internal */ -export function queueRender(block: BlockSvg) { +export function queueRender(block: BlockSvg): Promise { queueBlock(block); - if (!pid) pid = window.requestAnimationFrame(doRenders); + if (!afterRendersPromise) { + afterRendersPromise = new Promise((resolve) => { + window.requestAnimationFrame(() => { + doRenders(); + resolve(); + }); + }); + } + return afterRendersPromise; +} + +/** + * @returns A promise that resolves after the currently queued renders have + * been completed. + */ +export function finishQueuedRenders(): Promise { + // If there are no queued renders, return a resolved promise so `then` + // callbacks trigger immediately. + return afterRendersPromise ? afterRendersPromise : Promise.resolve(); } /** @@ -62,7 +93,7 @@ function doRenders() { rootBlocks.clear(); dirtyBlocks = new Set(); - pid = 0; + afterRendersPromise = null; } /** diff --git a/tests/mocha/index.html b/tests/mocha/index.html index d1359e907..4583a311b 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -113,6 +113,7 @@ 'Blockly.test.procedureMap', 'Blockly.test.procedures', 'Blockly.test.registry', + 'Blockly.test.renderManagement', 'Blockly.test.serialization', 'Blockly.test.shortcutRegistry', 'Blockly.test.touch', diff --git a/tests/mocha/render_management_test.js b/tests/mocha/render_management_test.js new file mode 100644 index 000000000..95dfb3f4a --- /dev/null +++ b/tests/mocha/render_management_test.js @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.declareModuleId('Blockly.test.renderManagement'); + +import {sharedTestSetup, sharedTestTeardown} from './test_helpers/setup_teardown.js'; + + +suite('Render Management', function() { + setup(function() { + this.clock = sharedTestSetup.call(this).clock; + }); + + teardown(function() { + sharedTestTeardown.call(this); + }); + + suite('finish queued renders callback', function() { + function createMockBlock() { + return { + hasRendered: false, + renderEfficiently: function() {this.hasRendered = true;}, + + // All of the APIs the render management system needs. + getParent: () => null, + getChildren: () => [], + isDisposed: () => false, + getConnections_: () => [], + getRelativeToSurfaceXY: () => ({x: 0, y: 0}), + workspace: { + resizeContents: () => {}, + }, + }; + } + + test('the queueRender promise is properly resolved after rendering', + function() { + const block = createMockBlock(); + const promise = Blockly.renderManagement.queueRender(block) + .then(() => { + chai.assert.isTrue( + block.hasRendered, 'Expected block to be rendered'); + }); + this.clock.runAll(); + return promise; + }); + + test( + 'the finish queued renders promise is properly resolved after rendering', + function() { + const block = createMockBlock(); + Blockly.renderManagement.queueRender(block); + const promise = Blockly.renderManagement.finishQueuedRenders(() => { + chai.assert.isTrue( + block.hasRendered, 'Expected block to be rendered'); + }); + this.clock.runAll(); + return promise; + }); + }); +});