Bug 1149385 - Render retaining paths in the dominators view; r=jsantell
authorNick Fitzgerald <fitzgen@gmail.com>
Tue, 23 Feb 2016 15:38:29 -0800
changeset 321610 dca97b7dfd1f46bacc098f21315640231f93ef68
parent 321609 923f6b1493b90dea9bcb69eaa32a4b7014246ce6
child 321611 307f90b2422f1d2dcd4c43dbec00e3daf31a6306
push id5913
push userjlund@mozilla.com
push dateMon, 25 Apr 2016 16:57:49 +0000
treeherdermozilla-beta@dcaf0a6fa115 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjsantell
bugs1149385
milestone47.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 1149385 - Render retaining paths in the dominators view; r=jsantell This commit extends the devtools' memory panel's dominators view with a sidebar that displays the 5 shortest retaining paths from the GC roots for the selected node in the dominator tree.
devtools/client/locales/en-US/memory.properties
devtools/client/memory/actions/moz.build
devtools/client/memory/actions/sizes.js
devtools/client/memory/app.js
devtools/client/memory/components/heap.js
devtools/client/memory/components/moz.build
devtools/client/memory/components/shortest-paths.js
devtools/client/memory/constants.js
devtools/client/memory/memory.xhtml
devtools/client/memory/reducers.js
devtools/client/memory/reducers/moz.build
devtools/client/memory/reducers/sizes.js
devtools/client/memory/test/chrome/chrome.ini
devtools/client/memory/test/chrome/head.js
devtools/client/memory/test/chrome/test_ShortestPaths_01.html
devtools/client/memory/test/chrome/test_ShortestPaths_02.html
devtools/client/themes/memory.css
--- a/devtools/client/locales/en-US/memory.properties
+++ b/devtools/client/locales/en-US/memory.properties
@@ -363,8 +363,16 @@ heapview.field.totalcount.tooltip=The nu
 
 # LOCALIZATION NOTE (heapview.field.name): The name of the column in the heap
 # view for name.
 heapview.field.name=Name
 
 # LOCALIZATION NOTE (heapview.field.name.tooltip): The tooltip for the column
 # header in the heap view for name.
 heapview.field.name.tooltip=The name of this group
+
+# LOCALIZATION NOTE (shortest-paths.header): The header label for the shortest
+# paths pane.
+shortest-paths.header=Retaining Paths from GC Roots
+
+# LOCALIZATION NOTE (shortest-paths.select-node): The message displayed in the
+# shortest paths pane when a node is not yet selected.
+shortest-paths.select-node=Select a node to view its retaining paths
--- a/devtools/client/memory/actions/moz.build
+++ b/devtools/client/memory/actions/moz.build
@@ -7,11 +7,12 @@ DevToolsModules(
     'allocations.js',
     'breakdown.js',
     'diffing.js',
     'dominatorTreeBreakdown.js',
     'filter.js',
     'inverted.js',
     'io.js',
     'refresh.js',
+    'sizes.js',
     'snapshot.js',
     'view.js',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/actions/sizes.js
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const { actions } = require("../constants");
+
+exports.resizeShortestPaths = function (newSize) {
+  return {
+    type: actions.RESIZE_SHORTEST_PATHS,
+    size: newSize,
+  };
+};
--- a/devtools/client/memory/app.js
+++ b/devtools/client/memory/app.js
@@ -28,16 +28,17 @@ const {
   expandCensusNode,
   collapseCensusNode,
   focusCensusNode,
   expandDominatorTreeNode,
   collapseDominatorTreeNode,
   focusDominatorTreeNode,
 } = require("./actions/snapshot");
 const { changeViewAndRefresh } = require("./actions/view");
+const { resizeShortestPaths } = require("./actions/sizes");
 const {
   breakdownNameToSpec,
   getBreakdownDisplayData,
   dominatorTreeBreakdownNameToSpec,
   getDominatorTreeBreakdownDisplayData,
 } = require("./utils");
 const Toolbar = createFactory(require("./components/toolbar"));
 const List = createFactory(require("./components/list"));
@@ -108,17 +109,17 @@ const MemoryApp = createClass({
       heapWorker,
       breakdown,
       allocations,
       inverted,
       toolbox,
       filter,
       diffing,
       view,
-      dominatorTreeBreakdown
+      sizes,
     } = this.props;
 
     const selectedSnapshot = snapshots.find(s => s.selected);
 
     const onClickSnapshotListItem = diffing && diffing.state === diffingState.SELECTING
       ? snapshot => dispatch(selectSnapshotForDiffingAndRefresh(heapWorker, snapshot))
       : snapshot => dispatch(selectSnapshotAndRefresh(heapWorker, snapshot.id));
 
@@ -232,16 +233,20 @@ const MemoryApp = createClass({
             onDominatorTreeFocus: node => {
               assert(view === viewState.DOMINATOR_TREE,
                      "If focusing dominator tree nodes, should be in dominator tree view");
               assert(selectedSnapshot, "...and we should have a selected snapshot");
               assert(selectedSnapshot.dominatorTree,
                      "...and that snapshot should have a dominator tree");
               dispatch(focusDominatorTreeNode(selectedSnapshot.id, node));
             },
+            onShortestPathsResize: newSize => {
+              dispatch(resizeShortestPaths(newSize));
+            },
+            sizes,
             view,
           })
         )
       )
     );
   },
 });
 
