Bug 925425 - richgrid slots implementation, revised tests. r=mbrubeck
authorSam Foster <sfoster@mozilla.com>
Wed, 16 Oct 2013 16:17:17 -0700
changeset 165818 270856fbcd794f328357eb3ade669635d46a2c0f
parent 165817 c85aeddc20e4818528b22c51679ceaf2286b7f5d
child 165819 24fa5d6e4d62200773fad723a8afbd628cbdf9c6
push id428
push userbbajaj@mozilla.com
push dateTue, 28 Jan 2014 00:16:25 +0000
treeherdermozilla-release@cd72a7ff3a75 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmbrubeck
bugs925425
milestone27.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 925425 - richgrid slots implementation, revised tests. r=mbrubeck
browser/metro/base/content/Site.js
browser/metro/base/content/bindings/grid.xml
browser/metro/base/content/browser.css
browser/metro/base/content/startui/BookmarksView.js
browser/metro/base/content/startui/HistoryView.js
browser/metro/base/content/startui/RemoteTabsView.js
browser/metro/base/content/startui/Start.xul
browser/metro/base/content/startui/TopSitesView.js
browser/metro/base/tests/mochitest/browser_history.js
browser/metro/base/tests/mochitest/browser_tilegrid.xul
browser/metro/base/tests/mochitest/browser_tiles.js
browser/metro/theme/tiles.css
--- a/browser/metro/base/content/Site.js
+++ b/browser/metro/base/content/Site.js
@@ -4,17 +4,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 'use strict';
 
 /**
  * dumb model class to provide default values for sites.
  * link parameter/model object expected to have a .url property, and optionally .title
  */
 function Site(aLink) {
-  if(!aLink.url) {
+  if (!aLink.url) {
     throw Cr.NS_ERROR_INVALID_ARG;
   }
   this._link = aLink;
 }
 
 Site.prototype = {
   icon: '',
   get url() {
@@ -59,16 +59,16 @@ Site.prototype = {
     for (let key in attrs) {
       if (undefined === attrs[key]) {
         aNode.removeAttribute(key);
       } else {
         aNode.setAttribute(key, attrs[key]);
       }
     }
     // is binding already applied?
-    if (aNode.refresh) {
+    if ('refresh' in aNode) {
       // just update it
       aNode.refresh();
     } else {
       // these attribute values will get picked up later when the binding is applied
     }
   }
 };
--- a/browser/metro/base/content/bindings/grid.xml
+++ b/browser/metro/base/content/bindings/grid.xml
@@ -22,17 +22,17 @@
       <property name="_grid" readonly="true" onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'grid');"/>
 
       <property name="isBound" readonly="true" onget="return !!this._grid"/>
       <property name="isArranging" readonly="true" onget="return !!this._scheduledArrangeItemsTimerId"/>
 
       <field name="controller">null</field>
 
       <!-- collection of child items excluding empty tiles -->
-      <property name="items" readonly="true" onget="return this.querySelectorAll('richgriditem');"/>
+      <property name="items" readonly="true" onget="return this.querySelectorAll('richgriditem[value]');"/>
       <property name="itemCount" readonly="true" onget="return this.items.length;"/>
 
       <!-- nsIDOMXULMultiSelectControlElement (not fully implemented) -->
 
       <method name="clearSelection">
         <body>
           <![CDATA[
             // 'selection' and 'selected' are confusingly overloaded here
@@ -91,17 +91,17 @@
         </body>
       </method>
 
       <method name="handleItemClick">
         <parameter name="aItem"/>
         <parameter name="aEvent"/>
         <body>
           <![CDATA[
-            if(!this.isBound)
+            if (!this.isBound)
               return;
 
             if ("single" == this.getAttribute("seltype")) {
               // we'll republish this as a selectionchange event on the grid
               aEvent.stopPropagation();
               this.selectItem(aItem);
             }
 
@@ -111,17 +111,17 @@
         </body>
       </method>
 
       <method name="handleItemContextMenu">
         <parameter name="aItem"/>
         <parameter name="aEvent"/>
         <body>
           <![CDATA[
-            if(!this.isBound || this.suppressOnSelect)
+            if (!this.isBound || this.suppressOnSelect)
               return;
             // we'll republish this as a selectionchange event on the grid
             aEvent.stopPropagation();
             this.toggleItemSelection(aItem);
           ]]>
         </body>
       </method>
 
@@ -170,17 +170,17 @@
           ]]>
         </setter>
       </property>
 
       <!-- partial implementation of multiple selection interface -->
       <property name="selectedItems">
         <getter>
           <![CDATA[
-            return this.querySelectorAll("richgriditem[selected]");
+            return this.querySelectorAll("richgriditem[value][selected]");
           ]]>
         </getter>
       </property>
 
       <property name="selectedIndex">
         <getter>
           <![CDATA[
             return this.getIndexOfItem(this._selectedItem);
@@ -199,58 +199,146 @@
       </property>
 
       <method name="appendItem">
         <parameter name="aLabel"/>
         <parameter name="aValue"/>
         <parameter name="aSkipArrange"/>
         <body>
           <![CDATA[
-            let addition = this._createItemElement(aLabel, aValue);
-            this.appendChild(addition);
+            let item = this.nextSlot();
+            item.setAttribute("value", aValue);
+            item.setAttribute("label", aLabel);
+
             if (!aSkipArrange)
               this.arrangeItems();
-            return addition;
+            return item;
           ]]>
         </body>
       </method>
 
+      <method name="_slotValues">
+        <body><![CDATA[
+          return Array.map(this.children, (cnode) => cnode.getAttribute("value"));
+        ]]></body>
+      </method>
+
+      <property name="minSlots" readonly="true"
+                onget="return this.getAttribute('minSlots') || 3;"/>
+
       <method name="clearAll">
         <parameter name="aSkipArrange"/>
         <body>
           <![CDATA[
-            while (this.firstChild) {
-              this.removeChild(this.firstChild);
+            const ELEMENT_NODE_TYPE = Components.interfaces.nsIDOMNode.ELEMENT_NODE;
+            let slotCount = this.minSlots;
+            let childIndex = 0;
+            let child = this.firstChild;
+            while (child) {
+              // remove excess elements and non-element nodes
+              if (child.nodeType !== ELEMENT_NODE_TYPE || childIndex+1 > slotCount) {
+                let orphanNode = child;
+                child = orphanNode.nextSibling;
+                this.removeChild(orphanNode);
+                continue;
+              }
+              if (child.hasAttribute("value")) {
+                this._releaseSlot(child);
+              }
+              child = child.nextSibling;
+              childIndex++;
             }
+            // create our quota of item slots
+            for (let count = this.childElementCount; count < slotCount; count++) {
+              this.appendChild( this._createItemElement() );
+            }
+
             if (!aSkipArrange)
               this.arrangeItems();
           ]]>
         </body>
       </method>
 
+      <method name="_slotAt">
+        <parameter name="anIndex"/>
+        <body>
+          <![CDATA[
+            // backfill with new slots as necessary
+            let count = Math.max(1+anIndex, this.minSlots) - this.childElementCount;
+            for (; count > 0; count--) {
+              this.appendChild( this._createItemElement() );
+            }
+            return this.children[anIndex];
+          ]]>
+        </body>
+      </method>
+
+      <method name="nextSlot">
+        <body>
+          <![CDATA[
+            if (!this.itemCount) {
+              return this._slotAt(0);
+            }
+            let lastItem = this.items[this.itemCount-1];
+            let nextIndex = 1 + Array.indexOf(this.children, lastItem);
+            return this._slotAt(nextIndex);
+          ]]>
+        </body>
+      </method>
+
+      <method name="_releaseSlot">
+        <parameter name="anItem"/>
+        <body>
+          <![CDATA[
+            // Flush out data and state attributes so we can recycle this slot/element
+            let exclude = { value: 1, tiletype: 1 };
+            let attrNames = [attr.name for (attr of anItem.attributes)];
+            for (let attrName of attrNames) {
+              if (!(attrName in exclude))
+                anItem.removeAttribute(attrName);
+            }
+            // clear out inline styles
+            anItem.removeAttribute("style");
+            // finally clear the value, which should apply the richgrid-empty-item binding
+            anItem.removeAttribute("value");
+          ]]>
+        </body>
+      </method>
+
       <method name="insertItemAt">
         <parameter name="anIndex"/>
         <parameter name="aLabel"/>
         <parameter name="aValue"/>
         <parameter name="aSkipArrange"/>
         <body>
           <![CDATA[
+            anIndex = Math.min(this.itemCount, anIndex);
+            let insertedItem;
             let existing = this.getItemAtIndex(anIndex);
-            let addition = this._createItemElement(aLabel, aValue);
             if (existing) {
-              this.insertBefore(addition, existing);
-            } else {
-              this.appendChild(addition);
+              // use an empty slot if we have one, otherwise insert it
+              let childIndex = Array.indexOf(this.children, existing);
+              if (childIndex > 0 && !this.children[childIndex-1].hasAttribute("value")) {
+                insertedItem = this.children[childIndex-1];
+              } else {
+                insertedItem = this.insertBefore(this._createItemElement(),existing);
+              }
             }
+            if (!insertedItem) {
+              insertedItem = this._slotAt(anIndex);
+            }
+            insertedItem.setAttribute("value", aValue);
+            insertedItem.setAttribute("label", aLabel);
             if (!aSkipArrange)
               this.arrangeItems();
-            return addition;
+            return insertedItem;
           ]]>
         </body>
       </method>
+
       <method name="removeItemAt">
         <parameter name="anIndex"/>
         <parameter name="aSkipArrange"/>
         <body>
           <![CDATA[
             let item = this.getItemAtIndex(anIndex);
             if (!item)
               return null;
@@ -261,17 +349,23 @@
 
       <method name="removeItem">
         <parameter name="aItem"/>
         <parameter name="aSkipArrange"/>
         <body>
           <![CDATA[
             if (!aItem || Array.indexOf(this.items, aItem) < 0)
               return null;
+
             let removal = this.removeChild(aItem);
+            // replace the slot if necessary
+            if (this.childElementCount < this.minSlots) {
+              this.nextSlot();
+            }
+
             if (removal && !aSkipArrange)
                 this.arrangeItems();
 
             // note that after removal the node is unbound
             // so none of the richgriditem binding methods & properties are available
             return removal;
           ]]>
         </body>
@@ -417,16 +511,17 @@
             return true;
           ]]>
         </getter>
       </property>
 
       <field name="_scheduledArrangeItemsTimerId">null</field>
       <field name="_scheduledArrangeItemsTries">0</field>
       <field name="_maxArrangeItemsRetries">5</field>
+
       <method name="_scheduleArrangeItems">
         <parameter name="aTime"/>
         <body>
           <![CDATA[
               // cap the number of times we reschedule calling arrangeItems
               if (
                   !this._scheduledArrangeItemsTimerId &&
                   this._maxArrangeItemsRetries > this._scheduledArrangeItemsTries
@@ -448,50 +543,51 @@
             if (!this._canLayout) {
               // try again later
               this._scheduleArrangeItems();
               return;
             }
 
             let itemDims = this._itemSize;
             let containerDims = this._containerSize;
+            let slotsCount = this.childElementCount;
 
             // reset the flags
             if (this._scheduledArrangeItemsTimerId) {
               clearTimeout(this._scheduledArrangeItemsTimerId);
               delete this._scheduledArrangeItemsTimerId;
             }
             this._scheduledArrangeItemsTries = 0;
 
             // clear explicit width and columns before calculating from avail. height again
             let gridStyle = this._grid.style;
             gridStyle.removeProperty("min-width");
             gridStyle.removeProperty("-moz-column-count");
 
             if (this.hasAttribute("vertical")) {
               this._columnCount = Math.floor(containerDims.width / itemDims.width) || 1;
-              this._rowCount = Math.floor(this.itemCount / this._columnCount);
+              this._rowCount = Math.floor(slotsCount / this._columnCount);
             } else {
-              // We favor overflowing horizontally, not vertically (rows then colums)
-              // rows attribute = max rows
-              let maxRowCount = Math.min(this.getAttribute("rows") || Infinity, Math.floor(containerDims.height / itemDims.height));
-              this._rowCount = Math.min(this.itemCount, maxRowCount);
+              // rows attribute is fixed number of rows
+              let maxRows = Math.floor(containerDims.height / itemDims.height);
+              this._rowCount = this.getAttribute("rows") ?
+                                  // fit indicated rows when possible
+                                  Math.min(maxRows, this.getAttribute("rows")) :
+                                  // at least 1 row
+                                  Math.min(maxRows, slotsCount) || 1;
 
-              // columns attribute = min cols
-              this._columnCount = this.itemCount ?
-                    Math.max(
-                        // at least 1 column when there are items
-                        this.getAttribute("columns") || 1,
-                        Math.ceil(this.itemCount / this._rowCount)
-                    ) : this.getAttribute("columns") || 0;
+              // columns attribute is min number of cols
+              this._columnCount = Math.ceil(slotsCount / this._rowCount) || 1;
+              if (this.getAttribute("columns")) {
+                this._columnCount = Math.max(this._columnCount, this.getAttribute("columns"));
+              }
             }
 
             // width is typically auto, cap max columns by truncating items collection
             // or, setting max-width style property with overflow hidden
-            // '0' is an invalid value, just leave the property unset when 0 columns
             if (this._columnCount) {
               gridStyle.MozColumnCount = this._columnCount;
             }
             this._fireEvent("arranged");
           ]]>
         </body>
       </method>
       <method name="arrangeItemsNow">
@@ -516,16 +612,22 @@
                   onset="this.setAttribute('suppressonselect', val);"/>
       <property name="crossSlideBoundary"
           onget="return this.hasAttribute('crossslideboundary')? this.getAttribute('crossslideboundary') : Infinity;"/>
 
     <!-- Internal methods -->
       <field name="_xslideHandler"/>
       <constructor>
         <![CDATA[
+          // create our quota of item slots
+          for (let count = this.childElementCount, slotCount = this.minSlots;
+              count < slotCount; count++) {
+            this.appendChild( this._createItemElement() );
+          }
+
           if (this.controller && this.controller.gridBoundCallback != undefined)
             this.controller.gridBoundCallback();
 
           // set up cross-slide gesture handling for multiple-selection grids
           if ("undefined" !== typeof CrossSlide && "multiple" == this.getAttribute("seltype")) {
             this._xslideHandler = new CrossSlide.Handler(this, {
                   REARRANGESTART: this.crossSlideBoundary
             });
@@ -600,16 +702,17 @@
               }
             } else {
               throw new Error("Failed to find stylesheet to parse out richgriditem dimensions\n");
             }
             return typeSizes;
           ]]>
         </body>
       </method>
+
       <method name="_isIndexInBounds">
         <parameter name="anIndex"/>
         <body>
           <![CDATA[
             return anIndex >= 0 && anIndex < this.itemCount;
           ]]>
         </body>
       </method>
@@ -621,17 +724,17 @@
           <![CDATA[
             let item = this.ownerDocument.createElement("richgriditem");
             if (aValue) {
               item.setAttribute("value", aValue);
             }
             if (aLabel) {
               item.setAttribute("label", aLabel);
             }
-            if(this.hasAttribute("tiletype")) {
+            if (this.hasAttribute("tiletype")) {
               item.setAttribute("tiletype", this.getAttribute("tiletype"));
             }
             return item;
           ]]>
         </body>
       </method>
 
       <method name="_fireEvent">
@@ -699,16 +802,17 @@
               bendNode.style.transform = "perspective("+perspective+") rotateY(" + angle + "deg)";
               bendNode.style.transformOrigin = "right center";
             }
           }
           // mark when bend effect is applied
           aItem.setAttribute("bending", true);
         ]]></body>
       </method>
+
       <method name="unbendItem">
         <parameter name="aItem"/>
         <body><![CDATA[
           // clear the 'bend' transform on the contentBox element of the item
           let bendNode = 'richgriditem' == aItem.nodeName && aItem._contentBox;
           if (bendNode && aItem.hasAttribute("bending")) {
             bendNode.style.removeProperty('transform');
             bendNode.style.removeProperty('transformOrigin');
@@ -744,17 +848,17 @@
       <handler event="MozCrossSliding">
         <![CDATA[
           // MozCrossSliding is swipe gesture across a tile
           // The tile should follow the drag to reinforce the gesture
           // (with inertia/speedbump behavior)
           let state = event.crossSlidingState;
           let thresholds = this._xslideHandler.thresholds;
           let transformValue;
-          switch(state) {
+          switch (state) {
             case "cancelled":
               this.unbendItem(event.target);
               event.target.removeAttribute('crosssliding');
               // hopefully nothing else is transform-ing the tile
               event.target.style.removeProperty('transform');
               break;
             case "dragging":
             case "selecting":
@@ -839,17 +943,17 @@
       <property name="pinned"
                 onget="return this.hasAttribute('pinned')"
                 onset="if (val) { this.setAttribute('pinned', val) } else this.removeAttribute('pinned');"/>
 
       <method name="refresh">
         <body>
           <![CDATA[
             // Prevent an exception in case binding is not done yet.
-            if(!this.isBound)
+            if (!this.isBound)
               return;
 
             // Seed the binding properties from bound-node attribute values
             // Usage: node.refresh()
             //        - reinitializes all binding properties from their associated attributes
 
             this.iconSrc = this.getAttribute('iconURI');
             this.color = this.getAttribute("customColor");
@@ -909,30 +1013,30 @@
             this.removeAttribute("customImage");
             this._top.style.removeProperty("background-image");
           }
         ]]></setter>
       </property>
 
       <method name="refreshBackgroundImage">
         <body><![CDATA[
-          if(!this.isBound)
+          if (!this.isBound)
             return;
           if (this.backgroundImage) {
             this._top.style.removeProperty("background-image");
             this._top.style.setProperty("background-image", this.backgroundImage);
           }
         ]]></body>
       </method>
 
       <field name="_contextActions">null</field>
       <property name="contextActions">
         <getter>
           <![CDATA[
-            if(!this._contextActions) {
+            if (!this._contextActions) {
               this._contextActions = new Set();
               let actionSet = this._contextActions;
               let actions = this.getAttribute("data-contextactions");
               if (actions) {
                 actions.split(/[,\s]+/).forEach(function(verb){
                   actionSet.add(verb);
                 });
               }
@@ -954,17 +1058,23 @@
         ]]>
       </handler>
 
       <handler event="contextmenu">
         <![CDATA[
           // fires for right-click, long-click and (keyboard) contextmenu input
           // toggle the selected state of tiles in a grid
           let gridParent = this.control;
-          if(!this.isBound || !gridParent)
+          if (!this.isBound || !gridParent)
             return;
           gridParent.handleItemContextMenu(this, event);
         ]]>
       </handler>
     </handlers>
   </binding>
 
+  <binding id="richgrid-empty-item">
+    <content>
+      <html:div anonid="anon-tile" class="tile-content"></html:div>
+    </content>
+  </binding>
+
 </bindings>
--- a/browser/metro/base/content/browser.css
+++ b/browser/metro/base/content/browser.css
@@ -114,16 +114,19 @@ setting[type="menulist"] {
   -moz-binding: url("chrome://browser/content/bindings/urlbar.xml#urlbar-autocomplete");
 }
 
 richgrid {
   -moz-binding: url("chrome://browser/content/bindings/grid.xml#richgrid");
 }
 
 richgriditem {
+  -moz-binding: url("chrome://browser/content/bindings/grid.xml#richgrid-empty-item");
+}
+richgriditem[value] {
   -moz-binding: url("chrome://browser/content/bindings/grid.xml#richgrid-item");
 }
 
 placeitem {
   -moz-binding: url("chrome://browser/content/bindings/bindings.xml#place-item");
   background-color: transparent;
 }
 
--- a/browser/metro/base/content/startui/BookmarksView.js
+++ b/browser/metro/base/content/startui/BookmarksView.js
@@ -68,21 +68,21 @@ BookmarksView.prototype = Util.extend(Ob
   },
 
   handleItemClick: function bv_handleItemClick(aItem) {
     let url = aItem.getAttribute("value");
     StartUI.goToURI(url);
   },
 
   _getItemForBookmarkId: function bv__getItemForBookmark(aBookmarkId) {
-    return this._set.querySelector("richgriditem[bookmarkId='" + aBookmarkId + "']");
+    return this._set.querySelector("richgriditem[anonid='" + aBookmarkId + "']");
   },
 
   _getBookmarkIdForItem: function bv__getBookmarkForItem(aItem) {
-    return +aItem.getAttribute("bookmarkId");
+    return +aItem.getAttribute("anonid");
   },
 
   _updateItemWithAttrs: function dv__updateItemWithAttrs(anItem, aAttrs) {
     for (let name in aAttrs)
       anItem.setAttribute(name, aAttrs[name]);
   },
 
   getBookmarks: function bv_getBookmarks(aRefresh) {
@@ -137,37 +137,39 @@ BookmarksView.prototype = Util.extend(Ob
 
     // Remove extra items in case a refresh added more than the limit.
     // This can happen when undoing a delete.
     if (aRefresh) {
       while (this._set.itemCount > limit)
         this._set.removeItemAt(this._set.itemCount - 1, true);
     }
     this._set.arrangeItems();
+    this._set.removeAttribute("fade");
     this._inBatch = false;
     rootNode.containerOpen = false;
   },
 
   inCurrentView: function bv_inCurrentView(aParentId, aItemId) {
     if (this._root && aParentId != this._root)
       return false;
 
     return !!this._getItemForBookmarkId(aItemId);
   },
 
   clearBookmarks: function bv_clearBookmarks() {
-    this._set.clearAll();
+    if ('clearAll' in this._set)
+      this._set.clearAll();
   },
 
   addBookmark: function bv_addBookmark(aBookmarkId, aPos) {
     let index = this._bookmarkService.getItemIndex(aBookmarkId);
     let uri = this._bookmarkService.getBookmarkURI(aBookmarkId);
     let title = this._bookmarkService.getItemTitle(aBookmarkId) || uri.spec;
     let item = this._set.insertItemAt(aPos || index, title, uri.spec, this._inBatch);
-    item.setAttribute("bookmarkId", aBookmarkId);
+    item.setAttribute("anonid", aBookmarkId);
     this._setContextActions(item);
     this._updateFavicon(item, uri);
   },
 
   _setContextActions: function bv__setContextActions(aItem) {
     let itemId = this._getBookmarkIdForItem(aItem);
     aItem.setAttribute("data-contextactions", "delete," + (this._pinHelper.isPinned(itemId) ? "unpin" : "pin"));
     if (aItem.refresh) aItem.refresh();
@@ -193,16 +195,17 @@ BookmarksView.prototype = Util.extend(Ob
       this.removeBookmark(aBookmarkId);
       this.addBookmark(aBookmarkId);
       return;
     }
 
     let uri = this._bookmarkService.getBookmarkURI(aBookmarkId);
     let title = this._bookmarkService.getItemTitle(aBookmarkId) || uri.spec;
 
+    item.setAttribute("anonid", aBookmarkId);
     item.setAttribute("value", uri.spec);
     item.setAttribute("label", title);
 
     this._updateFavicon(item, uri);
   },
 
   removeBookmark: function bv_removeBookmark(aBookmarkId) {
     let item = this._getItemForBookmarkId(aBookmarkId);
--- a/browser/metro/base/content/startui/HistoryView.js
+++ b/browser/metro/base/content/startui/HistoryView.js
@@ -90,16 +90,17 @@ HistoryView.prototype = Util.extend(Obje
     // This can happen when undoing a delete.
     if (aRefresh) {
       while (this._set.itemCount > limit)
         this._set.removeItemAt(this._set.itemCount - 1);
     }
 
     rootNode.containerOpen = false;
     this._set.arrangeItems();
+    this._set.removeAttribute("fade");
     if (this._inBatch > 0)
       this._inBatch--;
   },
 
   addItemToSet: function addItemToSet(aURI, aTitle, aIcon, aPos) {
     let item = this._set.insertItemAt(aPos || 0, aTitle, aURI, this._inBatch);
     this._setContextActions(item);
     this._updateFavicon(item, aURI);
@@ -125,16 +126,19 @@ HistoryView.prototype = Util.extend(Obje
     if (!this._inBatch)
       this._set.arrangeItems();
   },
 
   doActionOnSelectedTiles: function bv_doActionOnSelectedTiles(aActionName, aEvent) {
     let tileGroup = this._set;
     let selectedTiles = tileGroup.selectedItems;
 
+    // just arrange the grid once at the end of any action handling
+    this._inBatch = true;
+
     switch (aActionName){
       case "delete":
         Array.forEach(selectedTiles, function(aNode) {
           if (!this._toRemove) {
             this._toRemove = [];
           }
 
           let uri = aNode.getAttribute("value");
@@ -177,19 +181,21 @@ HistoryView.prototype = Util.extend(Obje
         Array.forEach(selectedTiles, function(aNode) {
           let uri = aNode.getAttribute("value");
 
           this._pinHelper.setPinned(uri);
         }, this);
         break;
 
       default:
+        this._inBatch = false;
         return;
     }
 
+    this._inBatch = false;
     // Send refresh event so all view are in sync.
     this._sendNeedsRefresh();
   },
 
   handleEvent: function bv_handleEvent(aEvent) {
     switch (aEvent.type){
       case "MozAppbarDismissing":
         // If undo wasn't pressed, time to do definitive actions.
@@ -249,27 +255,28 @@ HistoryView.prototype = Util.extend(Obje
     }
   },
 
   onDeleteURI: function(aURI) {
     this.removeHistory(aURI.spec);
   },
 
   onClearHistory: function() {
-    this._set.clearAll();
+    if ('clearAll' in this._set)
+      this._set.clearAll();
   },
 
   onPageChanged: function(aURI, aWhat, aValue) {
     if (aWhat ==  Ci.nsINavHistoryObserver.ATTRIBUTE_FAVICON) {
       let changedItems = this._set.getItemsByUrl(aURI.spec);
       for (let item of changedItems) {
         let currIcon = item.getAttribute("iconURI");
         if (currIcon != aValue) {
           item.setAttribute("iconURI", aValue);
-          if("refresh" in item)
+          if ("refresh" in item)
             item.refresh();
         }
       }
     }
   },
 
   onDeleteVisits: function (aURI, aVisitTime, aGUID, aReason, aTransitionType) {
     if ((aReason ==  Ci.nsINavHistoryObserver.REASON_DELETED) && !this._inBatch) {
--- a/browser/metro/base/content/startui/RemoteTabsView.js
+++ b/browser/metro/base/content/startui/RemoteTabsView.js
@@ -88,16 +88,17 @@ RemoteTabsView.prototype = Util.extend(O
 
         let item = this._set.appendItem((title || url), url);
         item.setAttribute("iconURI", Weave.Utils.getIcon(icon));
 
       }, this);
     }
     this.setUIAccessVisible(show);
     this._set.arrangeItems();
+    this._set.removeAttribute("fade");
   },
 
   destruct: function destruct() {
     Weave.Svc.Obs.remove("weave:engine:sync:finish", this);
     Weave.Svc.Obs.remove("weave:service:logout:start-over", this);
     View.prototype.destruct.call(this);
   },
 
--- a/browser/metro/base/content/startui/Start.xul
+++ b/browser/metro/base/content/startui/Start.xul
@@ -43,39 +43,61 @@
   <hbox id="start-container" class="meta-section-container"
         observes="bcast_windowState">
   <!-- the start-container element has a max-height set in StartUI.js -->
       <vbox id="start-topsites" class="meta-section" expanded="true">
         <label class="meta-section-title wide-title" value="&topSitesHeader.label;"/>
         <html:div class="meta-section-title narrow-title" onclick="StartUI.onNarrowTitleClick('start-topsites')">
           &narrowTopSitesHeader.label;
         </html:div>
-        <richgrid id="start-topsites-grid" observes="bcast_windowState" set-name="topSites" rows="3" columns="3" tiletype="thumbnail" seltype="multiple" flex="1"/>
+        <richgrid id="start-topsites-grid" observes="bcast_windowState" set-name="topSites" rows="3" columns="3" tiletype="thumbnail" seltype="multiple" minSlots="8" fade="true" flex="1">
+          <richgriditem/>
+          <richgriditem/>
+          <richgriditem/>
+          <richgriditem/>
+          <richgriditem/>
+          <richgriditem/>
+          <richgriditem/>
+          <richgriditem/>
+          <richgriditem/>
+        </richgrid>
       </vbox>
 
       <vbox id="start-bookmarks" class="meta-section">
         <label class="meta-section-title wide-title" value="&bookmarksHeader.label;"/>
         <html:div class="meta-section-title narrow-title" onclick="StartUI.onNarrowTitleClick('start-bookmarks')">
           &narrowBookmarksHeader.label;
         </html:div>
-        <richgrid id="start-bookmarks-grid" observes="bcast_windowState" set-name="bookmarks" seltype="multiple" flex="1"/>
+        <richgrid id="start-bookmarks-grid" observes="bcast_windowState" set-name="bookmarks" seltype="multiple" fade="true" flex="1" minSlots="2">
+          <richgriditem/>
+          <richgriditem/>
+        </richgrid>
       </vbox>
 
       <vbox id="start-history" class="meta-section">
         <label class="meta-section-title wide-title" value="&recentHistoryHeader.label;"/>
         <html:div class="meta-section-title narrow-title" onclick="StartUI.onNarrowTitleClick('start-history')">
           &narrowRecentHistoryHeader.label;
         </html:div>
-        <richgrid id="start-history-grid" observes="bcast_windowState" set-name="recentHistory" seltype="multiple" flex="1"/>
+        <richgrid id="start-history-grid" observes="bcast_windowState" set-name="recentHistory" seltype="multiple" fade="true" flex="1">
+          <richgriditem/>
+          <richgriditem/>
+          <richgriditem/>
+        </richgrid>
       </vbox>
 
 #ifdef MOZ_SERVICES_SYNC
       <vbox id="start-remotetabs" class="meta-section">
         <label class="meta-section-title wide-title" value="&remoteTabsHeader.label;"/>
         <html:div id="snappedRemoteTabsLabel" class="meta-section-title narrow-title" onclick="StartUI.onNarrowTitleClick('start-remotetabs')">
           &narrowRemoteTabsHeader.label;
         </html:div>
-        <richgrid id="start-remotetabs-grid" observes="bcast_windowState" set-name="remoteTabs" seltype="multiple" flex="1"/>
+        <richgrid id="start-remotetabs-grid" observes="bcast_windowState" set-name="remoteTabs" seltype="multiple" fade="true" flex="1">
+          <richgriditem/>
+          <richgriditem/>
+          <richgriditem/>
+        </richgrid>
+
       </vbox>
 #endif
   </hbox>
   </html:body>
 </html:html>
--- a/browser/metro/base/content/startui/TopSitesView.js
+++ b/browser/metro/base/content/startui/TopSitesView.js
@@ -153,16 +153,19 @@ TopSitesView.prototype = Util.extend(Obj
         // flush, recreate all
       this.isUpdating = true;
       // destroy and recreate all item nodes, skip calling arrangeItems
       this.populateGrid();
     }
   },
 
   updateTile: function(aTileNode, aSite, aArrangeGrid) {
+    if (!(aSite && aSite.url)) {
+      throw new Error("Invalid Site object passed to TopSitesView updateTile");
+    }
     this._updateFavicon(aTileNode, Util.makeURI(aSite.url));
 
     Task.spawn(function() {
       let filepath = PageThumbsStorage.getFilePathForURL(aSite.url);
       if (yield OS.File.exists(filepath)) {
         aSite.backgroundImage = 'url("'+PageThumbs.getThumbnailURL(aSite.url)+'")';
         if ('backgroundImage' in aTileNode) {
           aTileNode.backgroundImage = aSite.backgroundImage;
@@ -187,24 +190,21 @@ TopSitesView.prototype = Util.extend(Obj
     let sites = TopSites.getSites();
     if (this._topSitesMax) {
       sites = sites.slice(0, this._topSitesMax);
     }
     let tileset = this._set;
     tileset.clearAll(true);
 
     for (let site of sites) {
-      // call to private _createItemElement is a temp measure
-      // we'll eventually just request the next slot
-      let item = tileset._createItemElement(site.title, site.url);
-
-      this.updateTile(item, site);
-      tileset.appendChild(item);
+      let slot = tileset.nextSlot();
+      this.updateTile(slot, site);
     }
     tileset.arrangeItems();
+    tileset.removeAttribute("fade");
     this.isUpdating = false;
   },
 
   forceReloadOfThumbnail: function forceReloadOfThumbnail(url) {
     let nodes = this._set.querySelectorAll('richgriditem[value="'+url+'"]');
     for (let item of nodes) {
       if ("isBound" in item && item.isBound) {
         item.refreshBackgroundImage();
@@ -239,17 +239,17 @@ TopSitesView.prototype = Util.extend(Obj
       } else {
         item.removeAttribute("tiletype");
       }
     }
   },
 
   // nsIObservers
   observe: function (aSubject, aTopic, aState) {
-    switch(aTopic) {
+    switch (aTopic) {
       case "Metro:RefreshTopsiteThumbnail":
         this.forceReloadOfThumbnail(aState);
         break;
     }
   },
 
   // nsINavHistoryObserver
   onBeginUpdateBatch: function() {
@@ -264,17 +264,18 @@ TopSitesView.prototype = Util.extend(Obj
 
   onTitleChanged: function(aURI, aPageTitle) {
   },
 
   onDeleteURI: function(aURI) {
   },
 
   onClearHistory: function() {
-    this._set.clearAll();
+    if ('clearAll' in this._set)
+      this._set.clearAll();
   },
 
   onPageChanged: function(aURI, aWhat, aValue) {
   },
 
   onDeleteVisits: function (aURI, aVisitTime, aGUID, aReason, aTransitionType) {
   },
 
--- a/browser/metro/base/tests/mochitest/browser_history.js
+++ b/browser/metro/base/tests/mochitest/browser_history.js
@@ -62,17 +62,17 @@ gTests.push({
     let promise = waitForEvent(Elements.contextappbar, "transitionend", null, Elements.contextappbar);
     unpinButton.click();
     yield promise;
 
     item = gStartView._set.getItemsByUrl(uriFromIndex(2))[0];
 
     ok(!item, "Item not in grid");
     ok(!gStartView._pinHelper.isPinned(uriFromIndex(2)), "Item unpinned");
-    ok(gStartView._set.itemCount === gStartView._limit, "Grid repopulated");
+    is(gStartView._set.itemCount, gStartView._limit, "Grid repopulated");
 
     // --------- unpin multiple items
 
     let item1 = gStartView._set.getItemsByUrl(uriFromIndex(0))[0];
     let item2 = gStartView._set.getItemsByUrl(uriFromIndex(5))[0];
     let item3 = gStartView._set.getItemsByUrl(uriFromIndex(12))[0];
 
     scrollToEnd();
@@ -119,17 +119,17 @@ gTests.push({
 
     let promise = waitForCondition(() => !restoreButton.hidden);
     EventUtils.synthesizeMouse(deleteButton, 10, 10, {}, window);
     yield promise;
 
     item = gStartView._set.getItemsByUrl(uriFromIndex(2))[0];
 
     ok(!item, "Item not in grid");
-    ok(HistoryTestHelper._nodes[uriFromIndex(2)], "Item not deleted yet");
+    ok(HistoryTestHelper._nodes[uriFromIndex(2)], "Item not actually deleted yet");
     ok(!restoreButton.hidden, "Restore button is visible.");
     ok(gStartView._set.itemCount === gStartView._limit, "Grid repopulated");
 
     let promise = waitForEvent(Elements.contextappbar, "transitionend", null, Elements.contextappbar);
     EventUtils.synthesizeMouse(restoreButton, 10, 10, {}, window);
     yield promise;
 
     item = gStartView._set.getItemsByUrl(uriFromIndex(2))[0];
@@ -145,34 +145,41 @@ gTests.push({
     sendContextMenuClickToElement(window, item, 10, 10);
     yield promise;
 
     yield waitForCondition(() => !deleteButton.hidden);
 
     ok(!deleteButton.hidden, "Delete button is visible.");
 
     let promise = waitForCondition(() => !restoreButton.hidden);
+    let populateGridSpy = spyOnMethod(gStartView, "populateGrid");
+
     EventUtils.synthesizeMouse(deleteButton, 10, 10, {}, window);
     yield promise;
 
+    is(populateGridSpy.callCount, 1, "populateGrid was called in response to the deleting a tile");
+
     item = gStartView._set.getItemsByUrl(uriFromIndex(2))[0];
 
     ok(!item, "Item not in grid");
     ok(HistoryTestHelper._nodes[uriFromIndex(2)], "Item not deleted yet");
     ok(!restoreButton.hidden, "Restore button is visible.");
 
     let promise = waitForEvent(Elements.contextappbar, "transitionend", null, Elements.contextappbar);
     Elements.contextappbar.dismiss();
     yield promise;
 
+    is(populateGridSpy.callCount, 1, "populateGrid not called when a removed item is actually deleted");
+    populateGridSpy.restore();
+
     item = gStartView._set.getItemsByUrl(uriFromIndex(2))[0];
 
     ok(!item, "Item not in grid");
     ok(!HistoryTestHelper._nodes[uriFromIndex(2)], "Item RIP");
-    ok(gStartView._set.itemCount === gStartView._limit, "Grid repopulated");
+    is(gStartView._set.itemCount, gStartView._limit, "Grid repopulated");
 
     // --------- delete multiple items and restore
 
     let item1 = gStartView._set.getItemsByUrl(uriFromIndex(0))[0];
     let item2 = gStartView._set.getItemsByUrl(uriFromIndex(5))[0];
     let item3 = gStartView._set.getItemsByUrl(uriFromIndex(12))[0];
 
     let initialLocation1 = gStartView._set.getIndexOfItem(item1);
--- a/browser/metro/base/tests/mochitest/browser_tilegrid.xul
+++ b/browser/metro/base/tests/mochitest/browser_tilegrid.xul
@@ -12,26 +12,29 @@
 <!DOCTYPE window []>
 
 <window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
 
   <vbox id="alayout">
       <richgrid id="grid_layout" seltype="single" flex="1">
       </richgrid>
   </vbox>
+  <vbox>
+      <richgrid id="slots_grid" seltype="single" minSlots="6" flex="1"/>
+  </vbox>
   <vbox style="height:600px">
     <hbox>
       <richgrid id="clearGrid" seltype="single" flex="1" rows="2">
         <richgriditem value="about:blank" id="clearGrid_item1" label="First item"/>
         <richgriditem value="about:blank" id="clearGrid_item2" label="2nd item"/>
         <richgriditem value="about:blank" id="clearGrid_item1" label="First item"/>
       </richgrid>
     </hbox>
     <hbox>
-      <richgrid id="emptyGrid" seltype="single" flex="1" rows="2">
+      <richgrid id="emptyGrid" seltype="single" flex="1" rows="2" minSlots="6">
       </richgrid>
     </hbox>
     <hbox>
       <richgrid id="grid1" seltype="single" flex="1">
         <richgriditem value="about:blank" id="grid1_item1" label="First item"/>
         <richgriditem value="about:blank" id="grid1_item2" label="2nd item"/>
       </richgrid>
     </hbox>
--- a/browser/metro/base/tests/mochitest/browser_tiles.js
+++ b/browser/metro/base/tests/mochitest/browser_tiles.js
@@ -4,27 +4,35 @@ function test() {
   waitForExplicitFinish();
   Task.spawn(function(){
     info(chromeRoot + "browser_tilegrid.xul");
     yield addTab(chromeRoot + "browser_tilegrid.xul");
     doc = Browser.selectedTab.browser.contentWindow.document;
   }).then(runTests);
 }
 
+function _checkIfBoundByRichGrid_Item(expected, node, idx) {
+  let binding = node.ownerDocument.defaultView.getComputedStyle(node).MozBinding;
+  let result = ('url("chrome://browser/content/bindings/grid.xml#richgrid-item")' == binding);
+  return (result == expected);
+}
+let isBoundByRichGrid_Item = _checkIfBoundByRichGrid_Item.bind(this, true);
+let isNotBoundByRichGrid_Item = _checkIfBoundByRichGrid_Item.bind(this, false);
+
 gTests.push({
   desc: "richgrid binding is applied",
   run: function() {
     ok(doc, "doc got defined");
 
     let grid = doc.querySelector("#grid1");
     ok(grid, "#grid1 is found");
     is(typeof grid.clearSelection, "function", "#grid1 has the binding applied");
-
     is(grid.items.length, 2, "#grid1 has a 2 items");
     is(grid.items[0].control, grid, "#grid1 item's control points back at #grid1'");
+    ok(Array.every(grid.items, isBoundByRichGrid_Item), "All items are bound by richgrid-item");
   }
 });
 
 gTests.push({
   desc: "item clicks are handled",
   run: function() {
     let grid = doc.querySelector("#grid1");
     is(typeof grid.handleItemClick, "function", "grid.handleItemClick is a function");
@@ -120,29 +128,38 @@ gTests.push({
     is(arrangeSpy.callCount, 1, "arrangeItems is called once when we clearAll");
     arrangeSpy.restore();
   }
 });
 
 gTests.push({
   desc: "empty grid",
   run: function() {
+    // XXX grids have minSlots and may not be ever truly empty
+
     let grid = doc.getElementById("emptyGrid");
     grid.arrangeItems();
     yield waitForCondition(() => !grid.isArranging);
 
-    // grid has rows=2 but 0 items
+    // grid has 2 rows, 6 slots, 0 items
     ok(grid.isBound, "binding was applied");
     is(grid.itemCount, 0, "empty grid has 0 items");
-    is(grid.rowCount, 0, "empty grid has 0 rows");
-    is(grid.columnCount, 0, "empty grid has 0 cols");
+    // minSlots attr. creates unpopulated slots
+    is(grid.rowCount, grid.getAttribute("rows"), "empty grid with rows-attribute has that number of rows");
+    is(grid.columnCount, 3, "empty grid has expected number of columns");
 
-    let columnsNode = grid._grid;
-    let cStyle = doc.defaultView.getComputedStyle(columnsNode);
-    is(cStyle.getPropertyValue("-moz-column-count"), "auto", "empty grid has -moz-column-count: auto");
+    // remove rows attribute and allow space for the grid to find its own height
+    // for its number of slots
+    grid.removeAttribute("rows");
+    grid.parentNode.style.height = 20+(grid.tileHeight*grid.minSlots)+"px";
+
+    grid.arrangeItems();
+    yield waitForCondition(() => !grid.isArranging);
+    is(grid.rowCount, grid.minSlots, "empty grid has this.minSlots rows");
+    is(grid.columnCount, 1, "empty grid has 1 column");
   }
 });
 
 gTests.push({
   desc: "appendItem",
   run: function() {
      // implements an appendItem with signature title, uri, returns item element
      // appendItem triggers arrangeItems
@@ -206,26 +223,35 @@ gTests.push({
      // implements an insertItemAt method, with index, title, uri.spec signature
      // insertItemAt triggers arrangeItems
     let grid = doc.querySelector("#grid3");
 
     is(grid.itemCount, 2, "2 items initially");
     is(typeof grid.insertItemAt, "function", "insertItemAt is a function on the grid");
 
     let arrangeStub = stubMethod(grid, "arrangeItems");
-    let insertedItem = grid.insertItemAt(1, "inserted item", "http://example.com/inserted");
+    let insertedAt0 = grid.insertItemAt(0, "inserted item 0", "http://example.com/inserted0");
+    let insertedAt00 = grid.insertItemAt(0, "inserted item 00", "http://example.com/inserted00");
+
+    ok(insertedAt0 && insertedAt00, "insertItemAt gives back an item");
+
+    is(insertedAt0.getAttribute("label"), "inserted item 0", "insertItemAt creates item with the correct label");
+    is(insertedAt0.getAttribute("value"), "http://example.com/inserted0", "insertItemAt creates item with the correct url value");
 
-    ok(insertedItem, "insertItemAt gives back an item");
-    is(grid.items[1], insertedItem, "item is inserted at the correct index");
-    is(insertedItem.getAttribute("label"), "inserted item", "insertItemAt creates item with the correct label");
-    is(insertedItem.getAttribute("value"), "http://example.com/inserted", "insertItemAt creates item with the correct url value");
-    is(grid.items[2].getAttribute("id"), "grid3_item2", "following item ends up at the correct index");
-    is(grid.itemCount, 3, "itemCount is incremented when we insertItemAt");
+    is(grid.items[0], insertedAt00, "item is inserted at the correct index");
+    is(grid.children[0], insertedAt00, "first item occupies the first slot");
+    is(grid.items[1], insertedAt0, "item is inserted at the correct index");
+    is(grid.children[1], insertedAt0, "next item occupies the next slot");
 
-    is(arrangeStub.callCount, 1, "arrangeItems is called when we insertItemAt");
+    is(grid.items[2].getAttribute("label"), "First item", "Old first item is now at index 2");
+    is(grid.items[3].getAttribute("label"), "2nd item", "Old 2nd item is now at index 3");
+
+    is(grid.itemCount, 4, "itemCount is incremented when we insertItemAt");
+
+    is(arrangeStub.callCount, 2, "arrangeItems is called when we insertItemAt");
     arrangeStub.restore();
   }
 });
 
 gTests.push({
   desc: "getIndexOfItem",
   run: function() {
      // implements a getIndexOfItem method, with item (element) signature
@@ -412,8 +438,177 @@ gTests.push({
 
     is(handlerStub.callCount, 1, "selectionchange event handler was called when we selected an item");
     is(handlerStub.calledWith[0].type, "selectionchange", "handler got a selectionchange event");
     is(handlerStub.calledWith[0].target, grid, "select event had the originating grid as the target");
     handlerStub.restore();
     doc.defaultView.removeEventListener("selectionchange", handler, false);
   }
 });
+
+function gridSlotsSetup() {
+    let grid = this.grid = doc.createElement("richgrid");
+    grid.setAttribute("minSlots", 6);
+    doc.documentElement.appendChild(grid);
+    is(grid.ownerDocument, doc, "created grid in the expected document");
+}
+function gridSlotsTearDown() {
+    this.grid && this.grid.parentNode.removeChild(this.grid);
+}
+
+gTests.push({
+  desc: "richgrid slots init",
+  setUp: gridSlotsSetup,
+  run: function() {
+    let grid = this.grid;
+    // grid is initially populated with empty slots matching the minSlots attribute
+    is(grid.children.length, 6, "minSlots slots are created");
+    is(grid.itemCount, 0, "slots do not count towards itemCount");
+    ok(Array.every(grid.children, (node) => node.nodeName == 'richgriditem'), "slots have nodeName richgriditem");
+    ok(Array.every(grid.children, isNotBoundByRichGrid_Item), "slots aren't bound by the richgrid-item binding");
+  },
+  tearDown: gridSlotsTearDown
+});
+
+gTests.push({
+  desc: "richgrid using slots for items",
+  setUp: gridSlotsSetup, // creates grid with minSlots = num. slots = 6
+  run: function() {
+    let grid = this.grid;
+    let numSlots = grid.getAttribute("minSlots");
+    is(grid.children.length, numSlots);
+    // adding items occupies those slots
+    for (let idx of [0,1,2,3,4,5,6]) {
+      let slot = grid.children[idx];
+      let item = grid.appendItem("item "+idx, "about:mozilla");
+      if (idx < numSlots) {
+        is(grid.children.length, numSlots);
+        is(slot, item, "The same node is reused when an item is assigned to a slot");
+      } else {
+        is(typeof slot, 'undefined');
+        ok(item);
+        is(grid.children.length, grid.itemCount);
+      }
+    }
+  },
+  tearDown: gridSlotsTearDown
+});
+
+gTests.push({
+  desc: "richgrid assign and release slots",
+  setUp: function(){
+    info("assign and release slots setUp");
+    this.grid = doc.getElementById("slots_grid");
+    this.grid.scrollIntoView();
+    let rect = this.grid.getBoundingClientRect();
+    info("slots grid at top: " + rect.top + ", window.pageYOffset: " + doc.defaultView.pageYOffset);
+  },
+  run: function() {
+    let grid = this.grid;
+    // start with 5 of 6 slots occupied
+    for (let idx of [0,1,2,3,4]) {
+      let item = grid.appendItem("item "+idx, "about:mozilla");
+      item.setAttribute("id", "test_item_"+idx);
+    }
+    is(grid.itemCount, 5);
+    is(grid.children.length, 6); // see setup, where we init with 6 slots
+    let firstItem = grid.items[0];
+
+    ok(firstItem.ownerDocument, "item has ownerDocument");
+    is(doc, firstItem.ownerDocument, "item's ownerDocument is the document we expect");
+
+    is(firstItem, grid.children[0], "Item and assigned slot are one and the same");
+    is(firstItem.control, grid, "Item is bound and its .control points back at the grid");
+
+    // before releasing, the grid should be nofified of clicks on that slot
+    let testWindow = grid.ownerDocument.defaultView;
+
+    let rect = firstItem.getBoundingClientRect();
+    {
+      let handleStub = stubMethod(grid, 'handleItemClick');
+      // send click to item and wait for next tick;
+      sendElementTap(testWindow, firstItem);
+      yield waitForMs(0);
+
+      is(handleStub.callCount, 1, "handleItemClick was called when we clicked an item");
+      handleStub.restore();
+    }
+    // _releaseSlot is semi-private, we don't expect consumers of the binding to call it
+    // but want to be sure it does what we expect
+    grid._releaseSlot(firstItem);
+
+    is(grid.itemCount, 4, "Releasing a slot gives us one less item");
+    is(firstItem, grid.children[0],"Released slot is still the same node we started with");
+
+    // after releasing, the grid should NOT be nofified of clicks
+    {
+      let handleStub = stubMethod(grid, 'handleItemClick');
+      // send click to item and wait for next tick;
+      sendElementTap(testWindow, firstItem);
+      yield waitForMs(0);
+
+      is(handleStub.callCount, 0, "handleItemClick was NOT called when we clicked a released slot");
+      handleStub.restore();
+    }
+
+    ok(!firstItem.mozMatchesSelector("richgriditem[value]"), "Released slot doesn't match binding selector");
+    ok(isNotBoundByRichGrid_Item(firstItem), "Released slot is no longer bound");
+
+    waitForCondition(() => isNotBoundByRichGrid_Item(firstItem));
+    ok(true, "Slot eventually gets unbound");
+    is(firstItem, grid.children[0], "Released slot is still at expected index in children collection");
+
+    let firstSlot = grid.children[0];
+    firstItem = grid.insertItemAt(0, "New item 0", "about:blank");
+    ok(firstItem == grid.items[0], "insertItemAt 0 creates item at expected index");
+    ok(firstItem == firstSlot, "insertItemAt occupies the released slot with the new item");
+    is(grid.itemCount, 5);
+    is(grid.children.length, 6);
+    is(firstItem.control, grid,"Item is bound and its .control points back at the grid");
+
+    let nextSlotIndex = grid.itemCount;
+    let lastItem = grid.insertItemAt(9, "New item 9", "about:blank");
+    // Check we don't create sparse collection of items
+    is(lastItem, grid.children[nextSlotIndex], "Item is appended at the next index when an out of bounds index is provided");
+    is(grid.children.length, 6);
+    is(grid.itemCount, 6);
+
+    grid.appendItem("one more", "about:blank");
+    is(grid.children.length, 7);
+    is(grid.itemCount, 7);
+
+    // clearAll results in slots being emptied
+    grid.clearAll();
+    is(grid.children.length, 6, "Extra slots are trimmed when we clearAll");
+    ok(!Array.some(grid.children, (node) => node.hasAttribute("value")), "All slots have no value attribute after clearAll")
+  },
+  tearDown: gridSlotsTearDown
+});
+
+gTests.push({
+  desc: "richgrid slot management",
+  setUp: gridSlotsSetup,
+  run: function() {
+    let grid = this.grid;
+    // populate grid with some items
+    let numSlots = grid.getAttribute("minSlots");
+    for (let idx of [0,1,2,3,4,5]) {
+      let item = grid.appendItem("item "+idx, "about:mozilla");
+    }
+
+    is(grid.itemCount, 6, "Grid setup with 6 items");
+    is(grid.children.length, 6, "Full grid has the expected number of slots");
+
+    // removing an item creates a replacement slot *on the end of the stack*
+    let item = grid.removeItemAt(0);
+    is(item.getAttribute("label"), "item 0", "removeItemAt gives back the populated node");
+    is(grid.children.length, 6);
+    is(grid.itemCount, 5);
+    is(grid.items[0].getAttribute("label"), "item 1", "removeItemAt removes the node so the nextSibling takes its place");
+    ok(grid.children[5] && !grid.children[5].hasAttribute("value"), "empty slot is added at the end of the existing children");
+
+    let item1 = grid.removeItem(grid.items[0]);
+    is(grid.children.length, 6);
+    is(grid.itemCount, 4);
+    is(grid.items[0].getAttribute("label"), "item 2", "removeItem removes the node so the nextSibling takes its place");
+  },
+  tearDown: gridSlotsTearDown
+});
--- a/browser/metro/theme/tiles.css
+++ b/browser/metro/theme/tiles.css
@@ -270,16 +270,36 @@ richgriditem[pinned]:-moz-locale-dir(rtl
 richgriditem[customColor] {
   color: #f1f1f1;
 }
 
 richgriditem[bending] > .tile-content {
   transform-origin: center center;
 }
 
+/* Empty/unused tiles */
+richgriditem:not([value]) {
+  visibility: hidden;
+}
+richgriditem[tiletype="thumbnail"]:not([value]) {
+  visibility: visible;
+}
+richgriditem:not([value]) > .tile-content {
+  padding: 10px 14px;
+}
+richgriditem[tiletype="thumbnail"]:not([value]) > .tile-content {
+  box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.05);
+  background-image: url("chrome://browser/skin/images/firefox-watermark.png");
+  background-origin: content-box;
+  background-repeat: no-repeat;
+  background-color: rgba(255,255,255, 0.2);
+  background-position: center center;
+  background-size: @grid_row_height@;
+}
+
 /* Snapped-view variation
    We use the compact, single-column grid treatment for <=320px */
 
 @media (max-width: 330px) {
 
   richgrid > .richgrid-grid {
     -moz-column-width: auto!important; /* let it flow */
     -moz-column-count: auto!important; /* let it flow */