incognitolm commited on
Commit
8dfb3cd
·
1 Parent(s): c613a09

syncing on new block

Browse files
client/src/components/BlockEditor/BlockEditor.tsx CHANGED
@@ -130,6 +130,21 @@ export default function BlockEditor() {
130
  // Set when the user changes the workspace during a drag while a remote XML
131
  // sync is deferred. Local edits must win over the older deferred snapshot.
132
  const localChangeDuringDragRef = useRef(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
  // Modal state for editing variable/function blocks
135
  const [editingModal, setEditingModal] = useState<BlockEditorModalState | null>(null);
@@ -328,16 +343,23 @@ export default function BlockEditor() {
328
  if (e.isStart) {
329
  dragEndId++;
330
  isDraggingRef.current = true;
331
- blocksCreatedDuringDragRef.current = false;
 
 
 
332
  localChangeDuringDragRef.current = false;
 
333
  draggedBlockId = e.blockId;
334
  emitBlockDragStart(e.blockId, xy.x, xy.y);
335
  // Poll block position during drag (BLOCK_MOVE may not fire)
336
  dragPoll = setInterval(() => {
337
- if (remoteUpdateRef.current || !isDraggingRef.current) {
338
  if (dragPoll) { clearInterval(dragPoll); dragPoll = null; }
339
  return;
340
  }
 
 
 
341
  const b = ws.getBlockById(draggedBlockId!);
342
  if (b) {
343
  const pos = b.getRelativeToSurfaceXY();
@@ -355,41 +377,19 @@ export default function BlockEditor() {
355
  requestAnimationFrame(() => {
356
  if (thisId !== dragEndId) return;
357
  isDraggingRef.current = false;
358
- saveBlocks();
359
- // Apply any deferred XML sync that arrived during drag
360
- const pendingXml = pendingSyncXmlRef.current;
361
- if (pendingXml) {
 
 
 
 
 
 
 
362
  pendingSyncXmlRef.current = null;
363
- const ws2 = workspaceRef.current;
364
- if (ws2) {
365
- // If the user changed anything locally during the drag, the
366
- // deferred XML predates those edits. Keep the local state;
367
- // saveBlocks() above already broadcast it to collaborators.
368
- if (blocksCreatedDuringDragRef.current || localChangeDuringDragRef.current) {
369
- console.log('[WS Reload] drag-end SKIPPING deferred XML: local edits during drag take priority');
370
- return;
371
- }
372
- console.log('[WS Reload] applying deferred XML after drag end');
373
- remoteUpdateRef.current = true;
374
- try {
375
- ws2.clear();
376
- const dom = Blockly.utils.xml.textToDom(pendingXml);
377
- Blockly.Xml.domToWorkspace(dom, ws2);
378
- try { (ws2 as any).clearSelection(); } catch {}
379
- try { ws2.setScale(useEditorStore.getState().zoom); } catch {}
380
- // Regenerate the compiled code now that the workspace reflects
381
- // the collaborator's XML (saveBlocks skips during remote loads).
382
- const currentFileId = useEditorStore.getState().activeFileId;
383
- if (currentFileId) refreshCodeForWorkspace(ws2, currentFileId);
384
- } catch (e) {
385
- console.error('[WS Reload] FAILED to apply deferred XML:', e);
386
- }
387
- requestAnimationFrame(() => { setTimeout(() => { remoteUpdateRef.current = false; }, 0); });
388
- requestAnimationFrame(() => {
389
- try { syncCustomFunctionBlocks(ws2); } catch {}
390
- try { rebuildToolbox(ws2); } catch {}
391
- });
392
- }
393
  }
394
  });
395
  }
@@ -418,30 +418,42 @@ export default function BlockEditor() {
418
  if (e.type === Blockly.Events.BLOCK_CHANGE && e.element === 'field' && !remoteUpdateRef.current) {
419
  emitBlockChange(e.blockId, e.name, e.newValue, e.element);
420
  }
421
- // Broadcast block creation (toolbox drag-out, paste, duplicate) so collaborator
422
- // screens can create the block immediately instead of waiting for XML sync.
 
 
 
 
423
  if (e.type === Blockly.Events.BLOCK_CREATE && !remoteUpdateRef.current) {
 
424
  if (isDraggingRef.current) {
425
  blocksCreatedDuringDragRef.current = true;
426
  }
427
- const block = ws.getBlockById(e.blockId);
428
- console.log('[BLOCK_CREATE] received:', { blockId: e.blockId, hasBlock: !!block, blockType: block?.type, isShadow: block?.isShadow?.(), hasParent: !!block?.getParent?.(), isDragging: isDraggingRef.current });
429
- if (block && !block.getParent()) {
 
 
 
 
 
 
 
430
  try {
431
- const dom = Blockly.Xml.blockToDom(block, { xy: true } as any);
432
  const xml = Blockly.Xml.domToText(dom);
433
- console.log('[BLOCK_CREATE] emitting block_create_event:', { blockId: e.blockId, activeFileId, xmlLen: xml.length });
434
- if (activeFileId) emitBlockCreateEvent(e.blockId, activeFileId, xml);
435
  } catch (err) {
436
  console.warn('[BLOCK_CREATE] failed to emit block_create_event:', err);
437
  }
438
- } else if (block && block.getParent()) {
439
- console.log('[BLOCK_CREATE] skipping: block has parent');
440
- }
441
  }
442
  });
443
 
444
  // Restore saved blocks for this specific file
 
 
 
445
  const savedXml = getBlocksXml(activeFileId);
446
  if (savedXml) {
447
  try {
@@ -453,9 +465,9 @@ export default function BlockEditor() {
453
  // the restored blocks even if the persisted file content is older than
454
  // the saved XML (e.g. leftover code from a previous session).
455
  if (activeFileId) refreshCodeForWorkspace(ws, activeFileId);
456
- requestAnimationFrame(() => { setTimeout(() => { remoteUpdateRef.current = false; }, 0); });
457
  } catch (e) {
458
- requestAnimationFrame(() => { setTimeout(() => { remoteUpdateRef.current = false; }, 0); });
459
  // saved XML is invalid, ignore
460
  }
461
  }
@@ -465,22 +477,15 @@ export default function BlockEditor() {
465
  });
466
 
467
  // Auto-save blocks on change - per-file
468
- const saveBlocks = () => {
469
  if (!workspaceProjectId || projectIdRef.current !== workspaceProjectId) return;
470
- if (remoteUpdateRef.current || isDraggingRef.current) {
471
- console.log('[saveBlocks] SKIPPING (remote:', remoteUpdateRef.current, 'dragging:', isDraggingRef.current, ') topBlocks:', ws.getTopBlocks(false).length);
472
- return;
473
- }
474
  const dom = Blockly.Xml.workspaceToDom(ws);
475
  const xml = Blockly.Xml.domToText(dom);
476
  const currentFileId = useEditorStore.getState().activeFileId;
477
- if (!currentFileId) return;
478
  const savedXml = getBlocksXml(currentFileId);
479
- if (savedXml === xml) {
480
- console.log('[saveBlocks] SKIPPING: XML unchanged for file:', currentFileId);
481
- return;
482
- }
483
- console.log('[saveBlocks] SAVING blocks for file:', currentFileId, 'xml length:', xml.length, 'topBlocks:', ws.getTopBlocks(false).length, '(old:', savedXml?.length, ')');
484
  setBlocksXml(currentFileId, xml);
485
  emitBlockStateSync(currentFileId, xml);
486
  const generatedCode = refreshCodeForWorkspace(ws, currentFileId);
@@ -493,7 +498,18 @@ export default function BlockEditor() {
493
  s.emit('file_changed', { projectId: pid, fileId: currentFileId, content: generatedCode });
494
  }
495
  };
496
- ws.addChangeListener(saveBlocks);
 
 
 
 
 
 
 
 
 
 
 
497
 
498
  return () => {
499
  resizeObserver.disconnect();
@@ -516,18 +532,25 @@ export default function BlockEditor() {
516
  delete (div as any).__pointerSvg;
517
  }
518
  isDraggingRef.current = false;
519
- ws.removeChangeListener(saveBlocks);
520
  ws.dispose();
521
  workspaceRef.current = null;
522
  };
523
- }, [framework, activeFileId, currentProject?.id]);
524
 
525
  // Apply remote block state changes (skip if workspace already matches)
526
  const blocksXmlForFile = useEditorStore((s) => s.blocksXml[activeFileId || '']);
527
  useEffect(() => {
528
- if (!activeFileId || !workspaceRef.current || remoteUpdateRef.current) return;
529
- const ws = workspaceRef.current;
530
  if (!blocksXmlForFile) return;
 
 
 
 
 
 
 
 
531
  // If the workspace already matches this XML, skip reload (avoid disrupting drag/edit)
532
  try {
533
  const currentDom = Blockly.Xml.workspaceToDom(ws);
@@ -537,7 +560,6 @@ export default function BlockEditor() {
537
  // If the user is currently dragging, defer the reload to avoid disrupting the drag.
538
  // Store the pending XML so the drag-end handler can apply it.
539
  if (isDraggingRef.current) {
540
- console.log('[WS Reload] DEFERRING: user is dragging');
541
  pendingSyncXmlRef.current = blocksXmlForFile;
542
  return;
543
  }
@@ -545,7 +567,6 @@ export default function BlockEditor() {
545
  // has fewer top-level blocks than the local workspace: otherwise deleting
546
  // blocks remotely would never propagate. Concurrent local edits are sent
547
  // back through the same channel and converge on the last update.
548
- console.log('[WS Reload] reloading workspace from XML (top blocks:', ws.getTopBlocks(false).length, ')');
549
  pendingSyncXmlRef.current = null;
550
  remoteUpdateRef.current = true;
551
  try {
@@ -562,17 +583,23 @@ export default function BlockEditor() {
562
  }
563
  // Blockly may fire events asynchronously (microtasks) after domToWorkspace.
564
  // Defer resetting the flag so those events see remoteUpdateRef=true.
565
- requestAnimationFrame(() => { setTimeout(() => { remoteUpdateRef.current = false; }, 0); });
566
  requestAnimationFrame(() => {
567
  try { syncCustomFunctionBlocks(ws); } catch {}
568
  try { rebuildToolbox(ws); } catch {}
569
  });
570
- }, [activeFileId, blocksXmlForFile]);
571
 
572
  // Move remote collaborator blocks to absolute positions during drag
573
  useEffect(() => {
574
  const ws = workspaceRef.current;
575
- if (!ws || !activeFileId || remoteUpdateRef.current) return;
 
 
 
 
 
 
576
  remoteUpdateRef.current = true;
577
  for (const [userId, drag] of Object.entries(remoteBlockDrags)) {
578
  const block = ws.getBlockById(drag.blockId);
@@ -588,12 +615,18 @@ export default function BlockEditor() {
588
  block.moveBy(dx, dy);
589
  }
590
  }
591
- remoteUpdateRef.current = false;
592
- }, [remoteBlockDrags, activeFileId]);
593
 
594
  // Connect blocks on receiver when remote user snaps a block into a parent
595
  useEffect(() => {
596
  if (!pendingBlockConnects.length) return;
 
 
 
 
 
 
597
  remoteUpdateRef.current = true;
598
  const ws = workspaceRef.current;
599
  if (ws) {
@@ -641,12 +674,16 @@ export default function BlockEditor() {
641
  // code explicitly after applying remote connections.
642
  if (activeFileId) refreshCodeForWorkspace(ws, activeFileId);
643
  }
644
- remoteUpdateRef.current = false;
645
- }, [pendingBlockConnects, blocksXmlForFile]);
646
 
647
  // Apply field value changes from remote collaborators (dropdowns, text fields, etc.)
648
  useEffect(() => {
649
  if (!pendingBlockChanges.length) return;
 
 
 
 
650
  remoteUpdateRef.current = true;
651
  const ws = workspaceRef.current;
652
  if (ws) {
@@ -660,61 +697,82 @@ export default function BlockEditor() {
660
  } catch {}
661
  }
662
  }
663
- remoteUpdateRef.current = false;
664
  // saveBlocks skips while remoteUpdateRef is set, so refresh the compiled
665
  // code explicitly after applying remote field value changes.
666
  if (ws && activeFileId) refreshCodeForWorkspace(ws, activeFileId);
667
- }, [pendingBlockChanges]);
668
 
669
  // Create blocks on receiver when remote user drags a new block from the toolbox
670
  useEffect(() => {
671
- if (!pendingBlockCreates.length) {
672
- console.log('[PendingCreate] no pending creates, skipping');
 
673
  return;
674
  }
675
- console.log('[PendingCreate] processing', pendingBlockCreates.length, 'pending creates');
676
- remoteUpdateRef.current = true;
677
  const ws = workspaceRef.current;
678
- if (ws && activeFileId) {
679
- let created = false;
680
- while (useCollaborationStore.getState().pendingBlockCreates.length > 0) {
681
- const create = useCollaborationStore.getState().shiftPendingBlockCreate();
682
- if (!create) continue;
683
- // Skip if block already exists (e.g. already created by XML sync)
684
- if (ws.getBlockById(create.blockId)) {
685
- console.log('[PendingCreate] block already exists:', create.blockId);
686
- continue;
687
- }
688
- try {
689
- console.log('[PendingCreate] creating block:', create.blockId);
690
- const dom = Blockly.utils.xml.textToDom(create.xml);
691
- Blockly.Xml.domToWorkspace(dom, ws);
692
- created = true;
693
- console.log('[PendingCreate] block created successfully:', create.blockId);
694
- } catch (e) {
695
- console.warn('[PendingCreate] failed to create block:', create.blockId, e);
 
 
 
 
 
 
 
 
 
 
 
 
696
  }
 
 
 
 
697
  }
698
- // Update blocksXml in the store so the blocksXmlForFile comparison
699
- // in the workspace-reload effect matches the current workspace,
700
- // preventing a destructive clear+reload that could conflict.
701
- if (created) {
702
- const dom = Blockly.Xml.workspaceToDom(ws);
703
- const xml = Blockly.Xml.domToText(dom);
704
- console.log('[PendingCreate] updating blocksXml after creating blocks');
705
- useEditorStore.getState().setBlocksXml(activeFileId, xml);
706
- // saveBlocks skips while remoteUpdateRef is set, so refresh the
707
- // compiled code explicitly after creating collaborator blocks.
708
- refreshCodeForWorkspace(ws, activeFileId);
709
- requestAnimationFrame(() => {
710
- try { syncCustomFunctionBlocks(ws); } catch {}
711
- try { rebuildToolbox(ws); } catch {}
712
- });
713
- }
714
- useCollaborationStore.setState({ pendingBlockCreates: [] });
715
  }
716
- requestAnimationFrame(() => { setTimeout(() => { remoteUpdateRef.current = false; }, 0); });
717
- }, [pendingBlockCreates, blocksXmlForFile]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
 
719
  // Collaborator block selection overlay
720
  const collaborators = useCollaborationStore((s) => s.collaborators);
 
130
  // Set when the user changes the workspace during a drag while a remote XML
131
  // sync is deferred. Local edits must win over the older deferred snapshot.
132
  const localChangeDuringDragRef = useRef(false);
133
+ // Recent block creation id. A toolbox drag emits BLOCK_CREATE before
134
+ // BLOCK_DRAG start, so this lets us tell that the drag created the block.
135
+ const recentCreateBlockIdRef = useRef<string | null>(null);
136
+ // Remote XML that arrived while another remote update was still in flight.
137
+ // It must be retried instead of silently dropped.
138
+ const deferredRemoteSyncRef = useRef(false);
139
+ const [remoteSyncTick, setRemoteSyncTick] = useState(0);
140
+
141
+ const finishRemoteUpdate = useCallback(() => {
142
+ remoteUpdateRef.current = false;
143
+ if (deferredRemoteSyncRef.current) {
144
+ deferredRemoteSyncRef.current = false;
145
+ setRemoteSyncTick((tick) => tick + 1);
146
+ }
147
+ }, []);
148
 
149
  // Modal state for editing variable/function blocks
150
  const [editingModal, setEditingModal] = useState<BlockEditorModalState | null>(null);
 
343
  if (e.isStart) {
344
  dragEndId++;
345
  isDraggingRef.current = true;
346
+ // A toolbox drag emits BLOCK_CREATE just before BLOCK_DRAG start.
347
+ // Track that this drag introduced a new block, so its drop wins
348
+ // over any older XML that arrived while the drag was in progress.
349
+ blocksCreatedDuringDragRef.current = recentCreateBlockIdRef.current === e.blockId;
350
  localChangeDuringDragRef.current = false;
351
+ recentCreateBlockIdRef.current = null;
352
  draggedBlockId = e.blockId;
353
  emitBlockDragStart(e.blockId, xy.x, xy.y);
354
  // Poll block position during drag (BLOCK_MOVE may not fire)
355
  dragPoll = setInterval(() => {
356
+ if (!isDraggingRef.current) {
357
  if (dragPoll) { clearInterval(dragPoll); dragPoll = null; }
358
  return;
359
  }
360
+ // Skip this tick while a remote apply is settling, but keep
361
+ // polling so later drag positions still reach collaborators.
362
+ if (remoteUpdateRef.current) return;
363
  const b = ws.getBlockById(draggedBlockId!);
364
  if (b) {
365
  const pos = b.getRelativeToSurfaceXY();
 
377
  requestAnimationFrame(() => {
378
  if (thisId !== dragEndId) return;
379
  isDraggingRef.current = false;
380
+ const hadLocalChanges =
381
+ blocksCreatedDuringDragRef.current || localChangeDuringDragRef.current;
382
+ // The dropped block is a local edit. Force the save even when a
383
+ // remote update is being applied, otherwise a quick drag can be
384
+ // overwritten by the remote XML before it is broadcast.
385
+ saveBlocks(hadLocalChanges);
386
+ // Any XML that arrived while dragging was deferred. The store now
387
+ // holds either the forced local save above or the collaborator's
388
+ // latest XML, so let the reload effect reconcile the workspace
389
+ // with that single source of truth.
390
+ if (pendingSyncXmlRef.current) {
391
  pendingSyncXmlRef.current = null;
392
+ setRemoteSyncTick((tick) => tick + 1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  }
394
  });
395
  }
 
418
  if (e.type === Blockly.Events.BLOCK_CHANGE && e.element === 'field' && !remoteUpdateRef.current) {
419
  emitBlockChange(e.blockId, e.name, e.newValue, e.element);
420
  }
421
+ // Broadcast block creation (toolbox drag-out, paste, duplicate) so
422
+ // collaborator screens can create the block immediately instead of
423
+ // waiting for the next full XML sync. BLOCK_CREATE fires from the block
424
+ // constructor, before Blockly has positioned a flyout block, so emit on
425
+ // a microtask to capture its real coordinates. blockToDomWithXY also
426
+ // preserves the block id that later drag/connect/change events use.
427
  if (e.type === Blockly.Events.BLOCK_CREATE && !remoteUpdateRef.current) {
428
+ recentCreateBlockIdRef.current = e.blockId;
429
  if (isDraggingRef.current) {
430
  blocksCreatedDuringDragRef.current = true;
431
  }
432
+ const createdBlockId = e.blockId;
433
+ const createdFileId = activeFileId;
434
+ queueMicrotask(() => {
435
+ if (!createdFileId || workspaceRef.current !== ws) return;
436
+ // Never emit a block from a workspace that has already been replaced
437
+ // by another file or project.
438
+ if (projectIdRef.current !== workspaceProjectId) return;
439
+ if (useEditorStore.getState().activeFileId !== createdFileId) return;
440
+ const block = ws.getBlockById(createdBlockId);
441
+ if (!block || block.isShadow() || block.isInsertionMarker()) return;
442
  try {
443
+ const dom = Blockly.Xml.blockToDomWithXY(block);
444
  const xml = Blockly.Xml.domToText(dom);
445
+ emitBlockCreateEvent(createdBlockId, createdFileId, xml);
 
446
  } catch (err) {
447
  console.warn('[BLOCK_CREATE] failed to emit block_create_event:', err);
448
  }
449
+ });
 
 
450
  }
451
  });
452
 
453
  // Restore saved blocks for this specific file
454
+ remoteUpdateRef.current = false;
455
+ deferredRemoteSyncRef.current = false;
456
+ pendingSyncXmlRef.current = null;
457
  const savedXml = getBlocksXml(activeFileId);
458
  if (savedXml) {
459
  try {
 
465
  // the restored blocks even if the persisted file content is older than
466
  // the saved XML (e.g. leftover code from a previous session).
467
  if (activeFileId) refreshCodeForWorkspace(ws, activeFileId);
468
+ requestAnimationFrame(() => { setTimeout(finishRemoteUpdate, 0); });
469
  } catch (e) {
470
+ requestAnimationFrame(() => { setTimeout(finishRemoteUpdate, 0); });
471
  // saved XML is invalid, ignore
472
  }
473
  }
 
477
  });
478
 
479
  // Auto-save blocks on change - per-file
480
+ const saveBlocks = (force = false) => {
481
  if (!workspaceProjectId || projectIdRef.current !== workspaceProjectId) return;
482
+ if ((remoteUpdateRef.current && !force) || isDraggingRef.current) return;
 
 
 
483
  const dom = Blockly.Xml.workspaceToDom(ws);
484
  const xml = Blockly.Xml.domToText(dom);
485
  const currentFileId = useEditorStore.getState().activeFileId;
486
+ if (!currentFileId || currentFileId !== activeFileId) return;
487
  const savedXml = getBlocksXml(currentFileId);
488
+ if (savedXml === xml) return;
 
 
 
 
489
  setBlocksXml(currentFileId, xml);
490
  emitBlockStateSync(currentFileId, xml);
491
  const generatedCode = refreshCodeForWorkspace(ws, currentFileId);
 
498
  s.emit('file_changed', { projectId: pid, fileId: currentFileId, content: generatedCode });
499
  }
500
  };
501
+ // A toolbox drag fires BLOCK_CREATE from the block constructor, before
502
+ // Blockly has positioned the block. Saving there would broadcast a
503
+ // transient (0,0) position. Defer one microtask: if a drag starts, the
504
+ // save is suppressed until drop; paste/programmatic creates still save.
505
+ const handleWorkspaceChange = (e: any) => {
506
+ if (e?.type === Blockly.Events.BLOCK_CREATE && !remoteUpdateRef.current) {
507
+ queueMicrotask(() => saveBlocks());
508
+ return;
509
+ }
510
+ saveBlocks();
511
+ };
512
+ ws.addChangeListener(handleWorkspaceChange);
513
 
514
  return () => {
515
  resizeObserver.disconnect();
 
532
  delete (div as any).__pointerSvg;
533
  }
534
  isDraggingRef.current = false;
535
+ ws.removeChangeListener(handleWorkspaceChange);
536
  ws.dispose();
537
  workspaceRef.current = null;
538
  };
539
+ }, [framework, activeFileId, currentProject?.id, finishRemoteUpdate]);
540
 
541
  // Apply remote block state changes (skip if workspace already matches)
542
  const blocksXmlForFile = useEditorStore((s) => s.blocksXml[activeFileId || '']);
543
  useEffect(() => {
544
+ if (!activeFileId || !workspaceRef.current) return;
 
545
  if (!blocksXmlForFile) return;
546
+ const ws = workspaceRef.current;
547
+ if (remoteUpdateRef.current) {
548
+ // A different remote update is still settling. Remember it and retry;
549
+ // dropping it here is what made quick updates (e.g. a block created and
550
+ // dropped while a create was being applied) disappear for collaborators.
551
+ deferredRemoteSyncRef.current = true;
552
+ return;
553
+ }
554
  // If the workspace already matches this XML, skip reload (avoid disrupting drag/edit)
555
  try {
556
  const currentDom = Blockly.Xml.workspaceToDom(ws);
 
560
  // If the user is currently dragging, defer the reload to avoid disrupting the drag.
561
  // Store the pending XML so the drag-end handler can apply it.
562
  if (isDraggingRef.current) {
 
563
  pendingSyncXmlRef.current = blocksXmlForFile;
564
  return;
565
  }
 
567
  // has fewer top-level blocks than the local workspace: otherwise deleting
568
  // blocks remotely would never propagate. Concurrent local edits are sent
569
  // back through the same channel and converge on the last update.
 
570
  pendingSyncXmlRef.current = null;
571
  remoteUpdateRef.current = true;
572
  try {
 
583
  }
584
  // Blockly may fire events asynchronously (microtasks) after domToWorkspace.
585
  // Defer resetting the flag so those events see remoteUpdateRef=true.
586
+ requestAnimationFrame(() => { setTimeout(finishRemoteUpdate, 0); });
587
  requestAnimationFrame(() => {
588
  try { syncCustomFunctionBlocks(ws); } catch {}
589
  try { rebuildToolbox(ws); } catch {}
590
  });
591
+ }, [activeFileId, blocksXmlForFile, remoteSyncTick, finishRemoteUpdate]);
592
 
593
  // Move remote collaborator blocks to absolute positions during drag
594
  useEffect(() => {
595
  const ws = workspaceRef.current;
596
+ if (!ws || !activeFileId) return;
597
+ if (remoteUpdateRef.current) {
598
+ // Retry once the in-flight apply settles so the latest drag position is
599
+ // not skipped.
600
+ deferredRemoteSyncRef.current = true;
601
+ return;
602
+ }
603
  remoteUpdateRef.current = true;
604
  for (const [userId, drag] of Object.entries(remoteBlockDrags)) {
605
  const block = ws.getBlockById(drag.blockId);
 
615
  block.moveBy(dx, dy);
616
  }
617
  }
618
+ finishRemoteUpdate();
619
+ }, [remoteBlockDrags, activeFileId, blocksXmlForFile, remoteSyncTick, finishRemoteUpdate]);
620
 
621
  // Connect blocks on receiver when remote user snaps a block into a parent
622
  useEffect(() => {
623
  if (!pendingBlockConnects.length) return;
624
+ if (remoteUpdateRef.current) {
625
+ // Another apply is still settling; retry after it finishes so this
626
+ // connection is not applied against a half-updated workspace.
627
+ deferredRemoteSyncRef.current = true;
628
+ return;
629
+ }
630
  remoteUpdateRef.current = true;
631
  const ws = workspaceRef.current;
632
  if (ws) {
 
674
  // code explicitly after applying remote connections.
675
  if (activeFileId) refreshCodeForWorkspace(ws, activeFileId);
676
  }
677
+ finishRemoteUpdate();
678
+ }, [pendingBlockConnects, blocksXmlForFile, remoteSyncTick, finishRemoteUpdate]);
679
 
680
  // Apply field value changes from remote collaborators (dropdowns, text fields, etc.)
681
  useEffect(() => {
682
  if (!pendingBlockChanges.length) return;
683
+ if (remoteUpdateRef.current) {
684
+ deferredRemoteSyncRef.current = true;
685
+ return;
686
+ }
687
  remoteUpdateRef.current = true;
688
  const ws = workspaceRef.current;
689
  if (ws) {
 
697
  } catch {}
698
  }
699
  }
700
+ finishRemoteUpdate();
701
  // saveBlocks skips while remoteUpdateRef is set, so refresh the compiled
702
  // code explicitly after applying remote field value changes.
703
  if (ws && activeFileId) refreshCodeForWorkspace(ws, activeFileId);
704
+ }, [pendingBlockChanges, remoteSyncTick, finishRemoteUpdate]);
705
 
706
  // Create blocks on receiver when remote user drags a new block from the toolbox
707
  useEffect(() => {
708
+ if (!activeFileId || !pendingBlockCreates.length) return;
709
+ if (remoteUpdateRef.current) {
710
+ deferredRemoteSyncRef.current = true;
711
  return;
712
  }
 
 
713
  const ws = workspaceRef.current;
714
+ if (!ws) return;
715
+ // Creates are queued together with their file id. Leave operations for
716
+ // other files untouched; they are recovered by the full XML sync (or
717
+ // applied when the user opens that file).
718
+ const forActiveFile = useCollaborationStore
719
+ .getState()
720
+ .pendingBlockCreates.filter((create) => create.fileId === activeFileId);
721
+ if (!forActiveFile.length) return;
722
+
723
+ remoteUpdateRef.current = true;
724
+ let created = false;
725
+ const processedBlockIds = new Set<string>();
726
+ for (const create of forActiveFile) {
727
+ processedBlockIds.add(create.blockId);
728
+ // Skip if block already exists (e.g. already created by XML sync)
729
+ if (ws.getBlockById(create.blockId)) continue;
730
+ try {
731
+ const dom = Blockly.utils.xml.textToDom(create.xml);
732
+ // `domToWorkspace` expects an <xml> wrapper; passing a bare <block>
733
+ // element silently creates nothing. Older payloads may also have lost
734
+ // the id (blockToDom's second argument is opt_noId), so restore it to
735
+ // keep later drag/connect/change events addressable.
736
+ const rootName = dom.nodeName.toLowerCase();
737
+ let wrapper: Element;
738
+ if (rootName === 'xml') {
739
+ wrapper = dom;
740
+ } else {
741
+ if (!dom.getAttribute('id')) dom.setAttribute('id', create.blockId);
742
+ wrapper = Blockly.utils.xml.createElement('xml');
743
+ wrapper.appendChild(dom);
744
  }
745
+ Blockly.Xml.domToWorkspace(wrapper, ws);
746
+ created = true;
747
+ } catch (e) {
748
+ console.warn('[Collaboration] failed to apply remote block create:', create.blockId, e);
749
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
750
  }
751
+ // Remove only the entries that were processed so newly queued creates are
752
+ // never dropped.
753
+ useCollaborationStore.setState((state) => ({
754
+ pendingBlockCreates: state.pendingBlockCreates.filter(
755
+ (create) =>
756
+ !(create.fileId === activeFileId && processedBlockIds.has(create.blockId))
757
+ ),
758
+ }));
759
+ // Update blocksXml in the store so the blocksXmlForFile comparison in the
760
+ // workspace-reload effect matches the current workspace, preventing a
761
+ // destructive clear+reload that could conflict.
762
+ if (created) {
763
+ const dom = Blockly.Xml.workspaceToDom(ws);
764
+ const xml = Blockly.Xml.domToText(dom);
765
+ useEditorStore.getState().setBlocksXml(activeFileId, xml);
766
+ // saveBlocks skips while remoteUpdateRef is set, so refresh the compiled
767
+ // code explicitly after creating collaborator blocks.
768
+ refreshCodeForWorkspace(ws, activeFileId);
769
+ requestAnimationFrame(() => {
770
+ try { syncCustomFunctionBlocks(ws); } catch {}
771
+ try { rebuildToolbox(ws); } catch {}
772
+ });
773
+ }
774
+ requestAnimationFrame(() => { setTimeout(finishRemoteUpdate, 0); });
775
+ }, [pendingBlockCreates, blocksXmlForFile, activeFileId, remoteSyncTick, finishRemoteUpdate]);
776
 
777
  // Collaborator block selection overlay
778
  const collaborators = useCollaborationStore((s) => s.collaborators);
client/src/components/ProjectEditor/ProjectEditor.tsx CHANGED
@@ -74,13 +74,15 @@ export default function ProjectEditor() {
74
  // snapshot, silently discarding local and collaborator edits.
75
  const hydratedProjectIdRef = useRef<string | null>(null);
76
  const resetProjectState = useEditorStore((s) => s.resetProjectState);
 
77
 
78
  // Declared before `useProjectSocket` so it runs before the new room's initial
79
  // block state arrives. Per-file caches are keyed by id and default files
80
  // repeat across projects, so stale entries must be cleared first.
81
  useEffect(() => {
82
  resetProjectState();
83
- }, [id, resetProjectState]);
 
84
 
85
  const { emitFileChanged, emitActiveFileChanged, emitSave, emitCursorMove } = useProjectSocket(id);
86
 
 
74
  // snapshot, silently discarding local and collaborator edits.
75
  const hydratedProjectIdRef = useRef<string | null>(null);
76
  const resetProjectState = useEditorStore((s) => s.resetProjectState);
77
+ const resetCollaborationState = useCollaborationStore((s) => s.resetTransientState);
78
 
79
  // Declared before `useProjectSocket` so it runs before the new room's initial
80
  // block state arrives. Per-file caches are keyed by id and default files
81
  // repeat across projects, so stale entries must be cleared first.
82
  useEffect(() => {
83
  resetProjectState();
84
+ resetCollaborationState();
85
+ }, [id, resetProjectState, resetCollaborationState]);
86
 
87
  const { emitFileChanged, emitActiveFileChanged, emitSave, emitCursorMove } = useProjectSocket(id);
88
 
client/src/hooks/useWebSocket.ts CHANGED
@@ -228,15 +228,11 @@ export function useWebSocket() {
228
  s.on('collaborator_block_create_event', ({ projectId, blockId, fileId, xml }: { projectId?: string; blockId: string; fileId: string; xml: string }) => {
229
  if (!isCurrentProject(projectId)) return;
230
  const store = useEditorStore.getState();
231
- console.log('[WS] collaborator_block_create_event received:', { blockId, fileId, activeFileId: store.activeFileId, xmlLength: xml?.length });
232
  // Only apply to the currently active file; otherwise the block would appear
233
  // on the wrong file's workspace. The full XML sync (block_state_synced) will
234
  // handle it when the collaborator switches to that file.
235
  if (store.activeFileId === fileId) {
236
- console.log('[WS] pushing pending block create:', blockId);
237
- useCollaborationStore.getState().pushPendingBlockCreate({ blockId, xml });
238
- } else {
239
- console.log('[WS] skipping block create - fileId mismatch:', { eventFileId: fileId, activeFileId: store.activeFileId });
240
  }
241
  });
242
 
 
228
  s.on('collaborator_block_create_event', ({ projectId, blockId, fileId, xml }: { projectId?: string; blockId: string; fileId: string; xml: string }) => {
229
  if (!isCurrentProject(projectId)) return;
230
  const store = useEditorStore.getState();
 
231
  // Only apply to the currently active file; otherwise the block would appear
232
  // on the wrong file's workspace. The full XML sync (block_state_synced) will
233
  // handle it when the collaborator switches to that file.
234
  if (store.activeFileId === fileId) {
235
+ useCollaborationStore.getState().pushPendingBlockCreate({ blockId, fileId, xml });
 
 
 
236
  }
237
  });
238
 
client/src/store/collaborationStore.ts CHANGED
@@ -40,7 +40,7 @@ interface CollaborationStore {
40
  remoteBlockDrags: Record<string, RemoteBlockDrag>;
41
  pendingBlockConnects: Array<{ childId: string; parentId: string; inputName?: string }>;
42
  pendingBlockChanges: Array<{ blockId: string; name: string; value: any }>;
43
- pendingBlockCreates: Array<{ blockId: string; xml: string }>;
44
  setConnected: (connected: boolean) => void;
45
  setCollaborators: (collaborators: Collaborator[]) => void;
46
  setOwner: (owner: CollaboratorOwner) => void;
@@ -62,8 +62,8 @@ interface CollaborationStore {
62
  shiftPendingBlockConnect: () => { childId: string; parentId: string; inputName?: string } | undefined;
63
  pushPendingBlockChange: (data: { blockId: string; name: string; value: any }) => void;
64
  shiftPendingBlockChange: () => { blockId: string; name: string; value: any } | undefined;
65
- pushPendingBlockCreate: (data: { blockId: string; xml: string }) => void;
66
- shiftPendingBlockCreate: () => { blockId: string; xml: string } | undefined;
67
  getCollaboratorsOnFile: (fileId: string) => Collaborator[];
68
  getCollaboratorsOnBlock: (blockId: string) => Collaborator[];
69
  getCollaboratorsOnElement: (elementId: string) => Collaborator[];
@@ -152,6 +152,18 @@ export const useCollaborationStore = create<CollaborationStore>((set, get) => ({
152
 
153
  setSavedBy: (username) => set({ savedBy: username }),
154
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  getCollaboratorsOnFile: (fileId) => {
156
  return get().collaborators.filter((c) => c.activeFileId === fileId);
157
  },
@@ -234,12 +246,4 @@ export const useCollaborationStore = create<CollaborationStore>((set, get) => ({
234
  set((state) => ({
235
  pendingBlockCreates: [...state.pendingBlockCreates, data],
236
  })),
237
- shiftPendingBlockCreate: () => {
238
- const state = get();
239
- const [first, ...rest] = state.pendingBlockCreates;
240
- if (first) {
241
- set({ pendingBlockCreates: rest });
242
- }
243
- return first;
244
- },
245
  }));
 
40
  remoteBlockDrags: Record<string, RemoteBlockDrag>;
41
  pendingBlockConnects: Array<{ childId: string; parentId: string; inputName?: string }>;
42
  pendingBlockChanges: Array<{ blockId: string; name: string; value: any }>;
43
+ pendingBlockCreates: Array<{ blockId: string; fileId: string; xml: string }>;
44
  setConnected: (connected: boolean) => void;
45
  setCollaborators: (collaborators: Collaborator[]) => void;
46
  setOwner: (owner: CollaboratorOwner) => void;
 
62
  shiftPendingBlockConnect: () => { childId: string; parentId: string; inputName?: string } | undefined;
63
  pushPendingBlockChange: (data: { blockId: string; name: string; value: any }) => void;
64
  shiftPendingBlockChange: () => { blockId: string; name: string; value: any } | undefined;
65
+ pushPendingBlockCreate: (data: { blockId: string; fileId: string; xml: string }) => void;
66
+ resetTransientState: () => void;
67
  getCollaboratorsOnFile: (fileId: string) => Collaborator[];
68
  getCollaboratorsOnBlock: (blockId: string) => Collaborator[];
69
  getCollaboratorsOnElement: (elementId: string) => Collaborator[];
 
152
 
153
  setSavedBy: (username) => set({ savedBy: username }),
154
 
155
+ // Queued block operations and remote drag markers are scoped to the project
156
+ // that produced them. Block ids and file ids are not globally unique enough
157
+ // to let them survive a project switch.
158
+ resetTransientState: () =>
159
+ set({
160
+ remotePointers: {},
161
+ remoteBlockDrags: {},
162
+ pendingBlockConnects: [],
163
+ pendingBlockChanges: [],
164
+ pendingBlockCreates: [],
165
+ }),
166
+
167
  getCollaboratorsOnFile: (fileId) => {
168
  return get().collaborators.filter((c) => c.activeFileId === fileId);
169
  },
 
246
  set((state) => ({
247
  pendingBlockCreates: [...state.pendingBlockCreates, data],
248
  })),
 
 
 
 
 
 
 
 
249
  }));