TreeEventBuffer: performance improvements

o added several methods on TreeEvent so that the internal rows must not
be copied:
  o containsNode()
  o hasNodes()
  o added TreeEvent.setNodes
  
o improved TreeEventBuffer.coalesceSameType
  (runs now in O(n) instead O(n^2))
  
o simplified TreeUtility.calculateCommonParentNode and added tests

Change-Id: I7e4751b117fb80d295f3457ab3f59ba5686d8c03
Reviewed-on: https://git.eclipse.org/r/75452
Tested-by: Hudson CI
Reviewed-by: Andi Bur <andi.bur@gmail.com>
diff --git a/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/table/TableEventBufferTest.java b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/table/TableEventBufferTest.java
index 2afc1d2..4dfc6ac 100644
--- a/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/table/TableEventBufferTest.java
+++ b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/table/TableEventBufferTest.java
@@ -602,7 +602,7 @@
     final int rowCount = 10;
     List<ITableRow> rows = new ArrayList<>(rowCount);
     LinkedList<TableEvent> events = new LinkedList<>();
-    for (int i = 0; i < 10; i++) {
+    for (int i = 0; i < rowCount; i++) {
       ITableRow row = mockRow(i);
       rows.add(row);
       events.add(new TableEvent(table, TableEvent.TYPE_ROWS_INSERTED, Collections.singletonList(row)));
@@ -621,7 +621,7 @@
     final int rowCount = 10;
     List<ITableRow> rows = new ArrayList<>(rowCount);
     LinkedList<TableEvent> events = new LinkedList<>();
-    for (int i = 0; i < 10; i++) {
+    for (int i = 0; i < rowCount; i++) {
       ITableRow row1 = mockRow(2 * i);
       ITableRow row2 = mockRow(2 * i + 1);
       rows.add(row1);
@@ -727,7 +727,7 @@
     assertEquals(Collections.emptyList(), e5.getRows());
   }
 
-  @Test(timeout = 2000)
+  @Test(timeout = 10000)
   public void testCoalesceSameTypeWithManyInsertEvents() throws Exception {
     final int eventCount = 10000;
     ITable table = mock(ITable.class);
@@ -742,7 +742,7 @@
     assertEquals(eventCount, CollectionUtility.firstElement(tableEvents).getRowCount());
   }
 
-  @Test(timeout = 2000)
+  @Test(timeout = 10000)
   public void testRemoveObsoleteWithManyInsertAndOneDeleteAllRowsEvent() throws Exception {
     final int insertEventCount = 10000;
     ITable table = mock(ITable.class);
@@ -761,7 +761,7 @@
 
   }
 
-  @Test(timeout = 2000)
+  @Test(timeout = 10000)
   public void testRemoveObsoleteWithManyInsertEvents() throws Exception {
     final int insertEventCount = 10000;
     ITable table = mock(ITable.class);
@@ -776,7 +776,7 @@
 
   }
 
-  @Test(timeout = 2000)
+  @Test(timeout = 10000)
   public void testRemoveIdenticalEventsWithManyInsertEvents() throws Exception {
     final int eventCount = 10000;
     ITable table = mock(ITable.class);
@@ -792,7 +792,7 @@
     assertEquals(eventCount, tableEvents.size());
   }
 
-  @Test(timeout = 2000)
+  @Test(timeout = 10000)
   public void testRemoveIdenticalEventsWithManyInsertAndDeleteEvents() throws Exception {
     final int eventCount = 10000;
     ITable table = mock(ITable.class);
diff --git a/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEventBufferTest.java b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEventBufferTest.java
index c4d6a1a..9b1eb80 100644
--- a/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEventBufferTest.java
+++ b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEventBufferTest.java
@@ -11,8 +11,10 @@
 package org.eclipse.scout.rt.client.ui.basic.tree;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -21,11 +23,12 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 
+import org.eclipse.scout.rt.platform.util.Assertions.AssertionException;
 import org.eclipse.scout.rt.platform.util.CollectionUtility;
-
 import org.junit.Before;
 import org.junit.Test;
 
@@ -96,11 +99,11 @@
     final List<TreeEvent> coalesced = m_testBuffer.consumeAndCoalesceEvents();
     assertEquals(3, coalesced.size());
     assertEquals(TreeEvent.TYPE_NODES_UPDATED, coalesced.get(0).getType());
-    assertEquals(3, coalesced.get(0).getNodes().size());
+    assertEquals(3, coalesced.get(0).getNodeCount());
     assertEquals(TreeEvent.TYPE_CHILD_NODE_ORDER_CHANGED, coalesced.get(1).getType());
-    assertEquals(3, coalesced.get(1).getNodes().size());
+    assertEquals(3, coalesced.get(1).getNodeCount());
     assertEquals(TreeEvent.TYPE_NODES_UPDATED, coalesced.get(2).getType());
-    assertEquals(4, coalesced.get(2).getNodes().size());
+    assertEquals(4, coalesced.get(2).getNodeCount());
   }
 
   /**
@@ -137,7 +140,7 @@
     final List<TreeEvent> coalesced = m_testBuffer.consumeAndCoalesceEvents();
     assertEquals(2, coalesced.size());
     assertEquals(TreeEvent.TYPE_NODES_INSERTED, coalesced.get(1).getType());
-    assertEquals(1, coalesced.get(1).getNodes().size());
+    assertEquals(1, coalesced.get(1).getNodeCount());
   }
 
   /**
@@ -166,21 +169,21 @@
     final List<TreeEvent> coalesced = m_testBuffer.consumeAndCoalesceEvents();
     assertEquals(8, coalesced.size());
     assertEquals(TreeEvent.TYPE_NODE_CHANGED, coalesced.get(0).getType());
-    assertEquals(1, coalesced.get(0).getNodes().size());
+    assertEquals(1, coalesced.get(0).getNodeCount());
     assertEquals(TreeEvent.TYPE_NODE_CHANGED, coalesced.get(1).getType());
-    assertEquals(1, coalesced.get(1).getNodes().size());
+    assertEquals(1, coalesced.get(1).getNodeCount());
     assertEquals(TreeEvent.TYPE_NODE_CHANGED, coalesced.get(2).getType());
-    assertEquals(1, coalesced.get(2).getNodes().size());
+    assertEquals(1, coalesced.get(2).getNodeCount());
     assertEquals(TreeEvent.TYPE_CHILD_NODE_ORDER_CHANGED, coalesced.get(3).getType());
-    assertEquals(3, coalesced.get(3).getNodes().size());
+    assertEquals(3, coalesced.get(3).getNodeCount());
     assertEquals(TreeEvent.TYPE_NODE_CHANGED, coalesced.get(4).getType());
-    assertEquals(1, coalesced.get(4).getNodes().size());
+    assertEquals(1, coalesced.get(4).getNodeCount());
     assertEquals(TreeEvent.TYPE_NODE_CHANGED, coalesced.get(5).getType());
-    assertEquals(1, coalesced.get(5).getNodes().size());
+    assertEquals(1, coalesced.get(5).getNodeCount());
     assertEquals(TreeEvent.TYPE_NODE_CHANGED, coalesced.get(6).getType());
-    assertEquals(1, coalesced.get(6).getNodes().size());
+    assertEquals(1, coalesced.get(6).getNodeCount());
     assertEquals(TreeEvent.TYPE_NODE_CHANGED, coalesced.get(7).getType());
-    assertEquals(1, coalesced.get(7).getNodes().size());
+    assertEquals(1, coalesced.get(7).getNodeCount());
   }
 
   /**
@@ -199,11 +202,11 @@
     final List<TreeEvent> coalesced = m_testBuffer.consumeAndCoalesceEvents();
     assertEquals(3, coalesced.size());
     assertEquals(TreeEvent.TYPE_NODES_INSERTED, coalesced.get(0).getType());
-    assertEquals(3, coalesced.get(0).getNodes().size());
+    assertEquals(3, coalesced.get(0).getNodeCount());
     assertEquals(TreeEvent.TYPE_CHILD_NODE_ORDER_CHANGED, coalesced.get(1).getType());
-    assertEquals(3, coalesced.get(1).getNodes().size());
+    assertEquals(3, coalesced.get(1).getNodeCount());
     assertEquals(TreeEvent.TYPE_NODES_UPDATED, coalesced.get(2).getType());
-    assertEquals(1, coalesced.get(2).getNodes().size());
+    assertEquals(1, coalesced.get(2).getNodeCount());
   }
 
   /**
@@ -222,7 +225,7 @@
     final List<TreeEvent> coalesced = m_testBuffer.consumeAndCoalesceEvents();
     assertEquals(1, coalesced.size());
     assertEquals(TreeEvent.TYPE_NODES_DELETED, coalesced.get(0).getType());
-    assertEquals(1, coalesced.get(0).getNodes().size());
+    assertEquals(1, coalesced.get(0).getNodeCount());
   }
 
   /**
@@ -300,7 +303,7 @@
     List<TreeEvent> coalesced = m_testBuffer.consumeAndCoalesceEvents();
     assertEquals(1, coalesced.size()); // 1x NODES_INSERTED
     assertEquals(TreeEvent.TYPE_NODES_INSERTED, coalesced.get(0).getType());
-    assertEquals(3, coalesced.get(0).getNodes().size());
+    assertEquals(3, coalesced.get(0).getNodeCount());
 
     // --- Test case 2: NODES_DELETED --------------------------------
 
@@ -316,7 +319,7 @@
     coalesced = m_testBuffer.consumeAndCoalesceEvents();
     assertEquals(1, coalesced.size()); // 1x NODES_INSERTED
     assertEquals(TreeEvent.TYPE_NODES_INSERTED, coalesced.get(0).getType());
-    assertEquals(3, coalesced.get(0).getNodes().size());
+    assertEquals(3, coalesced.get(0).getNodeCount());
   }
 
   /**
@@ -550,21 +553,21 @@
 
     assertType(TreeEvent.TYPE_NODES_CHECKED, coalesced, 0);
     assertEquals(nodeA, coalesced.get(0).getCommonParentNode());
-    assertEquals(1, coalesced.get(0).getNodes().size());
+    assertEquals(1, coalesced.get(0).getNodeCount());
 
     assertType(TreeEvent.TYPE_NODES_UPDATED, coalesced, 1);
 
     assertType(TreeEvent.TYPE_NODES_CHECKED, coalesced, 2);
     assertEquals(nodeB, coalesced.get(2).getCommonParentNode());
-    assertEquals(1, coalesced.get(2).getNodes().size());
+    assertEquals(1, coalesced.get(2).getNodeCount());
 
     assertType(TreeEvent.TYPE_NODES_CHECKED, coalesced, 3);
     assertEquals(nodeC, coalesced.get(3).getCommonParentNode());
-    assertEquals(1, coalesced.get(3).getNodes().size());
+    assertEquals(1, coalesced.get(3).getNodeCount());
 
     assertType(TreeEvent.TYPE_NODES_CHECKED, coalesced, 4);
     assertEquals(nodeA, coalesced.get(4).getCommonParentNode());
-    assertEquals(2, coalesced.get(4).getNodes().size());
+    assertEquals(2, coalesced.get(4).getNodeCount());
   }
 
   /**
@@ -629,11 +632,197 @@
     assertContainsNode(coalesced, 1, nodeA, nodeB);
   }
 
+  @Test
+  public void testCoalesceSameTypeCheckOrderSingleNodeEvents() {
+    ITree tree = mock(ITree.class);
+    final int nodeCount = 10;
+    List<ITreeNode> nodes = new ArrayList<>(nodeCount);
+    LinkedList<TreeEvent> events = new LinkedList<>();
+    for (int i = 0; i < nodeCount; i++) {
+      ITreeNode node = mockNode(String.valueOf(i));
+      nodes.add(node);
+      events.add(new TreeEvent(tree, TreeEvent.TYPE_NODES_INSERTED, Collections.singletonList(node)));
+    }
+
+    assertEquals(nodeCount, events.size());
+    m_testBuffer.coalesceSameType(events);
+
+    assertEquals(1, events.size());
+    assertEquals(nodes, events.get(0).getNodes());
+  }
+
+  @Test
+  public void testCoalesceSameTypeCheckOrderMultipleNodesEvents() {
+    ITree tree = mock(ITree.class);
+    final int nodeCount = 10;
+    List<ITreeNode> nodes = new ArrayList<>(nodeCount);
+    LinkedList<TreeEvent> events = new LinkedList<>();
+    for (int i = 0; i < nodeCount; i++) {
+      ITreeNode node1 = mockNode(String.valueOf(2 * i));
+      ITreeNode node2 = mockNode(String.valueOf(2 * i + 1));
+      nodes.add(node1);
+      nodes.add(node2);
+      events.add(new TreeEvent(tree, TreeEvent.TYPE_NODES_INSERTED, Arrays.asList(node1, node2)));
+    }
+
+    assertEquals(nodeCount, events.size());
+    m_testBuffer.coalesceSameType(events);
+
+    assertEquals(1, events.size());
+    assertEquals(nodes, events.get(0).getNodes());
+  }
+
+  @Test(timeout = 10000)
+  public void testCoalesceSameTypeWithManyInsertEvents() throws Exception {
+    final int eventCount = 10000;
+    ITree tree = mock(ITree.class);
+    ITreeNode parentA = mockNode("parentA");
+    LinkedList<TreeEvent> treeEvents = new LinkedList<>();
+    for (int i = 0; i < eventCount; i++) {
+      treeEvents.add(new TreeEvent(tree, TreeEvent.TYPE_NODES_INSERTED, Collections.singletonList(mockNode(String.valueOf(i), parentA))));
+    }
+
+    assertEquals(eventCount, treeEvents.size());
+    m_testBuffer.coalesceSameType(treeEvents);
+    assertEquals(1, treeEvents.size());
+    assertEquals(eventCount, CollectionUtility.firstElement(treeEvents).getNodeCount());
+  }
+
+  @Test(timeout = 10000)
+  public void testCoalesceSameTypeWithManyInsertHavingDifferentParentsEvents() throws Exception {
+    final int eventCount = 10000;
+    ITree tree = mock(ITree.class);
+    LinkedList<TreeEvent> treeEvents = new LinkedList<>();
+    ITreeNode parentA = mockNode("parentA");
+    ITreeNode parentB = mockNode("parentB");
+    for (int i = 0; i < eventCount; i++) {
+      treeEvents.add(new TreeEvent(tree, TreeEvent.TYPE_NODES_INSERTED, Collections.singletonList(mockNode(String.valueOf(2 * i), parentA))));
+      treeEvents.add(new TreeEvent(tree, TreeEvent.TYPE_NODES_INSERTED, Collections.singletonList(mockNode(String.valueOf(2 * i + 1), parentB))));
+    }
+
+    assertEquals(2 * eventCount, treeEvents.size());
+    m_testBuffer.coalesceSameType(treeEvents);
+    assertEquals(2, treeEvents.size());
+
+    TreeEvent firstEvent = treeEvents.get(0);
+    TreeEvent secondEvent = treeEvents.get(1);
+    assertEquals(eventCount, firstEvent.getNodeCount());
+    assertEquals(eventCount, secondEvent.getNodeCount());
+
+    assertSame(parentA, firstEvent.getCommonParentNode());
+    assertSame(parentB, secondEvent.getCommonParentNode());
+  }
+
+  @Test(timeout = 10000)
+  public void testCoalesceSameTypeWithManyInsertUpdateEvents() throws Exception {
+    final int eventCount = 10000;
+    ITree tree = mock(ITree.class);
+    LinkedList<TreeEvent> treeEvents = new LinkedList<>();
+    ITreeNode parentA = mockNode("parentA");
+    for (int i = 0; i < eventCount; i++) {
+      ITreeNode node = mockNode(String.valueOf(i), parentA);
+      treeEvents.add(new TreeEvent(tree, TreeEvent.TYPE_NODES_INSERTED, Collections.singletonList(node)));
+      treeEvents.add(new TreeEvent(tree, TreeEvent.TYPE_NODES_UPDATED, Collections.singletonList(node)));
+    }
+
+    assertEquals(2 * eventCount, treeEvents.size());
+    m_testBuffer.coalesceSameType(treeEvents);
+    assertEquals(2 * eventCount, treeEvents.size());
+  }
+
+  @Test(timeout = 10000)
+  public void testCoalesceSameTypeWithManyInsertInsertUpdateUpdateEvents() throws Exception {
+    final int eventCount = 10000;
+    ITree tree = mock(ITree.class);
+    LinkedList<TreeEvent> treeEvents = new LinkedList<>();
+    ITreeNode parentA = mockNode("parentA");
+    for (int i = 0; i < eventCount; i++) {
+      ITreeNode node1 = mockNode(String.valueOf(2 * i), parentA);
+      ITreeNode node2 = mockNode(String.valueOf(2 * i + 1), parentA);
+      treeEvents.add(new TreeEvent(tree, TreeEvent.TYPE_NODES_INSERTED, Collections.singletonList(node1)));
+      treeEvents.add(new TreeEvent(tree, TreeEvent.TYPE_NODES_INSERTED, Collections.singletonList(node2)));
+      treeEvents.add(new TreeEvent(tree, TreeEvent.TYPE_NODES_UPDATED, Collections.singletonList(node1)));
+      treeEvents.add(new TreeEvent(tree, TreeEvent.TYPE_NODES_UPDATED, Collections.singletonList(node2)));
+    }
+
+    assertEquals(4 * eventCount, treeEvents.size());
+    m_testBuffer.coalesceSameType(treeEvents);
+    assertEquals(2 * eventCount, treeEvents.size());
+  }
+
+  @Test(expected = AssertionException.class)
+  public void testTreeEventMergerNullInitilaEvent() {
+    new TreeEventBuffer.TreeEventMerger(null);
+  }
+
+  @Test
+  public void testTreeEventMerger() {
+    ITree tree = mock(ITree.class);
+    ITreeNode nodeA = mockNode("a");
+    ITreeNode nodeB = mockNode("b");
+    TreeEvent initialEvent = new TreeEvent(tree, TreeEvent.TYPE_NODE_CHANGED, Arrays.asList(nodeA, nodeB));
+    TreeEventBuffer.TreeEventMerger eventMerger = new TreeEventBuffer.TreeEventMerger(initialEvent);
+
+    // add first event
+    ITreeNode nodeC = mockNode("c");
+    TreeEvent e1 = new TreeEvent(tree, TreeEvent.TYPE_NODE_CHANGED, Arrays.asList(nodeA, nodeB, nodeC));
+    eventMerger.merge(e1);
+
+    // add second, empty event
+    TreeEvent e2 = new TreeEvent(tree, TreeEvent.TYPE_NODE_CHANGED);
+    eventMerger.merge(e2);
+
+    // add third event
+    ITreeNode nodeD = mockNode("d");
+    ITreeNode nodeE = mockNode("e");
+    TreeEvent e3 = new TreeEvent(tree, TreeEvent.TYPE_NODE_CHANGED, Arrays.asList(nodeD, nodeE));
+    eventMerger.merge(e3);
+
+    eventMerger.complete();
+    assertEquals(Arrays.asList(nodeD, nodeE, nodeC, nodeA, nodeB), initialEvent.getNodes());
+    assertNull(initialEvent.getCommonParentNode());
+
+    // invoke complete a second time has no effect
+    eventMerger.complete();
+    assertEquals(Arrays.asList(nodeD, nodeE, nodeC, nodeA, nodeB), initialEvent.getNodes());
+    assertNull(initialEvent.getCommonParentNode());
+  }
+
+  @Test
+  public void testTableEventMergerCompleteWithoutMerge() {
+    ITree tree = mock(ITree.class);
+    ITreeNode nodeA = mockNode("a");
+    ITreeNode nodeB = mockNode("b");
+    TreeEvent initialEvent = new TreeEvent(tree, TreeEvent.TYPE_NODE_CHANGED, Arrays.asList(nodeA, nodeB));
+    TreeEventBuffer.TreeEventMerger eventMerger = new TreeEventBuffer.TreeEventMerger(initialEvent);
+
+    eventMerger.complete();
+
+    assertEquals(Arrays.asList(nodeA, nodeB), initialEvent.getNodes());
+    assertNull(initialEvent.getCommonParentNode());
+  }
+
+  @Test
+  public void testTableEventMergerMergeAfterComplete() {
+    ITree tree = mock(ITree.class);
+    TreeEvent initialEvent = new TreeEvent(tree, TreeEvent.TYPE_NODE_CHANGED, mockNodes("a", "b"));
+    TreeEventBuffer.TreeEventMerger eventMerger = new TreeEventBuffer.TreeEventMerger(initialEvent);
+    eventMerger.complete();
+
+    TreeEvent e1 = new TreeEvent(tree, TreeEvent.TYPE_NODE_CHANGED, mockNodes("c", "d"));
+    try {
+      eventMerger.merge(e1);
+      fail("merge after complete must throw an " + IllegalStateException.class.getSimpleName());
+    }
+    catch (IllegalStateException epxected) {
+    }
+  }
+
   private void assertContainsNode(List<TreeEvent> events, int index, ITreeNode... expectedNodes) {
     TreeEvent event = events.get(index);
-    assertEquals(expectedNodes.length, event.getNodes().size());
+    assertEquals(expectedNodes.length, event.getNodeCount());
     for (ITreeNode node : expectedNodes) {
-      assertTrue(event.getNodes().contains(node));
+      assertTrue(event.containsNode(node));
     }
   }
 
@@ -681,6 +870,10 @@
   }
 
   private ITreeNode mockNode(String nodeId) {
+    return mockNode(nodeId, null);
+  }
+
+  private ITreeNode mockNode(String nodeId, ITreeNode parentNode) {
     ITreeNode node = m_mockNodes.get(nodeId);
     if (node != null) {
       return node;
@@ -688,6 +881,7 @@
     // Create a new
     node = mock(ITreeNode.class, "MockNode[" + nodeId + "]");
     when(node.getNodeId()).thenReturn(nodeId);
+    when(node.getParentNode()).thenReturn(parentNode);
     m_mockNodes.put(nodeId, node);
     return node;
   }
@@ -700,5 +894,4 @@
       when(childNode.getParentNode()).thenReturn(node);
     }
   }
-
 }
diff --git a/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEventTest.java b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEventTest.java
new file mode 100644
index 0000000..37ba33c
--- /dev/null
+++ b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEventTest.java
@@ -0,0 +1,271 @@
+package org.eclipse.scout.rt.client.ui.basic.tree;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * @since 5.2
+ */
+public class TreeEventTest {
+
+  private Map<String, ITreeNode> m_mockNodes;
+
+  @Before
+  public void setup() {
+    m_mockNodes = new HashMap<>();
+  }
+
+  @Test
+  public void testGetNodeNoNodes() {
+    ITree tree = mock(ITree.class);
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION);
+    assertNull(event.getNode());
+  }
+
+  @Test
+  public void testGetNodeSingleNode() {
+    ITree tree = mock(ITree.class);
+    ITreeNode root = mockNode("root");
+    ITreeNode node = mockNode("a");
+    when(node.getParentNode()).thenReturn(root);
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, node);
+    assertSame(node, event.getNode());
+  }
+
+  @Test
+  public void testGetNodeMultipleNode() {
+    ITree tree = mock(ITree.class);
+    ITreeNode root = mockNode("root");
+    ITreeNode nodeA = mockNode("a");
+    ITreeNode nodeB = mockNode("b");
+    when(nodeA.getParentNode()).thenReturn(root);
+    when(nodeB.getParentNode()).thenReturn(root);
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, Arrays.asList(nodeB, nodeA));
+    assertSame(nodeB, event.getNode());
+  }
+
+  @Test
+  public void testGetNodesNoNodes() {
+    ITree tree = mock(ITree.class);
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION);
+    assertEquals(Collections.emptyList(), event.getNodes());
+  }
+
+  @Test
+  public void testGetNodesSingleNode() {
+    ITree tree = mock(ITree.class);
+    ITreeNode node = mockNode("a");
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, node);
+    assertEquals(Collections.singletonList(node), event.getNodes());
+  }
+
+  @Test
+  public void testGetNodesMultipleNode() {
+    ITree tree = mock(ITree.class);
+    ITreeNode nodeA = mockNode("a");
+    ITreeNode nodeB = mockNode("b");
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, Arrays.asList(nodeB, nodeA));
+    assertEquals(Arrays.asList(nodeB, nodeA), event.getNodes());
+  }
+
+  @Test
+  public void testHasNodesNoNodes() {
+    ITree tree = mock(ITree.class);
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION);
+    assertFalse(event.hasNodes());
+  }
+
+  @Test
+  public void testHasNodesSingleNode() {
+    ITree tree = mock(ITree.class);
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, mockNode("a"));
+    assertTrue(event.hasNodes());
+  }
+
+  @Test
+  public void testHasNodesMultipleNode() {
+    ITree tree = mock(ITree.class);
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, mockNodes("a", "b"));
+    assertTrue(event.hasNodes());
+  }
+
+  @Test
+  public void testGetNodeCountNoNodes() {
+    ITree tree = mock(ITree.class);
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION);
+    assertEquals(0, event.getNodeCount());
+  }
+
+  @Test
+  public void testGetNodeCountSingleNode() {
+    ITree tree = mock(ITree.class);
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, mockNode("a"));
+    assertEquals(1, event.getNodeCount());
+  }
+
+  @Test
+  public void testGetNodeCountMultipleNode() {
+    ITree tree = mock(ITree.class);
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, mockNodes("a", "b"));
+    assertEquals(2, event.getNodeCount());
+  }
+
+  @Test
+  public void testContainsNodeNoNodes() {
+    ITree tree = mock(ITree.class);
+    ITreeNode nodeA = mockNode("a");
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION);
+    assertFalse(event.containsNode(nodeA));
+  }
+
+  @Test
+  public void testContainsNodeSingleNode() {
+    ITree tree = mock(ITree.class);
+    ITreeNode nodeA = mockNode("a");
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, nodeA);
+    assertTrue(event.containsNode(nodeA));
+  }
+
+  @Test
+  public void testContainsNodeMultipleNode() {
+    ITree tree = mock(ITree.class);
+    ITreeNode nodeA = mockNode("a");
+    ITreeNode nodeB = mockNode("b");
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, Arrays.asList(nodeA, nodeB));
+    assertTrue(event.containsNode(nodeA));
+    assertTrue(event.containsNode(nodeB));
+  }
+
+  @Test
+  public void testContainsNodeSingleVirtualResolvedNode() {
+    ITree tree = mock(ITree.class);
+    ITreeNode nodeA = mockNode("a");
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, wrapByVirtualNode(nodeA));
+    assertTrue(event.containsNode(nodeA));
+  }
+
+  @Test
+  public void testContainsNodeMultipleVirtualResolvedNode() {
+    ITree tree = mock(ITree.class);
+    ITreeNode nodeA = mockNode("a");
+    ITreeNode nodeB = mockNode("b");
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, Arrays.asList(wrapByVirtualNode(nodeA), wrapByVirtualNode(nodeB)));
+    assertTrue(event.containsNode(nodeA));
+    assertTrue(event.containsNode(nodeB));
+  }
+
+  @Test
+  public void testContainsNodeSingleVirtualUnresolvedNode() {
+    ITree tree = mock(ITree.class);
+    ITreeNode nodeA = mockNode("a");
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, new VirtualTreeNode());
+    assertFalse(event.containsNode(nodeA));
+  }
+
+  @Test
+  public void testContainsNodeMultipleVirtualUnrResolvedNode() {
+    ITree tree = mock(ITree.class);
+    ITreeNode nodeA = mockNode("a");
+    ITreeNode nodeB = mockNode("b");
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, Arrays.asList(new VirtualTreeNode(), new VirtualTreeNode()));
+    assertFalse(event.containsNode(nodeA));
+    assertFalse(event.containsNode(nodeB));
+  }
+
+  /**
+   * Currently it is not supported that a {@link VirtualTreeNode} references another {@link VirtualTreeNode}.
+   */
+  @Test
+  public void testContainsNodeSingleRecursiveVirtualResolvedNode() {
+    ITree tree = mock(ITree.class);
+    ITreeNode nodeA = mockNode("a");
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, wrapByVirtualNode(wrapByVirtualNode(wrapByVirtualNode(nodeA))));
+    assertFalse(event.containsNode(nodeA));
+  }
+
+  @Test
+  public void testSetNodesSameParent() {
+    ITree tree = mock(ITree.class);
+    ITreeNode parent = mockNode("parent");
+    ITreeNode nodeA = mockNode("a", parent);
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, nodeA);
+
+    assertEquals(Arrays.asList(nodeA), event.getNodes());
+    assertEquals(parent, event.getCommonParentNode());
+
+    ITreeNode nodeB = mockNode("b", parent);
+    event.setNodes(Arrays.asList(nodeA, nodeB));
+    assertEquals(Arrays.asList(nodeA, nodeB), event.getNodes());
+    assertEquals(parent, event.getCommonParentNode());
+  }
+
+  @Test
+  public void testSetNodesDifferentParents() {
+    ITree tree = mock(ITree.class);
+    ITreeNode parentA = mockNode("parentA");
+    ITreeNode nodeA = mockNode("a", parentA);
+    TreeEvent event = new TreeEvent(tree, TreeEvent.TYPE_NODE_ACTION, nodeA);
+
+    assertEquals(Arrays.asList(nodeA), event.getNodes());
+    assertEquals(parentA, event.getCommonParentNode());
+
+    ITreeNode parentB = mockNode("parentB");
+    ITreeNode nodeB = mockNode("b", parentB);
+
+    event.setNodes(Arrays.asList(nodeA, nodeB));
+
+    assertEquals(Arrays.asList(nodeA, nodeB), event.getNodes());
+    assertNull(event.getCommonParentNode());
+  }
+
+  private VirtualTreeNode wrapByVirtualNode(ITreeNode nodeA) {
+    VirtualTreeNode virtualNodaA = new VirtualTreeNode();
+    virtualNodaA.setResolvedNode(nodeA);
+    return virtualNodaA;
+  }
+
+  private List<ITreeNode> mockNodes(String... nodeIds) {
+    if (nodeIds == null) {
+      return null;
+    }
+    List<ITreeNode> rows = new ArrayList<>();
+    for (String nodeId : nodeIds) {
+      rows.add(mockNode(nodeId));
+    }
+    return rows;
+  }
+
+  private ITreeNode mockNode(String nodeId) {
+    return mockNode(nodeId, null);
+  }
+
+  private ITreeNode mockNode(String nodeId, ITreeNode parentNode) {
+    ITreeNode node = m_mockNodes.get(nodeId);
+    if (node != null) {
+      return node;
+    }
+    // Create a new
+    node = mock(ITreeNode.class, "MockNode[" + nodeId + "]");
+    when(node.getNodeId()).thenReturn(nodeId);
+    if (parentNode != null) {
+      when(node.getParentNode()).thenReturn(parentNode);
+    }
+    m_mockNodes.put(nodeId, node);
+    return node;
+  }
+}
diff --git a/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeNodeTest.java b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeNodeTest.java
new file mode 100644
index 0000000..eb75445
--- /dev/null
+++ b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeNodeTest.java
@@ -0,0 +1,153 @@
+package org.eclipse.scout.rt.client.ui.basic.tree;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * @since 5.2
+ */
+public class TreeNodeTest {
+
+  private P_TreeNode m_rootNode;
+  private Map<String, ITreeNode> m_mockNodes;
+
+  @Before
+  public void before() {
+    m_rootNode = new P_TreeNode();
+    m_mockNodes = new HashMap<>();
+  }
+
+  @Test
+  public void testContainsChildNodeNoChildNodes() {
+    ITreeNode nodeA = mockNode("a");
+    assertFalse(m_rootNode.containsChildNode(nodeA, false));
+    assertFalse(m_rootNode.containsChildNode(nodeA, true));
+  }
+
+  @Test
+  public void testContainsChildNodeOneChildNodes() {
+    ITreeNode nodeA = mockNode("a");
+    m_rootNode.addChildNodesInternal(0, Collections.singletonList(nodeA), false);
+    assertTrue(m_rootNode.containsChildNode(nodeA, false));
+    assertTrue(m_rootNode.containsChildNode(nodeA, true));
+  }
+
+  @Test
+  public void testContainsChildNodeMultipleChildNodes() {
+    ITreeNode nodeA = mockNode("a");
+    ITreeNode nodeB = mockNode("b");
+    m_rootNode.addChildNodesInternal(0, Arrays.asList(nodeA, nodeB), false);
+    assertTrue(m_rootNode.containsChildNode(nodeA, false));
+    assertTrue(m_rootNode.containsChildNode(nodeA, true));
+    assertTrue(m_rootNode.containsChildNode(nodeB, false));
+    assertTrue(m_rootNode.containsChildNode(nodeB, true));
+  }
+
+  @Test
+  public void testContainsChildNodeHierarchicalChildNodes() {
+    P_TreeNode nodeA = new P_TreeNode();
+    m_rootNode.addChildNodesInternal(0, Collections.singletonList(nodeA), true);
+    ITreeNode nodeB = mockNode("b");
+    nodeA.addChildNodesInternal(0, Collections.singletonList(nodeB), false);
+
+    assertTrue(m_rootNode.containsChildNode(nodeA, false));
+    assertTrue(m_rootNode.containsChildNode(nodeA, true));
+
+    assertFalse(m_rootNode.containsChildNode(nodeB, false));
+    assertTrue(m_rootNode.containsChildNode(nodeB, true));
+
+    assertTrue(nodeA.containsChildNode(nodeB, false));
+    assertTrue(nodeA.containsChildNode(nodeB, true));
+  }
+
+  @Test
+  public void testContainsChildNodeMultipleHierarchicalChildNodes() {
+    P_TreeNode nodeA = new P_TreeNode();
+    P_TreeNode nodeB = new P_TreeNode();
+    m_rootNode.addChildNodesInternal(0, Arrays.asList(nodeA, nodeB), false);
+
+    ITreeNode nodeX = mockNode("x");
+    nodeA.addChildNodesInternal(0, Collections.singletonList(nodeX), false);
+
+    ITreeNode nodeY = mockNode("y");
+    nodeB.addChildNodesInternal(0, Collections.singletonList(nodeY), false);
+
+    assertTrue(m_rootNode.containsChildNode(nodeA, false));
+    assertTrue(m_rootNode.containsChildNode(nodeA, true));
+
+    assertTrue(m_rootNode.containsChildNode(nodeB, false));
+    assertTrue(m_rootNode.containsChildNode(nodeB, true));
+
+    assertFalse(m_rootNode.containsChildNode(nodeX, false));
+    assertTrue(m_rootNode.containsChildNode(nodeX, true));
+    assertTrue(nodeA.containsChildNode(nodeX, false));
+    assertTrue(nodeA.containsChildNode(nodeX, true));
+    assertFalse(nodeB.containsChildNode(nodeX, false));
+    assertFalse(nodeB.containsChildNode(nodeX, true));
+
+    assertFalse(m_rootNode.containsChildNode(nodeY, false));
+    assertTrue(m_rootNode.containsChildNode(nodeY, true));
+    assertFalse(nodeA.containsChildNode(nodeY, false));
+    assertFalse(nodeA.containsChildNode(nodeY, true));
+    assertTrue(nodeB.containsChildNode(nodeY, false));
+    assertTrue(nodeB.containsChildNode(nodeY, true));
+  }
+
+  @Test
+  public void testContainsChildNodeResolvedVirtualNode() {
+    ITreeNode nodeA = mockNode("a");
+    m_rootNode.addChildNodesInternal(0, Collections.singletonList(wrapByVirtualNode(nodeA)), false);
+
+    assertTrue(m_rootNode.containsChildNode(nodeA, false));
+    assertTrue(m_rootNode.containsChildNode(nodeA, true));
+  }
+
+  @Test
+  public void testContainsChildNodeRecursiveResolvedVirtualNode() {
+    P_TreeNode nodeA = new P_TreeNode();
+    m_rootNode.addChildNodesInternal(0, Collections.singletonList(wrapByVirtualNode(nodeA)), false);
+
+    ITreeNode nodeB = mockNode("b");
+    nodeA.addChildNodesInternal(0, Collections.singletonList(wrapByVirtualNode(nodeB)), false);
+
+    assertTrue(m_rootNode.containsChildNode(nodeA, false));
+    assertTrue(m_rootNode.containsChildNode(nodeA, true));
+
+    assertFalse(m_rootNode.containsChildNode(nodeB, false));
+    assertTrue(m_rootNode.containsChildNode(nodeB, true));
+
+    assertTrue(nodeA.containsChildNode(nodeB, false));
+    assertTrue(nodeA.containsChildNode(nodeB, true));
+  }
+
+  private VirtualTreeNode wrapByVirtualNode(ITreeNode nodeA) {
+    VirtualTreeNode virtualNodaA = new VirtualTreeNode();
+    virtualNodaA.setResolvedNode(nodeA);
+    return virtualNodaA;
+  }
+
+  private ITreeNode mockNode(String nodeId) {
+    ITreeNode node = m_mockNodes.get(nodeId);
+    if (node != null) {
+      return node;
+    }
+    // Create a new
+    node = mock(ITreeNode.class, "MockNode[" + nodeId + "]");
+    when(node.getNodeId()).thenReturn(nodeId);
+    m_mockNodes.put(nodeId, node);
+    return node;
+  }
+
+  private static class P_TreeNode extends AbstractTreeNode {
+  }
+}
diff --git a/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeUtilityTest.java b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeUtilityTest.java
new file mode 100644
index 0000000..01f530e
--- /dev/null
+++ b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeUtilityTest.java
@@ -0,0 +1,77 @@
+package org.eclipse.scout.rt.client.ui.basic.tree;
+
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import org.junit.Test;
+
+/**
+ * @since 5.2
+ */
+public class TreeUtilityTest {
+
+  @Test
+  public void testCalculateCommonParentNodeNullAndEmpty() {
+    assertNull(TreeUtility.calculateCommonParentNode(null));
+    assertNull(TreeUtility.calculateCommonParentNode(Collections.<ITreeNode> emptySet()));
+  }
+
+  @Test
+  public void testCalculateCommonParentNodeOneNodeWithoutParent() {
+    ITreeNode nodeA = mockNode("a", null);
+    assertNull(TreeUtility.calculateCommonParentNode(Collections.singleton(nodeA)));
+  }
+
+  @Test
+  public void testCalculateCommonParentNodeOneNodeWithParent() {
+    ITreeNode rootNode = mockNode("root", null);
+    ITreeNode nodeA = mockNode("a", rootNode);
+    assertSame(rootNode, TreeUtility.calculateCommonParentNode(Collections.singleton(nodeA)));
+  }
+
+  @Test
+  public void testCalculateCommonParentNodeMultipleNodesHavingSameParent() {
+    ITreeNode rootNode = mockNode("root", null);
+    ITreeNode nodeA = mockNode("a", rootNode);
+    ITreeNode nodeB = mockNode("b", rootNode);
+    assertSame(rootNode, TreeUtility.calculateCommonParentNode(Arrays.asList(nodeA, nodeB)));
+  }
+
+  @Test
+  public void testCalculateCommonParentNodeMultipleNodesHavingNullParent() {
+    ITreeNode nodeA = mockNode("a", null);
+    ITreeNode nodeB = mockNode("b", null);
+    assertNull(TreeUtility.calculateCommonParentNode(Arrays.asList(nodeA, nodeB)));
+  }
+
+  @Test
+  public void testCalculateCommonParentNodeMultipleNodesHavingDifferentParent() {
+    ITreeNode parentA = mockNode("parentA", null);
+    ITreeNode nodeA = mockNode("a", parentA);
+    ITreeNode parentB = mockNode("parentB", null);
+    ITreeNode nodeB = mockNode("b", parentB);
+    assertNull(TreeUtility.calculateCommonParentNode(Arrays.asList(nodeA, nodeB)));
+  }
+
+  @Test
+  public void testCalculateCommonParentNodeMultipleNodesOneHavingNullParent() {
+    ITreeNode parentA = mockNode("parentA", null);
+    ITreeNode nodeA = mockNode("a", parentA);
+    ITreeNode nodeB = mockNode("b", null);
+    assertNull(TreeUtility.calculateCommonParentNode(Arrays.asList(nodeA, nodeB)));
+  }
+
+  private ITreeNode mockNode(String nodeId, ITreeNode parentNode) {
+    ITreeNode node = mock(ITreeNode.class, "MockNode[" + nodeId + "]");
+    when(node.getNodeId()).thenReturn(nodeId);
+    if (parentNode != null) {
+      when(node.getParentNode()).thenReturn(parentNode);
+    }
+    return node;
+  }
+}
diff --git a/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/VirtualTreeNodeTest.java b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/VirtualTreeNodeTest.java
new file mode 100644
index 0000000..baf03c1
--- /dev/null
+++ b/org.eclipse.scout.rt.client.test/src/test/java/org/eclipse/scout/rt/client/ui/basic/tree/VirtualTreeNodeTest.java
@@ -0,0 +1,73 @@
+package org.eclipse.scout.rt.client.ui.basic.tree;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * @since 5.2
+ */
+public class VirtualTreeNodeTest {
+
+  private VirtualTreeNode m_treeNode;
+  private Map<String, ITreeNode> m_mockNodes;
+  private ITreeNode m_nodeA;
+
+  @Before
+  public void before() {
+    m_treeNode = new VirtualTreeNode();
+    m_mockNodes = new HashMap<>();
+    m_nodeA = mockNode("a");
+  }
+
+  @Test
+  public void testContainsChildNodeNullNode() {
+    assertFalse(m_treeNode.containsChildNode(null, false));
+    assertFalse(m_treeNode.containsChildNode(null, true));
+  }
+
+  @Test
+  public void testContainsChildNodeUnresolved() {
+    assertFalse(m_treeNode.containsChildNode(m_nodeA, false));
+    assertFalse(m_treeNode.containsChildNode(m_nodeA, true));
+  }
+
+  @Test
+  public void testContainsChildNodeResolved() {
+    m_treeNode.setResolvedNode(m_nodeA);
+    assertTrue(m_treeNode.containsChildNode(m_nodeA, false));
+    assertTrue(m_treeNode.containsChildNode(m_nodeA, true));
+  }
+
+  @Test
+  public void testContainsChildNodeCascadingResolved() {
+    m_treeNode.setResolvedNode(wrapByVirtualNode(m_nodeA));
+    assertFalse(m_treeNode.containsChildNode(m_nodeA, false));
+    assertTrue(m_treeNode.containsChildNode(m_nodeA, true));
+  }
+
+  private VirtualTreeNode wrapByVirtualNode(ITreeNode nodeA) {
+    VirtualTreeNode virtualNodaA = new VirtualTreeNode();
+    virtualNodaA.setResolvedNode(nodeA);
+    return virtualNodaA;
+  }
+
+  private ITreeNode mockNode(String nodeId) {
+    ITreeNode node = m_mockNodes.get(nodeId);
+    if (node != null) {
+      return node;
+    }
+    // Create a new
+    node = mock(ITreeNode.class, "MockNode[" + nodeId + "]");
+    when(node.getNodeId()).thenReturn(nodeId);
+    m_mockNodes.put(nodeId, node);
+    return node;
+  }
+}
diff --git a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/table/TableEventBuffer.java b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/table/TableEventBuffer.java
index de9ded5..552a94d 100644
--- a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/table/TableEventBuffer.java
+++ b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/table/TableEventBuffer.java
@@ -572,7 +572,7 @@
     }
 
     /**
-     * Adds rows and columns. Using this method after invoking {@link #merge(TableEvent)} throws an
+     * Merges rows and columns. Using this method after invoking {@link #complete()} throws an
      * {@link IllegalStateException}.
      */
     public void merge(TableEvent event) {
diff --git a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/AbstractTreeNode.java b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/AbstractTreeNode.java
index 27269e9..9a71f54 100644
--- a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/AbstractTreeNode.java
+++ b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/AbstractTreeNode.java
@@ -35,6 +35,7 @@
 import org.eclipse.scout.rt.platform.annotations.ConfigProperty;
 import org.eclipse.scout.rt.platform.reflect.ConfigurationUtility;
 import org.eclipse.scout.rt.platform.util.CollectionUtility;
+import org.eclipse.scout.rt.platform.util.CompareUtility;
 import org.eclipse.scout.rt.platform.util.collection.OrderedCollection;
 import org.eclipse.scout.rt.platform.util.concurrent.OptimisticLock;
 import org.eclipse.scout.rt.shared.extension.AbstractExtension;
@@ -878,6 +879,25 @@
   }
 
   @Override
