Bug 892935 - Auto-expand elements with only text in the markup view. r=pbrosset, r=bgrins, a=sledru
authorDave Camp <dcamp@mozilla.com>
Wed, 13 May 2015 14:55:09 -0700
changeset 274741 448a2dea1c4ed6a347fb9dbc48ab479f706872bb
parent 274740 c5ca17a3c738d2ab80d3434ac07493954e64365b
child 274742 f6f750ed544b1d21037a11cc4f79ff5c010b933d
push id863
push userraliiev@mozilla.com
push dateMon, 03 Aug 2015 13:22:43 +0000
treeherdermozilla-release@f6321b14228d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspbrosset, bgrins, sledru
bugs892935
milestone40.0a2
Bug 892935 - Auto-expand elements with only text in the markup view. r=pbrosset, r=bgrins, a=sledru
browser/devtools/markupview/markup-view.css
browser/devtools/markupview/markup-view.js
browser/devtools/markupview/test/browser_markupview_mutation_01.js
browser/devtools/markupview/test/browser_markupview_navigation.js
browser/devtools/markupview/test/browser_markupview_textcontent_edit_01.js
browser/devtools/markupview/test/doc_markup_dragdrop.html
browser/devtools/markupview/test/doc_markup_edit.html
browser/devtools/markupview/test/doc_markup_toggle.html
toolkit/devtools/server/actors/inspector.js
toolkit/devtools/server/tests/mochitest/test_inspector-mutations-childlist.html
toolkit/devtools/server/tests/mochitest/test_inspector-release.html
toolkit/devtools/server/tests/mochitest/test_inspector-remove.html
--- a/browser/devtools/markupview/markup-view.css
+++ b/browser/devtools/markupview/markup-view.css
@@ -177,8 +177,12 @@ ul.children + .tag-line::before {
 .tag-line {
   cursor: default;
 }
 
 .markupview-events {
   display: none;
   cursor: pointer;
 }
+
+.editor.text {
+  display: inline-block;
+}
--- a/browser/devtools/markupview/markup-view.js
+++ b/browser/devtools/markupview/markup-view.js
@@ -869,17 +869,17 @@ MarkupView.prototype = {
    * Expand the container's children.
    */
   _expandContainer: function(aContainer) {
     return this._updateChildren(aContainer, {expand: true}).then(() => {
       if (this._destroyer) {
         console.warn("Could not expand the node, the markup-view was destroyed");
         return;
       }
-      aContainer.expanded = true;
+      aContainer.setExpanded(true);
     });
   },
 
   /**
    * Expand the node's children.
    */
   expandNode: function(aNode) {
     let container = this.getContainer(aNode);
@@ -914,17 +914,17 @@ MarkupView.prototype = {
     return this._expandAll(this.getContainer(aNode));
   },
 
   /**
    * Collapse the node's children.
    */
   collapseNode: function(aNode) {
     let container = this.getContainer(aNode);
-    container.expanded = false;
+    container.setExpanded(false);
   },
 
   /**
    * Returns either the innerHTML or the outerHTML for a remote node.
    * @param aNode The NodeFront to get the outerHTML / innerHTML for.
    * @param isOuter A boolean that, if true, makes the function return the
    *                outerHTML, otherwise the innerHTML.
    * @returns A promise that will be resolved with the outerHTML / innerHTML.
@@ -1268,21 +1268,50 @@ MarkupView.prototype = {
     if (this._queuedChildUpdates.has(aContainer)) {
       return this._queuedChildUpdates.get(aContainer);
     }
 
     if (!aContainer.childrenDirty) {
       return promise.resolve(aContainer);
     }
 
+    if (aContainer.singleTextChild
+        && aContainer.singleTextChild != aContainer.node.singleTextChild) {
+
+      // This container was doing double duty as a container for a single
+      // text child, back that out.
+      this._containers.delete(aContainer.singleTextChild);
+      aContainer.clearSingleTextChild();
+
+      if (aContainer.hasChildren && aContainer.selected) {
+        aContainer.setExpanded(true);
+      }
+    }
+
+    if (aContainer.node.singleTextChild) {
+      aContainer.setExpanded(false);
+      // this container will do double duty as the container for the single
+      // text child.
+      while (aContainer.children.firstChild) {
+        aContainer.children.removeChild(aContainer.children.firstChild);
+      }
+
+      aContainer.setSingleTextChild(aContainer.node.singleTextChild);
+
+      this._containers.set(aContainer.node.singleTextChild, aContainer);
+      aContainer.childrenDirty = false;
+      return promise.resolve(aContainer);
+    }
+
     if (!aContainer.hasChildren) {
       while (aContainer.children.firstChild) {
         aContainer.children.removeChild(aContainer.children.firstChild);
       }
       aContainer.childrenDirty = false;
+      aContainer.setExpanded(false);
       return promise.resolve(aContainer);
     }
 
     // If we're not expanded (or asked to update anyway), we're done for
     // now.  Note that this will leave the childrenDirty flag set, so when
     // expanded we'll refresh the child list.
     if (!(aContainer.expanded || expand)) {
       return promise.resolve(aContainer);
@@ -1709,21 +1738,32 @@ MarkupContainer.prototype = {
   _hasChildren: false,
 
   get hasChildren() {
     return this._hasChildren;
   },
 
   set hasChildren(aValue) {
     this._hasChildren = aValue;
+    this.updateExpander();
+  },
+
+  /**
+   * True if the current node can be expanded.
+   */
+  get canExpand() {
+    return this._hasChildren && !this.node.singleTextChild;
+  },
+
+  updateExpander: function() {
     if (!this.expander) {
       return;
     }
 
-    if (aValue) {
+    if (this.canExpand) {
       this.expander.style.visibility = "visible";
     } else {
       this.expander.style.visibility = "hidden";
     }
   },
 
   /**
    * If the node has children, return the list of containers for all these
@@ -1739,21 +1779,25 @@ MarkupContainer.prototype = {
 
   /**
    * True if the node has been visually expanded in the tree.
    */
   get expanded() {
     return !this.elt.classList.contains("collapsed");
   },
 
-  set expanded(aValue) {
+  setExpanded: function(aValue) {
     if (!this.expander) {
       return;
     }
 
+    if (!this.canExpand) {
+      aValue = false;
+    }
+
     if (aValue && this.elt.classList.contains("collapsed")) {
       // Expanding a node means cloning its "inline" closing tag into a new
       // tag-line that the user can interact with and showing the children.
       let closingTag = this.elt.querySelector(".close");
       if (closingTag) {
         if (!this.closeTagLine) {
           let line = this.markup.doc.createElement("div");
           line.classList.add("tag-line");
@@ -1770,16 +1814,17 @@ MarkupContainer.prototype = {
       }
 
       this.elt.classList.remove("collapsed");
       this.expander.setAttribute("open", "");
       this.hovered = false;
     } else if (!aValue) {
       if (this.closeTagLine) {
         this.elt.removeChild(this.closeTagLine);
+        this.closeTagLine = undefined;
       }
       this.elt.classList.add("collapsed");
       this.expander.removeAttribute("open");
     }
   },
 
   parentContainer: function() {
     return this.elt.parentNode ? this.elt.parentNode.container : null;
@@ -1820,17 +1865,17 @@ MarkupContainer.prototype = {
     this.markup.navigate(this);
     event.stopPropagation();
 
     // Preventing the default behavior will avoid the body to gain focus on
     // mouseup (through bubbling) when clicking on a non focusable node in the
     // line. So, if the click happened outside of a focusable element, do
     // prevent the default behavior, so that the tagname or textcontent gains
     // focus.
-    if (!target.closest(".open [tabindex]")) {
+    if (!target.closest(".editor [tabindex]")) {
       event.preventDefault();
     }
 
     // Start dragging the container after a delay.
     this.markup._dragStartEl = target;
     setTimeout(() => {
       // Make sure the mouse is still down and on target.
       if (!this._isMouseDown || this.markup._dragStartEl !== target ||
@@ -2176,16 +2221,26 @@ MarkupElementContainer.prototype = Herit
   copyImageDataUri: function() {
     // We need to send again a request to gettooltipData even if one was sent for
     // the tooltip, because we want the full-size image
     this.node.getImageData().then(data => {
       data.data.string().then(str => {
         clipboardHelper.copyString(str, this.markup.doc);
       });
     });
+  },
+
+  setSingleTextChild: function(singleTextChild) {
+    this.singleTextChild = singleTextChild;
+    this.editor.updateTextEditor();
+  },
+
+  clearSingleTextChild: function() {
+    this.singleTextChild = undefined;
+    this.editor.updateTextEditor();
   }
 });
 
 /**
  * Dummy container node used for the root document element.
  */
 function RootContainer(aMarkupView, aNode) {
   this.doc = aMarkupView.doc;
@@ -2203,17 +2258,19 @@ RootContainer.prototype = {
   destroy: function() {},
 
   /**
    * If the node has children, return the list of containers for all these
    * children.
    */
   getChildContainers: function() {
     return [...this.children.children].map(node => node.container);
-  }
+  },
+
+  setExpanded: function(aValue) {}
 };
 
 /**
  * Creates an editor for non-editable nodes.
  */
 function GenericEditor(aContainer, aNode) {
   this.container = aContainer;
   this.markup = this.container.markup;
@@ -2310,16 +2367,17 @@ TextEditor.prototype = {
       let longstr = null;
       this.node.getNodeValue().then(ret => {
         longstr = ret;
         return longstr.string();
       }).then(str => {
         longstr.release().then(null, console.error);
         if (this.selected) {
           this.value.textContent = str;
+          this.markup.emit("text-expand")
         }
       }).then(null, console.error);
     }
   },
 
   destroy: function() {}
 };
 
@@ -2395,16 +2453,22 @@ function ElementEditor(aContainer, aNode
   this.eventNode.style.display = this.node.hasEventListeners ? "inline-block" : "none";
 
   this.update();
   this.initialized = true;
 }
 
 ElementEditor.prototype = {
 
+  set selected(aValue) {
+    if (this.textEditor) {
+      this.textEditor.selected = aValue;
+    }
+  },
+
   flashAttribute: function(attrName) {
     if (this.animationTimers[attrName]) {
       clearTimeout(this.animationTimers[attrName]);
     }
 
     flashElementOn(this.getAttributeElement(attrName));
 
     this.animationTimers[attrName] = setTimeout(() => {
@@ -2447,16 +2511,43 @@ ElementEditor.prototype = {
         // Temporarily flash the attribute to highlight the change.
         // But not if this is the first time the editor instance has
         // been created.
         if (this.initialized) {
           this.flashAttribute(attr.name);
         }
       }
     }
+
+    this.updateTextEditor();
+  },
+
+  /**
+   * Update the inline text editor in case of a single text child node.
+   */
+  updateTextEditor: function() {
+    let node = this.node.singleTextChild;
+
+    if (this.textEditor && this.textEditor.node != node) {
+      this.elt.removeChild(this.textEditor.elt);
+      this.textEditor = null;
+    }
+
+    if (node && !this.textEditor) {
+      // Create a text editor added to this editor.
+      // This editor won't receive an update automatically, so we rely on
+      // child text editors to let us know that we need updating.
+      this.textEditor = new TextEditor(this.container, node, "text");
+      this.elt.insertBefore(this.textEditor.elt,
+                            this.elt.firstChild.nextSibling.nextSibling);
+    }
+
+    if (this.textEditor) {
+      this.textEditor.update();
+    }
   },
 
   _startModifyingAttributes: function() {
     return this.node.startModifyingAttributes();
   },
 
   /**
    * Get the element used for one of the attributes of this element
--- a/browser/devtools/markupview/test/browser_markupview_mutation_01.js
+++ b/browser/devtools/markupview/test/browser_markupview_mutation_01.js
@@ -67,22 +67,85 @@ const TEST_DATA = [
   },
   {
     desc: "Updating the text-content",
     test: () => {
       let node1 = getNode("#node1");
       node1.textContent = "newtext";
     },
     check: function*(inspector) {
-      let {children} = yield getContainerForSelector("#node1", inspector);
-      is(children.querySelector(".text").textContent.trim(), "newtext",
-        "The new textcontent was updated");
+      let container = yield getContainerForSelector("#node1", inspector);
+      ok(container.singleTextChild, "Has single text child.");
+      ok(!container.canExpand, "Can't expand container with singleTextChild.");
+      ok(!container.singleTextChild.canExpand, "Can't expand singleTextChild.");
+      is(container.editor.elt.querySelector(".text").textContent.trim(), "newtext",
+        "Single text child editor updated.");
     }
   },
   {
+    desc: "Adding a second text child",
+    test: () => {
+      let node1 = getNode("#node1");
+      let newText = node1.ownerDocument.createTextNode("more");
+      node1.appendChild(newText);
+    },
+    check: function*(inspector) {
+      let container = yield getContainerForSelector("#node1", inspector);
+      ok(!container.singleTextChild, "Does not have single text child.");
+      ok(container.canExpand, "Can expand container with child nodes.");
+      ok(container.editor.elt.querySelector(".text") == null,
+        "Single text child editor removed.");
+    },
+  },
+  {
+    desc: "Go from 2 to 1 text child",
+    test: () => {
+      let node1 = getNode("#node1");
+      node1.textContent = "newtext";
+    },
+    check: function*(inspector) {
+      let container = yield getContainerForSelector("#node1", inspector);
+      ok(container.singleTextChild, "Has single text child.");
+      ok(!container.canExpand, "Can't expand container with singleTextChild.");
+      ok(!container.singleTextChild.canExpand, "Can't expand singleTextChild.");
+      ok(container.editor.elt.querySelector(".text").textContent.trim(), "newtext",
+        "Single text child editor updated.");
+    },
+  },
+  {
+    desc: "Removing an only text child",
+    test: () => {
+      let node1 = getNode("#node1");
+      node1.innerHTML = "";
+    },
+    check: function*(inspector) {
+      let container = yield getContainerForSelector("#node1", inspector);
+      ok(!container.singleTextChild, "Does not have single text child.");
+      ok(!container.canExpand, "Can't expand empty container.");
+      ok(container.editor.elt.querySelector(".text") == null,
+        "Single text child editor removed.");
+    },
+  },
+  {
+    desc: "Go from 0 to 1 text child",
+    test: () => {
+      let node1 = getNode("#node1");
+      node1.textContent = "newtext";
+    },
+    check: function*(inspector) {
+      let container = yield getContainerForSelector("#node1", inspector);
+      ok(container.singleTextChild, "Has single text child.");
+      ok(!container.canExpand, "Can't expand container with singleTextChild.");
+      ok(!container.singleTextChild.canExpand, "Can't expand singleTextChild.");
+      ok(container.editor.elt.querySelector(".text").textContent.trim(), "newtext",
+        "Single text child editor updated.");
+    },
+  },
+
+  {
     desc: "Updating the innerHTML",
     test: () => {
       let node2 = getNode("#node2");
       node2.innerHTML = "<div><span>foo</span></div>";
     },
     check: function*(inspector) {
       let container = yield getContainerForSelector("#node2", inspector);
 
@@ -145,27 +208,30 @@ const TEST_DATA = [
       let node20 = getNode("#node20");
 
       let node1 = getNode("#node1");
 
       node1.appendChild(node20);
       node20.appendChild(node18);
     },
     check: function*(inspector) {
+      yield inspector.markup.expandAll();
+
       let {children} = yield getContainerForSelector("#node1", inspector);
       is(children.childNodes.length, 2,
         "Node1 now has 2 children (textnode and node20)");
 
       let node20 = children.childNodes[1];
-      let node20Children = node20.querySelector(".children")
-      is(node20Children.childNodes.length, 2, "Node20 has 2 children (21 and 18)");
+      let node20Children = node20.container.children;
+      is(node20Children.childNodes.length, 2,
+          "Node20 has 2 children (21 and 18)");
 
       let node21 = node20Children.childNodes[0];
-      is(node21.querySelector(".children").textContent.trim(), "line21",
-        "Node21 only has a text node child");
+      is(node21.container.editor.elt.querySelector(".text").textContent.trim(), "line21",
+        "Node21 has a single text child");
 
       let node18 = node20Children.childNodes[1];
       is(node18.querySelector(".open .attreditor .attr-value").textContent.trim(),
         "node18", "Node20's second child is indeed node18");
     }
   }
 ];
 
--- a/browser/devtools/markupview/test/browser_markupview_navigation.js
+++ b/browser/devtools/markupview/test/browser_markupview_navigation.js
@@ -28,18 +28,16 @@ const TEST_DATA = [
   ["right", "node7"],
   ["down", "*text*"],
   ["down", "node8"],
   ["left", "node7"],
   ["left", "node7"],
   ["right", "node7"],
   ["right", "*text*"],
   ["down", "node8"],
-  ["right", "node8"],
-  ["left", "node8"],
   ["down", "node9"],
   ["down", "node10"],
   ["down", "node11"],
   ["down", "node12"],
   ["right", "node12"],
   ["down", "*text*"],
   ["down", "node13"],
   ["down", "node14"],
--- a/browser/devtools/markupview/test/browser_markupview_textcontent_edit_01.js
+++ b/browser/devtools/markupview/test/browser_markupview_textcontent_edit_01.js
@@ -10,22 +10,65 @@ const TEST_URL = TEST_URL_ROOT + "doc_ma
 
 add_task(function*() {
   let {inspector} = yield addTab(TEST_URL).then(openInspector);
 
   info("Expanding all nodes");
   yield inspector.markup.expandAll();
   yield waitForMultipleChildrenUpdates(inspector);
 
-  let node = getNode(".node6").firstChild;
-  is(node.nodeValue, "line6", "The test node's text content is correct");
+  yield editContainer(inspector, {
+    selector: ".node6",
+    newValue: "New text",
+    oldValue: "line6"
+  });
+
+  yield editContainer(inspector, {
+    selector: "#node17",
+    newValue: "LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISCING ELIT. DONEC POSUERE PLACERAT MAGNA ET IMPERDIET.",
+    oldValue: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec posuere placerat magna et imperdiet.",
+    shortValue: true
+  });
+
+  yield editContainer(inspector, {
+    selector: "#node17",
+    newValue: "New value",
+    oldValue: "LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISCING ELIT. DONEC POSUERE PLACERAT MAGNA ET IMPERDIET.",
+    shortValue: true
+  });
+});
+
+function* editContainer(inspector, {selector, newValue, oldValue, shortValue}) {
+  let node = getNode(selector).firstChild;
+  is(node.nodeValue, oldValue, "The test node's text content is correct");
 
   info("Changing the text content");
+  let onMutated = inspector.once("markupmutation");
+  let container = yield getContainerForSelector(selector, inspector);
+  let field = container.elt.querySelector("pre");
+
+  if (shortValue) {
+    is (oldValue.indexOf(field.textContent.substring(0, field.textContent.length - 1)), 0,
+        "The shortened value starts with the full value " + field.textContent);
+    ok (oldValue.length > field.textContent.length, "The shortened value is short");
+  } else {
+    is (field.textContent, oldValue, "The text node has the correct original value");
+  }
+
+  inspector.markup.markNodeAsSelected(container.node);
+
+  if (shortValue) {
+    info("Waiting for the text to be updated");
+    yield inspector.markup.once("text-expand");
+  }
+
+  is (field.textContent, oldValue, "The text node has the correct original value after selecting");
+  setEditableFieldValue(field, newValue, inspector);
 
   info("Listening to the markupmutation event");
-  let onMutated = inspector.once("markupmutation");
-  let container = yield getContainerForSelector(".node6", inspector);
-  let field = container.elt.querySelector("pre");
-  setEditableFieldValue(field, "New text", inspector);
   yield onMutated;
 
-  is(node.nodeValue, "New text", "Test test node's text content has changed");
-});
+  is(node.nodeValue, newValue, "The test node's text content has changed");
+
+  info("Selecting the <body> to reset the selection");
+  let bodyContainer = yield getContainerForSelector("body", inspector);
+  inspector.markup.markNodeAsSelected(bodyContainer.node);
+}
--- a/browser/devtools/markupview/test/doc_markup_dragdrop.html
+++ b/browser/devtools/markupview/test/doc_markup_dragdrop.html
@@ -15,17 +15,17 @@ https://bugzilla.mozilla.org/show_bug.cg
 <body>
   <a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=858038">Mozilla Bug 858038</a>
   <p id="display"></p>
   <div id="content" style="display: none">
 
   </div>
   <input id="anonymousParent" />
 
-  <span id="before">Before</span>
+  <span id="before">Before<!-- Force not-inline --></span>
   <pre id="test">
     <span id="firstChild">First</span>
     <span id="middleChild">Middle</span>
     <span id="lastChild">Last</span>
   </pre>
   <span id="after">After</span>
 </body>
 </html>
--- a/browser/devtools/markupview/test/doc_markup_edit.html
+++ b/browser/devtools/markupview/test/doc_markup_edit.html
@@ -17,17 +17,17 @@
         <span class="node10">line10</span>
         <span class="node11">line11</span>
         <a class="node12">line12<span class="node13">line13</span></a>
       </p>
       <p id="node14">line14</p>
       <p class="node15">line15</p>
     </div>
     <div id="node16">
-      <p id="node17">line17</p>
+      <p id="node17">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec posuere placerat magna et imperdiet.</p>
     </div>
     <div id="node18">
       <div id="node19">
         <div id="node20">
           <div id="node21">
             line21
           </div>
         </div>
--- a/browser/devtools/markupview/test/doc_markup_toggle.html
+++ b/browser/devtools/markupview/test/doc_markup_toggle.html
@@ -1,28 +1,28 @@
 <!DOCTYPE html>
 <html lang="en">
 <head>
   <title>Expanding and collapsing markup-view containers</title>
 </head>
 <body>
   <ul>
     <li>
-      <span>list <em>item</em></span>
+      <span>list <em>item<!-- force expand --></em></span>
     </li>
     <li>
-      <span>list <em>item</em></span>
+      <span>list <em>item<!-- force expand --></em></span>
     </li>
     <li>
-      <span>list <em>item</em></span>
+      <span>list <em>item<!-- force expand --></em></span>
     </li>
     <li>
-      <span>list <em>item</em></span>
+      <span>list <em>item<!-- force expand --></em></span>
     </li>
     <li>
-      <span>list <em>item</em></span>
+      <span>list <em>item<!-- force expand --></em></span>
     </li>
     <li>
-      <span>list <em>item</em></span>
+      <span>list <em>item<!-- force expand --></em></span>
     </li>
   </ul>
 </body>
-</html>
\ No newline at end of file
+</html>
--- a/toolkit/devtools/server/actors/inspector.js
+++ b/toolkit/devtools/server/actors/inspector.js
@@ -222,25 +222,27 @@ var NodeActor = exports.NodeActor = prot
 
   // Returns the JSON representation of this object over the wire.
   form: function(detail) {
     if (detail === "actorid") {
       return this.actorID;
     }
 
     let parentNode = this.walker.parentNode(this);
+    let singleTextChild = this.walker.singleTextChild(this);
 
     let form = {
       actor: this.actorID,
       baseURI: this.rawNode.baseURI,
       parent: parentNode ? parentNode.actorID : undefined,
       nodeType: this.rawNode.nodeType,
       namespaceURI: this.rawNode.namespaceURI,
       nodeName: this.rawNode.nodeName,
       numChildren: this.numChildren,
+      singleTextChild: singleTextChild ? singleTextChild.form() : undefined,
 
       // doctype attributes
       name: this.rawNode.name,
       publicId: this.rawNode.publicId,
       systemId: this.rawNode.systemId,
 
       attrs: this.writeAttrs(),
       isBeforePseudoElement: this.isBeforePseudoElement,
@@ -748,16 +750,23 @@ let NodeFront = protocol.FrontClass(Node
 
     if (form.parent) {
       // Get the owner actor for this actor (the walker), and find the
       // parent node of this actor from it, creating a standin node if
       // necessary.
       let parentNodeFront = ctx.marshallPool().ensureParentFront(form.parent);
       this.reparent(parentNodeFront);
     }
+
+    if (form.singleTextChild) {
+      this.singleTextChild =
+        types.getType("domnode").read(form.singleTextChild, ctx);
+    } else {
+      this.singleTextChild = undefined;
+    }
   },
 
   /**
    * Returns the parent NodeFront for this NodeFront.
    */
   parentNode: function() {
     return this._parent;
   },
@@ -1450,16 +1459,45 @@ var WalkerActor = protocol.ActorClass({
     let parent = walker.parentNode();
     if (parent) {
       return this._ref(parent);
     }
     return null;
   },
 
   /**
+   * If the given NodeActor only has a single text node as a child,
+   * return that child's NodeActor.
+   *
+   * @param NodeActor node
+   */
+  singleTextChild: function(node) {
+    // Quick checks to prevent creating a new walker if possible.
+    if (node.isBeforePseudoElement ||
+        node.isAfterPseudoElement ||
+        node.rawNode.nodeType != Ci.nsIDOMNode.ELEMENT_NODE ||
+        node.rawNode.children.length > 0) {
+      return undefined;
+    }
+
+    let docWalker = this.getDocumentWalker(node.rawNode);
+    let firstChild = docWalker.firstChild();
+
+    // If the first child isn't a text node, or there are multiple children
+    // then bail out
+    if (!firstChild ||
+        firstChild.nodeType !== Ci.nsIDOMNode.TEXT_NODE ||
+        docWalker.nextSibling()) {
+      return undefined;
+    }
+
+    return this._ref(firstChild);
+  },
+
+  /**
    * Mark a node as 'retained'.
    *
    * A retained node is not released when `releaseNode` is called on its
    * parent, or when a parent is released with the `cleanup` option to
    * `getMutations`.
    *
    * When a retained node's parent is released, a retained mode is added to
    * the walker's "retained orphans" list.
@@ -2565,16 +2603,18 @@ var WalkerActor = protocol.ActorClass({
    * `childList` type is returned when the set of children for a node
    * has changed.  Includes extra data, which can be used by the client to
    * maintain its ownership subtree.
    *
    *   added: array of <domnode actor ID> - The list of actors *previously
    *     seen by the client* that were added to the target node.
    *   removed: array of <domnode actor ID> The list of actors *previously
    *     seen by the client* that were removed from the target node.
+   *   singleTextChild: If the node now has a single text child, it will
+   *     be sent here.
    *
    * Actors that are included in a MutationRecord's `removed` but
    * not in an `added` have been removed from the client's ownership
    * tree (either by being moved under a node the client has seen yet
    * or by being removed from the tree entirely), and is considered
    * 'orphaned'.
    *
    * Keep in mind that if a node that the client hasn't seen is moved
@@ -2703,16 +2743,21 @@ var WalkerActor = protocol.ActorClass({
           // it and let the client know so that its ownership tree is up
           // to date.
           this._orphaned.delete(addedActor);
           addedActors.push(addedActor.actorID);
         }
 
         mutation.removed = removedActors;
         mutation.added = addedActors;
+
+        let singleTextChild = this.singleTextChild(targetActor);
+        if (singleTextChild) {
+          mutation.singleTextChild = singleTextChild.form();
+        }
       }
       this.queueMutation(mutation);
     }
   },
 
   onFrameLoad: function({ window, isTopLevel }) {
     if (!this.rootDoc && isTopLevel) {
       this.rootDoc = window.document;
@@ -3195,16 +3240,24 @@ var WalkerFront = exports.WalkerFront = 
             }
             addedFront.reparent(targetFront)
 
             // The actor is reconnected to the ownership tree, unorphan
             // it.
             this._orphaned.delete(addedFront);
             addedFronts.push(addedFront);
           }
+
+          if (change.singleTextChild) {
+            targetFront.singleTextChild =
+              types.getType("domnode").read(change.singleTextChild, this);
+          } else {
+            targetFront.singleTextChild = undefined;
+          }
+
           // Before passing to users, replace the added and removed actor
           // ids with front in the mutation record.
           emittedMutation.added = addedFronts;
           emittedMutation.removed = removedFronts;
 
           // If this is coming from a DOM mutation, the actor's numChildren
           // was passed in. Otherwise, it is simulated from a frame load or
           // unload, so don't change the front's form.
--- a/toolkit/devtools/server/tests/mochitest/test_inspector-mutations-childlist.html
+++ b/toolkit/devtools/server/tests/mochitest/test_inspector-mutations-childlist.html
@@ -228,17 +228,17 @@ addTest(mutationTest({
   ],
   postCheck: function(mutations) {
     is(mutations.length, 1, "Should generate one mutation.");
     let change = mutations[0];
     is(change.type, "childList", "Should be a childList.");
     is(change.removed.length, 1, "Should have removed a child.");
     let ownership = clientOwnershipTree(gWalker);
     is(ownership.orphaned.length, 1, "Should have one orphaned subtree.");
-    is(ownershipTreeSize(ownership.orphaned[0]), 27, "Should have orphaned longlist and 26 children.");
+    is(ownershipTreeSize(ownership.orphaned[0]), 1 + 26 + 26, "Should have orphaned longlist, and 26 children, and 26 singleTextChilds");
   }
 }));
 
 // Orphan a node, and do clean it up.
 addTest(mutationTest({
   autoCleanup: true,
   load: ["#longlist div"],
   moves: [
@@ -263,17 +263,17 @@ addTest(mutationTest({
   ],
   postCheck: function(mutations) {
     is(mutations.length, 1, "Should generate one mutation.");
     let change = mutations[0];
     is(change.type, "childList", "Should be a childList.");
     is(change.removed.length, 1, "Should have removed a child.");
     let ownership = clientOwnershipTree(gWalker);
     is(ownership.orphaned.length, 1, "Should have one orphaned subtree.");
-    is(ownershipTreeSize(ownership.orphaned[0]), 27, "Should have orphaned longlist and 26 children.");
+    is(ownershipTreeSize(ownership.orphaned[0]), 1 + 26 + 26, "Should have orphaned longlist, 26 children, and 26 singleTextChilds.");
   }
 }));
 
 // Orphan a node by moving it into the tree but out of our visible subtree, and clean it up.
 addTest(mutationTest({
   autoCleanup: true,
   load: ["#longlist div"],
   moves: [
--- a/toolkit/devtools/server/tests/mochitest/test_inspector-release.html
+++ b/toolkit/devtools/server/tests/mochitest/test_inspector-release.html
@@ -47,28 +47,40 @@ addTest(function testReleaseSubtree() {
   let firstChild = null;
   promiseDone(gWalker.querySelectorAll(gWalker.rootNode, "#longlist div").then(list => {
     // Make sure we have the 26 children of longlist in our ownership tree.
     is(list.length, 26, "Expect 26 div children.");
     // Make sure we've read in all those children and incorporated them in our ownership tree.
     return list.items();
   }).then((items)=> {
     originalOwnershipSize = assertOwnership();
-    ok(originalOwnershipSize > 26, "Should have at least 26 items in our ownership tree");
+
+    // Here is how the ownership tree is summed up:
+    // #document                      1
+    //   <html>                       1
+    //     <body>                     1
+    //       <div id=longlist>        1
+    //         <div id=a>a</div>   26*2 (each child plus it's singleTextChild)
+    //         ...
+    //         <div id=z>z</div>
+    //                             -----
+    //                               56
+    is(originalOwnershipSize, 56, "Correct number of items in ownership tree");
     firstChild = items[0].actorID;
   }).then(() => {
     // Now get the longlist and release it from the ownership tree.
     return gWalker.querySelector(gWalker.rootNode, "#longlist");
   }).then(node => {
     longlist = node.actorID;
     return gWalker.releaseNode(node);
   }).then(() => {
-    // Our ownership size should now be 27 fewer (we forgot about #longlist + 26 children)
+    // Our ownership size should now be 53 fewer (we forgot about #longlist + 26 children + 26 singleTextChild nodes)
     let newOwnershipSize = assertOwnership();
-    is(newOwnershipSize, originalOwnershipSize - 27, "Ownership tree should have dropped by 27 nodes");
+    is(newOwnershipSize, originalOwnershipSize - 53,
+      "Ownership tree should be lower");
     // Now verify that some nodes have gone away
     return checkMissing(gClient, longlist);
   }).then(() => {
     return checkMissing(gClient, firstChild);
   }).then(runNextTest));
 });
 
 addTest(function cleanup() {
--- a/toolkit/devtools/server/tests/mochitest/test_inspector-remove.html
+++ b/toolkit/devtools/server/tests/mochitest/test_inspector-remove.html
@@ -65,26 +65,39 @@ addTest(function testRemoveSubtree() {
 
   promiseDone(gWalker.querySelector(gWalker.rootNode, "#longlist").then(listFront => {
     longlist = listFront;
     longlistID = longlist.actorID;
   }).then(() => {
     return gWalker.children(longlist);
   }).then((items)=> {
     originalOwnershipSize = assertOwnership();
-    ok(originalOwnershipSize > 26, "Should have at least 26 items in our ownership tree");
+    // Here is how the ownership tree is summed up:
+    // #document                      1
+    //   <html>                       1
+    //     <body>                     1
+    //       <div id=longlist>        1
+    //         <div id=a>a</div>   26*2 (each child plus it's singleTextChild)
+    //         ...
+    //         <div id=z>z</div>
+    //                             -----
+    //                               56
+    is(originalOwnershipSize, 56, "Correct number of items in ownership tree");
     return gWalker.removeNode(longlist);
   }).then(siblings => {
     is(siblings.previousSibling.rawNode(), previousSibling, "Should have returned the previous sibling.");
     is(siblings.nextSibling.rawNode(), nextSibling, "Should have returned the next sibling.");
     return waitForMutation(gWalker, isChildList);
   }).then(() => {
-    // Our ownership size should now be 25 fewer (we forgot about #longlist + 26 children, but learned about #longlist's prev/next sibling)
+    // Our ownership size should now be 51 fewer (we forgot about #longlist + 26
+    // children + 26 singleTextChild nodes, but learned about #longlist's
+    // prev/next sibling)
     let newOwnershipSize = assertOwnership();
-    is(newOwnershipSize, originalOwnershipSize - 25, "Ownership tree should have dropped by 25 nodes");
+    is(newOwnershipSize, originalOwnershipSize - 51,
+      "Ownership tree should be lower");
     // Now verify that some nodes have gone away
     return checkMissing(gClient, longlistID);
   }).then(runNextTest));
 });
 
 addTest(function cleanup() {
   delete gWalker;
   delete gClient;