Skip to content

Commit 6cee69e

Browse files
cwillisfclaude
andcommitted
fix: prevent argument reporter duplication in prototype blocks
Two related causes of spurious argument reporter duplication: 1. DuplicateOnDragDraggable now checks whether the reporter is currently connected to a procedures_prototype before deciding to copy itself. When dragged from outside the prototype (e.g. from a set-variable block after a sprite switch), it falls back to a normal BlockDragStrategy and permanently replaces itself with it, so future drags also behave normally. 2. During XML deserialization (Blockly.Xml.domToWorkspace), domToMutation runs before Blockly connects the block's <value> children. The mutation was creating argument reporters that were immediately orphaned when the XML children connected. A skipArgumentReporters_ flag, set only when <value> siblings are present in the XML, defers reporter creation to the XML loader. Resolves #3484 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a2e803a commit 6cee69e

1 file changed

Lines changed: 73 additions & 16 deletions

File tree

src/blocks/procedures.ts

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,20 @@ class DelegateToParentDraggable implements Blockly.IDraggable {
7979
}
8080

8181
/**
82-
* Class representing a draggable block that copies itself on drag.
82+
* Class representing a draggable block that copies itself on drag, but only
83+
* when the block is directly connected to a procedures_prototype block.
84+
* When dragged from any other position, it behaves like a normal block.
8385
*/
8486
class DuplicateOnDragDraggable implements Blockly.IDraggable {
8587
/**
86-
* The newly-created duplicate block.
88+
* The block being dragged: a newly-created duplicate when dragging from a
89+
* prototype, or the original block when dragging from elsewhere.
8790
*/
8891
private copy?: Blockly.BlockSvg
92+
/**
93+
* Whether this drag is duplicating the block (true) or moving it (false).
94+
*/
95+
private isDuplicating_ = false
8996
constructor(private block: Blockly.BlockSvg) {}
9097

9198
/**
@@ -97,23 +104,37 @@ class DuplicateOnDragDraggable implements Blockly.IDraggable {
97104
}
98105

99106
/**
100-
* Handles the start of a drag.
107+
* Handles the start of a drag. If the block is directly connected to a
108+
* procedures_prototype, creates a duplicate and drags that. Otherwise,
109+
* switches to a normal drag strategy and drags the original block.
101110
* @param e The event that triggered the drag.
102111
*/
103112
startDrag(e: PointerEvent) {
104-
const data = this.block.toCopyData()
105-
if (!data) {
106-
console.warn(
107-
'DuplicateOnDragDraggable.startDrag: failed to serialize block for copy',
108-
this.block.type,
109-
this.block.id,
110-
)
111-
return
113+
const parent = this.block.getParent()
114+
this.isDuplicating_ = parent?.type === 'procedures_prototype'
115+
116+
if (this.isDuplicating_) {
117+
const data = this.block.toCopyData()
118+
if (!data) {
119+
console.warn(
120+
'DuplicateOnDragDraggable.startDrag: failed to serialize block for copy',
121+
this.block.type,
122+
this.block.id,
123+
)
124+
return
125+
}
126+
this.copy = Blockly.clipboard.paste(data, this.block.workspace) as Blockly.BlockSvg
127+
this.copy.setDeletable(true)
128+
this.copy.setDragStrategy(new Blockly.dragging.BlockDragStrategy(this.copy))
129+
this.copy.startDrag(e)
130+
} else {
131+
// Not in a prototype: drag the original block normally and replace this
132+
// drag strategy so future drags also behave normally.
133+
const normalStrategy = new Blockly.dragging.BlockDragStrategy(this.block)
134+
this.block.setDragStrategy(normalStrategy)
135+
this.copy = this.block
136+
normalStrategy.startDrag(e)
112137
}
113-
this.copy = Blockly.clipboard.paste(data, this.block.workspace) as Blockly.BlockSvg
114-
this.copy.setDeletable(true)
115-
this.copy.setDragStrategy(new Blockly.dragging.BlockDragStrategy(this.copy))
116-
this.copy.startDrag(e)
117138
}
118139

119140
drag(newLoc: Blockly.utils.Coordinate, e?: PointerEvent) {
@@ -135,7 +156,11 @@ class DuplicateOnDragDraggable implements Blockly.IDraggable {
135156
}
136157

137158
revertDrag() {
138-
this.copy?.dispose()
159+
if (this.isDuplicating_) {
160+
this.copy?.dispose()
161+
} else {
162+
this.copy?.revertDrag()
163+
}
139164
}
140165

141166
getRelativeToSurfaceXY() {
@@ -210,7 +235,31 @@ function definitionDomToMutation(this: ProcedurePrototypeBlock | ProcedureDeclar
210235
this.argumentIds_ = JSON.parse(xmlElement.getAttribute('argumentids')!)
211236
this.displayNames_ = JSON.parse(xmlElement.getAttribute('argumentnames')!)
212237
this.argumentDefaults_ = JSON.parse(xmlElement.getAttribute('argumentdefaults')!)
238+
239+
// During full XML deserialization (Blockly.Xml.domToWorkspace), the mutation element
240+
// is part of the parsed XML tree and its parent element also contains <value> children
241+
// for the argument reporters. Blockly will connect those child blocks AFTER the
242+
// mutation runs. To avoid creating duplicate reporters here that would immediately
243+
// be orphaned when the XML children connect, skip reporter creation and let the
244+
// XML children provide them.
245+
// We detect this case by checking that the parent element has <value> children.
246+
// This distinguishes full deserialization from:
247+
// - Programmatic mutation (xmlElement.parentElement is null — freshly created element)
248+
// - Block creation from partial XML with no <value> children (e.g. createProcedureCallbackFactory)
249+
// - JSON serialization / undo-redo (xmlElement.parentElement is null — textToDom result)
250+
const xmlParent = xmlElement.parentElement
251+
const hasXmlArgReporters =
252+
this.type === 'procedures_prototype' &&
253+
xmlParent !== null &&
254+
Array.from(xmlParent.children).some((el) => el.tagName.toLowerCase() === 'value')
255+
if (hasXmlArgReporters) {
256+
;(this as ProcedurePrototypeBlock).skipArgumentReporters_ = true
257+
}
213258
this.updateDisplay_()
259+
if (hasXmlArgReporters) {
260+
;(this as ProcedurePrototypeBlock).skipArgumentReporters_ = false
261+
}
262+
214263
if ('updateArgumentReporterNames_' in this) {
215264
this.updateArgumentReporterNames_(prevArgIds, prevDisplayNames)
216265
}
@@ -521,6 +570,12 @@ function populateArgumentOnPrototype_(
521570
id: string,
522571
input: Blockly.Input,
523572
) {
573+
// During XML deserialization, skip connecting argument reporters here.
574+
// The block's <value> XML children will connect the reporters after the mutation runs.
575+
if (this.skipArgumentReporters_) {
576+
return
577+
}
578+
524579
let oldBlock: Blockly.BlockSvg | null = null
525580
if (connectionMap && id in connectionMap) {
526581
const saveInfo = connectionMap[id]
@@ -949,6 +1004,7 @@ Blockly.Blocks.procedures_prototype = {
9491004
this.argumentIds_ = []
9501005
this.argumentDefaults_ = []
9511006
this.warp_ = false
1007+
this.skipArgumentReporters_ = false
9521008

9531009
// Shared.
9541010
this.getProcCode = getProcCode.bind(this)
@@ -1139,6 +1195,7 @@ interface ProcedureCallBlock extends ProcedureBlock {
11391195
interface ProcedurePrototypeBlock extends ProcedureBlock {
11401196
displayNames_: string[]
11411197
argumentDefaults_: string[]
1198+
skipArgumentReporters_: boolean
11421199
createArgumentReporter_: (argumentType: ArgumentType, displayName: string) => Blockly.BlockSvg
11431200
updateArgumentReporterNames_: (prevArgIds: string[], prevDisplayNames: string[]) => void
11441201
}

0 commit comments

Comments
 (0)