+  public boolean containsChildNode(final ITreeNode node, final boolean recursive) {
+    synchronized (m_childNodeList) {
+      for (ITreeNode childNode : m_childNodeList) {
+        if (CompareUtility.equals(childNode, node)) {
+          return true;
+        }
+      }
+      if (recursive) {
+        for (ITreeNode childNode : m_childNodeList) {
+          if (childNode.containsChildNode(node, recursive)) {
+            return true;
+          }
+        }
+      }
+    }
+    return false;
+  }
+
+  @Override
   public ITreeNode findParentNode(Class<?> interfaceType) {
     ITreeNode test = getParentNode();
     while (test != null) {
diff --git a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/ITreeNode.java b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/ITreeNode.java
index 3d9f41f..40e4b8d 100644
--- a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/ITreeNode.java
+++ b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/ITreeNode.java
@@ -324,6 +324,12 @@
   List<ITreeNode> getChildNodes();
 
   /**
+   * @return Returns <code>true</code> if this node contains the given child node. Visits child nodes if recursive is
+   *         <code>true</code>.
+   */
+  boolean containsChildNode(ITreeNode node, boolean recursive);
+
+  /**
    * @see ITree#getNodeFilters() This method is Thread-Safe
    */
   List<ITreeNode> getFilteredChildNodes();
diff --git a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEvent.java b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEvent.java
index 7397939..328517f 100644
--- a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEvent.java
+++ b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEvent.java
@@ -22,6 +22,7 @@
 import org.eclipse.scout.rt.client.ui.action.menu.IMenu;
 import org.eclipse.scout.rt.client.ui.dnd.TransferObject;
 import org.eclipse.scout.rt.platform.util.CollectionUtility;
+import org.eclipse.scout.rt.platform.util.CompareUtility;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -201,12 +202,7 @@
   }
 
   public ITreeNode getDeselectedNode() {
-    if (CollectionUtility.hasElements(m_deselectedNodes)) {
-      return CollectionUtility.firstElement(m_deselectedNodes);
-    }
-    else {
-      return null;
-    }
+    return CollectionUtility.firstElement(m_deselectedNodes);
   }
 
   public Collection<ITreeNode> getDeselectedNodes() {
@@ -218,12 +214,7 @@
   }
 
   public ITreeNode getNewSelectedNode() {
-    if (CollectionUtility.hasElements(m_newSelectedNodes)) {
-      return CollectionUtility.firstElement(m_newSelectedNodes);
-    }
-    else {
-      return null;
-    }
+    return CollectionUtility.firstElement(m_newSelectedNodes);
   }
 
   public Collection<ITreeNode> getNewSelectedNodes() {
@@ -235,18 +226,56 @@
   }
 
   public ITreeNode getNode() {
-    if (CollectionUtility.hasElements(m_nodes)) {
-      return CollectionUtility.firstElement(m_nodes);
-    }
-    else {
-      return null;
-    }
+    return CollectionUtility.firstElement(m_nodes);
   }
 
   public Collection<ITreeNode> getNodes() {
     return CollectionUtility.arrayList(m_nodes);
   }
 
+  /**
+   * Updates the nodes of this events and computes the new common parent node.
+   */
+  protected void setNodes(Collection<ITreeNode> nodes) {
+    m_nodes = CollectionUtility.arrayList(nodes);
+    m_commonParentNode = TreeUtility.calculateCommonParentNode(nodes);
+  }
+
+  public boolean hasNodes() {
+    return m_nodes == null ? false : !m_nodes.isEmpty();
+  }
+
+  public int getNodeCount() {
+    return m_nodes == null ? 0 : m_nodes.size();
+  }
+
+  public boolean containsNode(ITreeNode nodeToFind) {
+    if (CollectionUtility.isEmpty(m_nodes)) {
+      return false;
+    }
+    for (ITreeNode node : m_nodes) {
+      if (CompareUtility.equals(node, nodeToFind)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public boolean containsNodeRecursive(ITreeNode nodeToFind) {
+    if (CollectionUtility.isEmpty(m_nodes)) {
+      return false;
+    }
+    if (containsNode(nodeToFind)) {
+      return true;
+    }
+    for (ITreeNode node : m_nodes) {
+      if (node.containsChildNode(nodeToFind, true)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   public ITreeNode getChildNode() {
     return getNode();
   }
diff --git a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEventBuffer.java b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEventBuffer.java
index e97a67d..7cfbe19 100644
--- a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEventBuffer.java
+++ b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeEventBuffer.java
@@ -10,13 +10,18 @@
  ******************************************************************************/
 package org.eclipse.scout.rt.client.ui.basic.tree;
 
+import static org.eclipse.scout.rt.platform.util.Assertions.assertNotNull;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.ListIterator;
+import java.util.Map;
 import java.util.Set;
 
 import org.eclipse.scout.rt.client.ui.AbstractEventBuffer;
@@ -99,7 +104,7 @@
         // Remove the current node from the event and check if was removed from a creation event
         TreeEvent newEvent = removeNode(event, nodeToRemove);
         if (creationTypesList.contains(event.getType())) {
-          boolean removed = (event.getNodes().size() != newEvent.getNodes().size());
+          boolean removed = (event.getNodeCount() != newEvent.getNodeCount());
           if (removed) {
             nodeRemovedFromCreationEvent = true;
           }
@@ -110,7 +115,7 @@
             // required anymore (the insertion event does not contain deleted nodes).
             ITreeNode parentToCheck = nodeToRemove.getParentNode();
             while (parentToCheck != null) {
-              if (containsNode(newEvent.getNodes(), parentToCheck)) {
+              if (newEvent.containsNode(parentToCheck)) {
                 nodeRemovedFromCreationEvent = true;
                 break;
               }
@@ -203,7 +208,7 @@
     for (Iterator<ITreeNode> it = nodes.iterator(); it.hasNext();) {
       ITreeNode node = it.next();
       for (TreeEvent event : events) {
-        if (event.getType() == oldType && containsNodeRec(event.getNodes(), node)) {
+        if (event.getType() == oldType && event.containsNodeRecursive(node)) {
           it.remove();
           removed = true;
           break; // no need to look further
@@ -214,37 +219,6 @@
   }
 
   /**
-   * @return <code>true</code> if 'nodes' contains 'nodeToFind'. Children of 'nodes are <b>not</b> considered, use
-   *         {@link #containsNodeRec(Collection, ITreeNode)} if they should be checked as well.
-   */
-  protected boolean containsNode(Collection<ITreeNode> nodes, ITreeNode nodeToFind) {
-    for (ITreeNode node : nodes) {
-      node = TreeUtility.unwrapResolvedNode(node);
-      if (CompareUtility.equals(node, nodeToFind)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  /**
-   * @return <code>true</code> if 'nodes' contains 'nodeToFind' or one of the children does so.
-   */
-  protected boolean containsNodeRec(Collection<ITreeNode> nodes, ITreeNode nodeToFind) {
-    for (ITreeNode node : nodes) {
-      // Unwrap resolved node to get the real answer to "getChildNodes()"
-      node = TreeUtility.unwrapResolvedNode(node);
-      if (CompareUtility.equals(node, nodeToFind)) {
-        return true;
-      }
-      if (containsNodeRec(node.getChildNodes(), nodeToFind)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  /**
    * @return all the given nodes, including their recursive child nodes.
    */
   protected Collection<ITreeNode> collectAllNodesRec(Collection<ITreeNode> nodes) {
@@ -262,76 +236,65 @@
    * Merge previous events of the same type into the current and delete the previous events
    */
   protected void coalesceSameType(List<TreeEvent> events) {
-    for (int j = 0; j < events.size() - 1; j++) {
-      int i = events.size() - 1 - j;
-      TreeEvent event = events.get(i);
+    if (events.size() < 2) {
+      return;
+    }
 
-      if (isCoalesceConsecutivePrevious(event.getType())) {
-        TreeEvent mergedEvent = coalesceConsecutivePrevious(event, events.subList(0, i));
-        if (mergedEvent != event) {
-          // replace in (now shorter) list
-          i = events.size() - 1 - j;
-          events.set(i, mergedEvent);
+    final Map<ITreeNode, TreeEvent> initialEventByPrentNode = new HashMap<>();
+    final Map<ITreeNode, TreeEventMerger> eventMergerByParent = new HashMap<>();
+    int previousEventType = -1;
+
+    for (ListIterator<TreeEvent> it = events.listIterator(events.size()); it.hasPrevious();) {
+      final TreeEvent event = it.previous();
+      final int type = event.getType();
+
+      // clean-up initial event and event merger maps
+      if (previousEventType != type && !initialEventByPrentNode.isEmpty()) {
+        for (TreeEventMerger merger : eventMergerByParent.values()) {
+          merger.complete();
         }
+        initialEventByPrentNode.clear();
+        eventMergerByParent.clear();
       }
-    }
-  }
 
-  /**
-   * Merge events of the same type in the given list into the current and delete the other events from the list.
-   *
-   * @return the updated event (with other events merged into it)
-   */
-  protected TreeEvent coalesceConsecutivePrevious(TreeEvent event, List<TreeEvent> list) {
-    for (ListIterator<TreeEvent> it = list.listIterator(list.size()); it.hasPrevious();) {
-      TreeEvent previous = it.previous();
-      if (event.getType() == previous.getType()) {
-        if (hasSameCommonParentNode(event, previous)) {
-          event = merge(previous, event);
-          it.remove();
-        }
+      if (!isCoalesceConsecutivePrevious(type)) {
+        continue;
       }
-      else {
-        return event;
+
+      previousEventType = type;
+      final ITreeNode parentNode = event.getCommonParentNode();
+
+      final TreeEvent initialEvent = initialEventByPrentNode.get(event.getCommonParentNode());
+      if (initialEvent == null) {
+        // this is the first event with given common parent node.
+        // put it into the initial event cache and continue with the next event
+        initialEventByPrentNode.put(parentNode, event);
+        continue;
       }
-    }
-    return event;
-  }
 
-  protected boolean hasSameCommonParentNode(TreeEvent event1, TreeEvent event2) {
-    ITreeNode node1 = event1.getCommonParentNode();
-    ITreeNode node2 = event2.getCommonParentNode();
-    return CompareUtility.equals(node1, node2);
-  }
-
-  /**
-   * @return a new event with same same property as 'second' but with the nodes of 'first' merged into
-   */
-  protected TreeEvent merge(TreeEvent first, TreeEvent second) {
-    return replaceNodesInEvent(second, mergeNodes(first.getNodes(), second.getNodes()));
-  }
-
-  /**
-   * Merge list of nodes, such that, if the same node is in both lists, only the one of the second list (later event) is
-   * kept.
-   */
-  protected List<ITreeNode> mergeNodes(Collection<ITreeNode> first, Collection<ITreeNode> second) {
-    List<ITreeNode> nodes = new ArrayList<>();
-    for (ITreeNode node : first) {
-      if (!second.contains(node)) {
-        nodes.add(node);
+      // there is already an initial event.
+      // check if there is already an event merger or create one
+      TreeEventMerger eventMerger = eventMergerByParent.get(parentNode);
+      if (eventMerger == null) {
+        eventMerger = new TreeEventMerger(initialEvent);
+        eventMergerByParent.put(parentNode, eventMerger);
       }
+
+      // merge current event and remove it from the original event list
+      eventMerger.merge(event);
+      it.remove();
     }
-    for (ITreeNode node : second) {
-      nodes.add(node);
+
+    // complete "open" event mergers
+    for (TreeEventMerger eventMerger : eventMergerByParent.values()) {
+      eventMerger.complete();
     }
-    return nodes;
   }
 
   protected void removeEmptyEvents(List<TreeEvent> events) {
     for (Iterator<TreeEvent> it = events.iterator(); it.hasNext();) {
       TreeEvent event = it.next();
-      if (isNodesRequired(event.getType()) && event.getNodes().isEmpty()
+      if (isNodesRequired(event.getType()) && !event.hasNodes()
           || isCommonParentNodeRequired(event.getType()) && event.getCommonParentNode() == null) {
         it.remove();
       }
@@ -371,7 +334,7 @@
       return false;
     }
     boolean identical = (event1.getType() == event2.getType()
-        && hasSameCommonParentNode(event1, event2)
+        && CompareUtility.equals(event1.getCommonParentNode(), event2.getCommonParentNode())
         && CollectionUtility.equalsCollection(event1.getNodes(), event2.getNodes(), true)
         && CollectionUtility.equalsCollection(event1.getDeselectedNodes(), event2.getDeselectedNodes(), true)
         && CollectionUtility.equalsCollection(event1.getNewSelectedNodes(), event2.getNewSelectedNodes(), true)
@@ -497,4 +460,80 @@
       }
     }
   }
+
+  /**
+   * Helper for merging nodes form other {@link TreeEvent}s into the initial target event. <br/>
+   * <b>Note</b>: The {@link #merge(TreeEvent)} method does not check any rules. The given event's nodes are merged in
+   * any case.<br/>
+   * <b>Usage</b>:
+   *
+   * <pre>
+   * TreeEventMerger eventMerger = new TreeEventMerger(targetEvent);
+   * eventMerger.merge(e1);
+   * eventMerger.merge(e2);
+   * eventMerger.complete();
+   * </pre>
+   */
+  protected static class TreeEventMerger {
+
+    private final TreeEvent m_targetEvent;
+    private Collection<ITreeNode> m_targetNodes;
+    private HashSet<ITreeNode> m_targetNodeSet;
+    private List<ITreeNode> m_mergedNodes;
+
+    public TreeEventMerger(TreeEvent targetEvent) {
+      m_targetEvent = assertNotNull(targetEvent, "targetEvent must not be null");
+      m_mergedNodes = new LinkedList<>();
+    }
+
+    /**
+     * Merges nodes. Using this method after invoking {@link #complete()} throws an {@link IllegalStateException}.
+     */
+    public void merge(TreeEvent event) {
+      if (m_mergedNodes == null) {
+        throw new IllegalStateException("Invocations of merge is not allowed after complete has been invoked.");
+      }
+      if (!event.hasNodes()) {
+        return;
+      }
+      ensureInitialized();
+      mergeCollections(event.getNodes(), m_mergedNodes, m_targetNodeSet);
+    }
+
+    protected void ensureInitialized() {
+      if (m_targetNodes != null) {
+        return;
+      }
+      m_targetNodes = m_targetEvent.getNodes();
+      m_targetNodeSet = new HashSet<>(m_targetNodes);
+    }
+
+    /**
+     * Completes the merge process. Subsequent invocations of this method does not have any effects.
+     */
+    public void complete() {
+      if (m_mergedNodes == null) {
+        return;
+      }
+      if (m_targetNodes != null) {
+        m_mergedNodes.addAll(m_targetNodes);
+        m_targetEvent.setNodes(m_mergedNodes);
+      }
+      m_mergedNodes = null;
+    }
+
+    /**
+     * Merge collections, such that, if an element is in both collections, only the one of the second collection (later
+     * event) is kept.
+     */
+    protected <TYPE> void mergeCollections(Collection<TYPE> source, List<TYPE> target, HashSet<TYPE> targetSet) {
+      for (Iterator<TYPE> it = source.iterator(); it.hasNext();) {
+        TYPE sourceElement = it.next();
+        if (!targetSet.add(sourceElement)) { // returns true, if the sourceElement has been added; false, if it was already in the set.
+          it.remove();
+        }
+      }
+      target.addAll(0, source);
+    }
+  }
 }
diff --git a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeUtility.java b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeUtility.java
index 8205716..7754298 100644
--- a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeUtility.java
+++ b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/TreeUtility.java
@@ -28,22 +28,17 @@
    * If every given node has the same parent, that common parent node will be returned, else null.
    */
   public static ITreeNode calculateCommonParentNode(Collection<? extends ITreeNode> nodes) {
-    if (!CollectionUtility.hasElements(nodes)) {
+    if (CollectionUtility.isEmpty(nodes)) {
       return null;
     }
-
-    if (nodes.size() == 1) {
-      return CollectionUtility.firstElement(nodes).getParentNode();
-    }
     Iterator<? extends ITreeNode> nodeIt = nodes.iterator();
-    ITreeNode test = nodeIt.next().getParentNode();
+    final ITreeNode commonParent = nodeIt.next().getParentNode();
     while (nodeIt.hasNext()) {
-      if (nodeIt.next().getParentNode() != test) {
-        test = null;
-        break;
+      if (nodeIt.next().getParentNode() != commonParent) {
+        return null;
       }
     }
-    return test;
+    return commonParent;
   }
 
   /**
diff --git a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/VirtualTreeNode.java b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/VirtualTreeNode.java
index 0ce0259..336a348 100644
--- a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/VirtualTreeNode.java
+++ b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/basic/tree/VirtualTreeNode.java
@@ -436,6 +436,20 @@
   }
 
   @Override
+  public boolean containsChildNode(ITreeNode node, boolean recursive) {
+    if (equals(node)) {
+      return true;
+    }
+    if (recursive) {
+      final ITreeNode resolvedNode = getResolvedNode();
+      if (resolvedNode != null) {
+        return resolvedNode.containsChildNode(node, recursive);
+      }
+    }
+    return false;
+  }
+
+  @Override
   public ITreeNode findParentNode(Class interfaceType) {
     ITreeNode test = getParentNode();
     while (test != null) {