--- a/devtools/client/memory/components/heap.js
+++ b/devtools/client/memory/components/heap.js
@@ -3,16 +3,18 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 const { DOM: dom, createClass, PropTypes, createFactory } = require("devtools/client/shared/vendor/react");
 const { assert, safeErrorString } = require("devtools/shared/DevToolsUtils");
 const Census = createFactory(require("./census"));
 const CensusHeader = createFactory(require("./census-header"));
 const DominatorTree = createFactory(require("./dominator-tree"));
 const DominatorTreeHeader = createFactory(require("./dominator-tree-header"));
+const HSplitBox = createFactory(require("devtools/client/shared/components/h-split-box"));
+const ShortestPaths = createFactory(require("./shortest-paths"));
 const { getStatusTextFull, L10N } = require("../utils");
 const { snapshotState: states, diffingState, viewState, dominatorTreeState } = require("../constants");
 const { snapshot: snapshotModel, diffingModel } = require("../models");
 
 /**
  * Get the app state's current state atom.
  *
  * @see the relevant state string constants in `../constants.js`.
@@ -140,20 +142,22 @@ const Heap = module.exports = createClas
     onSnapshotClick: PropTypes.func.isRequired,
     onLoadMoreSiblings: PropTypes.func.isRequired,
     onCensusExpand: PropTypes.func.isRequired,
     onCensusCollapse: PropTypes.func.isRequired,
     onDominatorTreeExpand: PropTypes.func.isRequired,
     onDominatorTreeCollapse: PropTypes.func.isRequired,
     onCensusFocus: PropTypes.func.isRequired,
     onDominatorTreeFocus: PropTypes.func.isRequired,
+    onShortestPathsResize: PropTypes.func.isRequired,
     snapshot: snapshotModel,
     onViewSourceInDebugger: PropTypes.func.isRequired,
     diffing: diffingModel,
     view: PropTypes.string.isRequired,
+    sizes: PropTypes.object.isRequired,
   },
 
   render() {
     let {
       snapshot,
       diffing,
       onSnapshotClick,
       onLoadMoreSiblings,
@@ -272,22 +276,43 @@ const Heap = module.exports = createClas
       onCollapse: node => this.props.onCensusCollapse(census, node),
       onFocus: node => this.props.onCensusFocus(census, node),
     }));
 
     return this._renderHeapView(state, ...contents);
   },
 
   _renderDominatorTree(state, onViewSourceInDebugger, dominatorTree, onLoadMoreSiblings) {
-    return this._renderHeapView(
-      state,
+    const tree = dom.div(
+      {
+        className: "vbox",
+        style: {
+          overflowY: "auto"
+        }
+      },
       DominatorTreeHeader(),
       DominatorTree({
         onViewSourceInDebugger,
         dominatorTree,
         onLoadMoreSiblings,
         onExpand: this.props.onDominatorTreeExpand,
         onCollapse: this.props.onDominatorTreeCollapse,
         onFocus: this.props.onDominatorTreeFocus,
       })
     );
+
+    const shortestPaths = ShortestPaths({
+      graph: dominatorTree.focused
+        ? dominatorTree.focused.shortestPaths
+        : null
+    });
+
+    return this._renderHeapView(
+      state,
+      HSplitBox({
+        start: tree,
+        end: shortestPaths,
+        startWidth: this.props.sizes.shortestPathsSize,
+        onResize: this.props.onShortestPathsResize,
+      })
+    );
   },
 });
--- a/devtools/client/memory/components/moz.build
+++ b/devtools/client/memory/components/moz.build
@@ -7,11 +7,12 @@ DevToolsModules(
     'census-header.js',
     'census-tree-item.js',
     'census.js',
     'dominator-tree-header.js',
     'dominator-tree-item.js',
     'dominator-tree.js',
     'heap.js',
     'list.js',
+    'shortest-paths.js',
     'snapshot-list-item.js',
     'toolbar.js',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/components/shortest-paths.js
@@ -0,0 +1,189 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const {
+  DOM: dom,
+  createClass,
+  PropTypes,
+} = require("devtools/client/shared/vendor/react");
+const { isSavedFrame } = require("devtools/shared/DevToolsUtils");
+const { getSourceNames } = require("devtools/client/shared/source-utils");
+const { L10N } = require("../utils");
+
+const { ViewHelpers } = require("resource://devtools/client/shared/widgets/ViewHelpers.jsm");
+const COMPONENTS_STRINGS_URI = "chrome://devtools/locale/components.properties";
+const componentsL10N = new ViewHelpers.L10N(COMPONENTS_STRINGS_URI);
+const UNKNOWN_SOURCE_STRING = componentsL10N.getStr("frame.unknownSource");
+
+const GRAPH_DEFAULTS = {
+  translate: [20, 20],
+  scale: 1
+};
+
+const NO_STACK = "noStack";
+const NO_FILENAME = "noFilename";
+const ROOT_LIST = "JS::ubi::RootList";
+
+function stringifyLabel(label, id) {
+  const sanitized = [];
+
+  for (let i = 0, length = label.length; i < length; i++) {
+    const piece = label[i];
+
+    if (isSavedFrame(piece)) {
+      const { short } = getSourceNames(piece.source, UNKNOWN_SOURCE_STRING);
+      sanitized[i] = `${piece.functionDisplayName} @ ${short}:${piece.line}:${piece.column}`;
+    } else if (piece === NO_STACK) {
+      sanitized[i] = L10N.getStr("tree-item.nostack");
+    } else if (piece === NO_FILENAME) {
+      sanitized[i] = L10N.getStr("tree-item.nofilename");
+    } else if (piece === ROOT_LIST) {
+      // Don't use the usual labeling machinery for root lists: replace it
+      // with the "GC Roots" string.
+      sanitized.splice(0, label.length);
+      sanitized.push(L10N.getStr("tree-item.rootlist"));
+      break;
+    } else {
+      sanitized[i] = "" + piece;
+    }
+  }
+
+  return `${sanitized.join(" › ")} @ 0x${id.toString(16)}`;
+}
+
+module.exports = createClass({
+  displayName: "ShortestPaths",
+
+  propTypes: {
+    graph: PropTypes.shape({
+      nodes: PropTypes.arrayOf(PropTypes.object),
+      edges: PropTypes.arrayOf(PropTypes.object),
+    }),
+  },
+
+  getInitialState() {
+    return { zoom: null };
+  },
+
+  shouldComponentUpdate(nextProps) {
+    return this.props.graph != nextProps.graph;
+  },
+
+  componentDidMount() {
+    if (this.props.graph) {
+      this._renderGraph(this.refs.container, this.props.graph);
+    }
+  },
+
+  componentDidUpdate() {
+    if (this.props.graph) {
+      this._renderGraph(this.refs.container, this.props.graph);
+    }
+  },
+
+  componentWillUnmount() {
+    if (this.state.zoom) {
+      this.state.zoom.on("zoom", null);
+    }
+  },
+
+  render() {
+    let contents;
+    if (this.props.graph) {
+      // Let the componentDidMount or componentDidUpdate method draw the graph
+      // with DagreD3. We just provide the container for the graph here.
+      contents = dom.div({
+        ref: "container",
+        style: {
+          flex: 1,
+          height: "100%",
+          width: "100%",
+        }
+      });
+    } else {
+      contents = dom.div(
+        {
+          id: "shortest-paths-select-node-msg"
+        },
+        L10N.getStr("shortest-paths.select-node")
+      );
+    }
+
+    return dom.div(
+      {
+        id: "shortest-paths",
+        className: "vbox",
+      },
+      dom.label(
+        {
+          id: "shortest-paths-header",
+          className: "header",
+        },
+        L10N.getStr("shortest-paths.header")
+      ),
+      contents
+    );
+  },
+
+  _renderGraph(container, { nodes, edges }) {
+    if (!container.firstChild) {
+      const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+      svg.setAttribute("id", "graph-svg");
+      svg.setAttribute("xlink", "http://www.w3.org/1999/xlink");
+      svg.style.width = "100%";
+      svg.style.height = "100%";
+
+      const target = document.createElementNS("http://www.w3.org/2000/svg", "g");
+      target.setAttribute("id", "graph-target");
+      target.style.width = "100%";
+      target.style.height = "100%";
+
+      svg.appendChild(target);
+      container.appendChild(svg);
+    }
+
+    const graph = new dagreD3.Digraph();
+
+    for (let i = 0; i < nodes.length; i++) {
+      graph.addNode(nodes[i].id, {
+        id: nodes[i].id,
+        label: stringifyLabel(nodes[i].label, nodes[i].id),
+      });
+    }
+
+    for (let i = 0; i < edges.length; i++) {
+      graph.addEdge(null, edges[i].from, edges[i].to, {
+        label: edges[i].name
+      });
+    }
+
+    const renderer = new dagreD3.Renderer();
+    renderer.drawNodes();
+    renderer.drawEdgePaths();
+
+    const svg = d3.select("#graph-svg");
+    const target = d3.select("#graph-target");
+
+    let zoom = this.state.zoom;
+    if (!zoom) {
+      zoom = d3.behavior.zoom().on("zoom", function() {
+        target.attr(
+          "transform",
+          `translate(${d3.event.translate}) scale(${d3.event.scale})`
+        );
+      });
+      svg.call(zoom);
+      this.setState({ zoom });
+    }
+
+    const { translate, scale } = GRAPH_DEFAULTS;
+    zoom.scale(scale);
+    zoom.translate(translate);
+    target.attr("transform", `translate(${translate}) scale(${scale})`);
+
+    const layout = dagreD3.layout();
+    renderer.layout(layout).run(graph, target);
+  },
+});
--- a/devtools/client/memory/constants.js
+++ b/devtools/client/memory/constants.js
@@ -96,16 +96,18 @@ actions.COMPUTE_DOMINATOR_TREE_END = "co
 actions.FETCH_DOMINATOR_TREE_START = "fetch-dominator-tree-start";
 actions.FETCH_DOMINATOR_TREE_END = "fetch-dominator-tree-end";
 actions.DOMINATOR_TREE_ERROR = "dominator-tree-error";
 actions.FETCH_IMMEDIATELY_DOMINATED_START = "fetch-immediately-dominated-start";
 actions.FETCH_IMMEDIATELY_DOMINATED_END = "fetch-immediately-dominated-end";
 actions.EXPAND_DOMINATOR_TREE_NODE = "expand-dominator-tree-node";
 actions.COLLAPSE_DOMINATOR_TREE_NODE = "collapse-dominator-tree-node";
 
+actions.RESIZE_SHORTEST_PATHS = "resize-shortest-paths";
+
 /*** Breakdowns ***************************************************************/
 
 const COUNT = { by: "count", count: true, bytes: true };
 const INTERNAL_TYPE = { by: "internalType", then: COUNT };
 const ALLOCATION_STACK = { by: "allocationStack", then: COUNT, noStack: COUNT };
 const OBJECT_CLASS = { by: "objectClass", then: COUNT, other: COUNT };
 
 const breakdowns = exports.breakdowns = {
--- a/devtools/client/memory/memory.xhtml
+++ b/devtools/client/memory/memory.xhtml
@@ -9,19 +9,34 @@
 <!-- This Source Code Form is subject to the terms of the Mozilla Public
    - License, v. 2.0. If a copy of the MPL was not distributed with this
    - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
 <html xmlns="http://www.w3.org/1999/xhtml">
   <head>
     <link rel="stylesheet" href="chrome://devtools/skin/widgets.css" type="text/css"/>
     <link rel="stylesheet" href="chrome://devtools/skin/memory.css" type="text/css"/>
     <link rel="stylesheet" href="chrome://devtools/skin/components-frame.css" type="text/css"/>
+    <link rel="stylesheet" href="chrome://devtools/skin/components-h-split-box.css" type="text/css"/>
+  </head>
+  <body class="theme-body">
+    <div id="app"></div>
+
+    <script type="application/javascript;version=1.8"
+            src="chrome://devtools/content/shared/theme-switching.js"
+            defer="true">
+    </script>
 
     <script type="application/javascript;version=1.8"
-            src="chrome://devtools/content/shared/theme-switching.js"/>
-    <script type="application/javascript;version=1.8"
-            src="initializer.js"></script>
-  </head>
-  <body class="theme-body">
-    <div id="app">
-    </div>
+            src="initializer.js"
+            defer="true">
+    </script>
+
+    <script type="application/javascript"
+            src="chrome://devtools/content/shared/vendor/d3.js"
+            defer="true">
+    </script>
+
+    <script type="application/javascript"
+            src="chrome://devtools/content/shared/vendor/dagre-d3.js"
+            defer="true">
+    </script>
   </body>
 </html>
--- a/devtools/client/memory/reducers.js
+++ b/devtools/client/memory/reducers.js
@@ -5,10 +5,11 @@
 
 exports.allocations = require("./reducers/allocations");
 exports.breakdown = require("./reducers/breakdown");
 exports.diffing = require("./reducers/diffing");
 exports.dominatorTreeBreakdown = require("./reducers/dominatorTreeBreakdown");
 exports.errors = require("./reducers/errors");
 exports.filter = require("./reducers/filter");
 exports.inverted = require("./reducers/inverted");
+exports.sizes = require("./reducers/sizes");
 exports.snapshots = require("./reducers/snapshots");
 exports.view = require("./reducers/view");
--- a/devtools/client/memory/reducers/moz.build
+++ b/devtools/client/memory/reducers/moz.build
@@ -6,11 +6,12 @@
 DevToolsModules(
     'allocations.js',
     'breakdown.js',
     'diffing.js',
     'dominatorTreeBreakdown.js',
     'errors.js',
     'filter.js',
     'inverted.js',
+    'sizes.js',
     'snapshots.js',
     'view.js',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/reducers/sizes.js
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const { actions } = require("../constants");
+const { immutableUpdate } = require("devtools/shared/DevToolsUtils");
+
+const handlers = Object.create(null);
+
+handlers[actions.RESIZE_SHORTEST_PATHS] = function (sizes, { size }) {
+  return immutableUpdate(sizes, { shortestPathsSize: size });
+};
+
+module.exports = function (sizes = { shortestPathsSize: .5 }, action) {
+  const handler = handlers[action.type];
+  return handler ? handler(sizes, action) : sizes;
+};
--- a/devtools/client/memory/test/chrome/chrome.ini
+++ b/devtools/client/memory/test/chrome/chrome.ini
@@ -6,10 +6,12 @@ support-files =
 [test_DominatorTree_01.html]
 [test_DominatorTree_02.html]
 [test_DominatorTree_03.html]
 [test_DominatorTreeItem_01.html]
 [test_Heap_01.html]
 [test_Heap_02.html]
 [test_Heap_03.html]
 [test_Heap_04.html]
+[test_ShortestPaths_01.html]
+[test_ShortestPaths_02.html]
 [test_Toolbar_01.html]
 [test_Toolbar_02.html]
--- a/devtools/client/memory/test/chrome/head.js
+++ b/devtools/client/memory/test/chrome/head.js
@@ -23,26 +23,28 @@ var {
   dominatorTreeState,
   snapshotState,
   viewState
 } = constants;
 
 const {
   getBreakdownDisplayData,
   getDominatorTreeBreakdownDisplayData,
+  L10N,
 } = require("devtools/client/memory/utils");
 
 var models = require("devtools/client/memory/models");
 
 var React = require("devtools/client/shared/vendor/react");
 var ReactDOM = require("devtools/client/shared/vendor/react-dom");
 var Heap = React.createFactory(require("devtools/client/memory/components/heap"));
 var CensusTreeItem = React.createFactory(require("devtools/client/memory/components/census-tree-item"));
 var DominatorTreeComponent = React.createFactory(require("devtools/client/memory/components/dominator-tree"));
 var DominatorTreeItem = React.createFactory(require("devtools/client/memory/components/dominator-tree-item"));
+var ShortestPaths = React.createFactory(require("devtools/client/memory/components/shortest-paths"));
 var Toolbar = React.createFactory(require("devtools/client/memory/components/toolbar"));
 
 // All tests are asynchronous.
 SimpleTest.waitForExplicitFinish();
 
 var noop = () => {};
 
 var TEST_CENSUS_TREE_ITEM_PROPS = Object.freeze({
@@ -128,16 +130,31 @@ var TEST_DOMINATOR_TREE = Object.freeze(
 var TEST_DOMINATOR_TREE_PROPS = Object.freeze({
   dominatorTree: TEST_DOMINATOR_TREE,
   onLoadMoreSiblings: noop,
   onViewSourceInDebugger: noop,
   onExpand: noop,
   onCollapse: noop,
 });
 
+var TEST_SHORTEST_PATHS_PROPS = Object.freeze({
+  graph: Object.freeze({
+    nodes: [
+      { id: 1, label: ["other", "SomeType"] },
+      { id: 2, label: ["other", "SomeType"] },
+      { id: 3, label: ["other", "SomeType"] },
+    ],
+    edges: [
+      { from: 1, to: 2, name: "1->2" },
+      { from: 1, to: 3, name: "1->3" },
+      { from: 2, to: 3, name: "2->3" },
+    ],
+  }),
+});
+
 var TEST_HEAP_PROPS = Object.freeze({
   onSnapshotClick: noop,
   onLoadMoreSiblings: noop,
   onCensusExpand: noop,
   onCensusCollapse: noop,
   onDominatorTreeExpand: noop,
   onDominatorTreeCollapse: noop,
   onCensusFocus: noop,
@@ -169,16 +186,18 @@ var TEST_HEAP_PROPS = Object.freeze({
       focused: null,
     }),
     dominatorTree: TEST_DOMINATOR_TREE,
     error: null,
     imported: false,
     creationTime: 0,
     state: snapshotState.SAVED_CENSUS,
   }),
+  sizes: Object.freeze({ shortestPathsSize: .5 }),
+  onShortestPathsResize: noop,
 });
 
 var TEST_TOOLBAR_PROPS = Object.freeze({
   breakdowns: getBreakdownDisplayData(),
   onTakeSnapshotClick: noop,
   onImportClick: noop,
   onBreakdownChange: noop,
   onToggleRecordAllocationStacks: noop,
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/chrome/test_ShortestPaths_01.html
@@ -0,0 +1,112 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that the ShortestPaths component properly renders a graph of the merged shortest paths.
+-->
+<head>
+    <meta charset="utf-8">
+    <title>Tree component test</title>
+    <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+    <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+
+    <script type="application/javascript"
+            src="chrome://devtools/content/shared/vendor/d3.js">
+    </script>
+    <script type="application/javascript"
+            src="chrome://devtools/content/shared/vendor/dagre-d3.js">
+    </script>
+</head>
+<body>
+    <!-- Give the container height so that the whole tree is rendered. -->
+    <div id="container" style="height: 900px;"></div>
+
+    <pre id="test">
+        <script src="head.js" type="application/javascript;version=1.8"></script>
+        <script type="application/javascript;version=1.8">
+         window.onload = Task.async(function* () {
+           try {
+             const container = document.getElementById("container");
+
+             yield renderComponent(ShortestPaths(TEST_SHORTEST_PATHS_PROPS), container);
+
+             let found1 = false;
+             let found2 = false;
+             let found3 = false;
+
+             let found1to2 = false;
+             let found1to3 = false;
+             let found2to3 = false;
+
+             const tspans = [...container.querySelectorAll("tspan")];
+             for (let el of tspans) {
+               const text = el.textContent.trim();
+               dumpn("tspan's text = " + text);
+
+               switch (text) {
+                 // Nodes
+
+                 case "other › SomeType @ 0x1": {
+                   ok(!found1, "Should only find node 1 once");
+                   found1 = true;
+                   break;
+                 }
+
+                 case "other › SomeType @ 0x2": {
+                   ok(!found2, "Should only find node 2 once");
+                   found2 = true;
+                   break;
+                 }
+
+                 case "other › SomeType @ 0x3": {
+                   ok(!found3, "Should only find node 3 once");
+                   found3 = true;
+                   break;
+                 }
+
+                 // Edges
+
+                 case "1->2": {
+                   ok(!found1to2, "Should only find edge 1->2 once");
+                   found1to2 = true;
+                   break;
+                 }
+
+                 case "1->3": {
+                   ok(!found1to3, "Should only find edge 1->3 once");
+                   found1to3 = true;
+                   break;
+                 }
+
+                 case "2->3": {
+                   ok(!found2to3, "Should only find edge 2->3 once");
+                   found2to3 = true;
+                   break;
+                 }
+
+                 // Unexpected
+
+                 default: {
+                   ok(false, `Unexpected tspan: ${text}`);
+                   break;
+                 }
+               }
+             }
+
+             ok(found1, "Should have rendered node 1");
+             ok(found2, "Should have rendered node 2");
+             ok(found3, "Should have rendered node 3");
+
+             ok(found1to2, "Should have rendered edge 1->2");
+             ok(found1to3, "Should have rendered edge 1->3");
+             ok(found2to3, "Should have rendered edge 2->3");
+
+           } catch(e) {
+             ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e));
+           } finally {
+             SimpleTest.finish();
+           }
+         });
+        </script>
+    </pre>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/chrome/test_ShortestPaths_02.html
@@ -0,0 +1,45 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that the ShortestPaths component renders a suggestion to select a node when there is no graph.
+-->
+<head>
+    <meta charset="utf-8">
+    <title>Tree component test</title>
+    <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+    <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+
+    <script type="application/javascript"
+            src="chrome://devtools/content/shared/vendor/d3.js">
+    </script>
+    <script type="application/javascript"
+            src="chrome://devtools/content/shared/vendor/dagre-d3.js">
+    </script>
+</head>
+<body>
+    <!-- Give the container height so that the whole tree is rendered. -->
+    <div id="container" style="height: 900px;"></div>
+
+    <pre id="test">
+        <script src="head.js" type="application/javascript;version=1.8"></script>
+        <script type="application/javascript;version=1.8">
+         window.onload = Task.async(function* () {
+           try {
+             const container = document.getElementById("container");
+
+             yield renderComponent(ShortestPaths(immutableUpdate(TEST_SHORTEST_PATHS_PROPS,
+                                                                 { graph: null })),
+                                   container);
+
+             ok(container.textContent.indexOf(L10N.getStr("shortest-paths.select-node")) !== -1,
+                "The node selection prompt is displayed");
+           } catch(e) {
+             ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e));
+           } finally {
+             SimpleTest.finish();
+           }
+         });
+        </script>
+    </pre>
+</body>
+</html>
--- a/devtools/client/themes/memory.css
+++ b/devtools/client/themes/memory.css
@@ -219,16 +219,36 @@ html, body, #app, #memory-tool {
   /* Text inside a selected item should not be custom colored. */
   color: inherit !important;
 }
 
 /**
  * Main panel
  */
 
