From c42b3f01c9b5e5ae6e2ad919e0809156966f3fa9 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 23 Jul 2026 14:14:24 +0200 Subject: [PATCH] fix(core): preserve remote patch interaction state --- .../core/src/store/use-scene-commits.test.ts | 55 +++++++++++++++++++ packages/core/src/store/use-scene.ts | 18 +++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/core/src/store/use-scene-commits.test.ts b/packages/core/src/store/use-scene-commits.test.ts index fb481e8e..5dc7d332 100644 --- a/packages/core/src/store/use-scene-commits.test.ts +++ b/packages/core/src/store/use-scene-commits.test.ts @@ -529,6 +529,61 @@ describe('scene commit boundary', () => { expect(useScene.getState().dirtyNodes.has(LEVEL_ID)).toBe(false) }) + test('dirties surviving siblings after a remote structural deletion', () => { + const siblingId = 'level_surviving_sibling' as AnyNodeId + const sibling = LevelNode.parse({ + id: siblingId, + parentId: BUILDING_ID, + children: [], + level: 1, + }) + const building = useScene.getState().nodes[BUILDING_ID] as AnyNode + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + [BUILDING_ID]: { ...building, children: [LEVEL_ID, siblingId] }, + [siblingId]: sibling, + }, + dirtyNodes: new Set(), + }) + + expect( + applySceneOperationPatch({ + materialChanges: [], + nodeCreates: [], + nodeDeletes: [{ node: useScene.getState().nodes[LEVEL_ID] as AnyNode, position: 0 }], + nodeUpdates: [], + }), + ).toBe(true) + + expect(useScene.getState().dirtyNodes.has(siblingId)).toBe(true) + }) + + test('preserves an external tool history pause while applying a remote patch', () => { + useScene.temporal.getState().pause() + expect(useScene.temporal.getState().isTracking).toBe(false) + + try { + expect( + applySceneOperationPatch({ + materialChanges: [], + nodeCreates: [], + nodeDeletes: [], + nodeUpdates: [ + { + data: { level: 2 } as Partial, + id: LEVEL_ID, + removeFields: [], + }, + ], + }), + ).toBe(true) + expect(useScene.temporal.getState().isTracking).toBe(false) + } finally { + useScene.temporal.getState().resume() + } + }) + test('rejects an invalid structural operation before mutating any field or material', () => { const missingParentId = 'building_missing' as AnyNodeId const orphan = LevelNode.parse({ diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 6d88cfbd..c4638887 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -1682,11 +1682,13 @@ export function applySceneOperationPatch(changes: SceneOperationPatch): boolean if (!next) return false const before = sceneHistorySnapshotFromState(beforeState) - pauseSceneHistory(useScene) + const shouldScopeHistoryPause = + useScene.temporal.getState().isTracking || getSceneHistoryPauseDepth() > 0 + if (shouldScopeHistoryPause) pauseSceneHistory(useScene) try { useScene.setState(next) } finally { - resumeSceneHistory(useScene) + if (shouldScopeHistoryPause) resumeSceneHistory(useScene) } const currentState = useScene.getState() @@ -1710,6 +1712,18 @@ export function applySceneOperationPatch(changes: SceneOperationPatch): boolean if (beforeParentId) currentState.markDirty(beforeParentId) if (currentParentId) currentState.markDirty(currentParentId) } + const structuralParentIds = new Set() + for (const { node } of changes.nodeCreates) { + if (node.parentId) structuralParentIds.add(node.parentId as AnyNodeId) + } + for (const { node } of changes.nodeDeletes) { + if (node.parentId) structuralParentIds.add(node.parentId as AnyNodeId) + } + for (const parentId of structuralParentIds) { + const parent = current.nodes[parentId] + if (!(parent && 'children' in parent && Array.isArray(parent.children))) continue + for (const childId of parent.children) currentState.markDirty(childId as AnyNodeId) + } if (changes.materialChanges.length > 0) { const materialRefs = new Set(changes.materialChanges.map(({ id }) => toSceneMaterialRef(id))) for (const node of Object.values(current.nodes)) {