From 60df3538fe34a96386f5f930352946c4aa9e2c0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 13:44:15 +0000 Subject: [PATCH 1/5] fix: heal block stack on delete for regular and if_clause blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes to ensure block-stack healing works correctly when deleting a block from the middle of a stack (A→B→C → delete B → A→C): 1. Override BlockDelete.prototype.run so that redo (forward=true) calls block.dispose(true) instead of the built-in dispose(false). The built-in redo skips healStack, which can leave the block below the deleted block orphaned when our custom connection-checker later rejects the BLOCK_MOVE reconnection event. 2. Monkey-patch Block.prototype.unplugFromStack_ to set a per-workspace _isHealingStack flag while the heal canConnect check executes. The if_clause connection-checker's doTypeChecks override now lets the check pass unconditionally when that flag is set, so the heal is never blocked by if_clause ordering rules. The ordering rules remain enforced for all other (non-heal) connection attempts. https://claude.ai/code/session_01R2kdQkpvQkDS5CnPBa53AM --- main/blocklyinit.js | 66 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/main/blocklyinit.js b/main/blocklyinit.js index 7691ce17a..a9bce706e 100644 --- a/main/blocklyinit.js +++ b/main/blocklyinit.js @@ -78,6 +78,33 @@ function initializeIfClauseConnectionChecker(workspace) { const originalDoTypeChecks = connectionChecker.doTypeChecks.bind(connectionChecker); + // Patch Block.prototype.unplugFromStack_ to set a workspace flag while the + // heal canConnect check runs. Our doTypeChecks override reads this flag to + // bypass strict if_clause ordering rules for heal connections — the heal + // just reconnects whatever was below the deleted block; we shouldn't further + // restrict that reconnection beyond what normal type-checking allows. + if ( + Blockly.Block?.prototype?.unplugFromStack_ && + !Blockly.Block.prototype.unplugFromStack_._healFlagPatched + ) { + const originalUnplugFromStack = + Blockly.Block.prototype.unplugFromStack_; + const patched = function (healStack) { + if (healStack && this.workspace) { + this.workspace._isHealingStack = true; + } + try { + originalUnplugFromStack.call(this, healStack); + } finally { + if (this.workspace) { + this.workspace._isHealingStack = false; + } + } + }; + patched._healFlagPatched = true; + Blockly.Block.prototype.unplugFromStack_ = patched; + } + function isRealBlock(block) { return ( !!block && @@ -153,6 +180,15 @@ function initializeIfClauseConnectionChecker(workspace) { return false; } + // During a heal (unplugFromStack_ reconnecting the block below the deleted + // block to the deleted block's parent) allow the connection regardless of + // if_clause ordering rules. The heal just preserves whatever was already + // connected; the ordering rules should not veto that. + const ws = a.getSourceBlock()?.workspace; + if (ws?._isHealingStack) { + return true; + } + // Get the blocks involved const blockA = a.getSourceBlock(); const blockB = b.getSourceBlock(); @@ -531,6 +567,36 @@ export function initializeWorkspace() { originalScrollBoundsIntoView.call(this, bounds); }; + // Patch BlockDelete.run so that redo (forward=true) heals the stack just + // like the original dispose call did. Without this, BlockDelete.run(true) + // calls dispose(false) which skips healing and can leave the block below the + // deleted block orphaned when the BLOCK_MOVE heal event is later replayed + // through canConnect and our custom checker rejects the reconnection. + const BlockDeleteClass = Blockly.Events.BlockDelete; + if (BlockDeleteClass) { + const originalBlockDeleteRun = BlockDeleteClass.prototype.run; + BlockDeleteClass.prototype.run = function (forward) { + if (forward) { + // Redo: delete the block(s) WITH stack healing so that the block + // below the deleted block is reconnected rather than orphaned. + const workspace = this.getEventWorkspace_(); + if (this.ids) { + for (let i = 0; i < this.ids.length; i++) { + const block = workspace.getBlockById(this.ids[i]); + if (block) { + block.dispose(true); // heal=true: reconnect next block to parent + } else if (this.ids[i] === this.blockId) { + console.warn("Can't delete non-existent block: " + this.ids[i]); + } + } + } + } else { + // Undo: restore block from saved JSON (standard Blockly behaviour). + originalBlockDeleteRun.call(this, forward); + } + }; + } + // Initialize workspace search const workspaceSearch = new WorkspaceSearch(workspace); workspaceSearch.init(); From b9b9c9cdef4f2fa4e4812c39f9ec3032fec15f76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 14:04:28 +0000 Subject: [PATCH 2/5] fix: revert _isHealingStack bypass that broke if_clause undo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _isHealingStack patch allowed invalid heals during deletion — e.g. reconnecting an ELSEIF block after a regular (non-if_clause) block. This created a state that could not be undone: the undo BLOCK_MOVE event tries to reconnect ELSEIF back to the original block, our checker correctly rejects that connection, and the blocks end up disconnected and disabled ("stack pops out"). The if_clause connection rules already correctly permit valid heals and reject invalid ones (a heal that would produce an illegal if_clause chain should stay rejected — making it succeed would just produce a broken workspace state that can't be reversed). Keep the BlockDelete.run(forward=true) override (dispose with heal=true) as it is safe: by the time DELETE.run executes during redo, the preceding BLOCK_MOVE events have already cleaned up the connections, so dispose(true) on a standalone block is a no-op for healing. https://claude.ai/code/session_01R2kdQkpvQkDS5CnPBa53AM --- main/blocklyinit.js | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/main/blocklyinit.js b/main/blocklyinit.js index a9bce706e..bdca36034 100644 --- a/main/blocklyinit.js +++ b/main/blocklyinit.js @@ -78,33 +78,6 @@ function initializeIfClauseConnectionChecker(workspace) { const originalDoTypeChecks = connectionChecker.doTypeChecks.bind(connectionChecker); - // Patch Block.prototype.unplugFromStack_ to set a workspace flag while the - // heal canConnect check runs. Our doTypeChecks override reads this flag to - // bypass strict if_clause ordering rules for heal connections — the heal - // just reconnects whatever was below the deleted block; we shouldn't further - // restrict that reconnection beyond what normal type-checking allows. - if ( - Blockly.Block?.prototype?.unplugFromStack_ && - !Blockly.Block.prototype.unplugFromStack_._healFlagPatched - ) { - const originalUnplugFromStack = - Blockly.Block.prototype.unplugFromStack_; - const patched = function (healStack) { - if (healStack && this.workspace) { - this.workspace._isHealingStack = true; - } - try { - originalUnplugFromStack.call(this, healStack); - } finally { - if (this.workspace) { - this.workspace._isHealingStack = false; - } - } - }; - patched._healFlagPatched = true; - Blockly.Block.prototype.unplugFromStack_ = patched; - } - function isRealBlock(block) { return ( !!block && @@ -180,15 +153,6 @@ function initializeIfClauseConnectionChecker(workspace) { return false; } - // During a heal (unplugFromStack_ reconnecting the block below the deleted - // block to the deleted block's parent) allow the connection regardless of - // if_clause ordering rules. The heal just preserves whatever was already - // connected; the ordering rules should not veto that. - const ws = a.getSourceBlock()?.workspace; - if (ws?._isHealingStack) { - return true; - } - // Get the blocks involved const blockA = a.getSourceBlock(); const blockB = b.getSourceBlock(); From d58e40d59c9339276f471d9bff1e7ddec64c38cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 16:29:19 +0000 Subject: [PATCH 3/5] fix: disable invalid if_clause blocks in-place instead of popping them Previously when an ELSEIF/ELSE block lost its valid preceding IF/ELSEIF (e.g. the IF was deleted), healing would fail because doTypeChecks rejected connecting ELSEIF after a regular block. The block became disconnected and floated on the workspace. Two changes: 1. doTypeChecks: allow ELSEIF/ELSE to connect after a non-if_clause block when not dragging (healing, undo-redo). During drag-and-drop the check still rejects to preserve visual feedback. 2. validateIfClausePositions: a new validator runs (with events disabled, so it does not affect undo history) after every structural change. It disables any ELSEIF/ELSE whose immediate predecessor is not a valid IF or ELSEIF, and re-enables them when they become valid again. https://claude.ai/code/session_01R2kdQkpvQkDS5CnPBa53AM --- main/blocklyinit.js | 67 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/main/blocklyinit.js b/main/blocklyinit.js index bdca36034..ff5924375 100644 --- a/main/blocklyinit.js +++ b/main/blocklyinit.js @@ -245,10 +245,12 @@ function initializeIfClauseConnectionChecker(workspace) { return false; } } else { - // Target is NOT if_clause - // ELSEIF and ELSE cannot connect after non-if_clause blocks + // Target is NOT if_clause. + // During drag-and-drop reject to give visual feedback. + // During healing / undo-redo (not dragging) allow the connection; + // validateIfClausePositions will disable the block in-place. if (movingMode === MODE.ELSEIF || movingMode === MODE.ELSE) { - return false; + if (workspace.isDragging()) return false; } } } else { @@ -310,6 +312,65 @@ function initializeIfClauseConnectionChecker(workspace) { return true; }; + + // Disable reason used to mark if_clause blocks that are structurally + // connected but in an invalid position (e.g. ELSEIF after a regular block). + const INVALID_IF_CLAUSE_REASON = "INVALID_IF_CLAUSE_POSITION"; + + // Scan all if_clause blocks and disable/enable them based on whether their + // predecessor is a valid if_clause. Runs with events disabled so the + // enable/disable state is derived (not recorded in the undo stack). + function validateIfClausePositions() { + Blockly.Events.disable(); + try { + for (const block of workspace.getAllBlocks(false)) { + if (block.type !== "if_clause") continue; + + const mode = block.getFieldValue("MODE"); + + // IF blocks can start a chain anywhere — always positionally valid. + if (mode === MODE.IF) { + block.setDisabledReason(false, INVALID_IF_CLAUSE_REASON); + continue; + } + + // ELSEIF / ELSE: valid only when the immediately preceding connected + // block is an if_clause whose mode is IF or ELSEIF (not ELSE). + const prevBlock = realPrev(block); + + if (!prevBlock) { + // Orphaned — disableOrphans handles the disabled state; clear ours. + block.setDisabledReason(false, INVALID_IF_CLAUSE_REASON); + continue; + } + + const validPrev = + prevBlock.type === "if_clause" && + prevBlock.getFieldValue("MODE") !== MODE.ELSE; + + block.setDisabledReason(!validPrev, INVALID_IF_CLAUSE_REASON); + } + } finally { + Blockly.Events.enable(); + } + } + + // Re-validate after any structural change so that if_clause blocks that + // land in an invalid position are disabled immediately, and those that + // become valid again are re-enabled. + workspace.addChangeListener(function (event) { + if ( + !event.isUiEvent && + (event.type === Blockly.Events.BLOCK_MOVE || + event.type === Blockly.Events.BLOCK_CREATE || + event.type === Blockly.Events.BLOCK_DELETE) + ) { + validateIfClausePositions(); + } + }); + + // Run once on initialisation to catch any blocks already in invalid positions. + validateIfClausePositions(); } export function initializeWorkspace() { From 42b9887c5da9e10bd757c96e34a9dbd87c3e8df1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 16:54:34 +0000 Subject: [PATCH 4/5] fix: add idempotence guard, BLOCK_CHANGE/MODE listener, and else disconnection fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add _healRedoPatched guard on BlockDeleteClass before patching BlockDelete.prototype.run, so that multiple calls to initializeWorkspace() do not double-wrap the method. 2. Extend the validateIfClausePositions change listener to also fire on BLOCK_CHANGE events when the MODE field changes, so that switching a block's mode (e.g. IF → ELSEIF → ELSE) immediately re-validates positions. 3. Fix: when a block above an ELSE clause changes its own MODE to ELSE, the ELSE below was being disconnected (popped out) because doTypeChecks returned false for Rule 1 and Rule 3 even outside of drag operations. Added workspace.isDragging() guards so these rules only reject during drag; validateIfClausePositions now disables the downstream block in-place instead. https://claude.ai/code/session_014kzTt5dFCV9FZwzN1vAPfi --- main/blocklyinit.js | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/main/blocklyinit.js b/main/blocklyinit.js index ff5924375..606cb56f5 100644 --- a/main/blocklyinit.js +++ b/main/blocklyinit.js @@ -225,9 +225,12 @@ function initializeIfClauseConnectionChecker(workspace) { if (targetIsIfClause) { const targetMode = targetBlock.getFieldValue("MODE"); - // Rule 1: Nothing can connect after ELSE + // Rule 1: Nothing can connect after ELSE. + // During drag-and-drop reject to give visual feedback. + // During healing / field-changes (not dragging) allow the connection; + // validateIfClausePositions will disable the block in-place. if (targetMode === MODE.ELSE) { - return false; + if (workspace.isDragging()) return false; } // Rule 2: ELSE cannot connect if it has if_clause blocks after it @@ -235,14 +238,16 @@ function initializeIfClauseConnectionChecker(workspace) { return false; } - // Rule 3: ELSE cannot be inserted in middle of chain + // Rule 3: ELSE cannot be inserted in middle of chain (drag only). + // When not dragging (e.g. a MODE field change), keep the connection + // and let validateIfClausePositions disable the block in-place. const targetHasNext = realNext(targetBlock); if ( targetHasNext && targetHasNext.type === "if_clause" && movingMode === MODE.ELSE ) { - return false; + if (workspace.isDragging()) return false; } } else { // Target is NOT if_clause. @@ -363,7 +368,10 @@ function initializeIfClauseConnectionChecker(workspace) { !event.isUiEvent && (event.type === Blockly.Events.BLOCK_MOVE || event.type === Blockly.Events.BLOCK_CREATE || - event.type === Blockly.Events.BLOCK_DELETE) + event.type === Blockly.Events.BLOCK_DELETE || + (event.type === Blockly.Events.BLOCK_CHANGE && + event.element === "field" && + event.name === "MODE")) ) { validateIfClausePositions(); } @@ -598,7 +606,8 @@ export function initializeWorkspace() { // deleted block orphaned when the BLOCK_MOVE heal event is later replayed // through canConnect and our custom checker rejects the reconnection. const BlockDeleteClass = Blockly.Events.BlockDelete; - if (BlockDeleteClass) { + if (BlockDeleteClass && !BlockDeleteClass._healRedoPatched) { + BlockDeleteClass._healRedoPatched = true; const originalBlockDeleteRun = BlockDeleteClass.prototype.run; BlockDeleteClass.prototype.run = function (forward) { if (forward) { From de1330879b99012d224ad5c8ff54aab338adc52e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 17:17:59 +0000 Subject: [PATCH 5/5] fix: disable else in-place instead of disconnecting on MODE field change The validateAndDisconnectInvalidChain_ method was explicitly calling nextConn.disconnect() + bumpNeighbours() when a block's MODE changed to ELSE and the next block became structurally invalid. This caused the else block below to be popped out of the chain rather than staying connected. Remove validateAndDisconnectInvalidChain_ and isInvalidInChain_ entirely. recomputeIfClauseValidity_() (already called immediately after) handles the invalid state by disabling the block in-place via setDisabledReason, which is the correct behaviour. https://claude.ai/code/session_014kzTt5dFCV9FZwzN1vAPfi --- blocks/control.js | 66 +---------------------------------------------- 1 file changed, 1 insertion(+), 65 deletions(-) diff --git a/blocks/control.js b/blocks/control.js index 29a88e1f6..618d6f61d 100644 --- a/blocks/control.js +++ b/blocks/control.js @@ -573,8 +573,7 @@ export function defineControlBlocks() { } } - // Validate and disconnect invalid subsequent blocks - this.validateAndDisconnectInvalidChain_(mode); + // Recompute validity (disables invalid blocks in-place; does not disconnect). this.recomputeIfClauseValidity_(); if (this.rendered) this.render(); @@ -678,69 +677,6 @@ export function defineControlBlocks() { } } }, - - validateAndDisconnectInvalidChain_: function (currentMode) { - // Skip validation if we're not in a workspace or during restore - if (!this.workspace || this._isRestoring) { - return; - } - - // Walk through the chain and find the first invalid block - let current = this; - let chainModes = [currentMode]; - - while (current) { - const nextConn = current.nextConnection; - if (!nextConn) break; - - const nextBlock = nextConn.targetBlock(); - - if (!nextBlock || nextBlock.type !== "if_clause") { - // End of if_clause chain, all valid - break; - } - - const nextMode = nextBlock.getFieldValue?.("MODE"); - if (!nextMode) break; - - const lastMode = chainModes[chainModes.length - 1]; - - // Check if this next block is valid given what came before - const isInvalid = this.isInvalidInChain_(lastMode, nextMode); - - if (isInvalid) { - // The nextBlock is invalid after lastMode - // Disconnect from the current block synchronously - if (nextBlock && !nextBlock.isDisposed() && nextConn.isConnected()) { - // Disconnect from the parent side - nextConn.disconnect(); - - // Bump the disconnected chain to a new location - if (nextBlock.rendered) { - nextBlock.bumpNeighbours(); - } - } - break; // Stop checking since we're disconnecting from here - } - - chainModes.push(nextMode); - current = nextBlock; - } - }, - - isInvalidInChain_: function (previousMode, nextMode) { - // ELSE cannot have anything after it - if (previousMode === MODE.ELSE) { - return true; - } - - // IF and ELSEIF can be followed by: - // - ELSEIF (valid) - // - ELSE (valid) - // - IF (valid - starts a new chain) - // So nothing is invalid after IF or ELSEIF - return false; - }, }; }