diff --git a/packages/blockly/core/serialization/blocks.ts b/packages/blockly/core/serialization/blocks.ts index af8910b3137..7afa363b99b 100644 --- a/packages/blockly/core/serialization/blocks.ts +++ b/packages/blockly/core/serialization/blocks.ts @@ -601,18 +601,27 @@ function tryToConnectParent( if (!connected) { const checker = child.workspace.connectionChecker; - throw new BadConnectionCheck( - checker.getErrorMessage( - checker.canConnectWithReason(childConnection, parentConnection, false), - childConnection, - parentConnection, - ), - parentConnection.type === inputTypes.VALUE - ? 'output connection' - : 'previous connection', - child, - state, + const reason = checker.getErrorMessage( + checker.canConnectWithReason(childConnection, parentConnection, false), + childConnection, + parentConnection, ); + if (child.isShadow()) { + throw new BadConnectionCheck( + reason, + parentConnection.type === inputTypes.VALUE + ? 'output connection' + : 'previous connection', + child, + state, + ); + } + console.warn( + `Connection check failed during JSON deserialization: ${reason}. ` + + `Block "${child.type}" (${child.id}) will be placed as a ` + + `top-level block instead.`, + ); + child.setDisabledReason(true, 'orphaned_connection_check'); } } diff --git a/packages/blockly/core/xml.ts b/packages/blockly/core/xml.ts index 3c06a39cca9..05654e6ee04 100644 --- a/packages/blockly/core/xml.ts +++ b/packages/blockly/core/xml.ts @@ -636,6 +636,7 @@ export function domToBlockInternal( // Create top-level block. eventUtils.disable(); const variablesBeforeCreation = workspace.getVariableMap().getAllVariables(); + const topBlocksBefore = new Set(workspace.getTopBlocks(false)); let topBlock; try { topBlock = domToBlockHeadless(xmlBlock, workspace); @@ -651,6 +652,30 @@ export function domToBlockInternal( for (let i = blocks.length - 1; i >= 0; i--) { (blocks[i] as BlockSvg).queueRender(); } + + // Initialize any orphaned blocks that failed to connect during + // deserialization (e.g. due to connection type check failures). + // These blocks were created but are not descendants of topBlock. + const connectedBlocks = new Set(blocks); + for (const block of workspace.getTopBlocks(false)) { + if (topBlocksBefore.has(block) || connectedBlocks.has(block)) continue; + const orphanSvg = block as BlockSvg; + orphanSvg.setConnectionTracking(false); + block.setDisabledReason(true, 'orphaned_connection_check'); + const orphanDescendants = block.getDescendants(false); + for (let i = orphanDescendants.length - 1; i >= 0; i--) { + (orphanDescendants[i] as BlockSvg).initSvg(); + } + for (let i = orphanDescendants.length - 1; i >= 0; i--) { + (orphanDescendants[i] as BlockSvg).queueRender(); + } + setTimeout(function () { + if (!orphanSvg.disposed) { + orphanSvg.setConnectionTracking(true); + } + }, 1); + } + // Populating the connection database may be deferred until after the // blocks have rendered. setTimeout(function () { @@ -666,6 +691,16 @@ export function domToBlockInternal( for (let i = blocks.length - 1; i >= 0; i--) { blocks[i].initModel(); } + // Initialize orphaned blocks on headless workspaces too. + const connectedBlocks = new Set(blocks); + for (const block of workspace.getTopBlocks(false)) { + if (topBlocksBefore.has(block) || connectedBlocks.has(block)) continue; + block.setDisabledReason(true, 'orphaned_connection_check'); + const orphanDescendants = block.getDescendants(false); + for (let i = orphanDescendants.length - 1; i >= 0; i--) { + orphanDescendants[i].initModel(); + } + } } } finally { eventUtils.enable(); diff --git a/packages/blockly/tests/mocha/jso_deserialization_test.js b/packages/blockly/tests/mocha/jso_deserialization_test.js index f6b47d7de6a..c4ad624b90d 100644 --- a/packages/blockly/tests/mocha/jso_deserialization_test.js +++ b/packages/blockly/tests/mocha/jso_deserialization_test.js @@ -14,6 +14,7 @@ import { import { sharedTestSetup, sharedTestTeardown, + workspaceTeardown, } from './test_helpers/setup_teardown.js'; suite('JSO Deserialization', function () { @@ -630,10 +631,50 @@ suite('JSO Deserialization', function () { ], }, }; - this.assertThrows( - state, - Blockly.serialization.exceptions.BadConnectionCheck, - ); + Blockly.serialization.workspaces.load(state, this.workspace); + + const allBlocks = this.workspace.getAllBlocks(false); + assert.equal(allBlocks.length, 2, 'Both blocks exist'); + + const mathBlock = allBlocks.find((b) => b.type === 'math_number'); + assert.isNotNull(mathBlock, 'math_number block exists'); + assert.isNull(mathBlock.getParent(), 'Orphan has no parent'); + }); + + test('Bad checks - orphan SVG is initialized on rendered workspace', function () { + const workspace = Blockly.inject('blocklyDiv'); + try { + const state = { + 'blocks': { + 'blocks': [ + { + 'type': 'logic_operation', + 'inputs': { + 'A': { + 'block': { + 'type': 'math_number', + }, + }, + }, + }, + ], + }, + }; + Blockly.serialization.workspaces.load(state, workspace); + this.clock.runAll(); + + const allBlocks = workspace.getAllBlocks(false); + assert.equal(allBlocks.length, 2, 'Both blocks exist'); + + const mathBlock = allBlocks.find((b) => b.type === 'math_number'); + assert.isNotNull(mathBlock, 'math_number block exists'); + assert.isNotNull( + mathBlock.getSvgRoot().parentNode, + 'Orphan SVG is in the DOM', + ); + } finally { + workspaceTeardown.call(this, workspace); + } }); }); diff --git a/packages/blockly/tests/mocha/xml_test.js b/packages/blockly/tests/mocha/xml_test.js index 94219f9a067..f1106c572a9 100644 --- a/packages/blockly/tests/mocha/xml_test.js +++ b/packages/blockly/tests/mocha/xml_test.js @@ -890,4 +890,90 @@ suite('XML', function () { }); }); }); + + suite('Connection check failures', function () { + setup(function () { + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'typed_input_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'INPUT', + 'check': 'Int', + }, + ], + }, + { + 'type': 'typed_output_block', + 'message0': '', + 'output': 'Long', + }, + ]); + addBlockTypeToCleanup(this.sharedCleanup, 'typed_input_block'); + addBlockTypeToCleanup(this.sharedCleanup, 'typed_output_block'); + }); + + test('Orphaned block SVG is in the DOM on rendered workspace', function () { + this.workspace = Blockly.inject('blocklyDiv'); + const xml = Blockly.utils.xml.textToDom( + '' + + '' + + ' ' + + ' ' + + ' ' + + '' + + '', + ); + Blockly.Xml.domToWorkspace(xml, this.workspace); + this.clock.runAll(); + + const allBlocks = this.workspace.getAllBlocks(false); + assert.equal(allBlocks.length, 2, 'Both blocks exist'); + + const topBlocks = this.workspace.getTopBlocks(false); + assert.equal(topBlocks.length, 2, 'Both blocks are top-level'); + + const outputBlock = allBlocks.find( + (b) => b.type === 'typed_output_block', + ); + assert.isNotNull( + outputBlock.getSvgRoot().parentNode, + 'Orphaned block SVG is in the DOM', + ); + assert.isNull(outputBlock.getParent(), 'Orphaned block has no parent'); + + workspaceTeardown.call(this, this.workspace); + }); + + test('Orphaned block is initialized on headless workspace', function () { + const workspace = new Blockly.Workspace(); + try { + const xml = Blockly.utils.xml.textToDom( + '' + + '' + + ' ' + + ' ' + + ' ' + + '' + + '', + ); + Blockly.Xml.domToWorkspace(xml, workspace); + + const allBlocks = workspace.getAllBlocks(false); + assert.equal(allBlocks.length, 2, 'Both blocks exist'); + + const topBlocks = workspace.getTopBlocks(false); + assert.equal(topBlocks.length, 2, 'Both blocks are top-level'); + + const outputBlock = allBlocks.find( + (b) => b.type === 'typed_output_block', + ); + assert.isNull(outputBlock.getParent(), 'Orphaned block has no parent'); + } finally { + workspaceTeardown.call(this, workspace); + } + }); + }); });