Skip to content

Commit 294b47f

Browse files
committed
Refactor collaboration sync to use incremental workspace updates and scheduled rendering
- Created blocklyApplier to patch changed blocks incrementally instead of triggering full workspace updates - Added refreshScheduler to batch UI updates and defer them during active local drags to prevent disruption - Initial synchronization now used state-based hydration instead of event playback queues - Improve newly created target/sprite initialization
1 parent 7687cb8 commit 294b47f

9 files changed

Lines changed: 556 additions & 387 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import * as constants from './constants.js';
2+
3+
/*
4+
Reconciles individual blocks in the live Blockly workspace against the VM
5+
block container (the source of truth, already updated from Yjs) so remote
6+
changes don't require a full workspace reload. Returns false whenever it
7+
hits anything it can't safely patch so the caller can fall back to a full
8+
vm.emitWorkspaceUpdate() rebuild.
9+
*/
10+
11+
function isObscuredShadow(target, vmBlock, blockId) {
12+
if (!vmBlock.shadow) return false;
13+
const parent = vmBlock.parent ? target.blocks.getBlock(vmBlock.parent) : null;
14+
if (!parent) return false;
15+
for (const inputName of Object.keys(parent.inputs)) {
16+
const input = parent.inputs[inputName];
17+
if (input.shadow === blockId) {
18+
return input.block !== blockId;
19+
}
20+
}
21+
return false;
22+
}
23+
24+
function collectVmSubtreeIds(target, rootId, ids) {
25+
const block = target.blocks.getBlock(rootId);
26+
if (!block || ids.has(rootId)) return ids;
27+
ids.add(rootId);
28+
for (const inputName of Object.keys(block.inputs)) {
29+
const input = block.inputs[inputName];
30+
if (input.block) collectVmSubtreeIds(target, input.block, ids);
31+
if (input.shadow && input.shadow !== input.block) collectVmSubtreeIds(target, input.shadow, ids);
32+
}
33+
if (block.next) collectVmSubtreeIds(target, block.next, ids);
34+
return ids;
35+
}
36+
37+
function reconcileExistence(Blockly, workspace, target, blockId) {
38+
const vmBlock = target.blocks.getBlock(blockId);
39+
const wsBlock = workspace.getBlockById(blockId);
40+
41+
if (!vmBlock && wsBlock) {
42+
wsBlock.getChildren().slice().forEach(child => {
43+
if (target.blocks.getBlock(child.id)) {
44+
child.unplug(false);
45+
}
46+
});
47+
wsBlock.dispose(false, false);
48+
return true;
49+
}
50+
51+
if (vmBlock && !wsBlock) {
52+
if (isObscuredShadow(target, vmBlock, blockId)) return true;
53+
54+
let rootId = blockId;
55+
let seen = new Set([rootId]);
56+
for (;;) {
57+
const current = target.blocks.getBlock(rootId);
58+
const parentId = current && current.parent;
59+
if (!parentId || workspace.getBlockById(parentId) || !target.blocks.getBlock(parentId)) break;
60+
if (seen.has(parentId)) return false;
61+
seen.add(parentId);
62+
rootId = parentId;
63+
}
64+
65+
const subtreeIds = collectVmSubtreeIds(target, rootId, new Set());
66+
subtreeIds.forEach(id => {
67+
const existing = workspace.getBlockById(id);
68+
if (existing) existing.dispose(false, false);
69+
});
70+
71+
const xml = target.blocks.blockToXML(rootId, target.comments);
72+
if (!xml) return false;
73+
const dom = Blockly.Xml.textToDom(`<xml>${xml}</xml>`);
74+
const blockDom = dom.firstElementChild || dom.firstChild;
75+
if (!blockDom) return false;
76+
Blockly.Xml.domToBlock(blockDom, workspace);
77+
}
78+
79+
return true;
80+
}
81+
82+
function reconcileFields(target, vmBlock, wsBlock) {
83+
for (const fieldName of Object.keys(vmBlock.fields)) {
84+
const vmField = vmBlock.fields[fieldName];
85+
if (!vmField || typeof vmField !== 'object') continue;
86+
const wsField = wsBlock.getField(fieldName);
87+
if (!wsField) continue;
88+
89+
if (vmField.id !== null && typeof vmField.id !== 'undefined') {
90+
const matchesId = String(wsField.getValue()) === String(vmField.id);
91+
const matchesText = wsField.getText() === String(vmField.value);
92+
if (!matchesId && !matchesText) return false;
93+
continue;
94+
}
95+
96+
if (String(wsField.getValue()) !== String(vmField.value)) {
97+
wsBlock.setFieldValue(String(vmField.value), fieldName);
98+
}
99+
}
100+
return true;
101+
}
102+
103+
function reconcileConnection(workspace, target, vmBlock, wsBlock, blockId) {
104+
const parentId = vmBlock.parent || null;
105+
const wsParent = wsBlock.getParent();
106+
107+
let viaNext = false;
108+
let expectedInputName = null;
109+
if (parentId) {
110+
const vmParent = target.blocks.getBlock(parentId);
111+
if (!vmParent) return false;
112+
if (vmParent.next === blockId) {
113+
viaNext = true;
114+
} else {
115+
for (const inputName of Object.keys(vmParent.inputs)) {
116+
const input = vmParent.inputs[inputName];
117+
if (input.block === blockId || input.shadow === blockId) {
118+
expectedInputName = inputName;
119+
break;
120+
}
121+
}
122+
if (!expectedInputName) return false;
123+
}
124+
}
125+
126+
let matches = false;
127+
if (!parentId) {
128+
matches = !wsParent;
129+
} else if (wsParent && wsParent.id === parentId) {
130+
if (viaNext) {
131+
matches = !!wsParent.nextConnection && wsParent.nextConnection.targetBlock() === wsBlock;
132+
} else {
133+
const input = wsParent.getInput(expectedInputName);
134+
matches = !!input && !!input.connection && input.connection.targetBlock() === wsBlock;
135+
}
136+
}
137+
if (matches) return true;
138+
139+
const childConnection = wsBlock.previousConnection || wsBlock.outputConnection;
140+
if (wsParent) {
141+
if (!childConnection || !childConnection.isConnected()) return false;
142+
childConnection.disconnect();
143+
}
144+
if (!parentId) return true;
145+
146+
const wsParentBlock = workspace.getBlockById(parentId);
147+
if (!wsParentBlock || !childConnection) return false;
148+
149+
let parentConnection = null;
150+
if (viaNext) {
151+
parentConnection = wsParentBlock.nextConnection;
152+
} else {
153+
const input = wsParentBlock.getInput(expectedInputName);
154+
parentConnection = input && input.connection;
155+
}
156+
if (!parentConnection) return false;
157+
158+
const occupant = parentConnection.targetBlock();
159+
if (occupant && occupant !== wsBlock && !occupant.isShadow()) {
160+
const occupantConnection = occupant.previousConnection || occupant.outputConnection;
161+
if (!occupantConnection || !occupantConnection.isConnected()) return false;
162+
occupantConnection.disconnect();
163+
}
164+
parentConnection.connect(childConnection);
165+
return true;
166+
}
167+
168+
function reconcileState(Blockly, workspace, target, blockId) {
169+
const vmBlock = target.blocks.getBlock(blockId);
170+
if (!vmBlock) return true;
171+
const wsBlock = workspace.getBlockById(blockId);
172+
if (!wsBlock) {
173+
return !!vmBlock.shadow;
174+
}
175+
176+
if (!!vmBlock.shadow !== wsBlock.isShadow()) return false;
177+
178+
if (!reconcileFields(target, vmBlock, wsBlock)) return false;
179+
if (!reconcileConnection(workspace, target, vmBlock, wsBlock, blockId)) return false;
180+
181+
if (vmBlock.topLevel && !wsBlock.getParent()) {
182+
const x = Number(vmBlock.x);
183+
const y = Number(vmBlock.y);
184+
if (isFinite(x) && isFinite(y)) {
185+
const xy = wsBlock.getRelativeToSurfaceXY();
186+
const dx = x - xy.x;
187+
const dy = y - xy.y;
188+
if (dx !== 0 || dy !== 0) wsBlock.moveBy(dx, dy);
189+
}
190+
}
191+
return true;
192+
}
193+
194+
export function reconcileBlocks(target, blockIds) {
195+
const Blockly = constants.mutableRefs.BlocklyInstance;
196+
const workspace = Blockly?.getMainWorkspace?.();
197+
if (!workspace || !target || typeof target.blocks.blockToXML !== 'function') return false;
198+
Blockly.Events.disable();
199+
try {
200+
for (const blockId of blockIds) {
201+
if (!reconcileExistence(Blockly, workspace, target, blockId)) return false;
202+
}
203+
for (const blockId of blockIds) {
204+
if (!reconcileState(Blockly, workspace, target, blockId)) return false;
205+
}
206+
return true;
207+
} catch (e) {
208+
if (constants.debugging) console.warn('Collaboration: incremental block sync failed, falling back to full refresh.', e);
209+
return false;
210+
} finally {
211+
Blockly.Events.enable();
212+
}
213+
}

src/addons/addons/collaboration/helpers/constants.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,6 @@ export const mutableRefs = {
7171
costumeIndexMaps: new Map(),
7272

7373
isUiTransition: false,
74-
initialSyncEvents: [],
75-
pendingLocalEvents: [],
7674

7775
roomUUID: null
7876
};

src/addons/addons/collaboration/helpers/costumeSync.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ export async function handleLocalCostumeChange(targetId, [op, idParam, data]) {
2828
if (eventGroup === 'yjs-remote-sync') {
2929
return;
3030
}
31-
31+
if (constants.mutableRefs.isInitialRoomSync) return;
32+
33+
3234
const target = constants.mutableRefs.vm.runtime.getTargetById(targetId);
3335
if (!target) return;
3436

0 commit comments

Comments
 (0)