+.vbox {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+  overflow: auto;
+  padding: 0;
+  margin: 0;
+}
+
+.vbox > * {
+  flex: 1;
+
+  /**
+   * By default, flex items have min-width: auto;
+   * (https://drafts.csswg.org/css-flexbox/#min-size-auto)
+   */
+  min-width: 0;
+}
+
 #heap-view {
   /**
    * Flex: contains a .heap-view-panel which needs to fill out all the
    * available space, horizontally and vertically.
    */;
   display: flex;
   /**
    * Flexing to fill out remaining horizontal space. The preceeding sibling
@@ -262,17 +282,18 @@ html, body, #app, #memory-tool {
   /**
    * By default, flex items have min-width: auto;
    * (https://drafts.csswg.org/css-flexbox/#min-size-auto)
    */
   min-width: 0;
 }
 
 #heap-view > .heap-view-panel > .snapshot-status,
-#heap-view > .heap-view-panel > .take-snapshot {
+#heap-view > .heap-view-panel > .take-snapshot,
+#shortest-paths-select-node-msg {
   margin: auto;
   margin-top: 65px;
   font-size: 120%;
 }
 
 #heap-view > .heap-view-panel > .take-snapshot {
   padding: 5px;
 }
@@ -292,30 +313,47 @@ html, body, #app, #memory-tool {
    * Flex: contains several span columns, all of which need to be laid out
    * horizontally. All columns except the last one have percentage widths, and
    * the last one needs to flex to fill out all remaining horizontal space.
    */
   display: flex;
   color: var(--theme-body-color);
   background-color: var(--theme-tab-toolbar-background);
   border-bottom: 1px solid var(--cell-border-color);
