fix(core): preserve remote patch interaction state

This commit is contained in:
Aymeric Rabot
2026-07-23 14:14:24 +02:00
parent 4b27966f9a
commit c42b3f01c9
2 changed files with 71 additions and 2 deletions
@@ -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<AnyNodeId>(),
})
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<AnyNode>,
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({
+16 -2
View File
@@ -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<AnyNodeId>()
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)) {