]> source.dussan.org Git - vaadin-framework.git/commitdiff
Updated row and spacer handling for Escalator (#11438)
authorAnna Koskinen <Ansku@users.noreply.github.com>
Thu, 29 Aug 2019 13:06:25 +0000 (16:06 +0300)
committerGitHub <noreply@github.com>
Thu, 29 Aug 2019 13:06:25 +0000 (16:06 +0300)
Updated row and spacer handling for Escalator.

Main changes:
- Spacers are only maintained and checked for rows that have DOM
representation, and not at all if there is no details generator. This
gives notable performance improvements to some particularly large Grids
- Escalator no longer tries to trim away any rows that don't fit within
the viewport just because a details row gets opened in Grid. This leads
to some increase in simultaneous DOM elements, but simplifies the logic
considerably. For example opening or closing details rows doesn't
require checking the overall content validity beyond the details row
itself anymore, but some repositioning at most. There are also no longer
any orphaned spacers without corresponding DOM rows.
- Spacers are better integrated into the overall position calculations.
- Some public methods that are no longer used by Escalator or have
changed functionality or order of operations. Any extending classes that
tap into row, spacer, or scroll position handling are likely to need
reworking after this update.
- Auto-detecting row height is delayed until Escalator is both attached
and displayed.

17 files changed:
client/src/main/java/com/vaadin/client/connectors/grid/DetailsManagerConnector.java
client/src/main/java/com/vaadin/client/connectors/grid/GridConnector.java
client/src/main/java/com/vaadin/client/widget/escalator/RowContainer.java
client/src/main/java/com/vaadin/client/widgets/Escalator.java
client/src/main/java/com/vaadin/client/widgets/Grid.java
server/src/main/java/com/vaadin/ui/Grid.java
shared/src/main/java/com/vaadin/shared/ui/grid/DetailsManagerState.java
uitest/src/main/java/com/vaadin/tests/components/treegrid/TreeGridBigDetailsManager.java
uitest/src/main/java/com/vaadin/tests/widgetset/client/grid/EscalatorBasicClientFeaturesWidget.java
uitest/src/main/java/com/vaadin/tests/widgetset/client/grid/EscalatorProxy.java
uitest/src/test/java/com/vaadin/tests/components/grid/GridComponentsTest.java
uitest/src/test/java/com/vaadin/tests/components/grid/GridScrolledToBottomTest.java
uitest/src/test/java/com/vaadin/tests/components/grid/basicfeatures/GridColumnResizeModeTest.java
uitest/src/test/java/com/vaadin/tests/components/grid/basicfeatures/escalator/EscalatorBasicsTest.java
uitest/src/test/java/com/vaadin/tests/components/grid/basicfeatures/escalator/EscalatorSpacerTest.java
uitest/src/test/java/com/vaadin/tests/components/treegrid/TreeGridBigDetailsManagerTest.java
uitest/src/test/java/com/vaadin/tests/components/treegrid/TreeGridDetailsManagerTest.java

index cddaf667190b01d48dbeb7b1cae82d351cdcf923..b3235952fb4caea013a5ab32456800a5663c27eb 100644 (file)
@@ -29,6 +29,7 @@ import com.vaadin.client.ConnectorMap;
 import com.vaadin.client.LayoutManager;
 import com.vaadin.client.ServerConnector;
 import com.vaadin.client.WidgetUtil;
+import com.vaadin.client.connectors.data.DataCommunicatorConnector;
 import com.vaadin.client.data.DataChangeHandler;
 import com.vaadin.client.extensions.AbstractExtensionConnector;
 import com.vaadin.client.ui.layout.ElementResizeListener;
@@ -36,7 +37,9 @@ import com.vaadin.client.widget.escalator.events.SpacerIndexChangedEvent;
 import com.vaadin.client.widget.escalator.events.SpacerIndexChangedHandler;
 import com.vaadin.client.widget.grid.HeightAwareDetailsGenerator;
 import com.vaadin.client.widgets.Grid;
+import com.vaadin.shared.Range;
 import com.vaadin.shared.Registration;
+import com.vaadin.shared.data.DataCommunicatorClientRpc;
 import com.vaadin.shared.ui.Connect;
 import com.vaadin.shared.ui.grid.DetailsManagerState;
 import com.vaadin.shared.ui.grid.GridState;
@@ -55,17 +58,12 @@ public class DetailsManagerConnector extends AbstractExtensionConnector {
 
     /* Map for tracking which details are open on which row */
     private TreeMap<Integer, String> indexToDetailConnectorId = new TreeMap<>();
-    /* Boolean flag to avoid multiple refreshes */
-    private boolean refreshing;
     /* For listening data changes that originate from DataSource. */
     private Registration dataChangeRegistration;
     /* For listening spacer index changes that originate from Escalator. */
     private HandlerRegistration spacerIndexChangedHandlerRegistration;
-
-    /**
-     * Handle for the spacer visibility change handler.
-     */
-    private HandlerRegistration spacerVisibilityChangeRegistration;
+    /* For listening when Escalator's visual range is changed. */
+    private HandlerRegistration rowVisibilityChangeHandlerRegistration;
 
     private final Map<Element, ScheduledCommand> elementToResizeCommand = new HashMap<Element, Scheduler.ScheduledCommand>();
     private final ElementResizeListener detailsRowResizeListener = event -> {
@@ -75,53 +73,199 @@ public class DetailsManagerConnector extends AbstractExtensionConnector {
         }
     };
 
+    /* for delayed alert if Grid needs to run or cancel pending operations */
+    private boolean delayedDetailsAddedOrUpdatedAlertTriggered = false;
+    private boolean delayedDetailsAddedOrUpdated = false;
+
+    /* for delayed re-positioning of Escalator contents to prevent gaps */
+    /* -1 is a possible spacer index in Escalator so can't be used as default */
+    private boolean delayedRepositioningTriggered = false;
+    private Integer delayedRepositioningStart = null;
+    private Integer delayedRepositioningEnd = null;
+
     /* calculated when the first details row is opened */
     private Double spacerCellBorderHeights = null;
 
+    private Range availableRowRange = Range.emptyRange();
+    private Range latestVisibleRowRange = Range.emptyRange();
+
     /**
      * DataChangeHandler for updating the visibility of detail widgets.
      */
     private final class DetailsChangeHandler implements DataChangeHandler {
+
         @Override
         public void resetDataAndSize(int estimatedNewDataSize) {
-            // Full clean up
-            indexToDetailConnectorId.clear();
+            // No need to do anything, dataUpdated and dataAvailable take care
+            // of cleanup.
         }
 
         @Override
         public void dataUpdated(int firstRowIndex, int numberOfRows) {
-            for (int i = 0; i < numberOfRows; ++i) {
-                int index = firstRowIndex + i;
-                detachIfNeeded(index, getDetailsComponentConnectorId(index));
+            if (!getState().hasDetailsGenerator) {
+                markDetailsAddedOrUpdatedForDelayedAlertToGrid(false);
+                return;
+            }
+            Range updatedRange = Range.withLength(firstRowIndex, numberOfRows);
+
+            // NOTE: this relies on Escalator getting updated first
+            Range newVisibleRowRange = getWidget().getEscalator()
+                    .getVisibleRowRange();
+
+            if (updatedRange.partitionWith(availableRowRange)[1]
+                    .length() != updatedRange.length()
+                    || availableRowRange.partitionWith(newVisibleRowRange)[1]
+                            .length() != newVisibleRowRange.length()) {
+                // full visible range not available yet or full refresh coming
+                // up anyway, leave updating to dataAvailable
+                if (numberOfRows == 1
+                        && latestVisibleRowRange.contains(firstRowIndex)) {
+                    // A single details row has been opened or closed within
+                    // visual range, trigger scrollTo after dataAvailable has
+                    // done its thing. Do not attempt to scroll to details rows
+                    // that are opened outside of the visual range.
+                    Scheduler.get().scheduleFinally(() -> {
+                        getParent().singleDetailsOpened(firstRowIndex);
+                        // we don't know yet whether there are details or not,
+                        // mark them added or updated just in case, so that
+                        // the potential scrolling attempt gets triggered after
+                        // another layout phase is finished
+                        markDetailsAddedOrUpdatedForDelayedAlertToGrid(true);
+                    });
+                }
+                return;
             }
-            if (numberOfRows == 1) {
+
+            // only trigger scrolling attempt if the single updated row is
+            // within existing visual range
+            boolean scrollToFirst = numberOfRows == 1
+                    && latestVisibleRowRange.contains(firstRowIndex);
+
+            if (!newVisibleRowRange.equals(latestVisibleRowRange)) {
+                // update visible range
+                latestVisibleRowRange = newVisibleRowRange;
+
+                // do full refresh
+                detachOldAndRefreshCurrentDetails();
+            } else {
+                // refresh only the updated range
+                refreshDetailsVisibilityWithRange(updatedRange);
+
+                // the update may have affected details row contents and size,
+                // recalculation and triggering of any pending navigation
+                // confirmations etc. is needed
+                triggerDelayedRepositioning(firstRowIndex, numberOfRows);
+            }
+
+            if (scrollToFirst) {
+                // scroll to opened row (if it got closed instead, nothing
+                // happens)
                 getParent().singleDetailsOpened(firstRowIndex);
+                markDetailsAddedOrUpdatedForDelayedAlertToGrid(true);
             }
-            // Deferred opening of new ones.
-            refreshDetails();
         }
 
-        /* The remaining methods will do a full refresh for now */
-
         @Override
         public void dataRemoved(int firstRowIndex, int numberOfRows) {
-            refreshDetails();
+            if (!getState().hasDetailsGenerator) {
+                markDetailsAddedOrUpdatedForDelayedAlertToGrid(false);
+                return;
+            }
+            Range removing = Range.withLength(firstRowIndex, numberOfRows);
+
+            // update the handled range to only contain rows that fall before
+            // the removed range
+            latestVisibleRowRange = Range
+                    .between(latestVisibleRowRange.getStart(),
+                            Math.max(latestVisibleRowRange.getStart(),
+                                    Math.min(firstRowIndex,
+                                            latestVisibleRowRange.getEnd())));
+
+            // reduce the available range accordingly
+            Range[] partitions = availableRowRange.partitionWith(removing);
+            Range removedAbove = partitions[0];
+            Range removedAvailable = partitions[1];
+            availableRowRange = Range.withLength(
+                    Math.max(0,
+                            availableRowRange.getStart()
+                                    - removedAbove.length()),
+                    Math.max(0, availableRowRange.length()
+                            - removedAvailable.length()));
+
+            for (int i = 0; i < numberOfRows; ++i) {
+                int rowIndex = firstRowIndex + i;
+                if (indexToDetailConnectorId.containsKey(rowIndex)) {
+                    String id = indexToDetailConnectorId.get(rowIndex);
+
+                    ComponentConnector connector = (ComponentConnector) ConnectorMap
+                            .get(getConnection()).getConnector(id);
+                    if (connector != null) {
+                        Element element = connector.getWidget().getElement();
+                        elementToResizeCommand.remove(element);
+                        getLayoutManager().removeElementResizeListener(element,
+                                detailsRowResizeListener);
+                    }
+                    indexToDetailConnectorId.remove(rowIndex);
+                }
+            }
+            // Grid and Escalator take care of their own cleanup at removal, no
+            // need to clear details from those. Because this removal happens
+            // instantly any pending scroll to row or such should not need
+            // another attempt and unless something else causes such need the
+            // pending operations should be cleared out.
+            markDetailsAddedOrUpdatedForDelayedAlertToGrid(false);
         }
 
         @Override
         public void dataAvailable(int firstRowIndex, int numberOfRows) {
-            refreshDetails();
+            if (!getState().hasDetailsGenerator) {
+                markDetailsAddedOrUpdatedForDelayedAlertToGrid(false);
+                return;
+            }
+
+            // update available range
+            availableRowRange = Range.withLength(firstRowIndex, numberOfRows);
+
+            // NOTE: this relies on Escalator getting updated first
+            Range newVisibleRowRange = getWidget().getEscalator()
+                    .getVisibleRowRange();
+            // only process the section that is actually available
+            newVisibleRowRange = availableRowRange
+                    .partitionWith(newVisibleRowRange)[1];
+            if (newVisibleRowRange.equals(latestVisibleRowRange)) {
+                // no need to update
+                return;
+            }
+
+            // check whether the visible range has simply got shortened
+            // (e.g. by changing the default row height)
+            boolean subsectionOfOld = latestVisibleRowRange
+                    .partitionWith(newVisibleRowRange)[1]
+                            .length() == newVisibleRowRange.length();
+
+            // update visible range
+            latestVisibleRowRange = newVisibleRowRange;
+
+            if (subsectionOfOld) {
+                // only detach extra rows
+                detachExcludingRange(latestVisibleRowRange);
+            } else {
+                // there are completely new visible rows, full refresh
+                detachOldAndRefreshCurrentDetails();
+            }
         }
 
         @Override
         public void dataAdded(int firstRowIndex, int numberOfRows) {
-            refreshDetails();
+            refreshDetailsVisibilityWithRange(
+                    Range.withLength(firstRowIndex, numberOfRows));
         }
     }
 
     /**
      * Height aware details generator for client-side Grid.
      */
+    @SuppressWarnings("deprecation")
     private class CustomDetailsGenerator
             implements HeightAwareDetailsGenerator {
 
@@ -129,8 +273,24 @@ public class DetailsManagerConnector extends AbstractExtensionConnector {
         public Widget getDetails(int rowIndex) {
             String id = getDetailsComponentConnectorId(rowIndex);
             if (id == null) {
+                detachIfNeeded(rowIndex, id);
                 return null;
             }
+            String oldId = indexToDetailConnectorId.get(rowIndex);
+            if (oldId != null && !oldId.equals(id)) {
+                // remove outdated connector
+                ComponentConnector connector = (ComponentConnector) ConnectorMap
+                        .get(getConnection()).getConnector(oldId);
+                if (connector != null) {
+                    Element element = connector.getWidget().getElement();
+                    elementToResizeCommand.remove(element);
+                    getLayoutManager().removeElementResizeListener(element,
+                            detailsRowResizeListener);
+                }
+            }
+
+            indexToDetailConnectorId.put(rowIndex, id);
+            getWidget().setDetailsVisible(rowIndex, true);
 
             Widget widget = getConnector(id).getWidget();
             getLayoutManager().addElementResizeListener(widget.getElement(),
@@ -169,7 +329,10 @@ public class DetailsManagerConnector extends AbstractExtensionConnector {
             ComponentConnector componentConnector = getConnector(id);
 
             getLayoutManager().setNeedsMeasureRecursively(componentConnector);
-            getLayoutManager().layoutNow();
+            if (!getLayoutManager().isLayoutRunning()
+                    && !getConnection().getMessageHandler().isUpdatingState()) {
+                getLayoutManager().layoutNow();
+            }
 
             Element element = componentConnector.getWidget().getElement();
             if (spacerCellBorderHeights == null) {
@@ -209,20 +372,144 @@ public class DetailsManagerConnector extends AbstractExtensionConnector {
         dataChangeRegistration = getWidget().getDataSource()
                 .addDataChangeHandler(new DetailsChangeHandler());
 
-        // When details element is shown, remeasure it in the layout phase
-        spacerVisibilityChangeRegistration = getParent().getWidget()
-                .addSpacerVisibilityChangedHandler(event -> {
-                    if (event.isSpacerVisible()) {
-                        String id = indexToDetailConnectorId
-                                .get(event.getRowIndex());
-                        ComponentConnector connector = (ComponentConnector) ConnectorMap
-                                .get(getConnection()).getConnector(id);
-                        getLayoutManager()
-                                .setNeedsMeasureRecursively(connector);
+        rowVisibilityChangeHandlerRegistration = getWidget()
+                .addRowVisibilityChangeHandler(event -> {
+                    if (getConnection().getMessageHandler().isUpdatingState()) {
+                        // don't update in the middle of state changes,
+                        // leave to dataAvailable
+                        return;
+                    }
+                    Range newVisibleRowRange = event.getVisibleRowRange();
+                    if (newVisibleRowRange.equals(latestVisibleRowRange)) {
+                        // no need to update
+                        return;
+                    }
+                    Range availableAndVisible = availableRowRange
+                            .partitionWith(newVisibleRowRange)[1];
+                    if (availableAndVisible.isEmpty()) {
+                        // nothing to update yet, leave to dataAvailable
+                        return;
+                    }
+
+                    if (!availableAndVisible.equals(latestVisibleRowRange)) {
+                        // check whether the visible range has simply got
+                        // shortened
+                        // (e.g. by changing the default row height)
+                        boolean subsectionOfOld = latestVisibleRowRange
+                                .partitionWith(newVisibleRowRange)[1]
+                                        .length() == newVisibleRowRange
+                                                .length();
+
+                        // update visible range
+                        latestVisibleRowRange = availableAndVisible;
+
+                        if (subsectionOfOld) {
+                            // only detach extra rows
+                            detachExcludingRange(latestVisibleRowRange);
+                        } else {
+                            // there are completely new visible rows, full
+                            // refresh
+                            detachOldAndRefreshCurrentDetails();
+                        }
+                    } else {
+                        // refresh only the visible range, nothing to detach
+                        refreshDetailsVisibilityWithRange(availableAndVisible);
+
+                        // the update may have affected details row contents and
+                        // size, recalculation is needed
+                        triggerDelayedRepositioning(
+                                availableAndVisible.getStart(),
+                                availableAndVisible.length());
                     }
                 });
     }
 
+    /**
+     * Triggers repositioning of the the contents from the first affected row
+     * downwards if any of the rows fall within the visual range. If any other
+     * delayed repositioning has been triggered within this round trip the
+     * affected range is expanded as needed. The processing is delayed to make
+     * sure all updates have time to get in, otherwise the repositioning will be
+     * calculated separately for each details row addition or removal from the
+     * server side (see
+     * {@link DataCommunicatorClientRpc#updateData(elemental.json.JsonArray)}
+     * implementation within {@link DataCommunicatorConnector}).
+     *
+     * @param firstRowIndex
+     *            the index of the first changed row
+     * @param numberOfRows
+     *            the number of changed rows
+     */
+    private void triggerDelayedRepositioning(int firstRowIndex,
+            int numberOfRows) {
+        if (delayedRepositioningStart == null
+                || delayedRepositioningStart > firstRowIndex) {
+            delayedRepositioningStart = firstRowIndex;
+        }
+        if (delayedRepositioningEnd == null
+                || delayedRepositioningEnd < firstRowIndex + numberOfRows) {
+            delayedRepositioningEnd = firstRowIndex + numberOfRows;
+        }
+        if (!delayedRepositioningTriggered) {
+            delayedRepositioningTriggered = true;
+
+            Scheduler.get().scheduleFinally(() -> {
+                // refresh the positions of all affected rows and those
+                // below them, unless all affected rows are outside of the
+                // visual range
+                if (getWidget().getEscalator().getVisibleRowRange()
+                        .intersects(Range.between(delayedRepositioningStart,
+                                delayedRepositioningEnd))) {
+                    getWidget().getEscalator().getBody().updateRowPositions(
+                            delayedRepositioningStart,
+                            getWidget().getEscalator().getBody().getRowCount()
+                                    - delayedRepositioningStart);
+                }
+                delayedRepositioningTriggered = false;
+                delayedRepositioningStart = null;
+                delayedRepositioningEnd = null;
+            });
+        }
+    }
+
+    /**
+     * Makes sure that after the layout phase has finished Grid will be informed
+     * whether any details rows were added or updated. This delay is needed to
+     * allow the details row(s) to get their final size, and it's possible that
+     * more than one operation that might affect that size or details row
+     * existence will be performed (and consequently this method called) before
+     * the check can actually be made.
+     * <p>
+     * If this method is called with value {@code true} at least once within the
+     * delay phase Grid will be told to run any pending position-sensitive
+     * operations it might have in store.
+     * <p>
+     * If this method is only called with value {@code false} within the delay
+     * period Grid will be told to cancel the pending operations.
+     * <p>
+     * If this method isn't called at all, Grid won't be instructed to either
+     * trigger the pending operations or cancel them and hence they remain in a
+     * pending state.
+     *
+     * @param newOrUpdatedDetails
+     *            {@code true} if the calling operation added or updated
+     *            details, {@code false} otherwise
+     */
+    private void markDetailsAddedOrUpdatedForDelayedAlertToGrid(
+            boolean newOrUpdatedDetails) {
+        if (newOrUpdatedDetails) {
+            delayedDetailsAddedOrUpdated = true;
+        }
+        if (!delayedDetailsAddedOrUpdatedAlertTriggered) {
+            delayedDetailsAddedOrUpdatedAlertTriggered = true;
+            Scheduler.get().scheduleFinally(() -> {
+                getParent().detailsRefreshed(delayedDetailsAddedOrUpdated);
+                delayedDetailsAddedOrUpdatedAlertTriggered = false;
+                delayedDetailsAddedOrUpdated = false;
+            });
+        }
+    }
+
     private void detachIfNeeded(int rowIndex, String id) {
         if (indexToDetailConnectorId.containsKey(rowIndex)) {
             if (indexToDetailConnectorId.get(rowIndex).equals(id)) {
@@ -256,8 +543,8 @@ public class DetailsManagerConnector extends AbstractExtensionConnector {
         dataChangeRegistration.remove();
         dataChangeRegistration = null;
 
-        spacerVisibilityChangeRegistration.removeHandler();
         spacerIndexChangedHandlerRegistration.removeHandler();
+        rowVisibilityChangeHandlerRegistration.removeHandler();
 
         indexToDetailConnectorId.clear();
     }
@@ -284,8 +571,7 @@ public class DetailsManagerConnector extends AbstractExtensionConnector {
      * @return connector id; {@code null} if row or id is not found
      */
     private String getDetailsComponentConnectorId(int rowIndex) {
-        JsonObject row = getParent().getWidget().getDataSource()
-                .getRow(rowIndex);
+        JsonObject row = getWidget().getDataSource().getRow(rowIndex);
 
         if (row == null || !row.hasKey(GridState.JSONKEY_DETAILS_VISIBLE)
                 || row.getString(GridState.JSONKEY_DETAILS_VISIBLE).isEmpty()) {
@@ -300,20 +586,29 @@ public class DetailsManagerConnector extends AbstractExtensionConnector {
     }
 
     /**
-     * Schedules a deferred opening for new details components.
+     * Refreshes the existence of details components within the given range, and
+     * gives a delayed notice to Grid if any got added or updated.
      */
-    private void refreshDetails() {
-        if (refreshing) {
+    private void refreshDetailsVisibilityWithRange(Range rangeToRefresh) {
+        if (!getState().hasDetailsGenerator) {
+            markDetailsAddedOrUpdatedForDelayedAlertToGrid(false);
             return;
         }
+        boolean newOrUpdatedDetails = false;
 
-        refreshing = true;
-        Scheduler.get().scheduleFinally(this::refreshDetailsVisibility);
-    }
+        // Don't update the latestVisibleRowRange class variable here, the
+        // calling method should take care of that if relevant.
+        Range currentVisibleRowRange = getWidget().getEscalator()
+                .getVisibleRowRange();
+
+        Range[] partitions = currentVisibleRowRange
+                .partitionWith(rangeToRefresh);
+
+        // only inspect the range where visible and refreshed rows overlap
+        Range intersectingRange = partitions[1];
 
-    private void refreshDetailsVisibility() {
-        boolean shownDetails = false;
-        for (int i = 0; i < getWidget().getDataSource().size(); ++i) {
+        for (int i = intersectingRange.getStart(); i < intersectingRange
+                .getEnd(); ++i) {
             String id = getDetailsComponentConnectorId(i);
 
             detachIfNeeded(i, id);
@@ -324,9 +619,98 @@ public class DetailsManagerConnector extends AbstractExtensionConnector {
 
             indexToDetailConnectorId.put(i, id);
             getWidget().setDetailsVisible(i, true);
-            shownDetails = true;
+            newOrUpdatedDetails = true;
+        }
+
+        markDetailsAddedOrUpdatedForDelayedAlertToGrid(newOrUpdatedDetails);
+    }
+
+    private void detachOldAndRefreshCurrentDetails() {
+        Range[] partitions = availableRowRange
+                .partitionWith(latestVisibleRowRange);
+        Range availableAndVisible = partitions[1];
+
+        detachExcludingRange(availableAndVisible);
+
+        boolean newOrUpdatedDetails = refreshRange(availableAndVisible);
+
+        markDetailsAddedOrUpdatedForDelayedAlertToGrid(newOrUpdatedDetails);
+    }
+
+    private void detachExcludingRange(Range keep) {
+        // remove all spacers that are no longer in range
+        for (Integer existingIndex : indexToDetailConnectorId.keySet()) {
+            if (!keep.contains(existingIndex)) {
+                detachDetails(existingIndex);
+            }
+        }
+    }
+
+    private boolean refreshRange(Range rangeToRefresh) {
+        // make sure all spacers that are currently in range are up to date
+        boolean newOrUpdatedDetails = false;
+        for (int i = rangeToRefresh.getStart(); i < rangeToRefresh
+                .getEnd(); ++i) {
+            int rowIndex = i;
+            if (refreshDetails(rowIndex)) {
+                newOrUpdatedDetails = true;
+            }
+        }
+        return newOrUpdatedDetails;
+    }
+
+    private void detachDetails(int rowIndex) {
+        String id = indexToDetailConnectorId.remove(rowIndex);
+        if (id != null) {
+            ComponentConnector connector = (ComponentConnector) ConnectorMap
+                    .get(getConnection()).getConnector(id);
+            if (connector != null) {
+                Element element = connector.getWidget().getElement();
+                elementToResizeCommand.remove(element);
+                getLayoutManager().removeElementResizeListener(element,
+                        detailsRowResizeListener);
+            }
+        }
+        getWidget().setDetailsVisible(rowIndex, false);
+    }
+
+    private boolean refreshDetails(int rowIndex) {
+        String id = getDetailsComponentConnectorId(rowIndex);
+        String oldId = indexToDetailConnectorId.get(rowIndex);
+        if ((oldId == null && id == null)
+                || (oldId != null && oldId.equals(id))) {
+            // nothing to update, move along
+            return false;
+        }
+        boolean newOrUpdatedDetails = false;
+        if (oldId != null) {
+            // Details have been hidden or updated, listeners attached
+            // to the old component need to be removed
+            ComponentConnector connector = (ComponentConnector) ConnectorMap
+                    .get(getConnection()).getConnector(oldId);
+            if (connector != null) {
+                Element element = connector.getWidget().getElement();
+                elementToResizeCommand.remove(element);
+                getLayoutManager().removeElementResizeListener(element,
+                        detailsRowResizeListener);
+            }
+            if (id == null) {
+                // hidden, clear reference
+                getWidget().setDetailsVisible(rowIndex, false);
+                indexToDetailConnectorId.remove(rowIndex);
+            } else {
+                // updated, replace reference
+                indexToDetailConnectorId.put(rowIndex, id);
+                newOrUpdatedDetails = true;
+            }
+        } else {
+            // new Details content, listeners will get attached to the connector
+            // when Escalator requests for the Details through
+            // CustomDetailsGenerator#getDetails(int)
+            indexToDetailConnectorId.put(rowIndex, id);
+            newOrUpdatedDetails = true;
+            getWidget().setDetailsVisible(rowIndex, true);
         }
-        refreshing = false;
-        getParent().detailsRefreshed(shownDetails);
+        return newOrUpdatedDetails;
     }
 }
index 62e04fdb9c650debd55730364034c3d6b022cb2e..74a584aa5b6db22028224e2bf5d4a50601ef8a23 100644 (file)
@@ -87,6 +87,61 @@ public class GridConnector extends AbstractListingConnector
 
     private Set<Runnable> refreshDetailsCallbacks = new HashSet<>();
 
+    /**
+     * Server-to-client RPC implementation for GridConnector.
+     * <p>
+     * The scrolling methods must trigger the scrolling only after any potential
+     * resizing or other similar action triggered from the server side within
+     * the same round trip has had a chance to happen, so there needs to be a
+     * delay. The delay is done with <code>scheduleFinally</code> rather than
+     * <code>scheduleDeferred</code> because the latter has been known to cause
+     * flickering in Grid.
+     *
+     */
+    private class GridConnectorClientRpc implements GridClientRpc {
+        private final Grid<JsonObject> grid;
+
+        private GridConnectorClientRpc(Grid<JsonObject> grid) {
+            this.grid = grid;
+        }
+
+        @Override
+        public void scrollToRow(int row, ScrollDestination destination) {
+            Scheduler.get().scheduleFinally(() -> {
+                grid.scrollToRow(row, destination);
+                // Add details refresh listener and handle possible detail
+                // for scrolled row.
+                addDetailsRefreshCallback(() -> {
+                    if (rowHasDetails(row)) {
+                        grid.scrollToRow(row, destination);
+                    }
+                });
+            });
+        }
+
+        @Override
+        public void scrollToStart() {
+            Scheduler.get().scheduleFinally(() -> grid.scrollToStart());
+        }
+
+        @Override
+        public void scrollToEnd() {
+            Scheduler.get().scheduleFinally(() -> {
+                grid.scrollToEnd();
+                addDetailsRefreshCallback(() -> {
+                    if (rowHasDetails(grid.getDataSource().size() - 1)) {
+                        grid.scrollToEnd();
+                    }
+                });
+            });
+        }
+
+        @Override
+        public void recalculateColumnWidths() {
+            grid.recalculateColumnWidths();
+        }
+    }
+
     private class ItemClickHandler
             implements BodyClickHandler, BodyDoubleClickHandler {
 
@@ -217,41 +272,7 @@ public class GridConnector extends AbstractListingConnector
         grid.setHeaderVisible(!grid.isHeaderVisible());
         grid.setFooterVisible(!grid.isFooterVisible());
 
-        registerRpc(GridClientRpc.class, new GridClientRpc() {
-
-            @Override
-            public void scrollToRow(int row, ScrollDestination destination) {
-                Scheduler.get().scheduleFinally(
-                        () -> grid.scrollToRow(row, destination));
-                // Add details refresh listener and handle possible detail for
-                // scrolled row.
-                addDetailsRefreshCallback(() -> {
-                    if (rowHasDetails(row)) {
-                        grid.scrollToRow(row, destination);
-                    }
-                });
-            }
-
-            @Override
-            public void scrollToStart() {
-                Scheduler.get().scheduleFinally(() -> grid.scrollToStart());
-            }
-
-            @Override
-            public void scrollToEnd() {
-                Scheduler.get().scheduleFinally(() -> grid.scrollToEnd());
-                addDetailsRefreshCallback(() -> {
-                    if (rowHasDetails(grid.getDataSource().size() - 1)) {
-                        grid.scrollToEnd();
-                    }
-                });
-            }
-
-            @Override
-            public void recalculateColumnWidths() {
-                grid.recalculateColumnWidths();
-            }
-        });
+        registerRpc(GridClientRpc.class, new GridConnectorClientRpc(grid));
 
         grid.addSortHandler(this::handleSortEvent);
         grid.setRowStyleGenerator(rowRef -> {
index 7c51bee86f5e8decd49a6091b720b5c10c9ed83e..e8b0335a282b0ffa36b1216b41af1b640e705cab 100644 (file)
@@ -131,6 +131,20 @@ public interface RowContainer {
         public void removeRows(int index, int numberOfRows)
                 throws IndexOutOfBoundsException, IllegalArgumentException;
 
+        /**
+         * Recalculates and updates the positions of rows and spacers within the
+         * given range and ensures there is no gap below the rows if there are
+         * enough rows to fill the space. Recalculates the scrollbars for
+         * virtual viewport.
+         *
+         * @param index
+         *            logical index of the first row to reposition
+         * @param numberOfRows
+         *            the number of rows to reposition
+         * @since 8.9
+         */
+        public void updateRowPositions(int index, int numberOfRows);
+
         /**
          * Sets a callback function that is executed when new rows are added to
          * the escalator.
index d926e0e2be901509dc81f0f2a1f24d4e6da241b2..9bee0bbeec3e5d8b8795f511428041ae704424c9 100644 (file)
@@ -85,6 +85,7 @@ import com.vaadin.client.widget.escalator.RowContainer.BodyRowContainer;
 import com.vaadin.client.widget.escalator.RowVisibilityChangeEvent;
 import com.vaadin.client.widget.escalator.RowVisibilityChangeHandler;
 import com.vaadin.client.widget.escalator.ScrollbarBundle;
+import com.vaadin.client.widget.escalator.ScrollbarBundle.Direction;
 import com.vaadin.client.widget.escalator.ScrollbarBundle.HorizontalScrollbarBundle;
 import com.vaadin.client.widget.escalator.ScrollbarBundle.VerticalScrollbarBundle;
 import com.vaadin.client.widget.escalator.Spacer;
@@ -160,6 +161,9 @@ import com.vaadin.shared.util.SharedUtil;
  BodyRowContainerImpl never displays large data sources
  entirely in the DOM, a physical index usually has no
  apparent direct relationship with its logical index.
+ This is the sectionRowIndex in TableRowElements.
+ RowIndex in TableRowElements displays the physical index
+ of all row elements, headers and footers included.
 
  VISUAL INDEX is the index relating to the order that you
  see a row in, in the browser, as it is rendered. The
@@ -183,6 +187,13 @@ import com.vaadin.shared.util.SharedUtil;
  order. See BodyRowContainerImpl.DeferredDomSorter for more
  about that.
 
+ It should be noted that the entire visual range is not
+ necessarily in view at any given time, although it should be
+ optimised to not exceed the maximum amount of rows that can
+ theoretically fit within the viewport when their associated
+ spacers have zero height, except by the two rows that are
+ required for tab navigation to work.
+
  */
 
 /**
@@ -748,13 +759,13 @@ public class Escalator extends Widget
         /*-{
             var vScroll = esc.@com.vaadin.client.widgets.Escalator::verticalScrollbar;
             var vScrollElem = vScroll.@com.vaadin.client.widget.escalator.ScrollbarBundle::getElement()();
-
+        
             var hScroll = esc.@com.vaadin.client.widgets.Escalator::horizontalScrollbar;
             var hScrollElem = hScroll.@com.vaadin.client.widget.escalator.ScrollbarBundle::getElement()();
-
+        
             return $entry(function(e) {
                 var target = e.target;
-
+        
                 // in case the scroll event was native (i.e. scrollbars were dragged, or
                 // the scrollTop/Left was manually modified), the bundles have old cache
                 // values. We need to make sure that the caches are kept up to date.
@@ -775,29 +786,29 @@ public class Escalator extends Widget
             return $entry(function(e) {
                 var deltaX = e.deltaX ? e.deltaX : -0.5*e.wheelDeltaX;
                 var deltaY = e.deltaY ? e.deltaY : -0.5*e.wheelDeltaY;
-
+        
                 // Delta mode 0 is in pixels; we don't need to do anything...
-
+        
                 // A delta mode of 1 means we're scrolling by lines instead of pixels
                 // We need to scale the number of lines by the default line height
                 if (e.deltaMode === 1) {
                     var brc = esc.@com.vaadin.client.widgets.Escalator::body;
                     deltaY *= brc.@com.vaadin.client.widgets.Escalator.AbstractRowContainer::getDefaultRowHeight()();
                 }
-
+        
                 // Other delta modes aren't supported
                 if ((e.deltaMode !== undefined) && (e.deltaMode >= 2 || e.deltaMode < 0)) {
                     var msg = "Unsupported wheel delta mode \"" + e.deltaMode + "\"";
-
+        
                     // Print warning message
                     esc.@com.vaadin.client.widgets.Escalator::logWarning(*)(msg);
                 }
-
+        
                 // IE8 has only delta y
                 if (isNaN(deltaY)) {
                     deltaY = -0.5*e.wheelDelta;
                 }
-
+        
                 @com.vaadin.client.widgets.Escalator.JsniUtil::moveScrollFromEvent(*)(esc, deltaX, deltaY, e);
             });
         }-*/;
@@ -1120,26 +1131,8 @@ public class Escalator extends Widget
 
         public void scrollToRow(final int rowIndex,
                 final ScrollDestination destination, final double padding) {
-
-            final double targetStartPx = (body.getDefaultRowHeight() * rowIndex)
-                    + body.spacerContainer
-                            .getSpacerHeightsSumUntilIndex(rowIndex);
-            final double targetEndPx = targetStartPx
-                    + body.getDefaultRowHeight();
-
-            final double viewportStartPx = getScrollTop();
-            final double viewportEndPx = viewportStartPx
-                    + body.getHeightOfSection();
-
-            final double scrollTop = getScrollPos(destination, targetStartPx,
-                    targetEndPx, viewportStartPx, viewportEndPx, padding);
-
-            /*
-             * note that it doesn't matter if the scroll would go beyond the
-             * content, since the browser will adjust for that, and everything
-             * falls into line accordingly.
-             */
-            setScrollTop(scrollTop);
+            body.scrollToRowSpacerOrBoth(rowIndex, destination, padding,
+                    ScrollType.ROW);
         }
     }
 
@@ -1411,6 +1404,8 @@ public class Escalator extends Widget
          *
          * @param tr
          *            the row element to remove.
+         * @param logicalRowIndex
+         *            logical index of the row that is to be removed
          */
         protected void paintRemoveRow(final TableRowElement tr,
                 final int logicalRowIndex) {
@@ -2117,10 +2112,16 @@ public class Escalator extends Widget
             positions.remove(tr);
         }
 
+        /**
+         * Triggers delayed auto-detection of default row height if it hasn't
+         * been set by that point and the Escalator is both attached and
+         * displayed.
+         */
         public void autodetectRowHeightLater() {
             autodetectingRowHeightLater = true;
             Scheduler.get().scheduleFinally(() -> {
-                if (defaultRowHeightShouldBeAutodetected && isAttached()) {
+                if (defaultRowHeightShouldBeAutodetected && isAttached()
+                        && WidgetUtil.isDisplayed(getElement())) {
                     autodetectRowHeightNow();
                     defaultRowHeightShouldBeAutodetected = false;
                 }
@@ -2143,9 +2144,15 @@ public class Escalator extends Widget
             }
         }
 
+        /**
+         * Auto-detect row height immediately, if possible. If Escalator isn't
+         * attached and displayed yet, auto-detecting cannot be performed
+         * correctly. In such cases auto-detecting is left to wait for these
+         * conditions to change, and will be performed when they do.
+         */
         public void autodetectRowHeightNow() {
-            if (!isAttached()) {
-                // Run again when attached
+            if (!isAttached() || !WidgetUtil.isDisplayed(getElement())) {
+                // Run again when attached and displayed
                 defaultRowHeightShouldBeAutodetected = true;
                 return;
             }
@@ -2183,8 +2190,8 @@ public class Escalator extends Widget
 
             /*
              * Ensure that element is not root nor the direct descendant of root
-             * (a row) and ensure the element is inside the dom hierarchy of the
-             * root element. If not, return.
+             * (a row or spacer) and ensure the element is inside the dom
+             * hierarchy of the root element. If not, return null.
              */
             if (root == element || element.getParentElement() == root
                     || !root.isOrHasChild(element)) {
@@ -2317,6 +2324,9 @@ public class Escalator extends Widget
          * @return the logical index of the given element
          */
         public int getLogicalRowIndex(final TableRowElement tr) {
+            // Note: BodyRowContainerImpl overrides this behaviour, since the
+            // physical index and logical index don't match there. For header
+            // and footer there is a match.
             return tr.getSectionRowIndex();
         };
 
@@ -2599,6 +2609,17 @@ public class Escalator extends Widget
          */
         private Consumer<List<TableRowElement>> newEscalatorRowCallback;
 
+        /**
+         * Set the logical index of the first dom row in visual order.
+         * <p>
+         * NOTE: this is not necessarily the first dom row in the dom tree, just
+         * the one positioned to the top with CSS. See maintenance notes at the
+         * top of this class for further information.
+         *
+         * @param topRowLogicalIndex
+         *            logical index of the first dom row in visual order, might
+         *            not match the dom tree order
+         */
         private void setTopRowLogicalIndex(int topRowLogicalIndex) {
             if (LogConfiguration.loggingIsEnabled(Level.INFO)) {
                 Logger.getLogger("Escalator.BodyRowContainer")
@@ -2618,10 +2639,33 @@ public class Escalator extends Widget
             this.topRowLogicalIndex = topRowLogicalIndex;
         }
 
+        /**
+         * Returns the logical index of the first dom row in visual order. This
+         * also gives the offset between the logical and visual indexes.
+         * <p>
+         * NOTE: this is not necessarily the first dom row in the dom tree, just
+         * the one positioned to the top with CSS. See maintenance notes at the
+         * top of this class for further information.
+         *
+         * @return logical index of the first dom row in visual order, might not
+         *         match the dom tree order
+         */
         public int getTopRowLogicalIndex() {
             return topRowLogicalIndex;
         }
 
+        /**
+         * Updates the logical index of the first dom row in visual order with
+         * the given difference.
+         * <p>
+         * NOTE: this is not necessarily the first dom row in the dom tree, just
+         * the one positioned to the top with CSS. See maintenance notes at the
+         * top of this class for further information.
+         *
+         * @param diff
+         *            the amount to increase or decrease the logical index of
+         *            the first dom row in visual order
+         */
         private void updateTopRowLogicalIndex(int diff) {
             setTopRowLogicalIndex(topRowLogicalIndex + diff);
         }
@@ -2690,6 +2734,8 @@ public class Escalator extends Widget
 
         private final SpacerContainer spacerContainer = new SpacerContainer();
 
+        private boolean insertingOrRemoving = false;
+
         public BodyRowContainerImpl(final TableSectionElement bodyElement) {
             super(bodyElement);
         }
@@ -2724,131 +2770,265 @@ public class Escalator extends Widget
 
             // TODO [[mpixscroll]]
             final double scrollTop = tBodyScrollTop;
-            final double viewportOffset = topElementPosition - scrollTop;
+            final double sectionHeight = getHeightOfSection();
 
             /*
-             * TODO [[optimize]] this if-else can most probably be refactored
-             * into a neater block of code
+             * Calculate how the visual range is situated in relation to the
+             * viewport. Negative value means part of visual range is hidden
+             * above or below the viewport, positive value means there is a gap
+             * at the top or the bottom of the viewport, zero means exact match.
+             * If there is a gap, some rows that are out of view may need to be
+             * recycled from the opposite end.
              */
+            final double viewportOffsetTop = topElementPosition - scrollTop;
+            final double viewportOffsetBottom = scrollTop + sectionHeight
+                    - getRowTop(
+                            getTopRowLogicalIndex() + visualRowOrder.size());
 
-            if (viewportOffset > 0) {
-                // there's empty room on top
-
-                double rowPx = getRowHeightsSumBetweenPx(scrollTop,
-                        topElementPosition);
-                int originalRowsToMove = (int) Math
-                        .ceil(rowPx / getDefaultRowHeight());
-                int rowsToMove = Math.min(originalRowsToMove,
-                        visualRowOrder.size());
-
-                final int end = visualRowOrder.size();
-                final int start = end - rowsToMove;
-                final int logicalRowIndex = getLogicalRowIndex(scrollTop);
-
-                moveAndUpdateEscalatorRows(Range.between(start, end), 0,
-                        logicalRowIndex);
-
-                setTopRowLogicalIndex(logicalRowIndex);
+            /*
+             * You can only scroll far enough to leave a gap if visualRowOrder
+             * contains a maximal amount of rows and there is at least one more
+             * outside of the visual range. Consequently there can only be a gap
+             * in one end of the viewport at a time.
+             */
+            if (viewportOffsetTop > 0 || (viewportOffsetTop == 0
+                    && getTopRowLogicalIndex() > 0)) {
+                /*
+                 * Scrolling up. Either there's empty room on top, or there
+                 * should be a buffer row for tab navigation on top, but there
+                 * isn't.
+                 */
+                recycleRowsUpOnScroll(viewportOffsetTop);
 
                 rowsWereMoved = true;
-            } else if (viewportOffset + nextRowBottomOffset <= 0) {
+            } else if ((viewportOffsetBottom > 0
+                    && (viewportOffsetTop + nextRowBottomOffset <= 0))
+                    || (viewportOffsetBottom == 0 && (getTopRowLogicalIndex()
+                            + visualRowOrder.size() < getRowCount() - 2))) {
                 /*
+                 * Scrolling down. Either there's empty room at the bottom and
                  * the viewport has been scrolled more than the topmost visual
-                 * row.
+                 * row, or there should be a buffer row at the bottom to ensure
+                 * tab navigation works, but there isn't.
                  */
+                recycleRowsDownOnScroll(topElementPosition, scrollTop);
+
+                // Moving rows may have removed more spacers and created another
+                // gap, this time the scroll position needs adjusting. The last
+                // row within visual range should be just below the viewport as
+                // a buffer for helping with tab navigation, unless it's the
+                // last row altogether.
+                int lastRowInVisualRange = getTopRowLogicalIndex()
+                        + visualRowOrder.size() - 1;
+                double expectedBottom = getRowTop(lastRowInVisualRange);
+                if (lastRowInVisualRange == getRowCount() - 1) {
+                    expectedBottom += getDefaultRowHeight() + spacerContainer
+                            .getSpacerHeight(lastRowInVisualRange);
+                }
+                if (expectedBottom < scrollTop + sectionHeight) {
+                    double expectedTop = Math.max(0,
+                            expectedBottom - sectionHeight);
+                    setBodyScrollPosition(tBodyScrollLeft, expectedTop);
+                    setScrollTop(expectedTop);
+                }
 
-                double rowPx = getRowHeightsSumBetweenPx(topElementPosition,
-                        scrollTop);
-
-                int originalRowsToMove = (int) (rowPx / getDefaultRowHeight());
-                int rowsToMove = Math.min(originalRowsToMove,
-                        visualRowOrder.size());
+                rowsWereMoved = true;
+            }
 
-                int logicalRowIndex;
-                if (rowsToMove < visualRowOrder.size()) {
-                    /*
-                     * We scroll so little that we can just keep adding the rows
-                     * below the current escalator
-                     */
-                    logicalRowIndex = getLogicalRowIndex(
-                            visualRowOrder.getLast()) + 1;
-                } else {
-                    /*
-                     * Since we're moving all escalator rows, we need to
-                     * calculate the first logical row index from the scroll
-                     * position.
-                     */
-                    logicalRowIndex = getLogicalRowIndex(scrollTop);
-                }
+            if (rowsWereMoved) {
+                fireRowVisibilityChangeEvent();
 
-                /*
-                 * Since we're moving the viewport downwards, the visual index
-                 * is always at the bottom. Note: Due to how
-                 * moveAndUpdateEscalatorRows works, this will work out even if
-                 * we move all the rows, and try to place them "at the end".
-                 */
-                final int targetVisualIndex = visualRowOrder.size();
+                // schedule updating of the physical indexes
+                domSorter.reschedule();
+            }
+        }
 
-                // make sure that we don't move rows over the data boundary
-                boolean aRowWasLeftBehind = false;
-                if (logicalRowIndex + rowsToMove > getRowCount()) {
-                    /*
-                     * TODO [[spacer]]: with constant row heights, there's
-                     * always exactly one row that will be moved beyond the data
-                     * source, when viewport is scrolled to the end. This,
-                     * however, isn't guaranteed anymore once row heights start
-                     * varying.
-                     */
-                    rowsToMove--;
-                    aRowWasLeftBehind = true;
-                }
+        /**
+         * Recycling rows up for {@link #updateEscalatorRowsOnScroll()}.
+         * <p>
+         * NOTE: This method should not be called directly from anywhere else.
+         *
+         * @param viewportOffsetTop
+         */
+        private void recycleRowsUpOnScroll(double viewportOffsetTop) {
+            /*
+             * We can ignore spacers here, because we keep enough rows within
+             * the visual range to fill the viewport completely whether or not
+             * any spacers are shown. There is a small tradeoff of having some
+             * rows rendered even if they are outside of the viewport, but this
+             * simplifies the handling significantly (we can't know what height
+             * any individual spacer has before it has been rendered, which
+             * happens with a delay) and keeps the visual range size stable
+             * while scrolling. Consequently, even if there are spacers within
+             * the current visual range, repositioning this many rows won't
+             * cause us to run out of rows at the bottom.
+             *
+             * The viewportOffsetTop is positive and we round up, and
+             * visualRowOrder can't be empty since we are scrolling, so there is
+             * always going to be at least one row to move. There should also be
+             * one buffer row that actually falls outside of the viewport, in
+             * order to ensure that tabulator navigation works if the rows have
+             * components in them. The buffer row is only needed if filling the
+             * gap doesn't bring us to the top row already.
+             */
+            int rowsToFillTheGap = (int) Math
+                    .ceil(viewportOffsetTop / getDefaultRowHeight());
+            // ensure we don't try to move more rows than are available
+            // above
+            rowsToFillTheGap = Math.min(rowsToFillTheGap,
+                    getTopRowLogicalIndex());
+            // add the buffer row if there is room for it
+            if (rowsToFillTheGap < getTopRowLogicalIndex()) {
+                ++rowsToFillTheGap;
+            }
+            // we may have scrolled up past all the rows and beyond, can
+            // only recycle as many rows as we have
+            int rowsToRecycle = Math.min(rowsToFillTheGap,
+                    visualRowOrder.size());
+
+            // select the rows to recycle from the end of the visual range
+            int end = visualRowOrder.size();
+            int start = end - rowsToRecycle;
 
-                /*
-                 * Make sure we don't scroll beyond the row content. This can
-                 * happen if we have spacers for the last rows.
-                 */
-                rowsToMove = Math.max(0,
-                        Math.min(rowsToMove, getRowCount() - logicalRowIndex));
+            /*
+             * Calculate the logical index for insertion point based on how many
+             * rows would be needed to fill the gap. Because we are recycling
+             * rows to the top the insertion index will also be the new top row
+             * logical index.
+             */
+            int newTopRowLogicalIndex = getTopRowLogicalIndex()
+                    - rowsToFillTheGap;
 
-                moveAndUpdateEscalatorRows(Range.between(0, rowsToMove),
-                        targetVisualIndex, logicalRowIndex);
+            // recycle the rows and move them to their new positions
+            moveAndUpdateEscalatorRows(Range.between(start, end), 0,
+                    newTopRowLogicalIndex);
 
-                if (aRowWasLeftBehind) {
-                    /*
-                     * To keep visualRowOrder as a spatially contiguous block of
-                     * rows, let's make sure that the one row we didn't move
-                     * visually still stays with the pack.
-                     */
-                    final Range strayRow = Range.withOnly(0);
+            setTopRowLogicalIndex(newTopRowLogicalIndex);
+        }
 
-                    /*
-                     * We cannot trust getLogicalRowIndex, because it hasn't yet
-                     * been updated. But since we're leaving rows behind, it
-                     * means we've scrolled to the bottom. So, instead, we
-                     * simply count backwards from the end.
-                     */
-                    final int topLogicalIndex = getRowCount()
-                            - visualRowOrder.size();
-                    moveAndUpdateEscalatorRows(strayRow, 0, topLogicalIndex);
-                }
+        /**
+         * Recycling rows down for {@link #updateEscalatorRowsOnScroll()}.
+         * <p>
+         * NOTE: This method should not be called directly from anywhere else.
+         *
+         * @param topElementPosition
+         * @param scrollTop
+         */
+        private void recycleRowsDownOnScroll(double topElementPosition,
+                double scrollTop) {
+            /*
+             * It's better to have any extra rows below than above, so move as
+             * many of them as possible regardless of how many are needed to
+             * fill the gap, as long as one buffer row remains at the top. It
+             * should not be possible to scroll down enough to create a gap
+             * without it being possible to recycle rows to fill the gap, so
+             * viewport itself doesn't need adjusting no matter what.
+             */
 
-                final int naiveNewLogicalIndex = getTopRowLogicalIndex()
-                        + originalRowsToMove;
-                final int maxLogicalIndex = getRowCount()
-                        - visualRowOrder.size();
-                setTopRowLogicalIndex(
-                        Math.min(naiveNewLogicalIndex, maxLogicalIndex));
+            // we already have the rows and spacers here and we don't want
+            // to recycle rows that are going to stay visible, so the
+            // spacers have to be taken into account
+            double extraRowPxAbove = getRowHeightsSumBetweenPxExcludingSpacers(
+                    topElementPosition, scrollTop);
+
+            // how many rows fit within that extra space and can be
+            // recycled, rounded towards zero to avoid moving any partially
+            // visible rows
+            int rowsToCoverTheExtra = (int) Math
+                    .floor(extraRowPxAbove / getDefaultRowHeight());
+            // leave one to ensure there is a buffer row to help with tab
+            // navigation
+            if (rowsToCoverTheExtra > 0) {
+                --rowsToCoverTheExtra;
+            }
+            /*
+             * Don't move more rows than there are to move, but also don't move
+             * more rows than should exist at the bottom. However, it's not
+             * possible to scroll down beyond available rows, so there is always
+             * at least one row to recycle.
+             */
+            int rowsToRecycle = Math.min(
+                    Math.min(rowsToCoverTheExtra, visualRowOrder.size()),
+                    getRowCount() - getTopRowLogicalIndex()
+                            - visualRowOrder.size());
+
+            // are only some of the rows getting recycled instead of all
+            // of them
+            boolean partialMove = rowsToRecycle < visualRowOrder.size();
+
+            // calculate the logical index where the rows should be moved
+            int logicalTargetIndex;
+            if (partialMove) {
+                /*
+                 * We scroll so little that we can just keep adding the rows
+                 * immediately below the current escalator.
+                 */
+                logicalTargetIndex = getTopRowLogicalIndex()
+                        + visualRowOrder.size();
+            } else {
+                /*
+                 * Since all escalator rows are getting recycled all spacers are
+                 * going to get removed and the calculations have to ignore the
+                 * spacers again in order to figure out which rows are to be
+                 * displayed. In practice we may end up scrolling further down
+                 * than the scroll position indicated initially as the spacers
+                 * that get removed give room for more rows than expected.
+                 *
+                 * We can rely on calculations here because there won't be any
+                 * old rows left to end up mismatched with.
+                 */
+                logicalTargetIndex = (int) Math
+                        .floor(scrollTop / getDefaultRowHeight());
 
-                rowsWereMoved = true;
+                /*
+                 * Make sure we don't try to move rows below the actual row
+                 * count, even if some of the rows end up hidden at the top as a
+                 * result. This won't leave us with any old rows in any case,
+                 * because we already checked earlier that there is room to
+                 * recycle all the rows. It's only a question of how the new
+                 * visual range gets positioned in relation to the viewport.
+                 */
+                if (logicalTargetIndex
+                        + visualRowOrder.size() > getRowCount()) {
+                    logicalTargetIndex = getRowCount() - visualRowOrder.size();
+                }
             }
 
-            if (rowsWereMoved) {
-                fireRowVisibilityChangeEvent();
-                domSorter.reschedule();
+            /*
+             * Recycle the rows and move them to their new positions. Since we
+             * are moving the viewport downwards, the visual target index is
+             * always at the bottom and matches the length of the visual range.
+             * Note: Due to how moveAndUpdateEscalatorRows works, this will work
+             * out even if we move all the rows, and try to place them
+             * "at the end".
+             */
+            moveAndUpdateEscalatorRows(Range.between(0, rowsToRecycle),
+                    visualRowOrder.size(), logicalTargetIndex);
+
+            // top row logical index needs to be updated differently
+            // depending on which update strategy was used, since the rows
+            // are being moved down
+            if (partialMove) {
+                // move down by the amount of recycled rows
+                updateTopRowLogicalIndex(rowsToRecycle);
+            } else {
+                // the insertion index is the new top row logical index
+                setTopRowLogicalIndex(logicalTargetIndex);
             }
         }
 
-        private double getRowHeightsSumBetweenPx(double y1, double y2) {
+        /**
+         * Calculates how much of the given range contains only rows with
+         * spacers excluded.
+         *
+         * @param y1
+         *            start position
+         * @param y2
+         *            end position
+         * @return position difference excluding any space taken up by spacers
+         */
+        private double getRowHeightsSumBetweenPxExcludingSpacers(double y1,
+                double y2) {
             assert y1 < y2 : "y1 must be smaller than y2";
 
             double viewportPx = y2 - y1;
@@ -2859,14 +3039,11 @@ public class Escalator extends Widget
             return viewportPx - spacerPx;
         }
 
-        private int getLogicalRowIndex(final double px) {
-            double rowPx = px - spacerContainer.getSpacerHeightsSumUntilPx(px);
-            return (int) (rowPx / getDefaultRowHeight());
-        }
-
         @Override
         public void insertRows(int index, int numberOfRows) {
+            insertingOrRemoving = true;
             super.insertRows(index, numberOfRows);
+            insertingOrRemoving = false;
 
             if (heightMode == HeightMode.UNDEFINED) {
                 setHeightByRows(getRowCount());
@@ -2875,7 +3052,9 @@ public class Escalator extends Widget
 
         @Override
         public void removeRows(int index, int numberOfRows) {
+            insertingOrRemoving = true;
             super.removeRows(index, numberOfRows);
+            insertingOrRemoving = false;
 
             if (heightMode == HeightMode.UNDEFINED) {
                 setHeightByRows(getRowCount());
@@ -2885,120 +3064,364 @@ public class Escalator extends Widget
         @Override
         protected void paintInsertRows(final int index,
                 final int numberOfRows) {
-            if (numberOfRows == 0) {
+            assert index >= 0
+                    && index < getRowCount() : "Attempting to insert a row "
+                            + "outside of the available range.";
+            assert numberOfRows > 0 : "Attempting to insert a non-positive "
+                    + "amount of rows, something must be wrong.";
+
+            if (numberOfRows <= 0) {
                 return;
             }
+            /*
+             * NOTE: this method handles and manipulates logical, visual, and
+             * physical indexes a lot. If you don't remember what those mean and
+             * how they relate to each other, see the top of this class for
+             * Maintenance Notes.
+             *
+             * At the beginning of this method the logical index of the data
+             * provider has already been updated to include the new rows, but
+             * visual and physical indexes have not, nor has the spacer indexing
+             * been updated, and the topRowLogicalIndex may be out of date as
+             * well.
+             */
 
-            spacerContainer.shiftSpacersByRows(index, numberOfRows);
+            // top of visible area before any rows are actually added
+            double scrollTop = getScrollTop();
+
+            // logical index of the first row within the visual range before any
+            // rows are actually added
+            int oldTopRowLogicalIndex = getTopRowLogicalIndex();
+
+            // length of the visual range before any rows are actually added
+            int oldVisualRangeLength = visualRowOrder.size();
 
             /*
-             * TODO: this method should probably only add physical rows, and not
-             * populate them - let everything be populated as appropriate by the
-             * logic that follows.
+             * If there is room for more dom rows within the maximum visual
+             * range, add them. Calling this method repositions all the rows and
+             * spacers below the insertion point and updates the spacer indexes
+             * accordingly.
              *
-             * This also would lead to the fact that paintInsertRows wouldn't
-             * need to return anything.
+             * TODO: Details rows should be added and populated here, since they
+             * have variable heights and affect the position calculations.
+             * Currently that's left to be triggered at the end and with a
+             * delay. If any new spacers exist, everything below them is going
+             * to be repositioned again for every spacer addition.
              */
             final List<TableRowElement> addedRows = fillAndPopulateEscalatorRowsIfNeeded(
-                    index, numberOfRows);
+                    index - oldTopRowLogicalIndex, index, numberOfRows);
+
+            // is the insertion point for new rows below visual range (viewport
+            // is irrelevant)
+            final boolean newRowsInsertedBelowVisualRange = index >= oldVisualRangeLength
+                    + oldTopRowLogicalIndex;
+
+            // is the insertion point for new rows above initial visual range
+            final boolean newRowsInsertedAboveVisualRange = index <= oldTopRowLogicalIndex;
+
+            // is the insertion point for new rows above viewport
+            final boolean newRowsInsertedAboveCurrentViewport = getRowTop(
+                    index) < scrollTop;
+
+            if (newRowsInsertedBelowVisualRange) {
+                /*
+                 * There is no change to scroll position, and all other changes
+                 * to positioning and indexing are out of visual range or
+                 * already done (if addedRows is not empty).
+                 */
+            } else if (newRowsInsertedAboveVisualRange && addedRows.isEmpty()
+                    && newRowsInsertedAboveCurrentViewport) {
+                /*
+                 * This section can only be reached if the insertion point is
+                 * above the visual range, the visual range already covers a
+                 * maximal amount of rows, and we are scrolled down enough that
+                 * the top row is either partially or completely hidden. The
+                 * last two points happen by default if the first row of the
+                 * visual range has any other logical index than zero. Any other
+                 * use cases involving the top row within the visual range need
+                 * different handling.
+                 */
+                paintInsertRowsAboveViewPort(index, numberOfRows,
+                        oldTopRowLogicalIndex);
+            } else if (newRowsInsertedAboveCurrentViewport) {
+                /*
+                 * Rows were inserted within the visual range but above the
+                 * viewport. This includes the use case where the insertion
+                 * point is just above the visual range and we are scrolled down
+                 * a bit but the visual range doesn't have maximal amount of
+                 * rows yet (can only happen with spacers in play), so more rows
+                 * were added to the visual range but no rows need to be
+                 * recycled.
+                 */
+                paintInsertRowsWithinVisualRangeButAboveViewport(index,
+                        numberOfRows, oldTopRowLogicalIndex, addedRows.size());
+            } else {
+                /*
+                 * Rows added within visual range and either within or below the
+                 * viewport. Recycled rows come from the END of the visual
+                 * range.
+                 */
+                paintInsertRowsWithinVisualRangeAndWithinOrBelowViewport(index,
+                        numberOfRows, oldTopRowLogicalIndex, addedRows.size());
+            }
 
             /*
-             * insertRows will always change the number of rows - update the
-             * scrollbar sizes.
+             * Calling insertRows will always change the number of rows - update
+             * the scrollbar sizes. This calculation isn't affected by actual
+             * dom rows amount or contents except for spacer heights. Spacers
+             * that don't fit the visual range are considered to have no height
+             * and might affect scrollbar calculations aversely, but that can't
+             * be avoided since they have unknown and variable heights.
              */
             scroller.recalculateScrollbarsForVirtualViewport();
+        }
+
+        /**
+         * Row insertion handling for {@link #paintInsertRows(int, int)} when
+         * the range will be inserted above the visual range.
+         * <p>
+         * NOTE: This method should not be called directly from anywhere else.
+         *
+         * @param index
+         * @param numberOfRows
+         * @param oldTopRowLogicalIndex
+         */
+        private void paintInsertRowsAboveViewPort(int index, int numberOfRows,
+                int oldTopRowLogicalIndex) {
+            /*
+             * Because there is no need to expand the visual range, no row or
+             * spacer contents get updated. All rows, spacers, and scroll
+             * position simply need to be shifted down accordingly and the
+             * spacer indexes need updating.
+             */
+            spacerContainer.updateSpacerIndexesForRowAndAfter(index,
+                    oldTopRowLogicalIndex + visualRowOrder.size(),
+                    numberOfRows);
+
+            // height of a single row
+            double defaultRowHeight = getDefaultRowHeight();
+
+            // height of new rows, out of visual range so spacers assumed to
+            // have no height
+            double newRowsHeight = numberOfRows * defaultRowHeight;
+
+            // update the positions
+            moveViewportAndContent(index, newRowsHeight, newRowsHeight,
+                    newRowsHeight);
+
+            // top row logical index moves down by the number of new rows
+            updateTopRowLogicalIndex(numberOfRows);
+        }
+
+        /**
+         * Row insertion handling for {@link #paintInsertRows(int, int)} when
+         * the range will be inserted within the visual range above the
+         * viewport.
+         * <p>
+         * NOTE: This method should not be called directly from anywhere else.
+         *
+         * @param index
+         * @param numberOfRows
+         * @param oldTopRowLogicalIndex
+         * @param addedRowCount
+         */
+        private void paintInsertRowsWithinVisualRangeButAboveViewport(int index,
+                int numberOfRows, int oldTopRowLogicalIndex,
+                int addedRowCount) {
+            /*
+             * Unless we are scrolled all the way to the top the visual range is
+             * always out of view because we need a buffer row for tabulator
+             * navigation. Depending on the scroll position and spacers there
+             * might even be several rendered rows above the viewport,
+             * especially when we are scrolled all the way to the bottom.
+             *
+             * Even though the new rows will be initially out of view they still
+             * need to be correctly populated and positioned. Their contents
+             * won't be refreshed if they become visible later on (e.g. when a
+             * spacer gets hidden, which causes more rows to fit within the
+             * viewport) because they are expected to be already up to date.
+             *
+             * Note that it's not possible to insert content so that it's
+             * partially visible at the top. A partially visible row at top will
+             * still be the exact same partially visible row after the
+             * insertion, no matter which side of that row the new content gets
+             * inserted to. This section handles the use case where the new
+             * content is inserted above the partially visible row.
+             *
+             * Because the insertion point is out of view above the viewport,
+             * the only thing that should change for the end user visually is
+             * the scroll handle, which gets a new position and possibly turns a
+             * bit smaller if a lot of rows got inserted.
+             *
+             * From a technical point of view this also means that any rows that
+             * might need to get recycled should be taken from the BEGINNING of
+             * the visual range, above the insertion point. There might still be
+             * some "extra" rows below the viewport as well, but those should be
+             * left alone. They are going to be needed where they are if any
+             * spacers get closed or reduced in size.
+             *
+             * On a practical level we need to tweak the virtual viewport --
+             * scroll handle positions, row and spacer positions, and ensure the
+             * scroll area height is calculated correctly. Viewport should
+             * remain in a fixed position in relation to the existing rows and
+             * display no new rows. If any rows get recycled and have spacers
+             * either before or after the update the height of those spacers
+             * affects the position calculations.
+             *
+             * Insertion point can be anywhere from just before the previous
+             * first row of the visual range to just before the first actually
+             * visible row. The insertion shifts down the content below
+             * insertion point, which excludes any dom rows that remain above
+             * the insertion point after recycling is finished. After the rows
+             * below insertion point have been moved the viewport needs to be
+             * shifted down a similar amount to regain its old relative position
+             * again.
+             *
+             * The visual range only ever contains at most as many rows as would
+             * fit within the viewport without any spacers with one extra row on
+             * both at the top and at the bottom as buffer rows, so the amount
+             * of rows that needs to be checked is always reasonably limited.
+             */
+            // insertion index within the visual range
+            int visualTargetIndex = index - oldTopRowLogicalIndex;
+
+            // how many dom rows before insertion point versus how many new
+            // rows didn't get their own dom rows -- smaller amount
+            // determines how many rows can and need to be recycled
+            int rowsToUpdate = Math.min(visualTargetIndex,
+                    numberOfRows - addedRowCount);
+
+            // height of a single row
+            double defaultRowHeight = getDefaultRowHeight();
+
+            boolean rowVisibilityChanged = false;
+            if (rowsToUpdate > 0) {
+                // recycle the rows and update the positions, adjust
+                // logical index for inserted rows that won't fit within
+                // visual range
+                int logicalIndex = index + numberOfRows - rowsToUpdate;
+                if (visualTargetIndex > 0) {
+                    // move after any added dom rows
+                    moveAndUpdateEscalatorRows(Range.between(0, rowsToUpdate),
+                            visualTargetIndex + addedRowCount, logicalIndex);
+                } else {
+                    // move before any added dom rows
+                    moveAndUpdateEscalatorRows(Range.between(0, rowsToUpdate),
+                            visualTargetIndex, logicalIndex);
+                }
+
+                // adjust viewport down to maintain the initial position
+                double newRowsHeight = numberOfRows * defaultRowHeight;
+                double newSpacerHeights = spacerContainer
+                        .getSpacerHeightsSumUntilIndex(
+                                logicalIndex + rowsToUpdate)
+                        - spacerContainer.getSpacerHeightsSumUntilIndex(index);
 
-            double spacerHeightsSumUntilIndex = spacerContainer
-                    .getSpacerHeightsSumUntilIndex(index);
-            final boolean addedRowsAboveCurrentViewport = index
-                    * getDefaultRowHeight()
-                    + spacerHeightsSumUntilIndex < getScrollTop();
-            final boolean addedRowsBelowCurrentViewport = index
-                    * getDefaultRowHeight()
-                    + spacerHeightsSumUntilIndex > getScrollTop()
-                            + getHeightOfSection();
-
-            if (addedRowsAboveCurrentViewport) {
                 /*
-                 * We need to tweak the virtual viewport (scroll handle
-                 * positions, table "scroll position" and row locations), but
-                 * without re-evaluating any rows.
+                 * FIXME: spacers haven't been added yet and they can cause
+                 * escalator contents to shift after the fact in a way that
+                 * can't be countered for here.
+                 *
+                 * FIXME: verticalScrollbar internal state causes this update to
+                 * fail partially and the next attempt at scrolling causes
+                 * things to jump.
+                 *
+                 * Couldn't find a quick fix to either problem and this use case
+                 * is somewhat marginal so left them here for now.
                  */
+                moveViewportAndContent(null, 0, 0,
+                        newSpacerHeights + newRowsHeight);
 
-                final double yDelta = numberOfRows * getDefaultRowHeight();
-                moveViewportAndContent(yDelta);
-                updateTopRowLogicalIndex(numberOfRows);
-            } else if (addedRowsBelowCurrentViewport) {
-                // NOOP, we already recalculated scrollbars.
+                rowVisibilityChanged = true;
             } else {
-                // some rows were added inside the current viewport
-                final int unupdatedLogicalStart = index + addedRows.size();
-                final int visualOffset = getLogicalRowIndex(
-                        visualRowOrder.getFirst());
+                // no rows to recycle but update the spacer indexes
+                spacerContainer.updateSpacerIndexesForRowAndAfter(index,
+                        index + numberOfRows - addedRowCount,
+                        numberOfRows - addedRowCount);
+
+                double newRowsHeight = numberOfRows * defaultRowHeight;
+                if (addedRowCount > 0) {
+                    // update the viewport, rows and spacers were
+                    // repositioned already by the method for adding dom
+                    // rows
+                    moveViewportAndContent(null, 0, 0, newRowsHeight);
+
+                    rowVisibilityChanged = true;
+                } else {
+                    // all changes are actually above the viewport after
+                    // all, update all positions
+                    moveViewportAndContent(index, newRowsHeight, newRowsHeight,
+                            newRowsHeight);
+                }
+            }
 
+            if (numberOfRows > addedRowCount) {
                 /*
-                 * At this point, we have added new escalator rows, if so
-                 * needed.
-                 *
-                 * If more rows were added than the new escalator rows can
-                 * account for, we need to start to spin the escalator to update
-                 * the remaining rows as well.
+                 * If there are more new rows than how many new dom rows got
+                 * added, the top row logical index necessarily gets shifted
+                 * down by that difference because recycling doesn't replace any
+                 * logical rows, just shifts them off the visual range, and the
+                 * inserted rows that don't fit to the visual range also push
+                 * the other rows down. If every new row got new dom rows as
+                 * well the top row logical index doesn't change, because the
+                 * insertion point was within the visual range.
                  */
-                final int rowsStillNeeded = numberOfRows - addedRows.size();
-
-                if (rowsStillNeeded > 0) {
-                    final Range unupdatedVisual = convertToVisual(
-                            Range.withLength(unupdatedLogicalStart,
-                                    rowsStillNeeded));
-                    final int end = getDomRowCount();
-                    final int start = end - unupdatedVisual.length();
-                    final int visualTargetIndex = unupdatedLogicalStart
-                            - visualOffset;
-                    moveAndUpdateEscalatorRows(Range.between(start, end),
-                            visualTargetIndex, unupdatedLogicalStart);
-
-                    // move the surrounding rows to their correct places.
-                    double rowTop = (unupdatedLogicalStart + (end - start))
-                            * getDefaultRowHeight();
-
-                    // TODO: Get rid of this try/catch block by fixing the
-                    // underlying issue. The reason for this erroneous behavior
-                    // might be that Escalator actually works 'by mistake', and
-                    // the order of operations is, in fact, wrong.
-                    try {
-                        final ListIterator<TableRowElement> i = visualRowOrder
-                                .listIterator(
-                                        visualTargetIndex + (end - start));
-
-                        int logicalRowIndexCursor = unupdatedLogicalStart;
-                        while (i.hasNext()) {
-                            rowTop += spacerContainer
-                                    .getSpacerHeight(logicalRowIndexCursor++);
-
-                            final TableRowElement tr = i.next();
-                            setRowPosition(tr, 0, rowTop);
-                            rowTop += getDefaultRowHeight();
-                        }
-                    } catch (Exception e) {
-                        Logger logger = getLogger();
-                        logger.warning(
-                                "Ignored out-of-bounds row element access");
-                        logger.warning("Escalator state: start=" + start
-                                + ", end=" + end + ", visualTargetIndex="
-                                + visualTargetIndex + ", visualRowOrder.size()="
-                                + visualRowOrder.size());
-                        logger.warning(e.toString());
-                    }
-                }
+                updateTopRowLogicalIndex(numberOfRows - addedRowCount);
+            }
 
+            if (rowVisibilityChanged) {
                 fireRowVisibilityChangeEvent();
+            }
+            if (rowsToUpdate > 0) {
+                // update the physical index
+                sortDomElements();
+            }
+        }
+
+        /**
+         * Row insertion handling for {@link #paintInsertRows(int, int)} when
+         * the range will be inserted within the visual range either within or
+         * below the viewport.
+         * <p>
+         * NOTE: This method should not be called directly from anywhere else.
+         *
+         * @param index
+         * @param numberOfRows
+         * @param oldTopRowLogicalIndex
+         * @param addedRowCount
+         */
+        private void paintInsertRowsWithinVisualRangeAndWithinOrBelowViewport(
+                int index, int numberOfRows, int oldTopRowLogicalIndex,
+                int addedRowCount) {
+            // insertion index within the visual range
+            int visualIndex = index - oldTopRowLogicalIndex;
+
+            // how many dom rows after insertion point versus how many new
+            // rows to add -- smaller amount determines how many rows can or
+            // need to be recycled, excluding the rows that already got new
+            // dom rows
+            int rowsToUpdate = Math.max(
+                    Math.min(visualRowOrder.size() - visualIndex, numberOfRows)
+                            - addedRowCount,
+                    0);
+
+            if (rowsToUpdate > 0) {
+                moveAndUpdateEscalatorRows(
+                        Range.between(visualRowOrder.size() - rowsToUpdate,
+                                visualRowOrder.size()),
+                        visualIndex + addedRowCount, index + addedRowCount);
+
+                fireRowVisibilityChangeEvent();
+
+                // update the physical index
                 sortDomElements();
             }
         }
 
         /**
          * Move escalator rows around, and make sure everything gets
-         * appropriately repositioned and repainted.
+         * appropriately repositioned and repainted. In the case of insertion or
+         * removal, following spacer indexes get updated as well.
          *
          * @param visualSourceRange
          *            the range of rows to move to a new place
@@ -3014,6 +3437,9 @@ public class Escalator extends Widget
             if (visualSourceRange.isEmpty()) {
                 return;
             }
+            int sourceRangeLength = visualSourceRange.length();
+            int domRowCount = getDomRowCount();
+            int rowCount = getRowCount();
 
             assert visualSourceRange.getStart() >= 0 : "Visual source start "
                     + "must be 0 or greater (was "
@@ -3025,18 +3451,18 @@ public class Escalator extends Widget
             assert visualTargetIndex >= 0 : "Visual target must be 0 or greater (was "
                     + visualTargetIndex + ")";
 
-            assert visualTargetIndex <= getDomRowCount() : "Visual target "
+            assert visualTargetIndex <= domRowCount : "Visual target "
                     + "must not be greater than the number of escalator rows (was "
-                    + visualTargetIndex + ", escalator rows " + getDomRowCount()
+                    + visualTargetIndex + ", escalator rows " + domRowCount
                     + ")";
 
             assert logicalTargetIndex
-                    + visualSourceRange.length() <= getRowCount() : "Logical "
+                    + sourceRangeLength <= rowCount : "Logical "
                             + "target leads to rows outside of the data range ("
                             + Range.withLength(logicalTargetIndex,
-                                    visualSourceRange.length())
-                            + " goes beyond "
-                            + Range.withLength(0, getRowCount()) + ")";
+                                    sourceRangeLength)
+                            + " goes beyond " + Range.withLength(0, rowCount)
+                            + ")";
 
             /*
              * Since we move a range into another range, the indices might move
@@ -3050,13 +3476,31 @@ public class Escalator extends Widget
             final int adjustedVisualTargetIndex;
             if (visualSourceRange.getStart() < visualTargetIndex) {
                 adjustedVisualTargetIndex = visualTargetIndex
-                        - visualSourceRange.length();
+                        - sourceRangeLength;
             } else {
                 adjustedVisualTargetIndex = visualTargetIndex;
             }
 
-            if (visualSourceRange.getStart() != adjustedVisualTargetIndex) {
+            int oldTopRowLogicalIndex = getTopRowLogicalIndex();
+
+            // first moved row's logical index before move
+            int oldSourceRangeLogicalStart = oldTopRowLogicalIndex
+                    + visualSourceRange.getStart();
+
+            // new top row logical index
+            int newTopRowLogicalIndex = logicalTargetIndex
+                    - adjustedVisualTargetIndex;
+
+            // variables for update types that require special handling
+            boolean recycledToTop = logicalTargetIndex < oldTopRowLogicalIndex;
+            boolean recycledFromTop = visualSourceRange.getStart() == 0;
+            boolean scrollingUp = recycledToTop
+                    && visualSourceRange.getEnd() == visualRowOrder.size();
+            boolean scrollingDown = recycledFromTop
+                    && logicalTargetIndex >= oldTopRowLogicalIndex
+                            + visualRowOrder.size();
 
+            if (visualSourceRange.getStart() != adjustedVisualTargetIndex) {
                 /*
                  * Reorder the rows to their correct places within
                  * visualRowOrder (unless rows are moved back to their original
@@ -3071,8 +3515,8 @@ public class Escalator extends Widget
                  */
 
                 final List<TableRowElement> removedRows = new ArrayList<>(
-                        visualSourceRange.length());
-                for (int i = 0; i < visualSourceRange.length(); i++) {
+                        sourceRangeLength);
+                for (int i = 0; i < sourceRangeLength; i++) {
                     final TableRowElement tr = visualRowOrder
                             .remove(visualSourceRange.getStart());
                     removedRows.add(tr);
@@ -3080,29 +3524,365 @@ public class Escalator extends Widget
                 visualRowOrder.addAll(adjustedVisualTargetIndex, removedRows);
             }
 
-            { // Refresh the contents of the affected rows
-                final ListIterator<TableRowElement> iter = visualRowOrder
-                        .listIterator(adjustedVisualTargetIndex);
-                for (int logicalIndex = logicalTargetIndex; logicalIndex < logicalTargetIndex
-                        + visualSourceRange.length(); logicalIndex++) {
-                    final TableRowElement tr = iter.next();
-                    refreshRow(tr, logicalIndex);
+            // refresh contents of rows to be recycled, returns the combined
+            // height of the spacers that got removed from visual range
+            double spacerHeightsOfRecycledRowsBefore = refreshRecycledRowContents(
+                    logicalTargetIndex, adjustedVisualTargetIndex,
+                    sourceRangeLength, oldSourceRangeLogicalStart);
+
+            boolean movedDown = adjustedVisualTargetIndex != visualTargetIndex;
+            boolean recycledToOrFromTop = recycledToTop || recycledFromTop;
+
+            // update spacer indexes unless we are scrolling -- with scrolling
+            // the remaining spacers are where they belong, the recycled ones
+            // were already removed, and new ones will be added with delay
+            if (!(scrollingUp || scrollingDown)) {
+                if (recycledToOrFromTop) {
+                    updateSpacerIndexesForMoveWhenRecycledToOrFromTop(
+                            oldSourceRangeLogicalStart, sourceRangeLength,
+                            oldTopRowLogicalIndex, newTopRowLogicalIndex,
+                            recycledFromTop);
+                } else {
+                    updateSpacerIndexesForMoveWhenNotRecycledToOrFromTop(
+                            logicalTargetIndex, oldSourceRangeLogicalStart,
+                            sourceRangeLength, movedDown);
                 }
             }
 
-            { // Reposition the rows that were moved
-                double newRowTop = getRowTop(logicalTargetIndex);
+            // Would be useful if new spacer heights could be determined
+            // here already but their contents are populated with delay.
+            // If the heights ever become available immediately, the
+            // handling that follows needs to be updated to take the new
+            // spacer heights into account.
+
+            repositionMovedRows(adjustedVisualTargetIndex, sourceRangeLength,
+                    newTopRowLogicalIndex);
+
+            // variables for reducing the amount of necessary parameters
+            boolean scrollingDownAndNoSpacersRemoved = scrollingDown
+                    && spacerHeightsOfRecycledRowsBefore <= 0d;
+            boolean spacerHeightsChanged = spacerHeightsOfRecycledRowsBefore > 0d;
+
+            repositionRowsShiftedByTheMove(visualSourceRange, visualTargetIndex,
+                    adjustedVisualTargetIndex, newTopRowLogicalIndex,
+                    scrollingDownAndNoSpacersRemoved, scrollingUp,
+                    recycledToTop);
+
+            repositionRowsBelowMovedAndShiftedIfNeeded(visualSourceRange,
+                    visualTargetIndex, adjustedVisualTargetIndex,
+                    newTopRowLogicalIndex, (scrollingUp || scrollingDown),
+                    recycledToOrFromTop, spacerHeightsChanged);
+        }
+
+        /**
+         * Refresh the contents of the affected rows for
+         * {@link #moveAndUpdateEscalatorRows(Range, int, int)}
+         * <p>
+         * NOTE: This method should not be called directly from anywhere else.
+         *
+         * @param logicalTargetIndex
+         * @param adjustedVisualTargetIndex
+         * @param sourceRangeLength
+         * @param spacerHeightsBeforeMoveTotal
+         * @param oldSourceRangeLogicalStart
+         * @return the combined height of any removed spacers
+         */
+        private double refreshRecycledRowContents(int logicalTargetIndex,
+                int adjustedVisualTargetIndex, int sourceRangeLength,
+                int oldSourceRangeLogicalStart) {
+            final ListIterator<TableRowElement> iter = visualRowOrder
+                    .listIterator(adjustedVisualTargetIndex);
+            double removedSpacerHeights = 0d;
+            for (int i = 0; i < sourceRangeLength; ++i) {
+                final TableRowElement tr = iter.next();
+                int logicalIndex = logicalTargetIndex + i;
+
+                // clear old spacer
+                SpacerContainer.SpacerImpl spacer = spacerContainer
+                        .getSpacer(oldSourceRangeLogicalStart + i);
+                if (spacer != null) {
+                    double spacerHeight = spacer.getHeight();
+                    removedSpacerHeights += spacerHeight;
+                    spacerContainer
+                            .removeSpacer(oldSourceRangeLogicalStart + i);
+                }
 
-                final ListIterator<TableRowElement> iter = visualRowOrder
-                        .listIterator(adjustedVisualTargetIndex);
-                for (int i = 0; i < visualSourceRange.length(); i++) {
-                    final TableRowElement tr = iter.next();
-                    setRowPosition(tr, 0, newRowTop);
+                refreshRow(tr, logicalIndex);
+            }
+            return removedSpacerHeights;
+        }
 
-                    newRowTop += getDefaultRowHeight();
-                    newRowTop += spacerContainer
-                            .getSpacerHeight(logicalTargetIndex + i);
+        /**
+         * Update the spacer indexes to correspond with logical indexes for
+         * {@link #moveAndUpdateEscalatorRows(Range, int, int)} when the move
+         * recycles rows to or from top
+         * <p>
+         * NOTE: This method should not be called directly from anywhere else.
+         *
+         * @param oldSourceRangeLogicalStart
+         * @param sourceRangeLength
+         * @param oldTopRowLogicalIndex
+         * @param newTopRowLogicalIndex
+         * @param recycledFromTop
+         */
+        private void updateSpacerIndexesForMoveWhenRecycledToOrFromTop(
+                int oldSourceRangeLogicalStart, int sourceRangeLength,
+                int oldTopRowLogicalIndex, int newTopRowLogicalIndex,
+                boolean recycledFromTop) {
+            if (recycledFromTop) {
+                // first rows are getting recycled thanks to insertion or
+                // removal, all the indexes below need to be updated
+                // accordingly
+                int indexesToShift;
+                if (newTopRowLogicalIndex != oldTopRowLogicalIndex) {
+                    indexesToShift = newTopRowLogicalIndex
+                            - oldTopRowLogicalIndex;
+                } else {
+                    indexesToShift = -sourceRangeLength;
                 }
+                spacerContainer.updateSpacerIndexesForRowAndAfter(
+                        oldSourceRangeLogicalStart + sourceRangeLength,
+                        oldTopRowLogicalIndex + visualRowOrder.size(),
+                        indexesToShift);
+            } else {
+                // rows recycled to the top, move the remaining spacer
+                // indexes up
+                spacerContainer.updateSpacerIndexesForRowAndAfter(
+                        oldSourceRangeLogicalStart + sourceRangeLength,
+                        getRowCount() + sourceRangeLength, -sourceRangeLength);
+            }
+        }
+
+        /**
+         * Update the spacer indexes to correspond with logical indexes for
+         * {@link #moveAndUpdateEscalatorRows(Range, int, int)} when the move
+         * does not recycle rows to or from top
+         * <p>
+         * NOTE: This method should not be called directly from anywhere else.
+         *
+         * @param logicalTargetIndex
+         * @param oldSourceRangeLogicalStart
+         * @param sourceRangeLength
+         * @param movedDown
+         */
+        private void updateSpacerIndexesForMoveWhenNotRecycledToOrFromTop(
+                int logicalTargetIndex, int oldSourceRangeLogicalStart,
+                int sourceRangeLength, boolean movedDown) {
+            if (movedDown) {
+                // move the shifted spacer indexes up to fill the freed
+                // space
+                spacerContainer.updateSpacerIndexesForRowAndAfter(
+                        oldSourceRangeLogicalStart + sourceRangeLength,
+                        logicalTargetIndex + sourceRangeLength,
+                        -sourceRangeLength);
+            } else {
+                // move the shifted spacer indexes down to fill the freed
+                // space
+                spacerContainer.updateSpacerIndexesForRowAndAfter(
+                        logicalTargetIndex, oldSourceRangeLogicalStart,
+                        sourceRangeLength);
+            }
+        }
+
+        /**
+         * Reposition the rows that were moved for
+         * {@link #moveAndUpdateEscalatorRows(Range, int, int)}
+         * <p>
+         * NOTE: This method should not be called directly from anywhere else.
+         *
+         * @param adjustedVisualTargetIndex
+         * @param sourceRangeLength
+         * @param newTopRowLogicalIndex
+         */
+        private void repositionMovedRows(int adjustedVisualTargetIndex,
+                int sourceRangeLength, int newTopRowLogicalIndex) {
+            int start = adjustedVisualTargetIndex;
+            updateRowPositions(newTopRowLogicalIndex + start, start,
+                    sourceRangeLength);
+        }
+
+        /**
+         * Reposition the rows that were shifted by the move for
+         * {@link #moveAndUpdateEscalatorRows(Range, int, int)}
+         * <p>
+         * NOTE: This method should not be called directly from anywhere else.
+         *
+         * @param visualSourceRange
+         * @param visualTargetIndex
+         * @param adjustedVisualTargetIndex
+         * @param newTopRowLogicalIndex
+         * @param scrollingDownAndNoSpacersRemoved
+         * @param scrollingUp
+         * @param recycledToTop
+         */
+        private void repositionRowsShiftedByTheMove(Range visualSourceRange,
+                int visualTargetIndex, int adjustedVisualTargetIndex,
+                int newTopRowLogicalIndex,
+                boolean scrollingDownAndNoSpacersRemoved, boolean scrollingUp,
+                boolean recycledToTop) {
+            if (visualSourceRange.length() == visualRowOrder.size()) {
+                // all rows got updated and were repositioned already
+                return;
+            }
+            if (scrollingDownAndNoSpacersRemoved || scrollingUp) {
+                // scrolling, no spacers got removed from or added above any
+                // remaining rows so everything is where it belongs already
+                // (there is no check for added spacers because adding happens
+                // with delay, whether any spacers are coming or not they don't
+                // exist yet and thus can't be taken into account here)
+                return;
+            }
+
+            if (adjustedVisualTargetIndex != visualTargetIndex) {
+                // rows moved down, shifted rows need to be moved up
+
+                int start = visualSourceRange.getStart();
+                updateRowPositions(newTopRowLogicalIndex + start, start,
+                        adjustedVisualTargetIndex - start);
+            } else {
+                // rows moved up, shifted rows need to be repositioned
+                // unless it's just a recycling and no spacer heights
+                // above got updated
+
+                if (recycledToTop) {
+                    // rows below the shifted ones need to be moved up (which is
+                    // done in the next helper method) but the shifted rows
+                    // themselves are already where they belong
+                    // (this should only be done if no spacers were added, but
+                    // we can't know that yet so we'll have to adjust for them
+                    // afterwards if any do appear)
+                    return;
+                }
+
+                int start = adjustedVisualTargetIndex
+                        + visualSourceRange.length();
+                updateRowPositions(newTopRowLogicalIndex + start, start,
+                        visualSourceRange.getEnd() - start);
+            }
+        }
+
+        /**
+         * If necessary, reposition the rows that are below those rows that got
+         * moved or shifted for
+         * {@link #moveAndUpdateEscalatorRows(Range, int, int)}
+         * <p>
+         * NOTE: This method should not be called directly from anywhere else.
+         *
+         * @param visualSourceRange
+         * @param visualTargetIndex
+         * @param adjustedVisualTargetIndex
+         * @param newTopRowLogicalIndex
+         * @param scrolling
+         * @param recycledToOrFromTop
+         * @param spacerHeightsChanged
+         */
+        private void repositionRowsBelowMovedAndShiftedIfNeeded(
+                Range visualSourceRange, int visualTargetIndex,
+                int adjustedVisualTargetIndex, int newTopRowLogicalIndex,
+                boolean scrolling, boolean recycledToOrFromTop,
+                boolean spacerHeightsChanged) {
+            /*
+             * There is no need to check if any rows preceding the source and
+             * target range need their positions adjusted, but rows below both
+             * may very well need it if spacer heights changed or rows got
+             * inserted or removed instead of just moved around.
+             *
+             * When scrolling to either direction all the rows already got
+             * processed by earlier stages, there are no unprocessed rows left
+             * either above or below.
+             */
+            if (!scrolling && (recycledToOrFromTop || spacerHeightsChanged)) {
+
+                int firstBelow;
+                if (adjustedVisualTargetIndex != visualTargetIndex) {
+                    // rows moved down
+                    firstBelow = adjustedVisualTargetIndex
+                            + visualSourceRange.length();
+                } else {
+                    // rows moved up
+                    firstBelow = visualSourceRange.getEnd();
+                }
+                updateRowPositions(newTopRowLogicalIndex + firstBelow,
+                        firstBelow, visualRowOrder.size() - firstBelow);
+            }
+        }
+
+        @Override
+        public void updateRowPositions(int index, int numberOfRows) {
+            Range visibleRowRange = getVisibleRowRange();
+            Range rangeToUpdate = Range.withLength(index, numberOfRows);
+            Range intersectingRange = visibleRowRange
+                    .partitionWith(rangeToUpdate)[1];
+
+            if (intersectingRange.isEmpty()) {
+                // no overlap with the visual range, ignore the positioning
+                return;
+            }
+
+            int adjustedIndex = intersectingRange.getStart();
+            int adjustedVisualIndex = adjustedIndex - getTopRowLogicalIndex();
+
+            updateRowPositions(adjustedIndex, adjustedVisualIndex,
+                    intersectingRange.length());
+
+            // make sure there is no unnecessary gap
+            adjustScrollPositionIfNeeded();
+
+            scroller.recalculateScrollbarsForVirtualViewport();
+        }
+
+        /**
+         * Re-calculates and updates the positions of rows and spacers within
+         * the given range. Doesn't touch the scroll positions.
+         *
+         * @param logicalIndex
+         *            logical index of the first row to reposition
+         * @param visualIndex
+         *            visual index of the first row to reposition
+         * @param numberOfRows
+         *            the number of rows to reposition
+         */
+        private void updateRowPositions(int logicalIndex, int visualIndex,
+                int numberOfRows) {
+            double newRowTop = getRowTop(logicalIndex);
+            for (int i = 0; i < numberOfRows; ++i) {
+                TableRowElement tr = visualRowOrder.get(visualIndex + i);
+                setRowPosition(tr, 0, newRowTop);
+                newRowTop += getDefaultRowHeight();
+
+                SpacerContainer.SpacerImpl spacer = spacerContainer
+                        .getSpacer(logicalIndex + i);
+                if (spacer != null) {
+                    spacer.setPosition(0, newRowTop);
+                    newRowTop += spacer.getHeight();
+                }
+            }
+        }
+
+        /**
+         * Checks whether there is an unexpected gap below the visible rows and
+         * adjusts the viewport if necessary.
+         */
+        private void adjustScrollPositionIfNeeded() {
+            double scrollTop = getScrollTop();
+            int firstBelowVisualRange = getTopRowLogicalIndex()
+                    + visualRowOrder.size();
+            double gapBelow = scrollTop + getHeightOfSection()
+                    - getRowTop(firstBelowVisualRange);
+            boolean bufferRowNeeded = gapBelow == 0
+                    && firstBelowVisualRange < getRowCount();
+            if (scrollTop > 0 && (gapBelow > 0 || bufferRowNeeded)) {
+                /*
+                 * This situation can be reached e.g. by removing a spacer.
+                 * Scroll position must be adjusted accordingly but no more than
+                 * there is room to scroll up. If a buffer row is needed make
+                 * sure the last row ends up at least slightly below the
+                 * viewport.
+                 */
+                double adjustedGap = Math.max(gapBelow,
+                        bufferRowNeeded ? 1 : 0);
+                double yDeltaScroll = Math.min(adjustedGap, scrollTop);
+                moveViewportAndContent(null, 0, 0, -yDeltaScroll);
             }
         }
 
@@ -3126,11 +3906,17 @@ public class Escalator extends Widget
          * the row at 20px.</dd>
          * </dl>
          *
+         * @deprecated This method isn't used by Escalator anymore since Vaadin
+         *             8.9 and the general row handling logic has been
+         *             rewritten, so attempting to call this method may lead to
+         *             unexpected consequences. This method is likely to get
+         *             removed soon.
          * @param yDelta
          *            the delta of pixels by which to move the viewport and
          *            content. A positive value moves everything downwards,
          *            while a negative value moves everything upwards
          */
+        @Deprecated
         public void moveViewportAndContent(final double yDelta) {
 
             if (yDelta == 0) {
@@ -3162,48 +3948,155 @@ public class Escalator extends Widget
         }
 
         /**
-         * Adds new physical escalator rows to the DOM at the given index if
-         * there's still a need for more escalator rows.
+         * Move rows, spacers, and/or viewport up or down. For rows and spacers
+         * either everything within visual range is affected (index
+         * {@code null}) or only those from the given row index forward.
+         * <p>
+         * This method does not update spacer indexes.
+         *
+         * @param index
+         *            the logical index from which forward the rows and spacers
+         *            should be updated, or {@code null} if all of them
+         * @param yDeltaRows
+         *            how much rows should be shifted in pixels
+         * @param yDeltaSpacers
+         *            how much spacers should be shifted in pixels
+         * @param yDeltaScroll
+         *            how much scroll position should be shifted in pixels
+         */
+        private void moveViewportAndContent(Integer index,
+                final double yDeltaRows, final double yDeltaSpacers,
+                final double yDeltaScroll) {
+
+            if (!WidgetUtil.pixelValuesEqual(yDeltaScroll, 0d)) {
+                double newTop = tBodyScrollTop + yDeltaScroll;
+                verticalScrollbar.setScrollPos(newTop);
+                setBodyScrollPosition(tBodyScrollLeft, newTop);
+            }
+
+            if (!WidgetUtil.pixelValuesEqual(yDeltaSpacers, 0d)) {
+                Collection<SpacerContainer.SpacerImpl> spacers;
+                if (index == null) {
+                    spacers = spacerContainer.getSpacersAfterPx(tBodyScrollTop,
+                            SpacerInclusionStrategy.PARTIAL);
+                } else {
+                    spacers = spacerContainer.getSpacersForRowAndAfter(index);
+                }
+                for (SpacerContainer.SpacerImpl spacer : spacers) {
+                    spacer.setPositionDiff(0, yDeltaSpacers);
+                }
+            }
+
+            if (!WidgetUtil.pixelValuesEqual(yDeltaRows, 0d)) {
+                if (index == null) {
+                    // move all visible rows to the desired direction
+                    for (TableRowElement tr : visualRowOrder) {
+                        setRowPosition(tr, 0, getRowTop(tr) + yDeltaRows);
+                    }
+                } else {
+                    // move all visible rows, including the index row, to the
+                    // desired direction
+                    shiftRowPositions(index - 1, yDeltaRows);
+                }
+            }
+        }
+
+        /**
+         * Adds new physical escalator rows to the DOM at the given visual index
+         * if there's still a need for more escalator rows.
          * <p>
          * If Escalator already is at (or beyond) max capacity, this method does
          * nothing to the DOM.
+         * <p>
+         * Calling this method repositions all the rows and spacers below the
+         * insertion point.
          *
-         * @param index
-         *            the index at which to add new escalator rows.
-         *            <em>Note:</em>It is assumed that the index is both the
-         *            visual index and the logical index.
+         * @param visualIndex
+         *            the index at which to add new escalator rows to DOM
+         * @param logicalIndex
+         *            the logical index that corresponds with the first new
+         *            escalator row, should usually be the same as visual index
+         *            because there is still need for new rows, but this is not
+         *            always the case e.g. if row height is changed
          * @param numberOfRows
          *            the number of rows to add at <code>index</code>
          * @return a list of the added rows
          */
         private List<TableRowElement> fillAndPopulateEscalatorRowsIfNeeded(
-                final int index, final int numberOfRows) {
+                final int visualIndex, final int logicalIndex,
+                final int numberOfRows) {
 
+            /*
+             * We want to maintain enough rows to fill the entire viewport even
+             * if their spacers have no height. If their spacers do have height
+             * some of these rows may end up outside of the viewport, but that's
+             * ok.
+             */
             final int escalatorRowsStillFit = getMaxVisibleRowCount()
                     - getDomRowCount();
             final int escalatorRowsNeeded = Math.min(numberOfRows,
                     escalatorRowsStillFit);
 
             if (escalatorRowsNeeded > 0) {
+                int rowsBeforeAddition = visualRowOrder.size();
 
+                // this is AbstractRowContainer method and not easily overridden
+                // to consider logical indexes separately from visual indexes,
+                // so as a workaround we create the rows as if those two were
+                // the same and then update the contents if needed
                 final List<TableRowElement> addedRows = paintInsertStaticRows(
-                        index, escalatorRowsNeeded);
-                visualRowOrder.addAll(index, addedRows);
-
-                double y = index * getDefaultRowHeight()
-                        + spacerContainer.getSpacerHeightsSumUntilIndex(index);
-                for (int i = index; i < visualRowOrder.size(); i++) {
-
-                    final TableRowElement tr;
-                    if (i - index < addedRows.size()) {
-                        tr = addedRows.get(i - index);
+                        visualIndex, escalatorRowsNeeded);
+                visualRowOrder.addAll(visualIndex, addedRows);
+
+                if (visualIndex != logicalIndex) {
+                    // row got populated with wrong contents, need to update
+                    int adjustedLogicalIndex = 0;
+                    if (visualIndex == 0) {
+                        // added to the beginning of visual range, use the
+                        // end of insertion range because the beginning might
+                        // not fit completely
+                        adjustedLogicalIndex = logicalIndex + numberOfRows
+                                - addedRows.size();
                     } else {
-                        tr = visualRowOrder.get(i);
+                        // added anywhere else, use the beginning of
+                        // insertion range and the rest of the rows get
+                        // recycled below if there is room for them
+                        adjustedLogicalIndex = logicalIndex;
+                    }
+                    for (int i = 0; i < addedRows.size(); ++i) {
+                        TableRowElement tr = addedRows.get(i);
+                        refreshRow(tr, adjustedLogicalIndex + i);
                     }
+                }
 
-                    setRowPosition(tr, 0, y);
-                    y += getDefaultRowHeight();
-                    y += spacerContainer.getSpacerHeight(i);
+                // if something is getting inserted instead of just being
+                // brought to visual range, the rows below the insertion point
+                // need to have their spacer indexes updated accordingly
+                if (logicalIndex >= getTopRowLogicalIndex()
+                        && visualIndex < rowsBeforeAddition) {
+                    spacerContainer.updateSpacerIndexesForRowAndAfter(
+                            logicalIndex, getRowCount(), addedRows.size());
+                }
+
+                // update the positions of the added rows and the rows below
+                // them
+                // TODO: this can lead to moving things around twice in case
+                // some rows didn't get new dom rows (e.g. when expanding a
+                // TreeGrid node with more children than can fit within the max
+                // visual range size), consider moving this update elsewhere
+                double rowTop = getRowTop(logicalIndex);
+                for (int i = visualIndex; i < visualRowOrder.size(); i++) {
+
+                    final TableRowElement tr = visualRowOrder.get(i);
+
+                    setRowPosition(tr, 0, rowTop);
+                    rowTop += getDefaultRowHeight();
+                    SpacerContainer.SpacerImpl spacer = spacerContainer
+                            .getSpacer(logicalIndex - visualIndex + i);
+                    if (spacer != null) {
+                        spacer.setPosition(0, rowTop);
+                        rowTop += spacer.getHeight();
+                    }
                 }
 
                 // Execute the registered callback function for newly created
@@ -3221,11 +4114,12 @@ public class Escalator extends Widget
             double heightOfSection = getHeightOfSection();
             // By including the possibly shown scrollbar height, we get a
             // consistent count and do not add/remove rows whenever a scrollbar
-            // is shown
+            // is shown. Make sure that two extra rows are included for
+            // assisting with tab navigation on both sides of the viewport.
             heightOfSection += horizontalScrollbarDeco.getOffsetHeight();
             double defaultRowHeight = getDefaultRowHeight();
             final int maxVisibleRowCount = (int) Math
-                    .ceil(heightOfSection / defaultRowHeight) + 1;
+                    .ceil(heightOfSection / defaultRowHeight) + 2;
 
             /*
              * maxVisibleRowCount can become negative if the headers and footers
@@ -3241,399 +4135,261 @@ public class Escalator extends Widget
             if (numberOfRows == 0) {
                 return;
             }
-
-            final Range viewportRange = getVisibleRowRange();
-            final Range removedRowsRange = Range.withLength(index,
-                    numberOfRows);
-
             /*
-             * Removing spacers as the very first step will correct the
-             * scrollbars and row offsets right away.
+             * NOTE: this method handles and manipulates logical, visual, and
+             * physical indexes a lot. If you don't remember what those mean and
+             * how they relate to each other, see the top of this class for
+             * Maintenance Notes.
              *
-             * TODO: actually, it kinda sounds like a Grid feature that a spacer
-             * would be associated with a particular row. Maybe it would be
-             * better to have a spacer separate from rows, and simply collapse
-             * them if they happen to end up on top of each other. This would
-             * probably make supporting the -1 row pretty easy, too.
+             * At the beginning of this method the logical index of the data
+             * provider has already been updated to include the new rows, but
+             * visual and physical indexes have not, nor has the spacer indexing
+             * been updated, and the topRowLogicalIndex may be out of date as
+             * well.
              */
-            spacerContainer.paintRemoveSpacers(removedRowsRange);
 
-            final Range[] partitions = removedRowsRange
-                    .partitionWith(viewportRange);
-            final Range removedAbove = partitions[0];
-            final Range removedLogicalInside = partitions[1];
-            final Range removedVisualInside = convertToVisual(
-                    removedLogicalInside);
+            // logical index of the first old row, also the difference between
+            // logical index and visual index before any rows have been removed
+            final int oldTopRowLogicalIndex = getTopRowLogicalIndex();
+            // length of the visual range before anything gets removed
+            final int oldVisualRangeLength = visualRowOrder.size();
 
-            /*
-             * TODO: extract the following if-block to a separate method. I'll
-             * leave this be inlined for now, to make linediff-based code
-             * reviewing easier. Probably will be moved in the following patch
-             * set.
-             */
-
-            /*
-             * Adjust scroll position in one of two scenarios:
-             *
-             * 1) Rows were removed above. Then we just need to adjust the
-             * scrollbar by the height of the removed rows.
-             *
-             * 2) There are no logical rows above, and at least the first (if
-             * not more) visual row is removed. Then we need to snap the scroll
-             * position to the first visible row (i.e. reset scroll position to
-             * absolute 0)
-             *
-             * The logic is optimized in such a way that the
-             * moveViewportAndContent is called only once, to avoid extra
-             * reflows, and thus the code might seem a bit obscure.
-             */
-            final boolean firstVisualRowIsRemoved = !removedVisualInside
-                    .isEmpty() && removedVisualInside.getStart() == 0;
-
-            if (!removedAbove.isEmpty() || firstVisualRowIsRemoved) {
-                final double yDelta = removedAbove.length()
-                        * getDefaultRowHeight();
-                final double firstLogicalRowHeight = getDefaultRowHeight();
-                final boolean removalScrollsToShowFirstLogicalRow = verticalScrollbar
-                        .getScrollPos() - yDelta < firstLogicalRowHeight;
-
-                if (removedVisualInside.isEmpty()
-                        && (!removalScrollsToShowFirstLogicalRow
-                                || !firstVisualRowIsRemoved)) {
-                    /*
-                     * rows were removed from above the viewport, so all we need
-                     * to do is to adjust the scroll position to account for the
-                     * removed rows
-                     */
-                    moveViewportAndContent(-yDelta);
-                } else if (removalScrollsToShowFirstLogicalRow) {
-                    /*
-                     * It seems like we've removed all rows from above, and also
-                     * into the current viewport. This means we'll need to even
-                     * out the scroll position to exactly 0 (i.e. adjust by the
-                     * current negative scrolltop, presto!), so that it isn't
-                     * aligned funnily
-                     */
-                    moveViewportAndContent(-verticalScrollbar.getScrollPos());
-                }
-            }
+            // logical range of the removed rows
+            final Range removedRowsLogicalRange = Range.withLength(index,
+                    numberOfRows);
 
-            // ranges evaluated, let's do things.
-            if (!removedVisualInside.isEmpty()) {
-                int escalatorRowCount = body.getDomRowCount();
+            // check which parts of the removed range fall within or beyond the
+            // visual range
+            final Range[] partitions = removedRowsLogicalRange
+                    .partitionWith(Range.withLength(oldTopRowLogicalIndex,
+                            oldVisualRangeLength));
+            final Range removedLogicalAbove = partitions[0];
+            final Range removedLogicalBelow = partitions[2];
+            final Range removedLogicalWithin = partitions[1];
 
+            if (removedLogicalBelow.length() == numberOfRows) {
                 /*
-                 * remember: the rows have already been subtracted from the row
-                 * count at this point
+                 * Rows were removed entirely from below the visual range. No
+                 * rows to recycle or scroll position to adjust, just need to
+                 * recalculate scrollbar height. No need to touch the spacer
+                 * indexing or the physical index.
                  */
-                int rowsLeft = getRowCount();
-                if (rowsLeft < escalatorRowCount) {
-                    /*
-                     * Remove extra DOM rows and refresh contents.
-                     */
-                    for (int i = escalatorRowCount - 1; i >= rowsLeft; i--) {
-                        final TableRowElement tr = visualRowOrder.remove(i);
-                        paintRemoveRow(tr, i);
-                        removeRowPosition(tr);
-                    }
-
-                    // Move rest of the rows to the Escalator's top
-                    Range visualRange = Range.withLength(0,
-                            visualRowOrder.size());
-                    moveAndUpdateEscalatorRows(visualRange, 0, 0);
-
-                    sortDomElements();
-                    setTopRowLogicalIndex(0);
-
-                    scroller.recalculateScrollbarsForVirtualViewport();
-
-                    fireRowVisibilityChangeEvent();
-                    return;
-                } else {
-                    // No escalator rows need to be removed.
-
-                    /*
-                     * Two things (or a combination thereof) can happen:
-                     *
-                     * 1) We're scrolled to the bottom, the last rows are
-                     * removed. SOLUTION: moveAndUpdateEscalatorRows the
-                     * bottommost rows, and place them at the top to be
-                     * refreshed.
-                     *
-                     * 2) We're scrolled somewhere in the middle, arbitrary rows
-                     * are removed. SOLUTION: moveAndUpdateEscalatorRows the
-                     * removed rows, and place them at the bottom to be
-                     * refreshed.
-                     *
-                     * Since a combination can also happen, we need to handle
-                     * this in a smart way, all while avoiding
-                     * double-refreshing.
-                     */
+                scroller.recalculateScrollbarsForVirtualViewport();
 
-                    final double contentBottom = getRowCount()
-                            * getDefaultRowHeight();
-                    final double viewportBottom = tBodyScrollTop
-                            + getHeightOfSection();
-                    if (viewportBottom <= contentBottom) {
-                        /*
-                         * We're in the middle of the row container, everything
-                         * is added to the bottom
-                         */
-                        paintRemoveRowsAtMiddle(removedLogicalInside,
-                                removedVisualInside, 0);
-                    } else if (removedVisualInside.contains(0)
-                            && numberOfRows >= visualRowOrder.size()) {
-                        /*
-                         * We're removing so many rows that the viewport is
-                         * pushed up more than a screenful. This means we can
-                         * simply scroll up and everything will work without a
-                         * sweat.
-                         */
-
-                        double left = horizontalScrollbar.getScrollPos();
-                        double top = contentBottom
-                                - visualRowOrder.size() * getDefaultRowHeight();
-                        setBodyScrollPosition(left, top);
-
-                        Range allEscalatorRows = Range.withLength(0,
-                                visualRowOrder.size());
-                        int logicalTargetIndex = getRowCount()
-                                - allEscalatorRows.length();
-                        moveAndUpdateEscalatorRows(allEscalatorRows, 0,
-                                logicalTargetIndex);
-
-                        /*
-                         * moveAndUpdateEscalatorRows recalculates the rows, but
-                         * logical top row index bookkeeping is handled in this
-                         * method.
-                         *
-                         * TODO: Redesign how to keep it easy to track this.
-                         */
-                        updateTopRowLogicalIndex(
-                                -removedLogicalInside.length());
-
-                        /*
-                         * Scrolling the body to the correct location will be
-                         * fixed automatically. Because the amount of rows is
-                         * decreased, the viewport is pushed up as the scrollbar
-                         * shrinks. So no need to do anything there.
-                         *
-                         * TODO [[optimize]]: This might lead to a double body
-                         * refresh. Needs investigation.
-                         */
-                    } else if (contentBottom
-                            + (numberOfRows * getDefaultRowHeight())
-                            - viewportBottom < getDefaultRowHeight()) {
-                        /*
-                         * We're at the end of the row container, everything is
-                         * added to the top.
-                         */
-
-                        /*
-                         * FIXME [[spacer]]: above if-clause is coded to only
-                         * work with default row heights - will not work with
-                         * variable row heights
-                         */
-
-                        paintRemoveRowsAtBottom(removedLogicalInside,
-                                removedVisualInside);
-                        updateTopRowLogicalIndex(
-                                -removedLogicalInside.length());
-                    } else {
-                        /*
-                         * We're in a combination, where we need to both scroll
-                         * up AND show new rows at the bottom.
-                         *
-                         * Example: Scrolled down to show the second to last
-                         * row. Remove two. Viewport scrolls up, revealing the
-                         * row above row. The last element collapses up and into
-                         * view.
-                         *
-                         * Reminder: this use case handles only the case when
-                         * there are enough escalator rows to still render a
-                         * full view. I.e. all escalator rows will _always_ be
-                         * populated
-                         */
-                        /*-
-                         *  1       1      |1| <- newly rendered
-                         * |2|     |2|     |2|
-                         * |3| ==> |*| ==> |5| <- newly rendered
-                         * |4|     |*|
-                         *  5       5
-                         *
-                         *  1       1      |1| <- newly rendered
-                         * |2|     |*|     |4|
-                         * |3| ==> |*| ==> |5| <- newly rendered
-                         * |4|     |4|
-                         *  5       5
-                         */
-
-                        /*
-                         * STEP 1:
-                         *
-                         * reorganize deprecated escalator rows to bottom, but
-                         * don't re-render anything yet
-                         */
-                        /*-
-                         *  1       1       1
-                         * |2|     |*|     |4|
-                         * |3| ==> |*| ==> |*|
-                         * |4|     |4|     |*|
-                         *  5       5       5
-                         */
-                        double newTop = getRowTop(visualRowOrder
-                                .get(removedVisualInside.getStart()));
-                        for (int i = 0; i < removedVisualInside.length(); i++) {
-                            final TableRowElement tr = visualRowOrder
-                                    .remove(removedVisualInside.getStart());
-                            visualRowOrder.addLast(tr);
-                        }
+                // Visual range contents remain the same, no need to fire a
+                // RowVisibilityChangeEvent.
+            } else if (removedLogicalAbove.length() == numberOfRows) {
+                /*
+                 * Rows were removed entirely from above the visual range. No
+                 * rows to recycle, just need to update the spacer indexing and
+                 * the content positions. No need to touch the physical index.
+                 */
 
-                        for (int i = removedVisualInside
-                                .getStart(); i < escalatorRowCount; i++) {
-                            final TableRowElement tr = visualRowOrder.get(i);
-                            setRowPosition(tr, 0, (int) newTop);
-                            newTop += getDefaultRowHeight();
-                            newTop += spacerContainer.getSpacerHeight(
-                                    i + removedLogicalInside.getStart());
-                        }
+                // update the logical indexes of remaining spacers
+                spacerContainer.updateSpacerIndexesForRowAndAfter(
+                        oldTopRowLogicalIndex,
+                        oldTopRowLogicalIndex + oldVisualRangeLength,
+                        -numberOfRows);
 
-                        /*
-                         * STEP 2:
-                         *
-                         * manually scroll
-                         */
-                        /*-
-                         *  1      |1| <-- newly rendered (by scrolling)
-                         * |4|     |4|
-                         * |*| ==> |*|
-                         * |*|
-                         *  5       5
-                         */
-                        final double newScrollTop = contentBottom
-                                - getHeightOfSection();
-                        setScrollTop(newScrollTop);
-                        /*
-                         * Manually call the scroll handler, so we get immediate
-                         * effects in the escalator.
-                         */
-                        scroller.onScroll();
+                // default height of a single row
+                final double defaultRowHeight = getDefaultRowHeight();
 
-                        /*
-                         * Move the bottommost (n+1:th) escalator row to top,
-                         * because scrolling up doesn't handle that for us
-                         * automatically
-                         */
-                        moveAndUpdateEscalatorRows(
-                                Range.withOnly(escalatorRowCount - 1), 0,
-                                getLogicalRowIndex(visualRowOrder.getFirst())
-                                        - 1);
-                        updateTopRowLogicalIndex(-1);
+                // how much viewport, rows, and spacers should be shifted based
+                // on the removed rows, assume there were no spacers to remove
+                final double yDelta = numberOfRows * defaultRowHeight;
 
-                        /*
-                         * STEP 3:
-                         *
-                         * update remaining escalator rows
-                         */
-                        /*-
-                         * |1|     |1|
-                         * |4| ==> |4|
-                         * |*|     |5| <-- newly rendered
-                         *
-                         *  5
-                         */
+                // shift everything up
+                moveViewportAndContent(null, -yDelta, -yDelta, -yDelta);
 
-                        final int rowsScrolled = (int) (Math
-                                .ceil((viewportBottom - contentBottom)
-                                        / getDefaultRowHeight()));
-                        final int start = escalatorRowCount
-                                - (removedVisualInside.length() - rowsScrolled);
-                        final Range visualRefreshRange = Range.between(start,
-                                escalatorRowCount);
-                        final int logicalTargetIndex = getLogicalRowIndex(
-                                visualRowOrder.getFirst()) + start;
-                        // in-place move simply re-renders the rows.
-                        moveAndUpdateEscalatorRows(visualRefreshRange, start,
-                                logicalTargetIndex);
-                    }
-                }
+                // update the top row logical index according to any removed
+                // rows
+                updateTopRowLogicalIndex(-numberOfRows);
 
-                fireRowVisibilityChangeEvent();
-                sortDomElements();
-            }
+                // update scrollbar
+                scroller.recalculateScrollbarsForVirtualViewport();
 
-            updateTopRowLogicalIndex(-removedAbove.length());
+                // Visual range contents remain the same, no need to fire a
+                // RowVisibilityChangeEvent.
+            } else {
+                /*
+                 * Rows are being removed at least partially from within the
+                 * visual range. This is where things get tricky. We might have
+                 * to scroll up or down or nowhere at all, depending on the
+                 * situation.
+                 */
 
-            /*
-             * this needs to be done after the escalator has been shrunk down,
-             * or it won't work correctly (due to setScrollTop invocation)
-             */
-            scroller.recalculateScrollbarsForVirtualViewport();
+                // Visual range contents changed, RowVisibilityChangeEvent will
+                // be triggered within this method
+                paintRemoveRowsWithinVisualRange(index, numberOfRows,
+                        oldTopRowLogicalIndex, oldVisualRangeLength,
+                        removedLogicalAbove.length(), removedLogicalWithin);
+            }
         }
 
-        private void paintRemoveRowsAtMiddle(final Range removedLogicalInside,
-                final Range removedVisualInside, final int logicalOffset) {
-            /*-
-             *  :       :       :
-             * |2|     |2|     |2|
-             * |3| ==> |*| ==> |4|
-             * |4|     |4|     |6| <- newly rendered
-             *  :       :       :
+        /**
+         * Row removal handling for {@link #paintRemoveRows(int, int)} when the
+         * removed range intersects the visual range at least partially.
+         * <p>
+         * NOTE: This method should not be called directly from anywhere else.
+         *
+         * @param index
+         * @param numberOfRows
+         * @param oldTopRowLogicalIndex
+         * @param oldVisualRangeLength
+         * @param removedAboveLength
+         * @param removedLogicalWithin
+         */
+        private void paintRemoveRowsWithinVisualRange(int index,
+                int numberOfRows, int oldTopRowLogicalIndex,
+                int oldVisualRangeLength, int removedAboveLength,
+                Range removedLogicalWithin) {
+            /*
+             * Calculating where the visual range should start after the
+             * removals is not entirely trivial.
+             *
+             * Initially, any rows removed from within the visual range won't
+             * affect the top index, even if they are removed from the
+             * beginning, as the rows are also removed from the logical index.
+             * Likewise we don't need to care about rows removed from below the
+             * visual range. On the other hand, any rows removed from above the
+             * visual range do shift the index down.
+             *
+             * However, in all of these cases, if there aren't enough rows below
+             * the visual range to replace the content removed from within the
+             * visual range, more rows need to be brought in from above the old
+             * visual range in turn. This shifts the index down even further.
              */
 
-            final int escalatorRowCount = visualRowOrder.size();
-
-            final int logicalTargetIndex = getLogicalRowIndex(
-                    visualRowOrder.getLast())
-                    - (removedVisualInside.length() - 1) + logicalOffset;
-            moveAndUpdateEscalatorRows(removedVisualInside, escalatorRowCount,
-                    logicalTargetIndex);
-
-            // move the surrounding rows to their correct places.
-            final ListIterator<TableRowElement> iterator = visualRowOrder
-                    .listIterator(removedVisualInside.getStart());
-
-            double rowTop = getRowTop(
-                    removedLogicalInside.getStart() + logicalOffset);
-            for (int i = removedVisualInside.getStart(); i < escalatorRowCount
-                    - removedVisualInside.length(); i++) {
-                final TableRowElement tr = iterator.next();
-                setRowPosition(tr, 0, rowTop);
-                rowTop += getDefaultRowHeight();
-                rowTop += spacerContainer
-                        .getSpacerHeight(i + removedLogicalInside.getStart());
-            }
-        }
-
-        private void paintRemoveRowsAtBottom(final Range removedLogicalInside,
-                final Range removedVisualInside) {
-            /*-
-             *                  :
-             *  :       :      |4| <- newly rendered
-             * |5|     |5|     |5|
-             * |6| ==> |*| ==> |7|
-             * |7|     |7|
-             */
+            // scroll position before any rows or spacers are removed
+            double scrollTop = getScrollTop();
+
+            Range removedVisualWithin = convertToVisual(removedLogicalWithin);
+            int remainingVisualRangeRowCount = visualRowOrder.size()
+                    - removedVisualWithin.length();
+
+            int newTopRowLogicalIndex = oldTopRowLogicalIndex
+                    - removedAboveLength;
+            int rowsToIncludeFromBelow = Math.min(
+                    getRowCount() - newTopRowLogicalIndex
+                            - remainingVisualRangeRowCount,
+                    removedLogicalWithin.length());
+            int rowsToIncludeFromAbove = removedLogicalWithin.length()
+                    - rowsToIncludeFromBelow;
+            int rowsToRemoveFromDom = 0;
+            if (rowsToIncludeFromAbove > 0) {
+                // don't try to bring in more rows than exist, it's possible
+                // to remove enough rows that visual range won't be full
+                // anymore
+                rowsToRemoveFromDom = Math
+                        .max(rowsToIncludeFromAbove - newTopRowLogicalIndex, 0);
+                rowsToIncludeFromAbove -= rowsToRemoveFromDom;
+
+                newTopRowLogicalIndex -= rowsToIncludeFromAbove;
+            }
+
+            int visualIndexToRemove = Math.max(index - oldTopRowLogicalIndex,
+                    0);
+
+            // remove extra dom rows and their spacers if any
+            double removedFromDomSpacerHeights = 0d;
+            if (rowsToRemoveFromDom > 0) {
+                for (int i = 0; i < rowsToRemoveFromDom; ++i) {
+                    TableRowElement tr = visualRowOrder
+                            .remove(visualIndexToRemove);
+
+                    // logical index of this row before anything got removed
+                    int logicalRowIndex = oldTopRowLogicalIndex
+                            + visualIndexToRemove + i;
+                    double spacerHeight = spacerContainer
+                            .getSpacerHeight(logicalRowIndex);
+                    removedFromDomSpacerHeights += spacerHeight;
+                    spacerContainer.removeSpacer(logicalRowIndex);
+
+                    paintRemoveRow(tr, removedVisualWithin.getStart());
+                    removeRowPosition(tr);
+                }
 
-            final int logicalTargetIndex = getLogicalRowIndex(
-                    visualRowOrder.getFirst()) - removedVisualInside.length();
-            moveAndUpdateEscalatorRows(removedVisualInside, 0,
-                    logicalTargetIndex);
+                // update the associated row indexes for remaining spacers,
+                // even for those rows that are going to get recycled
+                spacerContainer.updateSpacerIndexesForRowAndAfter(
+                        oldTopRowLogicalIndex + visualIndexToRemove
+                                + rowsToRemoveFromDom,
+                        oldTopRowLogicalIndex + oldVisualRangeLength,
+                        -rowsToRemoveFromDom);
+            }
+
+            // add new content from below visual range, if there is any
+            if (rowsToIncludeFromBelow > 0) {
+                // removed rows are recycled to just below the old visual
+                // range, calculate the logical index of the insertion
+                // point that is just below the existing rows, taking into
+                // account that the indexing has changed with the removal
+                int firstBelow = newTopRowLogicalIndex + rowsToIncludeFromAbove
+                        + remainingVisualRangeRowCount;
+
+                moveAndUpdateEscalatorRows(
+                        Range.withLength(visualIndexToRemove,
+                                rowsToIncludeFromBelow),
+                        visualRowOrder.size(), firstBelow);
+            }
+
+            // add new content from above visual range, if there is any
+            // -- this is left last because most of the time it isn't even
+            // needed
+            if (rowsToIncludeFromAbove > 0) {
+                moveAndUpdateEscalatorRows(
+                        Range.withLength(visualIndexToRemove,
+                                rowsToIncludeFromAbove),
+                        0, newTopRowLogicalIndex);
+            }
+
+            // recycling updates all relevant row and spacer positions but
+            // if we only removed DOM rows and didn't recycle any we still
+            // need to shift up the rows below the removal point
+            if (rowsToIncludeFromAbove <= 0 && rowsToIncludeFromBelow <= 0) {
+                // update the positions for the rows and spacers below the
+                // removed ones, assume there is no need to update scroll
+                // position since the final check adjusts that if needed
+                double yDelta = numberOfRows * getDefaultRowHeight()
+                        + removedFromDomSpacerHeights;
+                moveViewportAndContent(
+                        newTopRowLogicalIndex + visualIndexToRemove, -yDelta,
+                        -yDelta, 0);
+            }
+
+            setTopRowLogicalIndex(newTopRowLogicalIndex);
 
-            // move the surrounding rows to their correct places.
-            int firstUpdatedIndex = removedVisualInside.getEnd();
-            final ListIterator<TableRowElement> iterator = visualRowOrder
-                    .listIterator(firstUpdatedIndex);
+            scroller.recalculateScrollbarsForVirtualViewport();
 
-            double rowTop = getRowTop(removedLogicalInside.getStart());
-            int i = 0;
-            while (iterator.hasNext()) {
-                final TableRowElement tr = iterator.next();
-                setRowPosition(tr, 0, rowTop);
-                rowTop += getDefaultRowHeight();
-                rowTop += spacerContainer
-                        .getSpacerHeight(firstUpdatedIndex + i++);
-            }
+            // calling this method also triggers adding new spacers to the
+            // recycled rows, if any are needed
+            fireRowVisibilityChangeEvent();
+
+            // populating the spacers might take a while, delay calculations
+            // or the viewport might get adjusted too high
+            Scheduler.get().scheduleFinally(() -> {
+                // make sure there isn't a gap at the bottom after removal
+                // and adjust the viewport if there is
+
+                // FIXME: this should be doable with
+                // adjustScrollPositionIfNeeded() but it uses current
+                // scrollTop, which may have ended in wrong position and
+                // results in assuming too big gap and consequently
+                // scrolling up too much
+                double extraSpaceAtBottom = scrollTop + getHeightOfSection()
+                        - getRowTop(getTopRowLogicalIndex()
+                                + visualRowOrder.size());
+                if (extraSpaceAtBottom > 0 && scrollTop > 0) {
+                    // we need to move the viewport up to adjust, while the
+                    // rows and spacers can remain where they are
+                    double yDeltaScroll = Math.min(extraSpaceAtBottom,
+                            scrollTop);
+                    moveViewportAndContent(null, 0, 0, -yDeltaScroll);
+                }
+            });
+
+            // update physical index
+            sortDomElements();
         }
 
         @Override
@@ -3680,16 +4436,10 @@ public class Escalator extends Widget
                 return Range.withLength(0, 0);
             }
 
-            /*
-             * TODO [[spacer]]: these assumptions will be totally broken with
-             * spacers.
-             */
-            final int maxVisibleRowCount = getMaxVisibleRowCount();
-            final int currentTopRowIndex = getLogicalRowIndex(
-                    visualRowOrder.getFirst());
+            final int currentTopRowIndex = getTopRowLogicalIndex();
 
-            final Range[] partitions = logicalRange.partitionWith(
-                    Range.withLength(currentTopRowIndex, maxVisibleRowCount));
+            final Range[] partitions = logicalRange
+                    .partitionWith(getVisibleRowRange());
             final Range insideRange = partitions[1];
             return insideRange.offsetBy(-currentTopRowIndex);
         }
@@ -3781,7 +4531,7 @@ public class Escalator extends Widget
              * This method indeed has a smell very similar to paintRemoveRows
              * and paintInsertRows.
              *
-             * Unfortunately, those the code can't trivially be shared, since
+             * Unfortunately, the code of those can't trivially be shared, since
              * there are some slight differences in the respective
              * responsibilities. The "paint" methods fake the addition and
              * removal of rows, and make sure to either push existing data out
@@ -3801,120 +4551,117 @@ public class Escalator extends Widget
                 return;
             }
 
+            int oldTopRowLogicalIndex = getTopRowLogicalIndex();
+            int oldVisualRangeLength = visualRowOrder.size();
+
             final int maxVisibleRowCount = getMaxVisibleRowCount();
             final int neededEscalatorRows = Math.min(maxVisibleRowCount,
                     body.getRowCount());
-            final int neededEscalatorRowsDiff = neededEscalatorRows
-                    - visualRowOrder.size();
 
-            if (neededEscalatorRowsDiff > 0) {
-                // needs more
+            final int rowDiff = neededEscalatorRows - oldVisualRangeLength;
 
-                /*
-                 * This is a workaround for the issue where we might be scrolled
-                 * to the bottom, and the widget expands beyond the content
-                 * range
-                 */
+            if (rowDiff > 0) {
+                // more rows are needed
 
-                final int index = visualRowOrder.size();
-                final int nextLastLogicalIndex;
+                // calculate the indexes for adding rows below the last row of
+                // the visual range
+                final int visualTargetIndex = oldVisualRangeLength;
+                final int logicalTargetIndex;
                 if (!visualRowOrder.isEmpty()) {
-                    nextLastLogicalIndex = getLogicalRowIndex(
-                            visualRowOrder.getLast()) + 1;
+                    logicalTargetIndex = oldTopRowLogicalIndex
+                            + visualTargetIndex;
                 } else {
-                    nextLastLogicalIndex = 0;
+                    logicalTargetIndex = 0;
                 }
 
-                final boolean contentWillFit = nextLastLogicalIndex < getRowCount()
-                        - neededEscalatorRowsDiff;
-                if (contentWillFit) {
-                    final List<TableRowElement> addedRows = fillAndPopulateEscalatorRowsIfNeeded(
-                            index, neededEscalatorRowsDiff);
+                // prioritise adding to the bottom so that there's less chance
+                // for a gap if a details row is later closed (e.g. by user)
+                final int addToBottom = Math.min(rowDiff,
+                        getRowCount() - logicalTargetIndex);
+                final int addToTop = rowDiff - addToBottom;
 
-                    /*
-                     * Since fillAndPopulateEscalatorRowsIfNeeded operates on
-                     * the assumption that index == visual index == logical
-                     * index, we thank for the added escalator rows, but since
-                     * they're painted in the wrong CSS position, we need to
-                     * move them to their actual locations.
-                     *
-                     * Note: this is the second (see body.paintInsertRows)
-                     * occasion where fillAndPopulateEscalatorRowsIfNeeded would
-                     * behave "more correctly" if it only would add escalator
-                     * rows to the DOM and appropriate bookkeping, and not
-                     * actually populate them :/
-                     */
-                    moveAndUpdateEscalatorRows(
-                            Range.withLength(index, addedRows.size()), index,
-                            nextLastLogicalIndex);
-                } else {
-                    /*
-                     * TODO [[optimize]]
-                     *
-                     * We're scrolled so far down that all rows can't be simply
-                     * appended at the end, since we might start displaying
-                     * escalator rows that don't exist. To avoid the mess that
-                     * is body.paintRemoveRows, this is a dirty hack that dumbs
-                     * the problem down to a more basic and already-solved
-                     * problem:
-                     *
-                     * 1) scroll all the way up 2) add the missing escalator
-                     * rows 3) scroll back to the original position.
-                     *
-                     * Letting the browser scroll back to our original position
-                     * will automatically solve any possible overflow problems,
-                     * since the browser will not allow us to scroll beyond the
-                     * actual content.
-                     */
+                if (addToTop > 0) {
+                    fillAndPopulateEscalatorRowsIfNeeded(0,
+                            oldTopRowLogicalIndex - addToTop, addToTop);
 
-                    final double oldScrollTop = getScrollTop();
-                    setScrollTop(0);
-                    scroller.onScroll();
-                    fillAndPopulateEscalatorRowsIfNeeded(index,
-                            neededEscalatorRowsDiff);
-                    setScrollTop(oldScrollTop);
-                    scroller.onScroll();
+                    updateTopRowLogicalIndex(-addToTop);
                 }
-            } else if (neededEscalatorRowsDiff < 0) {
-                // needs less
-
-                final ListIterator<TableRowElement> iter = visualRowOrder
-                        .listIterator(visualRowOrder.size());
-                for (int i = 0; i < -neededEscalatorRowsDiff; i++) {
-                    final Element last = iter.previous();
-                    last.removeFromParent();
-                    iter.remove();
+                if (addToBottom > 0) {
+                    fillAndPopulateEscalatorRowsIfNeeded(visualTargetIndex,
+                            logicalTargetIndex, addToBottom);
+                }
+            } else if (rowDiff < 0) {
+                // rows need to be removed
+
+                // prioritise removing rows from above the viewport as they are
+                // less likely to be needed in a hurry -- the rows below are
+                // more likely to slide into view when spacer contents are
+                // updated
+
+                // top of visible area before any rows are actually added
+                double scrollTop = getScrollTop();
+
+                // visual index of the first actually visible row, including
+                // spacer
+                int oldFirstVisibleVisualIndex = -1;
+                ListIterator<TableRowElement> iter = visualRowOrder
+                        .listIterator(0);
+                for (int i = 0; i < visualRowOrder.size(); ++i) {
+                    if (positions.getTop(iter.next()) > scrollTop) {
+                        break;
+                    }
+                    oldFirstVisibleVisualIndex = i;
                 }
 
-                /*
-                 * If we were scrolled to the bottom so that we didn't have an
-                 * extra escalator row at the bottom, we'll probably end up with
-                 * blank space at the bottom of the escalator, and one extra row
-                 * above the header.
-                 *
-                 * Experimentation idea #1: calculate "scrollbottom" vs content
-                 * bottom and remove one row from top, rest from bottom. This
-                 * FAILED, since setHeight has already happened, thus we never
-                 * will detect ourselves having been scrolled all the way to the
-                 * bottom.
-                 */
+                int rowsToRemoveFromAbove = Math.max(0, Math
+                        .min(Math.abs(rowDiff), oldFirstVisibleVisualIndex));
 
-                if (!visualRowOrder.isEmpty()) {
-                    final double firstRowTop = getRowTop(
-                            visualRowOrder.getFirst());
-                    final double firstRowMinTop = tBodyScrollTop
-                            - getDefaultRowHeight();
-                    if (firstRowTop < firstRowMinTop) {
-                        final int newLogicalIndex = getLogicalRowIndex(
-                                visualRowOrder.getLast()) + 1;
-                        moveAndUpdateEscalatorRows(Range.withOnly(0),
-                                visualRowOrder.size(), newLogicalIndex);
-                        updateTopRowLogicalIndex(1);
+                boolean spacersRemovedFromAbove = false;
+                if (rowsToRemoveFromAbove > 0) {
+                    double initialSpacerHeightSum = spacerContainer
+                            .getSpacerHeightsSum();
+                    iter = visualRowOrder.listIterator(0);
+                    for (int i = 0; i < rowsToRemoveFromAbove; ++i) {
+                        final Element first = iter.next();
+                        first.removeFromParent();
+                        iter.remove();
+
+                        spacerContainer.removeSpacer(oldTopRowLogicalIndex + i);
                     }
+                    spacersRemovedFromAbove = initialSpacerHeightSum != spacerContainer
+                            .getSpacerHeightsSum();
                 }
+
+                // if there weren't enough rows above, remove the rest from
+                // below
+                int rowsToRemoveFromBelow = Math.abs(rowDiff)
+                        - rowsToRemoveFromAbove;
+                if (rowsToRemoveFromBelow > 0) {
+                    iter = visualRowOrder.listIterator(visualRowOrder.size());
+                    for (int i = 1; i <= rowsToRemoveFromBelow; ++i) {
+                        final Element last = iter.previous();
+                        last.removeFromParent();
+                        iter.remove();
+
+                        spacerContainer.removeSpacer(oldTopRowLogicalIndex
+                                + oldVisualRangeLength - i);
+                    }
+                }
+
+                updateTopRowLogicalIndex(rowsToRemoveFromAbove);
+
+                if (spacersRemovedFromAbove) {
+                    updateRowPositions(oldTopRowLogicalIndex, 0,
+                            visualRowOrder.size());
+                }
+
+                // removing rows might cause a gap at the bottom
+                adjustScrollPositionIfNeeded();
             }
 
-            if (neededEscalatorRowsDiff != 0) {
+            if (rowDiff != 0) {
+                scroller.recalculateScrollbarsForVirtualViewport();
+
                 fireRowVisibilityChangeEvent();
             }
 
@@ -3930,18 +4677,28 @@ public class Escalator extends Widget
             Profiler.enter(
                     "Escalator.BodyRowContainer.reapplyDefaultRowHeights");
 
-            double spacerHeights = 0;
+            double spacerHeightsAboveViewport = spacerContainer
+                    .getSpacerHeightsSumUntilPx(
+                            verticalScrollbar.getScrollPos());
+            double allSpacerHeights = spacerContainer.getSpacerHeightsSum();
 
             /* step 1: resize and reposition rows */
+
+            // there should be no spacers above the visual range
+            double spacerHeights = 0;
             for (int i = 0; i < visualRowOrder.size(); i++) {
                 TableRowElement tr = visualRowOrder.get(i);
                 reapplyRowHeight(tr, getDefaultRowHeight());
 
                 final int logicalIndex = getTopRowLogicalIndex() + i;
-                setRowPosition(tr, 0,
-                        logicalIndex * getDefaultRowHeight() + spacerHeights);
-
-                spacerHeights += spacerContainer.getSpacerHeight(logicalIndex);
+                double y = logicalIndex * getDefaultRowHeight() + spacerHeights;
+                setRowPosition(tr, 0, y);
+                SpacerContainer.SpacerImpl spacer = spacerContainer
+                        .getSpacer(logicalIndex);
+                if (spacer != null) {
+                    spacer.setPosition(0, y + getDefaultRowHeight());
+                    spacerHeights += spacer.getHeight();
+                }
             }
 
             /*
@@ -3949,16 +4706,16 @@ public class Escalator extends Widget
              * place
              */
 
-            /*
-             * This ratio needs to be calculated with the scrollsize (not max
-             * scroll position) in order to align the top row with the new
-             * scroll position.
-             */
-            double scrollRatio = verticalScrollbar.getScrollPos()
-                    / verticalScrollbar.getScrollSize();
+            // scrollRatio has to be calculated without spacers for it to be
+            // comparable between different row heights
+            double scrollRatio = (verticalScrollbar.getScrollPos()
+                    - spacerHeightsAboveViewport)
+                    / (verticalScrollbar.getScrollSize() - allSpacerHeights);
             scroller.recalculateScrollbarsForVirtualViewport();
-            verticalScrollbar.setScrollPos((int) (getDefaultRowHeight()
-                    * getRowCount() * scrollRatio));
+            // spacer heights have to be added back for setting new scrollPos
+            verticalScrollbar.setScrollPos(
+                    (int) ((getDefaultRowHeight() * getRowCount() * scrollRatio)
+                            + spacerHeightsAboveViewport));
             setBodyScrollPosition(horizontalScrollbar.getScrollPos(),
                     verticalScrollbar.getScrollPos());
             scroller.onScroll();
@@ -3968,10 +4725,6 @@ public class Escalator extends Widget
              */
             verifyEscalatorCount();
 
-            int logicalLogical = (int) (getRowTop(visualRowOrder.getFirst())
-                    / getDefaultRowHeight());
-            setTopRowLogicalIndex(logicalLogical);
-
             Profiler.leave(
                     "Escalator.BodyRowContainer.reapplyDefaultRowHeights");
         }
@@ -4041,7 +4794,9 @@ public class Escalator extends Widget
              * position in the DOM will remain undefined.
              */
 
-            // If a spacer was not reordered, it means that it's out of view.
+            // If a spacer was not reordered, it means that it's out of visual
+            // range. This should never happen with default Grid implementations
+            // but it's possible on an extended Escalator.
             for (SpacerContainer.SpacerImpl unmovedSpacer : spacers.values()) {
                 unmovedSpacer.hide();
             }
@@ -4105,6 +4860,21 @@ public class Escalator extends Widget
             return rowContainingFocus;
         }
 
+        /**
+         * Returns the cell object which contains information about the cell or
+         * spacer the element is in. As an implementation detail each spacer is
+         * a row with one cell, but they are stored in their own container and
+         * share the indexing with the regular rows.
+         *
+         * @param element
+         *            The element to get the cell for. If element is not present
+         *            in row or spacer container then <code>null</code> is
+         *            returned.
+         *
+         * @return the cell reference of the element, or <code>null</code> if
+         *         element is not present in the {@link RowContainer} or the
+         *         {@link SpacerContainer}.
+         */
         @Override
         public Cell getCell(Element element) {
             Cell cell = super.getCell(element);
@@ -4115,6 +4885,16 @@ public class Escalator extends Widget
             // Convert DOM coordinates to logical coordinates for rows
             TableRowElement rowElement = (TableRowElement) cell.getElement()
                     .getParentElement();
+            if (!visualRowOrder.contains(rowElement)) {
+                for (Entry<Integer, SpacerContainer.SpacerImpl> entry : spacerContainer
+                        .getSpacers().entrySet()) {
+                    if (rowElement.equals(entry.getValue().getRootElement())) {
+                        return new Cell(entry.getKey(), cell.getColumn(),
+                                cell.getElement());
+                    }
+                }
+                return null;
+            }
             return new Cell(getLogicalRowIndex(rowElement), cell.getColumn(),
                     cell.getElement());
         }
@@ -4142,17 +4922,20 @@ public class Escalator extends Widget
         }
 
         /**
-         * <em>Calculates</em> the correct top position of a row at a logical
-         * index, regardless if there is one there or not.
+         * <em>Calculates</em> the expected top position of a row at a logical
+         * index, regardless if there is one there currently or not.
          * <p>
-         * A correct result requires that both {@link #getDefaultRowHeight()} is
-         * consistent, and the placement and height of all spacers above the
-         * given logical index are consistent.
+         * This method relies on fixed row height (by
+         * {@link #getDefaultRowHeight()}) and can only take into account
+         * spacers that are within visual range. Any scrolling might invalidate
+         * these results, so this method shouldn't be used to estimate scroll
+         * positions.
          *
          * @param logicalIndex
          *            the logical index of the row for which to calculate the
          *            top position
-         * @return the position at which to place a row in {@code logicalIndex}
+         * @return the position where the row should currently be, were it to
+         *         exist
          * @see #getRowTop(TableRowElement)
          */
         private double getRowTop(int logicalIndex) {
@@ -4204,9 +4987,393 @@ public class Escalator extends Widget
             spacerContainer.reapplySpacerWidths();
         }
 
-        void scrollToSpacer(int spacerIndex, ScrollDestination destination,
-                int padding) {
-            spacerContainer.scrollToSpacer(spacerIndex, destination, padding);
+        void scrollToRowSpacerOrBoth(int targetRowIndex,
+                ScrollDestination destination, double padding,
+                ScrollType scrollType) {
+            if (!ensureScrollingAllowed()) {
+                return;
+            }
+            validateScrollDestination(destination, (int) padding);
+            // ignore the special case of -1 index spacer from the row index
+            // validation
+            if (!(targetRowIndex == -1 && !ScrollType.ROW.equals(scrollType))) {
+                // throws an IndexOutOfBoundsException if not valid
+                verifyValidRowIndex(targetRowIndex);
+            }
+            int oldTopRowLogicalIndex = getTopRowLogicalIndex();
+            int visualRangeLength = visualRowOrder.size();
+            int paddingInRows = 0;
+            if (!WidgetUtil.pixelValuesEqual(padding, 0d)) {
+                paddingInRows = (int) Math
+                        .ceil(Double.valueOf(padding) / getDefaultRowHeight());
+            }
+
+            // calculate the largest index necessary to include at least
+            // partially below the top of the viewport and the smallest index
+            // necessary to include at least partially above the bottom of the
+            // viewport (target row itself might not be if padding is negative)
+            int firstVisibleIndexIfScrollingUp = targetRowIndex - paddingInRows;
+            int lastVisibleIndexIfScrollingDown = targetRowIndex
+                    + paddingInRows;
+
+            int oldFirstBelowIndex = oldTopRowLogicalIndex + visualRangeLength;
+            int newTopRowLogicalIndex;
+            int logicalTargetIndex;
+            switch (destination) {
+            case ANY:
+                // scroll as little as possible, take into account that there
+                // needs to be a buffer row at both ends if there is room for
+                // one
+                boolean newRowsNeededAbove = (firstVisibleIndexIfScrollingUp < oldTopRowLogicalIndex)
+                        || (firstVisibleIndexIfScrollingUp == oldTopRowLogicalIndex
+                                && targetRowIndex > 0);
+                boolean rowsNeededBelow = (lastVisibleIndexIfScrollingDown >= oldFirstBelowIndex)
+                        || ((lastVisibleIndexIfScrollingDown == oldFirstBelowIndex
+                                - 1) && (oldFirstBelowIndex < getRowCount()));
+                if (newRowsNeededAbove) {
+                    // scroll up, add buffer row if it fits
+                    logicalTargetIndex = Math
+                            .max(firstVisibleIndexIfScrollingUp - 1, 0);
+                    newTopRowLogicalIndex = logicalTargetIndex;
+                } else if (rowsNeededBelow) {
+                    // scroll down, add buffer row if it fits
+                    newTopRowLogicalIndex = Math.min(
+                            lastVisibleIndexIfScrollingDown + 1,
+                            getRowCount() - 1) - visualRangeLength + 1;
+                    if (newTopRowLogicalIndex
+                            - oldTopRowLogicalIndex < visualRangeLength) {
+                        // partial recycling, target index at the end of
+                        // current range
+                        logicalTargetIndex = oldFirstBelowIndex;
+                    } else {
+                        // full recycling, target index the same as the new
+                        // top row index
+                        logicalTargetIndex = newTopRowLogicalIndex;
+                    }
+                } else {
+                    // no need to recycle rows but viewport might need
+                    // adjusting regardless
+                    logicalTargetIndex = -1;
+                    newTopRowLogicalIndex = oldTopRowLogicalIndex;
+                }
+                break;
+            case END:
+                // target row at the bottom of the viewport
+                newTopRowLogicalIndex = Math.min(
+                        lastVisibleIndexIfScrollingDown + 1, getRowCount() - 1)
+                        - visualRangeLength + 1;
+                if ((newTopRowLogicalIndex > oldTopRowLogicalIndex)
+                        && (newTopRowLogicalIndex
+                                - oldTopRowLogicalIndex < visualRangeLength)) {
+                    // partial recycling, target index at the end of
+                    // current range
+                    logicalTargetIndex = oldFirstBelowIndex;
+                } else {
+                    // full recycling, target index the same as the new
+                    // top row index
+                    logicalTargetIndex = newTopRowLogicalIndex;
+                }
+                break;
+            case MIDDLE:
+                // target row at the middle of the viewport, padding has to be
+                // zero or we never would have reached this far
+                newTopRowLogicalIndex = targetRowIndex - visualRangeLength / 2;
+                // ensure we don't attempt to go beyond the bottom row
+                if (newTopRowLogicalIndex + visualRangeLength > getRowCount()) {
+                    newTopRowLogicalIndex = getRowCount() - visualRangeLength;
+                }
+                if (newTopRowLogicalIndex < oldTopRowLogicalIndex) {
+                    logicalTargetIndex = newTopRowLogicalIndex;
+                } else if (newTopRowLogicalIndex > oldTopRowLogicalIndex) {
+                    if (newTopRowLogicalIndex
+                            - oldTopRowLogicalIndex < visualRangeLength) {
+                        // partial recycling, target index at the end of
+                        // current range
+                        logicalTargetIndex = oldFirstBelowIndex;
+                    } else {
+                        // full recycling, target index the same as the new
+                        // top row index
+                        logicalTargetIndex = newTopRowLogicalIndex;
+                    }
+                } else {
+                    logicalTargetIndex = -1;
+                }
+                break;
+            case START:
+                // target row at the top of the viewport, include buffer
+                // row if there is room for one
+                logicalTargetIndex = Math
+                        .max(firstVisibleIndexIfScrollingUp - 1, 0);
+                newTopRowLogicalIndex = logicalTargetIndex;
+                break;
+            default:
+                throw new IllegalArgumentException(
+                        "Internal: Unsupported ScrollDestination: "
+                                + destination.name());
+            }
+
+            // adjust visual range if necessary
+            if (newTopRowLogicalIndex < oldTopRowLogicalIndex) {
+                adjustVisualRangeUpForScrollToRowSpacerOrBoth(
+                        oldTopRowLogicalIndex, visualRangeLength,
+                        logicalTargetIndex);
+            } else if (newTopRowLogicalIndex > oldTopRowLogicalIndex) {
+                adjustVisualRangeDownForScrollToRowSpacerOrBoth(
+                        oldTopRowLogicalIndex, visualRangeLength,
+                        newTopRowLogicalIndex, logicalTargetIndex);
+            }
+            boolean rowsWereMoved = newTopRowLogicalIndex != oldTopRowLogicalIndex;
+
+            // update scroll position if necessary
+            double scrollTop = calculateScrollPositionForScrollToRowSpacerOrBoth(
+                    targetRowIndex, destination, padding, scrollType);
+            if (scrollTop != getScrollTop()) {
+                setScrollTop(scrollTop);
+                setBodyScrollPosition(tBodyScrollLeft, scrollTop);
+            }
+
+            if (rowsWereMoved) {
+                fireRowVisibilityChangeEvent();
+
+                // schedule updating of the physical indexes
+                domSorter.reschedule();
+            }
+        }
+
+        /**
+         * Checks that scrolling is allowed and resets the scroll position if
+         * it's not.
+         *
+         * @return {@code true} if scrolling is allowed, {@code false} otherwise
+         */
+        private boolean ensureScrollingAllowed() {
+            if (isScrollLocked(Direction.VERTICAL)) {
+                // no scrolling can happen
+                if (getScrollTop() != tBodyScrollTop) {
+                    setBodyScrollPosition(tBodyScrollLeft, getScrollTop());
+                }
+                return false;
+            }
+            return true;
+        }
+
+        /**
+         * Adjusts visual range up for
+         * {@link #scrollToRowSpacerOrBoth(int, ScrollDestination, double, boolean, boolean)},
+         * reuse at your own peril.
+         *
+         * @param oldTopRowLogicalIndex
+         * @param visualRangeLength
+         * @param logicalTargetIndex
+         */
+        private void adjustVisualRangeUpForScrollToRowSpacerOrBoth(
+                int oldTopRowLogicalIndex, int visualRangeLength,
+                int logicalTargetIndex) {
+            // recycle at most the visual range's worth of rows to fill
+            // the gap between the new visualTargetIndex and the existing
+            // rows
+            int rowsToRecycle = Math.min(
+                    oldTopRowLogicalIndex - logicalTargetIndex,
+                    visualRangeLength);
+            // recycle from the end to the beginning
+            moveAndUpdateEscalatorRows(
+                    Range.withLength(visualRangeLength - rowsToRecycle,
+                            rowsToRecycle),
+                    0, logicalTargetIndex);
+            // update the index
+            setTopRowLogicalIndex(logicalTargetIndex);
+        }
+
+        /**
+         * Adjusts visual range down for
+         * {@link #scrollToRowSpacerOrBoth(int, ScrollDestination, double, boolean, boolean)},
+         * reuse at your own peril.
+         *
+         * @param oldTopRowLogicalIndex
+         * @param visualRangeLength
+         * @param newTopRowLogicalIndex
+         * @param logicalTargetIndex
+         */
+        private void adjustVisualRangeDownForScrollToRowSpacerOrBoth(
+                int oldTopRowLogicalIndex, int visualRangeLength,
+                int newTopRowLogicalIndex, int logicalTargetIndex) {
+            // recycle at most the visual range's worth of rows to fill
+            // the gap between the new visualTargetIndex and the existing
+            // rows
+            int rowsToRecycle;
+            if (newTopRowLogicalIndex
+                    - oldTopRowLogicalIndex >= visualRangeLength) {
+                // full recycling
+                rowsToRecycle = visualRangeLength;
+            } else {
+                // partial recycling
+                rowsToRecycle = newTopRowLogicalIndex - oldTopRowLogicalIndex;
+            }
+            // recycle from the beginning to the end
+            moveAndUpdateEscalatorRows(Range.withLength(0, rowsToRecycle),
+                    visualRangeLength, logicalTargetIndex);
+            // update the index
+            setTopRowLogicalIndex(newTopRowLogicalIndex);
+        }
+
+        /**
+         * Calculates scroll position for
+         * {@link #scrollToRowSpacerOrBoth(int, ScrollDestination, double, boolean, boolean)},
+         * reuse at your own peril.
+         *
+         * @param targetRowIndex
+         * @param destination
+         * @param padding
+         * @param scrollType
+         * @return expected scroll position
+         */
+        private double calculateScrollPositionForScrollToRowSpacerOrBoth(
+                int targetRowIndex, ScrollDestination destination,
+                double padding, ScrollType scrollType) {
+            /*
+             * attempting to scroll above first row or below last row would get
+             * automatically corrected later but that causes unnecessary
+             * calculations, so try not to overshoot
+             */
+            double sectionHeight = getHeightOfSection();
+            double rowTop = getRowTop(targetRowIndex);
+            double spacerHeight = spacerContainer
+                    .getSpacerHeight(targetRowIndex);
+
+            double scrollTop;
+            switch (destination) {
+            case ANY:
+                if (!ScrollType.SPACER.equals(scrollType)
+                        && Math.max(rowTop - padding, 0) < getScrollTop()) {
+                    // within visual range but row top above the viewport or not
+                    // enough padding, shift a little
+                    scrollTop = Math.max(rowTop - padding, 0);
+                } else if (ScrollType.SPACER.equals(scrollType)
+                        && Math.max(rowTop + getDefaultRowHeight() - padding,
+                                0) < getScrollTop()) {
+                    // within visual range but spacer top above the viewport or
+                    // not enough padding, shift a little
+                    scrollTop = Math
+                            .max(rowTop + getDefaultRowHeight() - padding, 0);
+                } else if (ScrollType.ROW.equals(scrollType)
+                        && rowTop + getDefaultRowHeight()
+                                + padding > getScrollTop() + sectionHeight) {
+                    // within visual range but end of row below the viewport
+                    // or not enough padding, shift a little
+                    scrollTop = rowTop + getDefaultRowHeight() - sectionHeight
+                            + padding;
+                    // ensure that we don't overshoot beyond bottom
+                    scrollTop = Math.min(scrollTop,
+                            getRowTop(getRowCount() - 1) + getDefaultRowHeight()
+                                    + spacerContainer
+                                            .getSpacerHeight(getRowCount() - 1)
+                                    - sectionHeight);
+                    // if padding is set we want to overshoot or undershoot,
+                    // otherwise make sure the top of the row is in view
+                    if (padding == 0) {
+                        scrollTop = Math.min(scrollTop, rowTop);
+                    }
+                } else if (rowTop + getDefaultRowHeight() + spacerHeight
+                        + padding > getScrollTop() + sectionHeight) {
+                    // within visual range but end of spacer below the viewport
+                    // or not enough padding, shift a little
+                    scrollTop = rowTop + getDefaultRowHeight() + spacerHeight
+                            - sectionHeight + padding;
+                    // ensure that we don't overshoot beyond bottom
+                    scrollTop = Math.min(scrollTop,
+                            getRowTop(getRowCount() - 1) + getDefaultRowHeight()
+                                    + spacerContainer
+                                            .getSpacerHeight(getRowCount() - 1)
+                                    - sectionHeight);
+                    // if padding is set we want to overshoot or undershoot,
+                    // otherwise make sure the top of the row or spacer is
+                    // in view
+                    if (padding == 0) {
+                        if (ScrollType.SPACER.equals(scrollType)) {
+                            scrollTop = Math.min(scrollTop,
+                                    rowTop + getDefaultRowHeight());
+                        } else {
+                            scrollTop = Math.min(scrollTop, rowTop);
+                        }
+                    }
+                } else {
+                    // we are fine where we are
+                    scrollTop = getScrollTop();
+                }
+                break;
+            case END:
+                if (ScrollType.ROW.equals(scrollType)
+                        && rowTop + getDefaultRowHeight()
+                                + padding > getScrollTop() + sectionHeight) {
+                    // within visual range but end of row below the viewport
+                    // or not enough padding, shift a little
+                    scrollTop = rowTop + getDefaultRowHeight() - sectionHeight
+                            + padding;
+                    // ensure that we don't overshoot beyond bottom
+                    scrollTop = Math.min(scrollTop,
+                            getRowTop(getRowCount() - 1) + getDefaultRowHeight()
+                                    + spacerContainer
+                                            .getSpacerHeight(getRowCount() - 1)
+                                    - sectionHeight);
+                } else if (rowTop + getDefaultRowHeight() + spacerHeight
+                        + padding > getScrollTop() + sectionHeight) {
+                    // within visual range but end of spacer below the viewport
+                    // or not enough padding, shift a little
+                    scrollTop = rowTop + getDefaultRowHeight() + spacerHeight
+                            - sectionHeight + padding;
+                    // ensure that we don't overshoot beyond bottom
+                    scrollTop = Math.min(scrollTop,
+                            getRowTop(getRowCount()) - sectionHeight);
+                } else {
+                    // we are fine where we are
+                    scrollTop = getScrollTop();
+                }
+                break;
+            case MIDDLE:
+                double center;
+                if (ScrollType.ROW.equals(scrollType)) {
+                    // center the row itself
+                    center = rowTop + (getDefaultRowHeight() / 2.0);
+                } else if (ScrollType.ROW_AND_SPACER.equals(scrollType)) {
+                    // center both
+                    center = rowTop
+                            + ((getDefaultRowHeight() + spacerHeight) / 2.0);
+                } else {
+                    // center the spacer
+                    center = rowTop + getDefaultRowHeight()
+                            + (spacerHeight / 2.0);
+                }
+                scrollTop = center - Math.ceil(sectionHeight / 2.0);
+                // ensure that we don't overshoot beyond bottom
+                scrollTop = Math.min(scrollTop,
+                        getRowTop(getRowCount() - 1) + getDefaultRowHeight()
+                                + spacerContainer
+                                        .getSpacerHeight(getRowCount() - 1)
+                                - sectionHeight);
+                // ensure that we don't overshoot beyond top
+                scrollTop = Math.max(0, scrollTop);
+                break;
+            case START:
+                if (!ScrollType.SPACER.equals(scrollType)
+                        && Math.max(rowTop - padding, 0) < getScrollTop()) {
+                    // row top above the viewport or not enough padding, shift a
+                    // little
+                    scrollTop = Math.max(rowTop - padding, 0);
+                } else if (ScrollType.SPACER.equals(scrollType)
+                        && Math.max(rowTop + getDefaultRowHeight() - padding,
+                                0) < getScrollTop()) {
+                    // spacer top above the viewport or not enough padding,
+                    // shift a little
+                    scrollTop = Math
+                            .max(rowTop + getDefaultRowHeight() - padding, 0);
+                } else {
+                    scrollTop = getScrollTop();
+                }
+                break;
+            default:
+                scrollTop = getScrollTop();
+            }
+            return scrollTop;
         }
 
         @Override
@@ -4845,6 +6012,17 @@ public class Escalator extends Widget
                 UIObject.setStylePrimaryName(deco, style + "-spacer-deco");
             }
 
+            /**
+             * Clear spacer height without moving other contents.
+             *
+             * @see #setHeight(double)
+             */
+            private void clearHeight() {
+                height = 0;
+                root.getStyle().setHeight(0, Unit.PX);
+                updateDecoratorGeometry(0);
+            }
+
             public void setHeight(double height) {
 
                 assert height >= 0 : "Height must be more >= 0 (was " + height
@@ -4996,7 +6174,13 @@ public class Escalator extends Widget
             /**
              * Updates the spacer's visibility parameters, based on whether it
              * is being currently visible or not.
+             *
+             * @deprecated Escalator no longer uses this logic at initialisation
+             *             as there can only be a limited number of spacers and
+             *             hidden spacers within visual range interfere with
+             *             position calculations.
              */
+            @Deprecated
             public void updateVisibility() {
                 if (isInViewport()) {
                     show();
@@ -5015,15 +6199,13 @@ public class Escalator extends Widget
             public void show() {
                 getRootElement().getStyle().clearDisplay();
                 getDecoElement().getStyle().clearDisplay();
-                Escalator.this.fireEvent(
-                        new SpacerVisibilityChangedEvent(getRow(), true));
+                fireEvent(new SpacerVisibilityChangedEvent(getRow(), true));
             }
 
             public void hide() {
                 getRootElement().getStyle().setDisplay(Display.NONE);
                 getDecoElement().getStyle().setDisplay(Display.NONE);
-                Escalator.this.fireEvent(
-                        new SpacerVisibilityChangedEvent(getRow(), false));
+                fireEvent(new SpacerVisibilityChangedEvent(getRow(), false));
             }
 
             /**
@@ -5136,23 +6318,8 @@ public class Escalator extends Widget
             assert !destination.equals(ScrollDestination.MIDDLE)
                     || padding != 0 : "destination/padding check should be done before this method";
 
-            if (!rowIndexToSpacer.containsKey(spacerIndex)) {
-                throw new IllegalArgumentException(
-                        "No spacer open at index " + spacerIndex);
-            }
-
-            SpacerImpl spacer = rowIndexToSpacer.get(spacerIndex);
-            double targetStartPx = spacer.getTop();
-            double targetEndPx = targetStartPx + spacer.getHeight();
-
-            Range viewportPixels = getViewportPixels();
-            double viewportStartPx = viewportPixels.getStart();
-            double viewportEndPx = viewportPixels.getEnd();
-
-            double scrollTop = getScrollPos(destination, targetStartPx,
-                    targetEndPx, viewportStartPx, viewportEndPx, padding);
-
-            setScrollTop(scrollTop);
+            body.scrollToRowSpacerOrBoth(spacerIndex, destination, padding,
+                    ScrollType.SPACER);
         }
 
         public void reapplySpacerWidths() {
@@ -5163,12 +6330,29 @@ public class Escalator extends Widget
             }
         }
 
+        /**
+         * @deprecated This method is no longer used by Escalator and is likely
+         *             to be removed soon.
+         *
+         * @param removedRowsRange
+         */
+        @Deprecated
         public void paintRemoveSpacers(Range removedRowsRange) {
             removeSpacers(removedRowsRange);
             shiftSpacersByRows(removedRowsRange.getStart(),
                     -removedRowsRange.length());
         }
 
+        /**
+         * Removes spacers of the given range without moving other contents.
+         * <p>
+         * NOTE: Changed functionality since 8.9. Previous incarnation of this
+         * method updated the positions of all the contents below the first
+         * removed spacer.
+         *
+         * @param removedRange
+         *            logical range of spacers to remove
+         */
         @SuppressWarnings("boxing")
         public void removeSpacers(Range removedRange) {
 
@@ -5180,15 +6364,16 @@ public class Escalator extends Widget
                 return;
             }
 
-            for (SpacerImpl spacer : removedSpacers.values()) {
-                /*
-                 * [[optimization]] TODO: Each invocation of the setHeight
-                 * method has a cascading effect in the DOM. if this proves to
-                 * be slow, the DOM offset could be updated as a batch.
-                 */
+            double specialSpacerHeight = removedRange.contains(-1)
+                    ? getSpacerHeight(-1)
+                    : 0;
+
+            for (Entry<Integer, SpacerImpl> entry : removedSpacers.entrySet()) {
+                SpacerImpl spacer = entry.getValue();
 
+                rowIndexToSpacer.remove(entry.getKey());
                 destroySpacerContent(spacer);
-                spacer.setHeight(0); // resets row offsets
+                spacer.clearHeight();
                 spacer.getRootElement().removeFromParent();
                 spacer.getDecoElement().removeFromParent();
             }
@@ -5200,6 +6385,15 @@ public class Escalator extends Widget
                 spacerScrollerRegistration.removeHandler();
                 spacerScrollerRegistration = null;
             }
+
+            // if a rowless spacer at the top got removed, all rows and spacers
+            // need to be moved up accordingly
+            if (!WidgetUtil.pixelValuesEqual(specialSpacerHeight, 0)) {
+                double scrollDiff = Math.min(specialSpacerHeight,
+                        getScrollTop());
+                body.moveViewportAndContent(null, -specialSpacerHeight,
+                        -specialSpacerHeight, -scrollDiff);
+            }
         }
 
         public Map<Integer, SpacerImpl> getSpacers() {
@@ -5427,7 +6621,8 @@ public class Escalator extends Widget
 
         /**
          * Gets the amount of pixels occupied by spacers until a logical row
-         * index.
+         * index. The spacer of the row corresponding with the given index isn't
+         * included.
          *
          * @param logicalIndex
          *            a logical row index
@@ -5500,7 +6695,8 @@ public class Escalator extends Widget
 
             initSpacerContent(spacer);
 
-            body.sortDomElements();
+            // schedule updating of the physical indexes
+            body.domSorter.reschedule();
         }
 
         private void updateExistingSpacer(int rowIndex, double newHeight) {
@@ -5572,7 +6768,7 @@ public class Escalator extends Widget
             assert getElement().isOrHasChild(spacer
                     .getElement()) : "Spacer element somehow got detached from Escalator during attaching";
 
-            spacer.updateVisibility();
+            spacer.show();
         }
 
         public String getSubPartName(Element subElement) {
@@ -5607,10 +6803,11 @@ public class Escalator extends Widget
         }
 
         /**
-         * Shifts spacers at and after a specific row by an amount of rows.
+         * Shifts spacers at and after a specific row by an amount of rows that
+         * don't contain spacers of their own.
          * <p>
-         * This moves both their associated row index and also their visual
-         * placement.
+         * This moves both their associated logical row index and also their
+         * visual placement.
          * <p>
          * <em>Note:</em> This method does not check for the validity of any
          * arguments.
@@ -5639,6 +6836,40 @@ public class Escalator extends Widget
             }
         }
 
+        /**
+         * Update the associated logical row indexes for spacers without moving
+         * their actual positions.
+         * <p>
+         * <em>Note:</em> This method does not check for the validity of any
+         * arguments.
+         *
+         * @param startIndex
+         *            the previous logical index of first row to update
+         * @param endIndex
+         *            the previous logical index of first row that doesn't need
+         *            updating anymore
+         * @param numberOfRows
+         *            the number of rows to shift the associated logical index
+         *            with. A positive value is downwards, a negative value is
+         *            upwards.
+         */
+        private void updateSpacerIndexesForRowAndAfter(int startIndex,
+                int endIndex, int numberOfRows) {
+            List<SpacerContainer.SpacerImpl> spacers = new ArrayList<>(
+                    getSpacersForRowAndAfter(startIndex));
+            spacers.removeAll(getSpacersForRowAndAfter(endIndex));
+            if (numberOfRows < 0) {
+                for (SpacerContainer.SpacerImpl spacer : spacers) {
+                    spacer.setRowIndex(spacer.getRow() + numberOfRows);
+                }
+            } else {
+                for (int i = spacers.size() - 1; i >= 0; --i) {
+                    SpacerContainer.SpacerImpl spacer = spacers.get(i);
+                    spacer.setRowIndex(spacer.getRow() + numberOfRows);
+                }
+            }
+        }
+
         private void updateSpacerDecosVisibility() {
             final Range visibleRowRange = getVisibleRowRange();
             Collection<SpacerImpl> visibleSpacers = rowIndexToSpacer
@@ -5745,6 +6976,10 @@ public class Escalator extends Widget
         }
     }
 
+    enum ScrollType {
+        ROW, SPACER, ROW_AND_SPACER
+    }
+
     // abs(atan(y/x))*(180/PI) = n deg, x = 1, solve y
     /**
      * The solution to
@@ -5847,6 +7082,13 @@ public class Escalator extends Widget
 
     private boolean layoutIsScheduled = false;
     private ScheduledCommand layoutCommand = () -> {
+        // ensure that row heights have been set or auto-detected if
+        // auto-detection is already possible, because visibility changes might
+        // not trigger the default check that happens in onLoad()
+        header.autodetectRowHeightLater();
+        body.autodetectRowHeightLater();
+        footer.autodetectRowHeightLater();
+
         recalculateElementSizes();
         layoutIsScheduled = false;
     };
@@ -6031,6 +7273,9 @@ public class Escalator extends Widget
     protected void onLoad() {
         super.onLoad();
 
+        // ensure that row heights have been set or auto-detected if
+        // auto-detection is already possible, if not the check will be
+        // performed again in layoutCommand
         header.autodetectRowHeightLater();
         body.autodetectRowHeightLater();
         footer.autodetectRowHeightLater();
@@ -6400,11 +7645,9 @@ public class Escalator extends Widget
     public void scrollToRow(final int rowIndex,
             final ScrollDestination destination, final int padding)
             throws IndexOutOfBoundsException, IllegalArgumentException {
-        Scheduler.get().scheduleFinally(() -> {
-            validateScrollDestination(destination, padding);
-            verifyValidRowIndex(rowIndex);
-            scroller.scrollToRow(rowIndex, destination, padding);
-        });
+        verifyValidRowIndex(rowIndex);
+        body.scrollToRowSpacerOrBoth(rowIndex, destination, padding,
+                ScrollType.ROW);
     }
 
     private void verifyValidRowIndex(final int rowIndex) {
@@ -6416,7 +7659,7 @@ public class Escalator extends Widget
 
     /**
      * Scrolls the body vertically so that the spacer at the given row index is
-     * visible and there is at least {@literal padding} pixesl to the given
+     * visible and there is at least {@literal padding} pixels to the given
      * scroll destination.
      *
      * @since 7.5.0
@@ -6437,8 +7680,8 @@ public class Escalator extends Widget
     public void scrollToSpacer(final int spacerIndex,
             ScrollDestination destination, final int padding)
             throws IllegalArgumentException {
-        validateScrollDestination(destination, padding);
-        body.scrollToSpacer(spacerIndex, destination, padding);
+        body.scrollToRowSpacerOrBoth(spacerIndex, destination, padding,
+                ScrollType.SPACER);
     }
 
     /**
@@ -6468,53 +7711,13 @@ public class Escalator extends Widget
     public void scrollToRowAndSpacer(final int rowIndex,
             final ScrollDestination destination, final int padding)
             throws IllegalArgumentException {
+        // wait for the layout phase to finish
         Scheduler.get().scheduleFinally(() -> {
-            validateScrollDestination(destination, padding);
             if (rowIndex != -1) {
                 verifyValidRowIndex(rowIndex);
             }
-
-            // row range
-            final Range rowRange;
-            if (rowIndex != -1) {
-                int rowTop = (int) Math.floor(body.getRowTop(rowIndex));
-                int rowHeight = (int) Math.ceil(body.getDefaultRowHeight());
-                rowRange = Range.withLength(rowTop, rowHeight);
-            } else {
-                rowRange = Range.withLength(0, 0);
-            }
-
-            // get spacer
-            final SpacerContainer.SpacerImpl spacer = body.spacerContainer
-                    .getSpacer(rowIndex);
-
-            if (rowIndex == -1 && spacer == null) {
-                throw new IllegalArgumentException("Cannot scroll to row index "
-                        + "-1, as there is no spacer open at that index.");
-            }
-
-            // make into target range
-            final Range targetRange;
-            if (spacer != null) {
-                final int spacerTop = (int) Math.floor(spacer.getTop());
-                final int spacerHeight = (int) Math.ceil(spacer.getHeight());
-                Range spacerRange = Range.withLength(spacerTop, spacerHeight);
-
-                targetRange = rowRange.combineWith(spacerRange);
-            } else {
-                targetRange = rowRange;
-            }
-
-            // get params
-            int targetStart = targetRange.getStart();
-            int targetEnd = targetRange.getEnd();
-            double viewportStart = getScrollTop();
-            double viewportEnd = viewportStart + body.getHeightOfSection();
-
-            double scrollPos = getScrollPos(destination, targetStart, targetEnd,
-                    viewportStart, viewportEnd, padding);
-
-            setScrollTop(scrollPos);
+            body.scrollToRowSpacerOrBoth(rowIndex, destination, padding,
+                    ScrollType.ROW_AND_SPACER);
         });
     }
 
@@ -6608,12 +7811,8 @@ public class Escalator extends Widget
 
     private void fireRowVisibilityChangeEvent() {
         if (!body.visualRowOrder.isEmpty()) {
-            int visibleRangeStart = body
-                    .getLogicalRowIndex(body.visualRowOrder.getFirst());
-            int visibleRangeEnd = body
-                    .getLogicalRowIndex(body.visualRowOrder.getLast()) + 1;
-
-            int visibleRowCount = visibleRangeEnd - visibleRangeStart;
+            int visibleRangeStart = body.getTopRowLogicalIndex();
+            int visibleRowCount = body.visualRowOrder.size();
             fireEvent(new RowVisibilityChangeEvent(visibleRangeStart,
                     visibleRowCount));
         } else {
@@ -6695,6 +7894,11 @@ public class Escalator extends Widget
      * @see #setHeightMode(HeightMode)
      */
     public void setHeightByRows(double rows) throws IllegalArgumentException {
+        if (heightMode == HeightMode.UNDEFINED && body.insertingOrRemoving) {
+            // this will be called again once the operation is finished, ignore
+            // for now
+            return;
+        }
         if (rows < 0) {
             throw new IllegalArgumentException(
                     "The number of rows must be a positive number.");
@@ -7037,21 +8241,10 @@ public class Escalator extends Widget
         if (type.equalsIgnoreCase("header")) {
             container = getHeader();
         } else if (type.equalsIgnoreCase("cell")) {
-            // If wanted row is not visible, we need to scroll there.
-            Range visibleRowRange = getVisibleRowRange();
             if (indices.length > 0) {
-                // Contains a row number, ensure it is available and visible
-                boolean rowInCache = visibleRowRange.contains(indices[0]);
-
-                // Scrolling might be a no-op if row is already in the viewport
+                // If wanted row is not visible, we need to scroll there.
+                // Scrolling might be a no-op if row is already in the viewport.
                 scrollToRow(indices[0], ScrollDestination.ANY, 0);
-
-                if (!rowInCache) {
-                    // Row was not in cache, scrolling caused lazy loading and
-                    // the caller needs to wait and call this method again to be
-                    // able to get the requested element
-                    return null;
-                }
             }
             container = getBody();
         } else if (type.equalsIgnoreCase("footer")) {
@@ -7119,6 +8312,10 @@ public class Escalator extends Widget
 
     private Element getSubPartElementSpacer(SubPartArguments args) {
         if ("spacer".equals(args.getType()) && args.getIndicesLength() == 1) {
+            // If spacer's row is not visible, we need to scroll there.
+            // Scrolling might be a no-op if row is already in the viewport.
+            scrollToSpacer(args.getIndex(0), ScrollDestination.ANY, 0);
+
             return body.spacerContainer.getSubPartElement(args.getIndex(0));
         } else {
             return null;
index 2ca2ffa96107e6a0aa7f4aa3a8e257d055b69195..3a67dcfd3b985138539d99abe5347725d0a66b02 100755 (executable)
@@ -3829,7 +3829,7 @@ public class Grid<T> extends ResizeComposite implements HasSelectionHandlers<T>,
                  * height, but the spacer cell (td) has the borders, which
                  * should go on top of the previous row and next row.
                  */
-                double contentHeight;
+                final double contentHeight;
                 if (detailsGenerator instanceof HeightAwareDetailsGenerator) {
                     HeightAwareDetailsGenerator sadg = (HeightAwareDetailsGenerator) detailsGenerator;
                     contentHeight = sadg.getDetailsHeight(rowIndex);
@@ -3839,8 +3839,32 @@ public class Grid<T> extends ResizeComposite implements HasSelectionHandlers<T>,
                 }
                 double borderTopAndBottomHeight = WidgetUtil
                         .getBorderTopAndBottomThickness(spacerElement);
-                double measuredHeight = contentHeight
-                        + borderTopAndBottomHeight;
+                double measuredHeight = 0d;
+                if (contentHeight > 0) {
+                    measuredHeight = contentHeight + borderTopAndBottomHeight;
+                } else {
+                    Scheduler.get().scheduleFinally(() -> {
+                        // make sure the spacer hasn't got removed
+                        if (spacer.getElement().getParentElement() != null) {
+                            // re-check the height
+                            double confirmedContentHeight = WidgetUtil
+                                    .getRequiredHeightBoundingClientRectDouble(
+                                            element);
+                            if (confirmedContentHeight > 0) {
+                                double confirmedMeasuredHeight = confirmedContentHeight
+                                        + WidgetUtil
+                                                .getBorderTopAndBottomThickness(
+                                                        spacer.getElement());
+                                escalator.getBody().setSpacer(spacer.getRow(),
+                                        confirmedMeasuredHeight);
+                                if (getHeightMode() == HeightMode.UNDEFINED) {
+                                    setHeightByRows(getEscalator().getBody()
+                                            .getRowCount());
+                                }
+                            }
+                        }
+                    });
+                }
                 assert getElement().isOrHasChild(
                         spacerElement) : "The spacer element wasn't in the DOM during measurement, but was assumed to be.";
                 spacerHeight = measuredHeight;
@@ -7208,6 +7232,9 @@ public class Grid<T> extends ResizeComposite implements HasSelectionHandlers<T>,
 
                     @Override
                     public void dataRemoved(int firstIndex, int numberOfItems) {
+                        for (int i = 0; i < numberOfItems; ++i) {
+                            visibleDetails.remove(firstIndex + i);
+                        }
                         escalator.getBody().removeRows(firstIndex,
                                 numberOfItems);
                         Range removed = Range.withLength(firstIndex,
@@ -9213,17 +9240,6 @@ public class Grid<T> extends ResizeComposite implements HasSelectionHandlers<T>,
                 recalculateColumnWidths();
             }
 
-            if (getEscalatorInnerHeight() != autoColumnWidthsRecalculator.lastCalculatedInnerHeight) {
-                Scheduler.get().scheduleFinally(() -> {
-                    // Trigger re-calculation of all row positions.
-                    RowContainer.BodyRowContainer body = getEscalator()
-                            .getBody();
-                    if (!body.isAutodetectingRowHeightLater()) {
-                        body.setDefaultRowHeight(body.getDefaultRowHeight());
-                    }
-                });
-            }
-
             // Vertical resizing could make editor positioning invalid so it
             // needs to be recalculated on resize
             if (isEditorActive()) {
index fa6237a83ae90a1424b185af44552421c49f8f02..104e90c3b96f62cb0421cbb764102f099333be68 100644 (file)
@@ -737,6 +737,7 @@ public class Grid<T> extends AbstractListing<T> implements HasComponents,
             if (this.generator != generator) {
                 removeAllComponents();
             }
+            getState().hasDetailsGenerator = generator != null;
             this.generator = generator;
             visibleDetails.forEach(this::refresh);
         }
index 1ac8987f01bf7def9f0fecb9d55fd8751839f84b..cb202f96bb02a5dc913af6bd7f370c7e5d0b91ec 100644 (file)
@@ -22,4 +22,12 @@ package com.vaadin.shared.ui.grid;
  */
 public class DetailsManagerState extends AbstractGridExtensionState {
 
+    /**
+     * For informing the connector when details handling can be skipped
+     * altogether as it's not possible to have any details rows without a
+     * generator.
+     * 
+     * @since 8.9
+     */
+    public boolean hasDetailsGenerator = false;
 }
index 5699ee43720b5f8d1f313332be9137d3e3eb989d..8fb9a358e4e0bcb1055bcd70b8a32ddec222d0f3 100644 (file)
@@ -48,7 +48,7 @@ public class TreeGridBigDetailsManager extends AbstractTestUI {
         treeGrid.setSizeFull();
         treeGrid.addColumn(String::toString).setCaption("String")
                 .setId("string");
-        treeGrid.addColumn((i) -> "--").setCaption("Nothing");
+        treeGrid.addColumn((i) -> items.indexOf(i)).setCaption("Index");
         treeGrid.setHierarchyColumn("string");
         treeGrid.setDetailsGenerator(
                 row -> new Label("details for " + row.toString()));
@@ -77,21 +77,54 @@ public class TreeGridBigDetailsManager extends AbstractTestUI {
             treeGrid.collapse(items);
         });
         collapseAll.setId("collapseAll");
+        @SuppressWarnings("deprecation")
         Button scrollTo55 = new Button("Scroll to 55",
                 event -> treeGrid.scrollTo(55));
         scrollTo55.setId("scrollTo55");
         scrollTo55.setVisible(false);
+        Button scrollTo3055 = new Button("Scroll to 3055",
+                event -> treeGrid.scrollTo(3055));
+        scrollTo3055.setId("scrollTo3055");
+        scrollTo3055.setVisible(false);
+        Button scrollToEnd = new Button("Scroll to end",
+                event -> treeGrid.scrollToEnd());
+        scrollToEnd.setId("scrollToEnd");
+        scrollToEnd.setVisible(false);
+        Button scrollToStart = new Button("Scroll to start",
+                event -> treeGrid.scrollToStart());
+        scrollToStart.setId("scrollToStart");
+        scrollToStart.setVisible(false);
+
+        Button toggle15 = new Button("Toggle 15",
+                event -> treeGrid.setDetailsVisible(items.get(15),
+                        !treeGrid.isDetailsVisible(items.get(15))));
+        toggle15.setId("toggle15");
+        toggle15.setVisible(false);
+
+        Button toggle3000 = new Button("Toggle 3000",
+                event -> treeGrid.setDetailsVisible(items.get(3000),
+                        !treeGrid.isDetailsVisible(items.get(3000))));
+        toggle3000.setId("toggle3000");
+        toggle3000.setVisible(false);
+
         Button addGrid = new Button("Add grid", event -> {
             addComponent(treeGrid);
             getLayout().setExpandRatio(treeGrid, 2);
             scrollTo55.setVisible(true);
+            scrollTo3055.setVisible(true);
+            scrollToEnd.setVisible(true);
+            scrollToStart.setVisible(true);
+            toggle15.setVisible(true);
+            toggle3000.setVisible(true);
         });
         addGrid.setId("addGrid");
 
         addComponents(
                 new HorizontalLayout(showDetails, hideDetails, expandAll,
                         collapseAll),
-                new HorizontalLayout(addGrid, scrollTo55));
+                new HorizontalLayout(scrollTo55, scrollTo3055, scrollToEnd,
+                        scrollToStart),
+                new HorizontalLayout(addGrid, toggle15, toggle3000));
 
         getLayout().getParent().setHeight("100%");
         getLayout().setHeight("100%");
index 2d7dd5cc370ddecf17f9c88637bfdf271027c667..941f1ce928c1cf921a3c3956030385e1389c849e 100644 (file)
@@ -1,9 +1,12 @@
 package com.vaadin.tests.widgetset.client.grid;
 
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 import com.google.gwt.core.client.Duration;
+import com.google.gwt.core.client.Scheduler;
 import com.google.gwt.core.client.Scheduler.ScheduledCommand;
 import com.google.gwt.dom.client.TableCellElement;
 import com.google.gwt.user.client.DOM;
@@ -16,6 +19,8 @@ import com.vaadin.client.widget.escalator.RowContainer;
 import com.vaadin.client.widget.escalator.RowContainer.BodyRowContainer;
 import com.vaadin.client.widget.escalator.Spacer;
 import com.vaadin.client.widget.escalator.SpacerUpdater;
+import com.vaadin.client.widget.escalator.events.SpacerIndexChangedEvent;
+import com.vaadin.client.widget.escalator.events.SpacerIndexChangedHandler;
 import com.vaadin.client.widgets.Escalator;
 import com.vaadin.shared.ui.grid.ScrollDestination;
 import com.vaadin.tests.widgetset.client.v7.grid.PureGWTTestApplication;
@@ -156,6 +161,7 @@ public class EscalatorBasicClientFeaturesWidget
         private int rowCounter = 0;
         private final List<Integer> columns = new ArrayList<>();
         private final List<Integer> rows = new ArrayList<>();
+        private final Map<Integer, Integer> spacers = new HashMap<>();
 
         @SuppressWarnings("boxing")
         public void insertRows(final int offset, final int amount) {
@@ -247,6 +253,11 @@ public class EscalatorBasicClientFeaturesWidget
                             cell.setColSpan(2);
                         }
                     }
+                    if (spacers.containsKey(cell.getRow()) && !escalator
+                            .getBody().spacerExists(cell.getRow())) {
+                        escalator.getBody().setSpacer(cell.getRow(),
+                                spacers.get(cell.getRow()));
+                    }
                 }
 
                 @Override
@@ -262,7 +273,12 @@ public class EscalatorBasicClientFeaturesWidget
         public void removeRows(final int offset, final int amount) {
             for (int i = 0; i < amount; i++) {
                 rows.remove(offset);
+                if (spacers.containsKey(offset + i)) {
+                    spacers.remove(offset + i);
+                }
             }
+            // the following spacers get their indexes updated through
+            // SpacerIndexChangedHandler
         }
 
         public void removeColumns(final int offset, final int amount) {
@@ -313,6 +329,21 @@ public class EscalatorBasicClientFeaturesWidget
         createFrozenMenu();
         createColspanMenu();
         createSpacerMenu();
+
+        escalator.addHandler(new SpacerIndexChangedHandler() {
+            @Override
+            public void onSpacerIndexChanged(SpacerIndexChangedEvent event) {
+                // remove spacer from old index and move to new index
+                Integer height = data.spacers.remove(event.getOldIndex());
+                if (height != null) {
+                    data.spacers.put(event.getNewIndex(), height);
+                } else {
+                    // no height, make sure the new index doesn't
+                    // point to anything else either
+                    data.spacers.remove(event.getNewIndex());
+                }
+            }
+        }, SpacerIndexChangedEvent.TYPE);
     }
 
     private void createFrozenMenu() {
@@ -558,11 +589,24 @@ public class EscalatorBasicClientFeaturesWidget
                     @Override
                     public void init(Spacer spacer) {
                         spacer.getElement().appendChild(DOM.createInputText());
+                        updateRowPositions(spacer);
                     }
 
                     @Override
                     public void destroy(Spacer spacer) {
                         spacer.getElement().removeAllChildren();
+                        updateRowPositions(spacer);
+                    }
+
+                    private void updateRowPositions(Spacer spacer) {
+                        if (spacer.getRow() < escalator.getBody()
+                                .getRowCount()) {
+                            Scheduler.get().scheduleFinally(() -> {
+                                escalator.getBody().updateRowPositions(
+                                        spacer.getRow(),
+                                        escalator.getBody().getRowCount());
+                            });
+                        }
                     }
                 }), menupath);
 
@@ -575,12 +619,18 @@ public class EscalatorBasicClientFeaturesWidget
     private void createSpacersMenuForRow(final int rowIndex,
             String[] menupath) {
         menupath = new String[] { menupath[0], menupath[1], "Row " + rowIndex };
-        addMenuCommand("Set 100px",
-                () -> escalator.getBody().setSpacer(rowIndex, 100), menupath);
-        addMenuCommand("Set 50px",
-                () -> escalator.getBody().setSpacer(rowIndex, 50), menupath);
-        addMenuCommand("Remove",
-                () -> escalator.getBody().setSpacer(rowIndex, -1), menupath);
+        addMenuCommand("Set 100px", () -> {
+            escalator.getBody().setSpacer(rowIndex, 100);
+            data.spacers.put(rowIndex, 100);
+        }, menupath);
+        addMenuCommand("Set 50px", () -> {
+            escalator.getBody().setSpacer(rowIndex, 50);
+            data.spacers.put(rowIndex, 50);
+        }, menupath);
+        addMenuCommand("Remove", () -> {
+            escalator.getBody().setSpacer(rowIndex, -1);
+            data.spacers.remove(rowIndex);
+        }, menupath);
         addMenuCommand("Scroll here (ANY, 0)", () -> escalator
                 .scrollToSpacer(rowIndex, ScrollDestination.ANY, 0), menupath);
         addMenuCommand("Scroll here row+spacer below (ANY, 0)", () -> escalator
@@ -596,6 +646,9 @@ public class EscalatorBasicClientFeaturesWidget
         } else {
             container.insertRows(offset, number);
         }
+        if (container.getRowCount() > offset + number) {
+            container.refreshRows(offset + number, container.getRowCount());
+        }
     }
 
     private void removeRows(final RowContainer container, int offset,
@@ -606,6 +659,9 @@ public class EscalatorBasicClientFeaturesWidget
         } else {
             container.removeRows(offset, number);
         }
+        if (container.getRowCount() > offset) {
+            container.refreshRows(offset, container.getRowCount());
+        }
     }
 
     private void insertColumns(final int offset, final int number) {
index fe6f7ab94b3f5e0af7a40f0b40f568609814f843..5d5148109b21ce055efc908f5ead8588db52ba25 100644 (file)
@@ -125,6 +125,11 @@ public class EscalatorProxy extends Escalator {
             throw new UnsupportedOperationException(
                     "setNewRowCallback is not supported");
         }
+
+        @Override
+        public void updateRowPositions(int index, int numberOfRows) {
+            rowContainer.updateRowPositions(index, numberOfRows);
+        }
     }
 
     private class RowContainerProxy implements RowContainer {
index 3ff3d61809eb038ad7b74df78a1b5208eb9cf9a1..d0122cd9df9ca082c0c417b675605f262f833045 100644 (file)
@@ -1,5 +1,9 @@
 package com.vaadin.tests.components.grid;
 
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
 import java.util.Locale;
 import java.util.stream.IntStream;
 import java.util.stream.Stream;
@@ -21,10 +25,6 @@ import com.vaadin.testbench.elements.TextFieldElement;
 import com.vaadin.testbench.parallel.BrowserUtil;
 import com.vaadin.tests.tb3.MultiBrowserTest;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-
 public class GridComponentsTest extends MultiBrowserTest {
 
     @Test
@@ -219,19 +219,14 @@ public class GridComponentsTest extends MultiBrowserTest {
                 getScrollLeft(grid));
 
         // Navigate back to fully visible TextField
-        new Actions(getDriver()).sendKeys(Keys.chord(Keys.SHIFT, Keys.TAB))
-                .perform();
+        pressKeyWithModifier(Keys.SHIFT, Keys.TAB);
         assertEquals(
                 "Grid should not scroll when focusing the text field again. ",
                 scrollMax, getScrollLeft(grid));
 
         // Navigate to out of viewport TextField in Header
-        new Actions(getDriver()).sendKeys(Keys.chord(Keys.SHIFT, Keys.TAB))
-                .perform();
-        // After Chrome 75, sendkeys issues
-        if (BrowserUtil.isChrome(getDesiredCapabilities())) {
-            grid.getHeaderCell(1, 0).findElement(By.id("headerField")).click();
-        }
+        pressKeyWithModifier(Keys.SHIFT, Keys.TAB);
+
         assertEquals("Focus should be in TextField in Header", "headerField",
                 getFocusedElement().getAttribute("id"));
         assertEquals("Grid should've scrolled back to start.", 0,
@@ -248,10 +243,30 @@ public class GridComponentsTest extends MultiBrowserTest {
 
         // Navigate to currently out of viewport TextField on Row 8
         new Actions(getDriver()).sendKeys(Keys.TAB, Keys.TAB).perform();
-        assertTrue("Grid should be scrolled to show row 7",
+        assertTrue("Grid should be scrolled to show row 8",
                 Integer.parseInt(grid.getVerticalScroller()
                         .getAttribute("scrollTop")) > scrollTopRow7);
 
+        // Focus button in first visible row of Grid
+        grid.getCell(2, 2).findElement(By.id("row_2")).click();
+        int scrollTopRow2 = Integer
+                .parseInt(grid.getVerticalScroller().getAttribute("scrollTop"));
+
+        // Navigate to currently out of viewport Button on Row 1
+        pressKeyWithModifier(Keys.SHIFT, Keys.TAB);
+        pressKeyWithModifier(Keys.SHIFT, Keys.TAB);
+        int scrollTopRow1 = Integer
+                .parseInt(grid.getVerticalScroller().getAttribute("scrollTop"));
+        assertTrue("Grid should be scrolled to show row 1",
+                scrollTopRow1 < scrollTopRow2);
+
+        // Continue further to the very first row
+        pressKeyWithModifier(Keys.SHIFT, Keys.TAB);
+        pressKeyWithModifier(Keys.SHIFT, Keys.TAB);
+        assertTrue("Grid should be scrolled to show row 0",
+                Integer.parseInt(grid.getVerticalScroller()
+                        .getAttribute("scrollTop")) < scrollTopRow1);
+
         // Focus button in last row of Grid
         grid.getCell(999, 2).findElement(By.id("row_999")).click();
         // Navigate to out of viewport TextField in Footer
@@ -288,4 +303,12 @@ public class GridComponentsTest extends MultiBrowserTest {
         assertFalse("Row " + i + " should not have a button",
                 row.getCell(2).isElementPresent(ButtonElement.class));
     }
+
+    // Workaround for Chrome 75, sendKeys(Keys.chord(Keys.SHIFT, Keys.TAB))
+    // doesn't work anymore
+    private void pressKeyWithModifier(Keys keyModifier, Keys key) {
+        new Actions(getDriver()).keyDown(keyModifier).perform();
+        new Actions(getDriver()).sendKeys(key).perform();
+        new Actions(getDriver()).keyUp(keyModifier).perform();
+    }
 }
index e4fd2fc2ddc0ace85c21cae6bc291b49bf2c3071..a37e266d30abf3b9b37db17f8470e575fb693995 100644 (file)
@@ -70,28 +70,27 @@ public class GridScrolledToBottomTest extends MultiBrowserTest {
         Actions actions = new Actions(driver);
         actions.clickAndHold(splitter).moveByOffset(0, -rowHeight / 2).release()
                 .perform();
-        // the last row is now only half visible, and in DOM tree it's actually
-        // the first row now but positioned to the bottom
+        // the last row is now only half visible
 
         // can't query grid.getRow(99) now or it moves the row position,
         // have to use element query instead
         List<WebElement> rows = grid.findElement(By.className("v-grid-body"))
                 .findElements(By.className("v-grid-row"));
-        WebElement firstRow = rows.get(0);
         WebElement lastRow = rows.get(rows.size() - 1);
+        WebElement secondToLastRow = rows.get(rows.size() - 2);
 
         // ensure the scrolling didn't jump extra
         assertEquals("Person 99",
-                firstRow.findElement(By.className("v-grid-cell")).getText());
-        assertEquals("Person 98",
                 lastRow.findElement(By.className("v-grid-cell")).getText());
+        assertEquals("Person 98", secondToLastRow
+                .findElement(By.className("v-grid-cell")).getText());
 
         // re-calculate current end position
         gridBottomY = grid.getLocation().getY() + grid.getSize().getHeight();
         // ensure the correct final row really is only half visible at the
         // bottom
-        assertThat(gridBottomY, greaterThan(firstRow.getLocation().getY()));
-        assertThat(firstRow.getLocation().getY() + rowHeight,
+        assertThat(gridBottomY, greaterThan(lastRow.getLocation().getY()));
+        assertThat(lastRow.getLocation().getY() + rowHeight,
                 greaterThan(gridBottomY));
     }
 }
index df51706ddb513a5877c35f09e4b2ef236863f408..812052a13ce4aa8ac83b76ae2565c8e583606871 100644 (file)
@@ -103,12 +103,12 @@ public class GridColumnResizeModeTest extends GridBasicsTest {
 
         // ANIMATED resize mode
         drag(handle, 100);
-        assertTrue(
+        assertTrue("Expected width: " + cell.getSize().getWidth(),
                 getLogRow(0).contains("Column resized: caption=Column 1, width="
                         + cell.getSize().getWidth()));
 
         drag(handle, -100);
-        assertTrue(
+        assertTrue("Expected width: " + cell.getSize().getWidth(),
                 getLogRow(0).contains("Column resized: caption=Column 1, width="
                         + cell.getSize().getWidth()));
 
@@ -117,12 +117,12 @@ public class GridColumnResizeModeTest extends GridBasicsTest {
         sleep(250);
 
         drag(handle, 100);
-        assertTrue(
+        assertTrue("Expected width: " + cell.getSize().getWidth(),
                 getLogRow(0).contains("Column resized: caption=Column 1, width="
                         + cell.getSize().getWidth()));
 
         drag(handle, -100);
-        assertTrue(
+        assertTrue("Expected width: " + cell.getSize().getWidth(),
                 getLogRow(0).contains("Column resized: caption=Column 1, width="
                         + cell.getSize().getWidth()));
     }
index 3e8e7d65a3a030b9665a15b2e1c1383033eb3571..87fbb358b9d25a67854a8cac94f08a21c0d25107 100644 (file)
@@ -3,12 +3,16 @@ package com.vaadin.tests.components.grid.basicfeatures.escalator;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
 
 import java.io.IOException;
 
 import org.junit.Before;
 import org.junit.Test;
+import org.openqa.selenium.By;
+import org.openqa.selenium.WebElement;
 
+import com.vaadin.testbench.TestBenchElement;
 import com.vaadin.testbench.elements.NotificationElement;
 import com.vaadin.tests.components.grid.basicfeatures.EscalatorBasicClientFeaturesTest;
 
@@ -49,13 +53,29 @@ public class EscalatorBasicsTest extends EscalatorBasicClientFeaturesTest {
         scrollHorizontallyTo(50);
 
         selectMenuPath(GENERAL, DETACH_ESCALATOR);
+        waitForElementNotPresent(By.className("v-escalator"));
         selectMenuPath(GENERAL, ATTACH_ESCALATOR);
+        waitForElementPresent(By.className("v-escalator"));
 
         assertEquals("Vertical scroll position", 50, getScrollTop());
         assertEquals("Horizontal scroll position", 50, getScrollLeft());
 
+        TestBenchElement bodyCell = getBodyCell(2, 0);
+        WebElement viewport = findElement(
+                By.className("v-escalator-tablewrapper"));
+        WebElement header = findElement(By.className("v-escalator-header"));
+        // ensure this is the first (partially) visible cell
+        assertTrue(
+                viewport.getLocation().getX() > bodyCell.getLocation().getX());
+        assertTrue(viewport.getLocation().getX() < bodyCell.getLocation().getX()
+                + bodyCell.getSize().getWidth());
+        assertTrue(header.getLocation().getY()
+                + header.getSize().getHeight() > bodyCell.getLocation().getY());
+        assertTrue(header.getLocation().getY()
+                + header.getSize().getHeight() < bodyCell.getLocation().getY()
+                        + bodyCell.getSize().getHeight());
         assertEquals("First cell of first visible row", "Row 2: 0,2",
-                getBodyCell(0, 0).getText());
+                bodyCell.getText());
     }
 
     private void assertEscalatorIsRemovedCorrectly() {
index 7924b67503100bfb9327a0db5426a231e4a43254..45e08f9683bbc0df78573b9e8272480cf241888d 100644 (file)
@@ -275,12 +275,17 @@ public class EscalatorSpacerTest extends EscalatorBasicClientFeaturesTest {
         selectMenuPath(FEATURES, SPACERS, ROW_1, SET_100PX);
 
         /*
-         * we check for row -3 instead of -1, because escalator has two rows
+         * we check for row -2 instead of -1, because escalator has one row
          * buffered underneath the footer
          */
         selectMenuPath(COLUMNS_AND_ROWS, BODY_ROWS, SCROLL_TO, ROW_75);
         Thread.sleep(500);
-        assertEquals("Row 75: 0,75", getBodyCell(-3, 0).getText());
+        TestBenchElement cell75 = getBodyCell(-2, 0);
+        assertEquals("Row 75: 0,75", cell75.getText());
+        // confirm the scroll position
+        WebElement footer = findElement(By.className("v-escalator-footer"));
+        assertEquals(footer.getLocation().y,
+                cell75.getLocation().y + cell75.getSize().height);
 
         selectMenuPath(COLUMNS_AND_ROWS, BODY_ROWS, SCROLL_TO, ROW_25);
         Thread.sleep(500);
@@ -406,17 +411,52 @@ public class EscalatorSpacerTest extends EscalatorBasicClientFeaturesTest {
     }
 
     @Test
-    public void spacersAreInCorrectDomPositionAfterScroll() {
+    public void spacersAreInCorrectDomPositionAfterScroll()
+            throws InterruptedException {
         selectMenuPath(FEATURES, SPACERS, ROW_1, SET_100PX);
 
-        scrollVerticallyTo(32); // roughly one row's worth
+        scrollVerticallyTo(40); // roughly two rows' worth
 
+        // both rows should still be within DOM after this little scrolling, so
+        // the spacer should be the third element within the body (index: 2)
         WebElement tbody = getEscalator().findElement(By.tagName("tbody"));
-        WebElement spacer = getChild(tbody, 1);
+        WebElement spacer = getChild(tbody, 2);
         String cssClass = spacer.getAttribute("class");
         assertTrue(
-                "element index 1 was not a spacer (class=\"" + cssClass + "\")",
+                "element index 2 was not a spacer (class=\"" + cssClass + "\")",
                 cssClass.contains("-spacer"));
+
+        // Scroll to last DOM row (Row 20). The exact position varies a bit
+        // depending on the browser.
+        int scrollTo = 172;
+        while (scrollTo < 176) {
+            scrollVerticallyTo(scrollTo);
+            Thread.sleep(500);
+
+            // if spacer is still the third (index: 2) body element, i.e. not
+            // enough scrolling to re-purpose any rows, scroll a bit further
+            spacer = getChild(tbody, 2);
+            cssClass = spacer.getAttribute("class");
+            if (cssClass.contains("-spacer")) {
+                ++scrollTo;
+            } else {
+                break;
+            }
+        }
+        if (getChild(tbody, 20).getText().startsWith("Row 22:")) {
+            // Some browsers scroll too much, spacer should be out of visual
+            // range
+            assertNull("Element found where there should be none",
+                    getChild(tbody, 21));
+        } else {
+            // second row should still be within DOM but the first row out of
+            // it, so the spacer should be the second element within the body
+            // (index: 1)
+            spacer = getChild(tbody, 1);
+            cssClass = spacer.getAttribute("class");
+            assertTrue("element index 1 was not a spacer (class=\"" + cssClass
+                    + "\")", cssClass.contains("-spacer"));
+        }
     }
 
     @Test
index 76c700ec8f63cbf712709aa568147d101dd98652..b145dc99bc0321e0fc0f1171e8e5b7b4ad01e988 100644 (file)
@@ -1,12 +1,16 @@
 package com.vaadin.tests.components.treegrid;
 
 import static org.hamcrest.Matchers.greaterThanOrEqualTo;
+import static org.hamcrest.Matchers.not;
 import static org.hamcrest.number.IsCloseTo.closeTo;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThat;
 
 import java.util.List;
 
+import org.junit.Before;
 import org.junit.Test;
 import org.openqa.selenium.StaleElementReferenceException;
 import org.openqa.selenium.WebDriver;
@@ -15,6 +19,7 @@ import org.openqa.selenium.support.ui.ExpectedCondition;
 import org.openqa.selenium.support.ui.ExpectedConditions;
 
 import com.vaadin.testbench.By;
+import com.vaadin.testbench.TestBenchElement;
 import com.vaadin.testbench.elements.ButtonElement;
 import com.vaadin.testbench.elements.TreeGridElement;
 import com.vaadin.tests.tb3.MultiBrowserTest;
@@ -33,13 +38,18 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
     private static final String HIDE_DETAILS = "hideDetails";
     private static final String ADD_GRID = "addGrid";
     private static final String SCROLL_TO_55 = "scrollTo55";
+    private static final String SCROLL_TO_3055 = "scrollTo3055";
+    private static final String SCROLL_TO_END = "scrollToEnd";
+    private static final String SCROLL_TO_START = "scrollToStart";
+    private static final String TOGGLE_15 = "toggle15";
+    private static final String TOGGLE_3000 = "toggle3000";
 
     private TreeGridElement treeGrid;
     private int expectedSpacerHeight = 0;
     private int expectedRowHeight = 0;
 
     private ExpectedCondition<Boolean> expectedConditionDetails(final int root,
-            final int branch, final int leaf) {
+            final Integer branch, final Integer leaf) {
         return new ExpectedCondition<Boolean>() {
             @Override
             public Boolean apply(WebDriver arg0) {
@@ -49,9 +59,14 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
             @Override
             public String toString() {
                 // waiting for...
+                if (leaf != null) {
+                    return String.format(
+                            "Leaf %s/%s/%s details row contents to be found",
+                            root, branch, leaf);
+                }
                 return String.format(
-                        "Leaf %s/%s/%s details row contents to be found", root,
-                        branch, leaf);
+                        "Branch %s/%s details row contents to be found", root,
+                        branch);
             }
         };
     }
@@ -87,6 +102,11 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
         return null;
     }
 
+    private WebElement getRow(int index) {
+        return treeGrid.getBody().findElements(By.className("v-treegrid-row"))
+                .get(index);
+    }
+
     private void ensureExpectedSpacerHeightSet() {
         if (expectedSpacerHeight == 0) {
             expectedSpacerHeight = treeGrid
@@ -94,9 +114,6 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
                     .getHeight();
             assertThat((double) expectedSpacerHeight, closeTo(27d, 2d));
         }
-        if (expectedRowHeight == 0) {
-            expectedRowHeight = treeGrid.getRow(0).getSize().getHeight();
-        }
     }
 
     private void assertSpacerCount(int expectedSpacerCount) {
@@ -129,10 +146,6 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
                 previousSpacer = spacer;
                 continue;
             }
-            if (spacer.getLocation().y == 0) {
-                // FIXME: find out why there are cases like this out of order
-                continue;
-            }
             // -1 should be enough, but increased tolerance to -3 for FireFox
             // and IE11 since a few pixels' discrepancy isn't relevant for this
             // fix
@@ -148,16 +161,24 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
                 treeGrid.findElements(By.className(CLASSNAME_ERROR)).size());
     }
 
-    @Test
-    public void expandAllOpenAllInitialDetails_toggleOneTwice_hideAll() {
-        openTestURL();
-        $(ButtonElement.class).id(EXPAND_ALL).click();
-        $(ButtonElement.class).id(SHOW_DETAILS).click();
+    private void addGrid() {
         $(ButtonElement.class).id(ADD_GRID).click();
-
         waitForElementPresent(By.className(CLASSNAME_TREEGRID));
 
         treeGrid = $(TreeGridElement.class).first();
+        expectedRowHeight = treeGrid.getRow(0).getSize().getHeight();
+    }
+
+    @Before
+    public void before() {
+        openTestURL();
+    }
+
+    @Test
+    public void expandAllOpenAllInitialDetails_toggleOneTwice_hideAll() {
+        $(ButtonElement.class).id(EXPAND_ALL).click();
+        $(ButtonElement.class).id(SHOW_DETAILS).click();
+        addGrid();
 
         waitUntil(expectedConditionDetails(0, 0, 0));
         ensureExpectedSpacerHeightSet();
@@ -205,14 +226,9 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
 
     @Test
     public void expandAllOpenAllInitialDetails_toggleAll() {
-        openTestURL();
         $(ButtonElement.class).id(EXPAND_ALL).click();
         $(ButtonElement.class).id(SHOW_DETAILS).click();
-        $(ButtonElement.class).id(ADD_GRID).click();
-
-        waitForElementPresent(By.className(CLASSNAME_TREEGRID));
-
-        treeGrid = $(TreeGridElement.class).first();
+        addGrid();
 
         waitUntil(expectedConditionDetails(0, 0, 0));
         ensureExpectedSpacerHeightSet();
@@ -231,9 +247,9 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
         assertSpacerPositions();
 
         // FIXME: TreeGrid fails to update cache correctly when you expand all
-        // and after a long, long wait you end up with 3321 open details rows
-        // and row 63/8/0 in view instead of 95 and 0/0/0 as expected.
-        // WaitUntil timeouts by then.
+        // and triggers client-side exceptions for rows that fall outside of the
+        // cache because they try to extend the cache with a range that isn't
+        // connected to the cached range
         if (true) {// remove this block after fixed
             return;
         }
@@ -241,7 +257,7 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
         $(ButtonElement.class).id(EXPAND_ALL).click();
 
         // State should have returned to what it was before collapsing.
-        waitUntil(expectedConditionDetails(0, 0, 0));
+        waitUntil(expectedConditionDetails(0, 0, 0), 15);
         assertSpacerCount(spacerCount);
         assertSpacerHeights();
         assertSpacerPositions();
@@ -251,13 +267,8 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
 
     @Test
     public void expandAllOpenNoInitialDetails_showSeveral_toggleOneByOne() {
-        openTestURL();
         $(ButtonElement.class).id(EXPAND_ALL).click();
-        $(ButtonElement.class).id(ADD_GRID).click();
-
-        waitForElementPresent(By.className(CLASSNAME_TREEGRID));
-
-        treeGrid = $(TreeGridElement.class).first();
+        addGrid();
 
         // open details for several rows, leave one out from the hierarchy that
         // is to be collapsed
@@ -309,18 +320,30 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
         assertNoErrors();
     }
 
+    @Test
+    public void expandAllOpenAllInitialDetails_hideOne() {
+        $(ButtonElement.class).id(EXPAND_ALL).click();
+        $(ButtonElement.class).id(SHOW_DETAILS).click();
+        addGrid();
+
+        // check the position of a row
+        int oldY = treeGrid.getCell(2, 0).getLocation().getY();
+
+        // hide the spacer from previous row
+        treeGrid.getCell(1, 0).click();
+
+        // ensure the investigated row moved
+        assertNotEquals(oldY, treeGrid.getCell(2, 0).getLocation().getY());
+    }
+
     @Test
     public void expandAllOpenAllInitialDetailsScrolled_toggleOne_hideAll() {
-        openTestURL();
         $(ButtonElement.class).id(EXPAND_ALL).click();
         $(ButtonElement.class).id(SHOW_DETAILS).click();
-        $(ButtonElement.class).id(ADD_GRID).click();
+        addGrid();
 
-        waitForElementPresent(By.className(CLASSNAME_TREEGRID));
         $(ButtonElement.class).id(SCROLL_TO_55).click();
 
-        treeGrid = $(TreeGridElement.class).first();
-
         waitUntil(expectedConditionDetails(1, 2, 0));
         ensureExpectedSpacerHeightSet();
         int spacerCount = treeGrid.findElements(By.className(CLASSNAME_SPACER))
@@ -333,8 +356,7 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
         waitUntil(ExpectedConditions.not(expectedConditionDetails(1, 2, 0)));
         assertSpacerHeights();
         assertSpacerPositions();
-        // FIXME: gives 128, not 90 as expected
-        // assertSpacerCount(spacerCount);
+        assertSpacerCount(spacerCount);
 
         treeGrid.expandWithClick(50);
 
@@ -342,8 +364,7 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
         waitUntil(expectedConditionDetails(1, 2, 0));
         assertSpacerHeights();
         assertSpacerPositions();
-        // FIXME: gives 131, not 90 as expected
-        // assertSpacerCount(spacerCount);
+        assertSpacerCount(spacerCount);
 
         // test that repeating the toggle still doesn't change anything
 
@@ -352,16 +373,14 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
         waitUntil(ExpectedConditions.not(expectedConditionDetails(1, 2, 0)));
         assertSpacerHeights();
         assertSpacerPositions();
-        // FIXME: gives 128, not 90 as expected
-        // assertSpacerCount(spacerCount);
+        assertSpacerCount(spacerCount);
 
         treeGrid.expandWithClick(50);
 
         waitUntil(expectedConditionDetails(1, 2, 0));
         assertSpacerHeights();
         assertSpacerPositions();
-        // FIXME: gives 131, not 90 as expected
-        // assertSpacerCount(spacerCount);
+        assertSpacerCount(spacerCount);
 
         // test that hiding all still won't break things
 
@@ -373,17 +392,13 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
 
     @Test
     public void expandAllOpenAllInitialDetailsScrolled_toggleAll() {
-        openTestURL();
         $(ButtonElement.class).id(EXPAND_ALL).click();
         $(ButtonElement.class).id(SHOW_DETAILS).click();
-        $(ButtonElement.class).id(ADD_GRID).click();
+        addGrid();
 
-        waitForElementPresent(By.className(CLASSNAME_TREEGRID));
         $(ButtonElement.class).id(SCROLL_TO_55).click();
 
-        treeGrid = $(TreeGridElement.class).first();
-
-        waitUntil(expectedConditionDetails(1, 1, 0));
+        waitUntil(expectedConditionDetails(1, 3, 0));
         ensureExpectedSpacerHeightSet();
 
         int spacerCount = treeGrid.findElements(By.className(CLASSNAME_SPACER))
@@ -400,7 +415,8 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
         assertSpacerHeights();
         assertSpacerPositions();
 
-        // FIXME: collapsing too many rows after scrolling still causes a chaos
+        // FIXME: collapsing and expanding too many rows after scrolling still
+        // fails to reset to the same state
         if (true) { // remove this block after fixed
             return;
         }
@@ -408,7 +424,7 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
         $(ButtonElement.class).id(EXPAND_ALL).click();
 
         // State should have returned to what it was before collapsing.
-        waitUntil(expectedConditionDetails(1, 1, 0));
+        waitUntil(expectedConditionDetails(1, 3, 0));
         assertSpacerCount(spacerCount);
         assertSpacerHeights();
         assertSpacerPositions();
@@ -418,27 +434,24 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
 
     @Test
     public void expandAllOpenNoInitialDetailsScrolled_showSeveral_toggleOneByOne() {
-        openTestURL();
         $(ButtonElement.class).id(EXPAND_ALL).click();
-        $(ButtonElement.class).id(ADD_GRID).click();
+        addGrid();
 
-        waitForElementPresent(By.className(CLASSNAME_TREEGRID));
         $(ButtonElement.class).id(SCROLL_TO_55).click();
 
-        treeGrid = $(TreeGridElement.class).first();
         assertSpacerCount(0);
 
         // open details for several rows, leave one out from the hierarchy that
         // is to be collapsed
-        treeGrid.getCell(50, 0).click();
-        treeGrid.getCell(51, 0).click();
-        treeGrid.getCell(52, 0).click();
-        // no click for cell (53, 0)
-        treeGrid.getCell(54, 0).click();
-        treeGrid.getCell(55, 0).click();
-        treeGrid.getCell(56, 0).click();
-        treeGrid.getCell(57, 0).click();
-        treeGrid.getCell(58, 0).click();
+        treeGrid.getCell(50, 0).click(); // Branch 1/2
+        treeGrid.getCell(51, 0).click(); // Leaf 1/2/0
+        treeGrid.getCell(52, 0).click(); // Leaf 1/2/1
+        // no click for cell (53, 0) // Leaf 1/2/2
+        treeGrid.getCell(54, 0).click(); // Branch 1/3
+        treeGrid.getCell(55, 0).click(); // Leaf 1/3/0
+        treeGrid.getCell(56, 0).click(); // Leaf 1/3/1
+        treeGrid.getCell(57, 0).click(); // Leaf 1/3/2
+        treeGrid.getCell(58, 0).click(); // Branch 1/4
         int spacerCount = 8;
 
         waitUntil(expectedConditionDetails(1, 2, 0));
@@ -507,4 +520,351 @@ public class TreeGridBigDetailsManagerTest extends MultiBrowserTest {
         assertNoErrors();
     }
 
+    @Test
+    public void expandAllOpenAllInitialDetailsScrolled_hideOne() {
+        $(ButtonElement.class).id(EXPAND_ALL).click();
+        $(ButtonElement.class).id(SHOW_DETAILS).click();
+        addGrid();
+
+        $(ButtonElement.class).id(SCROLL_TO_55).click();
+
+        // check the position of a row
+        int oldY = treeGrid.getCell(52, 0).getLocation().getY();
+
+        // hide the spacer from previous row
+        treeGrid.getCell(51, 0).click();
+
+        // ensure the investigated row moved
+        assertNotEquals(oldY, treeGrid.getCell(52, 0).getLocation().getY());
+    }
+
+    @Test
+    public void expandAllOpenAllInitialDetailsScrolledFar_toggleOne_hideAll() {
+        $(ButtonElement.class).id(EXPAND_ALL).click();
+        $(ButtonElement.class).id(SHOW_DETAILS).click();
+        addGrid();
+
+        $(ButtonElement.class).id(SCROLL_TO_3055).click();
+
+        waitUntil(expectedConditionDetails(74, 4, 0));
+        ensureExpectedSpacerHeightSet();
+        int spacerCount = treeGrid.findElements(By.className(CLASSNAME_SPACER))
+                .size();
+        assertSpacerPositions();
+
+        treeGrid.collapseWithClick(3051);
+
+        // collapsing one shouldn't affect spacer count, just update the cache
+        waitUntil(ExpectedConditions.not(expectedConditionDetails(1, 2, 0)));
+        assertSpacerHeights();
+        assertSpacerPositions();
+        assertSpacerCount(spacerCount);
+
+        treeGrid.expandWithClick(3051);
+
+        // expanding back shouldn't affect spacer count, just update the cache
+        waitUntil(expectedConditionDetails(74, 4, 0));
+        assertSpacerHeights();
+        assertSpacerPositions();
+        assertSpacerCount(spacerCount);
+
+        // test that repeating the toggle still doesn't change anything
+
+        treeGrid.collapseWithClick(3051);
+
+        waitUntil(ExpectedConditions.not(expectedConditionDetails(74, 4, 0)));
+        assertSpacerHeights();
+        assertSpacerPositions();
+        assertSpacerCount(spacerCount);
+
+        treeGrid.expandWithClick(3051);
+
+        waitUntil(expectedConditionDetails(74, 4, 0));
+        assertSpacerHeights();
+        assertSpacerPositions();
+        assertSpacerCount(spacerCount);
+
+        // test that hiding all still won't break things
+
+        $(ButtonElement.class).id(HIDE_DETAILS).click();
+        waitForElementNotPresent(By.className(CLASSNAME_SPACER));
+
+        assertNoErrors();
+    }
+
+    @Test
+    public void expandAllOpenAllInitialDetailsScrolledFar_toggleAll() {
+        $(ButtonElement.class).id(EXPAND_ALL).click();
+        $(ButtonElement.class).id(SHOW_DETAILS).click();
+        addGrid();
+
+        $(ButtonElement.class).id(SCROLL_TO_3055).click();
+
+        waitUntil(expectedConditionDetails(74, 4, 0));
+        ensureExpectedSpacerHeightSet();
+
+        int spacerCount = treeGrid.findElements(By.className(CLASSNAME_SPACER))
+                .size();
+        assertSpacerPositions();
+
+        $(ButtonElement.class).id(COLLAPSE_ALL).click();
+
+        waitForElementNotPresent(By.className(CLASSNAME_LEAF));
+
+        // There should still be a full cache's worth of details rows open,
+        // just not the same rows than before collapsing all.
+        assertSpacerCount(spacerCount);
+        assertSpacerHeights();
+        assertSpacerPositions();
+
+        // FIXME: collapsing and expanding too many rows after scrolling still
+        // fails to reset to the same state
+        if (true) { // remove this block after fixed
+            return;
+        }
+
+        $(ButtonElement.class).id(EXPAND_ALL).click();
+
+        // State should have returned to what it was before collapsing.
+        waitUntil(expectedConditionDetails(74, 4, 0));
+        assertSpacerCount(spacerCount);
+        assertSpacerHeights();
+        assertSpacerPositions();
+
+        assertNoErrors();
+    }
+
+    @Test
+    public void expandAllOpenNoInitialDetailsScrolledFar_showSeveral_toggleOneByOne() {
+        $(ButtonElement.class).id(EXPAND_ALL).click();
+        addGrid();
+
+        $(ButtonElement.class).id(SCROLL_TO_3055).click();
+
+        assertSpacerCount(0);
+
+        // open details for several rows, leave one out from the hierarchy that
+        // is to be collapsed
+        treeGrid.getCell(3051, 0).click(); // Branch 74/4
+        treeGrid.getCell(3052, 0).click(); // Leaf 74/4/0
+        treeGrid.getCell(3053, 0).click(); // Leaf 74/4/1
+        // no click for cell (3054, 0) // Leaf 74/4/2
+        treeGrid.getCell(3055, 0).click(); // Branch 74/5
+        treeGrid.getCell(3056, 0).click(); // Leaf 74/5/0
+        treeGrid.getCell(3057, 0).click(); // Leaf 74/5/1
+        treeGrid.getCell(3058, 0).click(); // Leaf 74/5/2
+        treeGrid.getCell(3059, 0).click(); // Branch 74/6
+        int spacerCount = 8;
+
+        waitUntil(expectedConditionDetails(74, 4, 0));
+        assertSpacerCount(spacerCount);
+        ensureExpectedSpacerHeightSet();
+        assertSpacerPositions();
+
+        // toggle the branch with partially open details rows
+        treeGrid.collapseWithClick(3051);
+
+        waitUntil(ExpectedConditions.not(expectedConditionDetails(74, 4, 0)));
+        assertSpacerCount(spacerCount - 2);
+        assertSpacerHeights();
+        assertSpacerPositions();
+
+        treeGrid.expandWithClick(3051);
+
+        waitUntil(expectedConditionDetails(74, 4, 0));
+        assertSpacerCount(spacerCount);
+        assertSpacerHeights();
+        assertSpacerPositions();
+
+        // toggle the branch with fully open details rows
+        treeGrid.collapseWithClick(3055);
+
+        waitUntil(ExpectedConditions.not(expectedConditionDetails(74, 5, 0)));
+        assertSpacerCount(spacerCount - 3);
+        assertSpacerHeights();
+        assertSpacerPositions();
+
+        treeGrid.expandWithClick(3055);
+
+        waitUntil(expectedConditionDetails(74, 5, 0));
+        assertSpacerCount(spacerCount);
+        assertSpacerHeights();
+        assertSpacerPositions();
+
+        // repeat both toggles to ensure still no errors
+        treeGrid.collapseWithClick(3051);
+
+        waitUntil(ExpectedConditions.not(expectedConditionDetails(74, 4, 0)));
+        assertSpacerCount(spacerCount - 2);
+        assertSpacerHeights();
+        assertSpacerPositions();
+
+        treeGrid.expandWithClick(3051);
+
+        waitUntil(expectedConditionDetails(74, 4, 0));
+        assertSpacerCount(spacerCount);
+        assertSpacerHeights();
+        assertSpacerPositions();
+        treeGrid.collapseWithClick(3055);
+
+        waitUntil(ExpectedConditions.not(expectedConditionDetails(74, 5, 0)));
+        assertSpacerCount(spacerCount - 3);
+        assertSpacerHeights();
+        assertSpacerPositions();
+
+        treeGrid.expandWithClick(3055);
+
+        waitUntil(expectedConditionDetails(74, 5, 0));
+        assertSpacerCount(spacerCount);
+        assertSpacerHeights();
+        assertSpacerPositions();
+
+        assertNoErrors();
+    }
+
+    @Test
+    public void expandAllOpenAllInitialDetailsScrolledFar_hideOne() {
+        $(ButtonElement.class).id(EXPAND_ALL).click();
+        $(ButtonElement.class).id(SHOW_DETAILS).click();
+        addGrid();
+
+        $(ButtonElement.class).id(SCROLL_TO_3055).click();
+
+        // check the position of a row
+        int oldY = treeGrid.getCell(3052, 0).getLocation().getY();
+
+        // hide the spacer from previous row
+        treeGrid.getCell(3051, 0).click();
+
+        // ensure the investigated row moved
+        assertNotEquals(oldY, treeGrid.getCell(52, 0).getLocation().getY());
+    }
+
+    @Test
+    public void expandAllOpenAllInitialDetails_checkScrollPositions() {
+        $(ButtonElement.class).id(EXPAND_ALL).click();
+        $(ButtonElement.class).id(SHOW_DETAILS).click();
+        addGrid();
+
+        TestBenchElement tableWrapper = treeGrid.getTableWrapper();
+
+        $(ButtonElement.class).id(SCROLL_TO_55).click();
+        waitUntil(expectedConditionDetails(1, 3, 0));
+
+        WebElement detailsRow = getSpacer(1, 3, 0);
+        assertNotNull("Spacer for row 55 not found", detailsRow);
+
+        int wrapperY = tableWrapper.getLocation().getY();
+        int wrapperHeight = tableWrapper.getSize().getHeight();
+
+        int detailsY = detailsRow.getLocation().getY();
+        int detailsHeight = detailsRow.getSize().getHeight();
+
+        assertThat("Scroll to 55 didn't scroll as expected",
+                (double) detailsY + detailsHeight,
+                closeTo(wrapperY + wrapperHeight, 1d));
+
+        $(ButtonElement.class).id(SCROLL_TO_3055).click();
+        waitUntil(expectedConditionDetails(74, 5, null));
+
+        detailsRow = getSpacer(74, 5, null);
+        assertNotNull("Spacer for row 3055 not found", detailsRow);
+
+        detailsY = detailsRow.getLocation().getY();
+        detailsHeight = detailsRow.getSize().getHeight();
+
+        assertThat("Scroll to 3055 didn't scroll as expected",
+                (double) detailsY + detailsHeight,
+                closeTo(wrapperY + wrapperHeight, 1d));
+
+        $(ButtonElement.class).id(SCROLL_TO_END).click();
+        waitUntil(expectedConditionDetails(99, 9, 2));
+
+        detailsRow = getSpacer(99, 9, 2);
+        assertNotNull("Spacer for last row not found", detailsRow);
+
+        detailsY = detailsRow.getLocation().getY();
+        detailsHeight = detailsRow.getSize().getHeight();
+
+        // the layout jumps sometimes, check again
+        wrapperY = tableWrapper.getLocation().getY();
+        wrapperHeight = tableWrapper.getSize().getHeight();
+
+        assertThat("Scroll to end didn't scroll as expected",
+                (double) detailsY + detailsHeight,
+                closeTo(wrapperY + wrapperHeight, 1d));
+
+        $(ButtonElement.class).id(SCROLL_TO_START).click();
+        waitUntil(expectedConditionDetails(0, 0, 0));
+
+        WebElement firstRow = getRow(0);
+        TestBenchElement header = treeGrid.getHeader();
+
+        assertThat("Scroll to start didn't scroll as expected",
+                (double) firstRow.getLocation().getY(),
+                closeTo(wrapperY + header.getSize().getHeight(), 1d));
+    }
+
+    @Test
+    public void expandAllOpenNoInitialDetails_testToggleScrolling() {
+        $(ButtonElement.class).id(EXPAND_ALL).click();
+        addGrid();
+
+        TestBenchElement tableWrapper = treeGrid.getTableWrapper();
+        int wrapperY = tableWrapper.getLocation().getY();
+
+        WebElement firstRow = getRow(0);
+        int firstRowY = firstRow.getLocation().getY();
+
+        TestBenchElement header = treeGrid.getHeader();
+        int headerHeight = header.getSize().getHeight();
+
+        assertThat("Unexpected initial scroll position", (double) firstRowY,
+                closeTo(wrapperY + headerHeight, 1d));
+
+        $(ButtonElement.class).id(TOGGLE_15).click();
+
+        firstRowY = firstRow.getLocation().getY();
+
+        assertThat(
+                "Toggling row 15's details open should have caused scrolling",
+                (double) firstRowY, not(closeTo(wrapperY + headerHeight, 1d)));
+
+        $(ButtonElement.class).id(SCROLL_TO_START).click();
+
+        firstRowY = firstRow.getLocation().getY();
+
+        assertThat("Scrolling to start failed", (double) firstRowY,
+                closeTo(wrapperY + headerHeight, 1d));
+
+        $(ButtonElement.class).id(TOGGLE_15).click();
+
+        firstRowY = firstRow.getLocation().getY();
+
+        assertThat(
+                "Toggling row 15's details closed should not have caused scrolling",
+                (double) firstRowY, closeTo(wrapperY + headerHeight, 1d));
+
+        $(ButtonElement.class).id(TOGGLE_3000).click();
+
+        firstRowY = firstRow.getLocation().getY();
+
+        assertThat(
+                "Toggling row 3000's details open should not have caused scrolling",
+                (double) firstRowY, closeTo(wrapperY + headerHeight, 1d));
+
+        $(ButtonElement.class).id(SCROLL_TO_55).click();
+
+        WebElement row = getRow(0);
+        assertNotEquals("First row should be out of visual range", firstRowY,
+                row);
+
+        $(ButtonElement.class).id(TOGGLE_15).click();
+
+        assertEquals(
+                "Toggling row 15's details open should not have caused scrolling "
+                        + "when row 15 is outside of visual range",
+                row, getRow(0));
+    }
+
 }
index e5301a4731a97712ca427fc839fe23561c0fdaed..9ca8b2a974f310249958d1442ce29533c9ba1ec1 100644 (file)
@@ -3,6 +3,7 @@ package com.vaadin.tests.components.treegrid;
 import static org.hamcrest.Matchers.greaterThanOrEqualTo;
 import static org.hamcrest.number.IsCloseTo.closeTo;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThat;
 
@@ -292,4 +293,25 @@ public class TreeGridDetailsManagerTest extends MultiBrowserTest {
         assertNoErrors();
     }
 
+    @Test
+    public void expandAllOpenAllInitialDetails_hideOne() {
+        openTestURL();
+        $(ButtonElement.class).id(EXPAND_ALL).click();
+        $(ButtonElement.class).id(SHOW_DETAILS).click();
+        $(ButtonElement.class).id(ADD_GRID).click();
+
+        waitForElementPresent(By.className(CLASSNAME_TREEGRID));
+
+        treeGrid = $(TreeGridElement.class).first();
+
+        // check the position of a row
+        int oldY = treeGrid.getCell(2, 0).getLocation().getY();
+
+        // hide the spacer from previous row
+        treeGrid.getCell(1, 0).click();
+
+        // ensure the investigated row moved
+        assertNotEquals(oldY, treeGrid.getCell(2, 0).getLocation().getY());
+    }
+
 }