+  flex: 0;
+}
+
+.header > span,
+#shortest-paths-header {
+  text-overflow: ellipsis;
+  line-height: var(--heap-tree-header-height);
+  justify-content: center;
+  justify-self: center;
+  white-space: nowrap;
 }
 
 .header > span {
   overflow: hidden;
-  text-overflow: ellipsis;
-  line-height: var(--heap-tree-header-height);
-  justify-content: center;
-  white-space: nowrap;
 }
 
 .header > .heap-tree-item-name {
   justify-content: flex-start;
 }
 
+#shortest-paths {
+  background-color: var(--theme-body-background);
+  overflow: hidden;
+  height: 100%;
+  width: 100%;
+}
+
+#shortest-paths-select-node-msg {
+  justify-self: center;
+}
+
 /**
  * Heap tree view body
  */
 
 .tree {
   /**
    * Flexing to fill out remaining vertical space. @see .heap-view-panel
    */
@@ -483,8 +521,55 @@ html, body, #app, #memory-tool {
 
 .no-allocation-stacks {
   border-color: var(--theme-splitter-color);
   border-style: solid;
   border-width: 0px 0px 1px 0px;
   text-align: center;
   padding: 5px;
 }
+
+/**
+ * Dagre-D3 graphs
+ */
+
+.edgePath path {
+  stroke-width: 1px;
+  fill: none;
+}
+
+.theme-dark .edgePath path {
+  stroke: var(--theme-body-color-alt);
+}
+.theme-light .edgePath path {
+  stroke: var(--theme-splitter-color);
+}
+
+g.edgeLabel rect {
+  fill: var(--theme-body-background);
+}
+g.edgeLabel tspan {
+  fill: var(--theme-body-color-alt);
+}
+
+.nodes rect {
+  stroke-width: 1px;
+}
+
+.nodes rect {
+  stroke: var(--theme-tab-toolbar-background);
+}
+.theme-light rect {
+  fill: var(--theme-tab-toolbar-background);
+}
+.theme-dark rect {
+  fill: var(--theme-toolbar-background);
+}
+
+text {
+  font-weight: 300;
+  font-family: "Helvetica Neue", Helvetica, Arial, sans-serf;
+  font-size: 14px;
+}
+
+text {
+  fill: var(--theme-body-color-alt);
+}