Bug 1229229 - Display dominator trees in the memory tool's UI; r=jsantell
authorNick Fitzgerald <fitzgen@gmail.com>
Wed, 13 Jan 2016 12:27:30 -0800
changeset 279769 3a531aa40e497499331f462fad9349201c9d7d98
parent 279768 f207059ae4bf2b1258dd73a2fe75160f7d267b39
child 279770 37f7935f02e132e6c18770871164472bc3bf130a
push id17001
push usernfitzgerald@mozilla.com
push dateWed, 13 Jan 2016 20:27:49 +0000
treeherderfx-team@3a531aa40e49 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjsantell
bugs1229229
milestone46.0a1
Bug 1229229 - Display dominator trees in the memory tool's UI; r=jsantell This patch overhauls the memory tool's frontend from being in a state of viewing a snapshot's census (the implicit default) or viewing a census diff (a special cased kind of thing) to one of three recognized view states: (1) census, (2) diffing, or (3) dominator tree. The logic surrounding our current view is more explicit now. View option (3) is the new one, whose introduction required those clean ups. Dominator trees are too large to render in full, all at once. Instead, we eagerly load and render a few levels deep and then incrementally fetch further subtrees as their parents are expanded in the UI. This means that we get new Tree component instances across incremental fetches. Before this commit, the Tree component stored a bunch of state internally (in this.state rather than this.props) and we would lose focus and expansion state across incremental fetches. This internal state has been pulled out and made as explicit props, which are now managed by actions and reducers just like the rest of the state.
devtools/client/locales/en-US/memory.properties
devtools/client/memory/actions/diffing.js
devtools/client/memory/actions/dominatorTreeBreakdown.js
devtools/client/memory/actions/io.js
devtools/client/memory/actions/moz.build
devtools/client/memory/actions/refresh.js
devtools/client/memory/actions/snapshot.js
devtools/client/memory/actions/view.js
devtools/client/memory/app.js
devtools/client/memory/components/census-header.js
devtools/client/memory/components/census-tree-item.js
devtools/client/memory/components/census.js
devtools/client/memory/components/dominator-tree-header.js
devtools/client/memory/components/dominator-tree-item.js
devtools/client/memory/components/dominator-tree.js
devtools/client/memory/components/heap.js
devtools/client/memory/components/list.js
devtools/client/memory/components/moz.build
devtools/client/memory/components/snapshot-list-item.js
devtools/client/memory/components/toolbar.js
devtools/client/memory/components/tree-item.js
devtools/client/memory/constants.js
devtools/client/memory/dominator-tree-lazy-children.js
devtools/client/memory/models.js
devtools/client/memory/moz.build
devtools/client/memory/reducers.js
devtools/client/memory/reducers/diffing.js
devtools/client/memory/reducers/dominatorTreeBreakdown.js
devtools/client/memory/reducers/moz.build
devtools/client/memory/reducers/snapshots.js
devtools/client/memory/reducers/view.js
devtools/client/memory/store.js
devtools/client/memory/test/browser/browser.ini
devtools/client/memory/test/browser/browser_memory_dominator_trees_01.js
devtools/client/memory/test/browser/doc_big_tree.html
devtools/client/memory/test/chrome/chrome.ini
devtools/client/memory/test/chrome/head.js
devtools/client/memory/test/chrome/test_DominatorTree_01.html
devtools/client/memory/test/chrome/test_DominatorTree_02.html
devtools/client/memory/test/chrome/test_Heap_01.html
devtools/client/memory/test/chrome/test_Heap_02.html
devtools/client/memory/test/chrome/test_Heap_03.html
devtools/client/memory/test/chrome/test_Toolbar_01.html
devtools/client/memory/test/chrome/test_Toolbar_02.html
devtools/client/memory/test/unit/head.js
devtools/client/memory/test/unit/test_action_diffing_05.js
devtools/client/memory/test/unit/test_dominator_trees_01.js
devtools/client/memory/test/unit/test_dominator_trees_02.js
devtools/client/memory/test/unit/test_dominator_trees_03.js
devtools/client/memory/test/unit/test_dominator_trees_04.js
devtools/client/memory/test/unit/test_dominator_trees_05.js
devtools/client/memory/test/unit/test_dominator_trees_06.js
devtools/client/memory/test/unit/test_dominator_trees_07.js
devtools/client/memory/test/unit/test_dominator_trees_08.js
devtools/client/memory/test/unit/test_dominator_trees_09.js
devtools/client/memory/test/unit/test_utils.js
devtools/client/memory/test/unit/xpcshell.ini
devtools/client/memory/utils.js
devtools/client/preferences/devtools.js
devtools/client/shared/components/frame.js
devtools/client/shared/components/test/mochitest/head.js
devtools/client/shared/components/test/mochitest/test_tree_01.html
devtools/client/shared/components/test/mochitest/test_tree_02.html
devtools/client/shared/components/test/mochitest/test_tree_04.html
devtools/client/shared/components/test/mochitest/test_tree_05.html
devtools/client/shared/components/test/mochitest/test_tree_06.html
devtools/client/shared/components/test/mochitest/test_tree_07.html
devtools/client/shared/components/test/mochitest/test_tree_08.html
devtools/client/shared/components/test/mochitest/test_tree_09.html
devtools/client/shared/components/test/mochitest/test_tree_10.html
devtools/client/shared/components/tree.js
devtools/client/themes/memory.css
--- a/devtools/client/locales/en-US/memory.properties
+++ b/devtools/client/locales/en-US/memory.properties
@@ -5,72 +5,88 @@
 # LOCALIZATION NOTE These strings are used inside the Memory Tools
 # which is available from the Web Developer sub-menu -> 'Memory'.
 # The correct localization of this file might be to keep it in
 # English, or another language commonly spoken among web developers.
 # You want to make that choice consistent across the developer tools.
 # A good criteria is the language in which you'd find the best
 # documentation on web development on the web.
 
-# LOCALIZATION NOTE (memory.label):
-# This string is displayed in the title of the tab when the memory tool is
-# displayed inside the developer tools window and in the Developer Tools Menu.
+# LOCALIZATION NOTE (memory.label): This string is displayed in the title of the
+# tab when the memory tool is displayed inside the developer tools window and in
+# the Developer Tools Menu.
 memory.label=Memory
 
-# LOCALIZATION NOTE (memory.panelLabel):
-# This is used as the label for the toolbox panel.
+# LOCALIZATION NOTE (memory.panelLabel): This is used as the label for the
+# toolbox panel.
 memory.panelLabel=Memory Panel
 
-# LOCALIZATION NOTE (memory.tooltip):
-# This string is displayed in the tooltip of the tab when the memory tool is
-# displayed inside the developer tools window.
+# LOCALIZATION NOTE (memory.tooltip): This string is displayed in the tooltip of
+# the tab when the memory tool is displayed inside the developer tools window.
 memory.tooltip=Memory
 
-# LOCALIZATION NOTE (snapshot.io.save): The label for the link that saves a snapshot
-# to disk.
+# LOCALIZATION NOTE (snapshot.io.save): The label for the link that saves a
+# snapshot to disk.
 snapshot.io.save=Save
 
-# LOCALIZATION NOTE (snapshot.io.save.window): The title for the window displayed when
-# saving a snapshot to disk.
+# LOCALIZATION NOTE (snapshot.io.save.window): The title for the window
+# displayed when saving a snapshot to disk.
 snapshot.io.save.window=Save Heap Snapshot
 
-# LOCALIZATION NOTE (snapshot.io.import.window): The title for the window displayed when
-# importing a snapshot form disk.
+# LOCALIZATION NOTE (snapshot.io.import.window): The title for the window
+# displayed when importing a snapshot form disk.
 snapshot.io.import.window=Import Heap Snapshot
 
 # LOCALIZATION NOTE (snapshot.io.filter): The title for the filter used to
 # filter file types (*.fxsnapshot)
 snapshot.io.filter=Firefox Heap Snapshots
 
-# LOCALIZATION NOTE (aggregate.mb): The label annotating the number of bytes (in megabytes)
-# in a snapshot. %S represents the value, rounded to 2 decimal points.
+# LOCALIZATION NOTE (aggregate.mb): The label annotating the number of bytes (in
+# megabytes) in a snapshot. %S represents the value, rounded to 2 decimal
+# points.
 aggregate.mb=%S MB
 
 # LOCALIZATION NOTE (snapshot-title.loading): The title for a snapshot before
 # it has a creation time to display.
 snapshot-title.loading=Processing…
 
 # LOCALIZATION NOTE (checkbox.invertTree): The label describing the boolean
 # checkbox whether or not to invert the tree.
 checkbox.invertTree=Invert tree
 
-# LOCALIZATION NOTE (checkbox.recordAllocationStacks): The label describing the boolean
-# checkbox whether or not to record allocation stacks.
+# LOCALIZATION NOTE (checkbox.recordAllocationStacks): The label describing the
+# boolean checkbox whether or not to record allocation stacks.
 checkbox.recordAllocationStacks=Record allocation stacks
 
 # LOCALIZATION NOTE (toolbar.breakdownBy): The label describing the select menu
 # options of the breakdown options.
 toolbar.breakdownBy=Group by:
 
-# LOCALIZATION NOTE (take-snapshot): The label describing the button that initiates
-# taking a snapshot, either as the main label, or a tooltip.
+# LOCALIZATION NOTE (toolbar.labelBy): The label describing the select menu
+# options of the label options.
+toolbar.labelBy=Label by:
+
+# LOCALIZATION NOTE (toolbar.view): The label for the view selector in the
+# toolbar.
+toolbar.view=View:
+
+# LOCALIZATION NOTE (toolbar.view.census): The label for the census view option
+# in the toolbar.
+toolbar.view.census=Aggregate
+
+# LOCALIZATION NOTE (toolbar.view.dominators): The label for the dominators view
+# option in the toolbar.
+toolbar.view.dominators=Dominators
+
+# LOCALIZATION NOTE (take-snapshot): The label describing the button that
+# initiates taking a snapshot, either as the main label, or a tooltip.
 take-snapshot=Take snapshot
 
-# LOCALIZATION NOTE (import-snapshot): The label describing the button that initiates
-# importing a snapshot.
+# LOCALIZATION NOTE (import-snapshot): The label describing the button that
+# initiates importing a snapshot.
 import-snapshot=Import…
 
 # LOCALIZATION NOTE (diff-snapshots): The label for the button that initiates
 # selecting two snapshots to diff with each other.
 diff-snapshots=+/-
 
 # LOCALIZATION NOTE (diff-snapshots.tooltip): The tooltip for the button that
 # initiates selecting two snapshots to diff with each other.
@@ -80,16 +96,20 @@ diff-snapshots.tooltip=Compare snapshots
 # memory tool's filter search box.
 filter.placeholder=Filter
 
 # LOCALIZATION NOTE (viewsourceindebugger): The label for the tooltip when hovering over
 # a link in the heap tree to jump to the debugger view.
 # %S represents the URL to match in the debugger.
 viewsourceindebugger=View source in Debugger → %S
 
+# LOCALIZATION NOTE (tree-item.load-more): The label for the links to fetch the
+# lazily loaded sub trees in the dominator tree view.
+tree-item.load-more=Load more…
+
 # LOCALIZATION NOTE (tree-item.nostack): The label describing the row in the heap tree
 # that represents a row broken down by allocation stack when no stack was available.
 tree-item.nostack=(no stack available)
 
 # LOCALIZATION NOTE (tree-item.nofilename): The label describing the row in the
 # heap tree that represents a row broken down by filename when no filename was
 # available.
 tree-item.nofilename=(no filename available)
@@ -120,17 +140,17 @@ diffing.prompt.selectComparison=Select t
 # LOCALIZATION NOTE (diffing.state.error): The label describing the diffing
 # state ERROR, used in the snapshot list when an error occurs while diffing two
 # snapshots.
 diffing.state.error=Error
 
 # LOCALIZATION NOTE (diffing.state.error.full): The text describing the diffing
 # state ERROR, used in the main view when an error occurs while diffing two
 # snapshots.
-diffing.state.error.full=There was an error while comparing snapshots
+diffing.state.error.full=There was an error while comparing snapshots.
 
 # LOCALIZATION NOTE (diffing.state.taking-diff): The label describing the diffin
 # state TAKING_DIFF, used in the snapshots list when computing the difference
 # between two snapshots.
 diffing.state.taking-diff=Computing difference…
 
 # LOCALIZATION NOTE (diffing.state.taking-diff.full): The label describing the
 # diffing state TAKING_DIFF, used in the main view when computing the difference
@@ -141,16 +161,48 @@ diffing.state.taking-diff.full=Computing difference…
 # state SELECTING.
 diffing.state.selecting=Select two snapshots to compare
 
 # LOCALIZATION NOTE (diffing.state.selecting.full): The label describing the
 # diffing state SELECTING, used in the main view when selecting snapshots to
 # diff.
 diffing.state.selecting.full=Select two snapshots to compare
 
+# LOCALIZATION NOTE (dominatorTree.state.computing): The label describing the
+# dominator tree state COMPUTING.
+dominatorTree.state.computing=Computing dominators…
+
+# LOCALIZATION NOTE (dominatorTree.state.computing): The label describing the
+# dominator tree state COMPUTING, used in the dominator tree view.
+dominatorTree.state.computing.full=Computing dominator tree…
+
+# LOCALIZATION NOTE (dominatorTree.state.fetching): The label describing the
+# dominator tree state FETCHING.
+dominatorTree.state.fetching=Computing sizes…
+
+# LOCALIZATION NOTE (dominatorTree.state.fetching): The label describing the
+# dominator tree state FETCHING, used in the dominator tree view.
+dominatorTree.state.fetching.full=Computing retained sizes…
+
+# LOCALIZATION NOTE (dominatorTree.state.incrementalFetching): The label
+# describing the dominator tree state INCREMENTAL_FETCHING.
+dominatorTree.state.incrementalFetching=Fetching…
+
+# LOCALIZATION NOTE (dominatorTree.state.incrementalFetching): The label describing the
+# dominator tree state INCREMENTAL_FETCHING, used in the dominator tree view.
+dominatorTree.state.incrementalFetching.full=Fetching subtree…
+
+# LOCALIZATION NOTE (dominatorTree.state.error): The label describing the
+# dominator tree state ERROR.
+dominatorTree.state.error=Error
+
+# LOCALIZATION NOTE (dominatorTree.state.error): The label describing the
+# dominator tree state ERROR, used in the dominator tree view.
+dominatorTree.state.error.full=There was an error while processing the dominator tree
+
 # LOCALIZATION NOTE (snapshot.state.saving.full): The label describing the
 # snapshot state SAVING, used in the main heap view.
 snapshot.state.saving.full=Saving snapshot…
 
 # LOCALIZATION NOTE (snapshot.state.importing.full): The label describing the
 # snapshot state IMPORTING, used in the main heap view.
 snapshot.state.importing.full=Importing…
 
@@ -188,16 +240,24 @@ snapshot.state.saving-census=Saving census…
 # state ERROR, used in the snapshot list view.
 snapshot.state.error=Error
 
 # LOCALIZATION NOTE (heapview.noAllocationStacks): The message displayed to
 # users when selecting a breakdown by "allocation stack" but no allocation
 # stacks were recorded in the heap snapshot.
 heapview.noAllocationStacks=No allocation stacks found. Record allocation stacks before taking a heap snapshot.
 
+# LOCALIZATION NOTE (heapview.field.retainedSize): The name of the column in the
+# dominator tree view for retained byte sizes.
+heapview.field.retainedSize=Retained Size (Bytes)
+
+# LOCALIZATION NOTE (heapview.field.shallowSize): The name of the column in the
+# dominator tree view for shallow byte sizes.
+heapview.field.shallowSize=Shallow Size (Bytes)
+
 # LOCALIZATION NOTE (heapview.field.bytes): The name of the column in the heap view for bytes.
 heapview.field.bytes=Bytes
 
 # LOCALIZATION NOTE (heapview.field.count): The name of the column in the heap view for count.
 heapview.field.count=Count
 
 # LOCALIZATION NOTE (heapview.field.totalbytes): The name of the column in the heap view for total bytes.
 heapview.field.totalbytes=Total Bytes
--- a/devtools/client/memory/actions/diffing.js
+++ b/devtools/client/memory/actions/diffing.js
@@ -1,27 +1,32 @@
 /* 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 { assert, reportException } = require("devtools/shared/DevToolsUtils");
-const { actions, diffingState } = require("../constants");
+const { actions, diffingState, viewState } = require("../constants");
 const {
   breakdownEquals,
   getSnapshot,
   censusIsUpToDate,
   snapshotIsDiffable
 } = require("../utils");
 
 /**
  * Toggle diffing mode on or off.
  */
 const toggleDiffing = exports.toggleDiffing = function () {
-  return { type: actions.TOGGLE_DIFFING };
+  return function(dispatch, getState) {
+    dispatch({
+      type: actions.CHANGE_VIEW,
+      view: getState().diffing ? viewState.CENSUS : viewState.DIFFING,
+    });
+  };
 };
 
 /**
  * Select the given snapshot for diffing.
  *
  * @param {snapshotModel} snapshot
  */
 const selectSnapshotForDiffing = exports.selectSnapshotForDiffing = function (snapshot) {
@@ -147,8 +152,44 @@ const refreshDiffing = exports.refreshDi
 const selectSnapshotForDiffingAndRefresh = exports.selectSnapshotForDiffingAndRefresh = function (heapWorker, snapshot) {
   return function*(dispatch, getState) {
     assert(getState().diffing,
            "If we are selecting for diffing, we must be in diffing mode");
     dispatch(selectSnapshotForDiffing(snapshot));
     yield dispatch(refreshDiffing(heapWorker));
   };
 };
+
+/**
+ * Expand the given node in the diffing's census's delta-report.
+ *
+ * @param {CensusTreeNode} node
+ */
+const expandDiffingCensusNode = exports.expandDiffingCensusNode = function (node) {
+  return {
+    type: actions.EXPAND_DIFFING_CENSUS_NODE,
+    node,
+  };
+};
+
+/**
+ * Collapse the given node in the diffing's census's delta-report.
+ *
+ * @param {CensusTreeNode} node
+ */
+const collapseDiffingCensusNode = exports.collapseDiffingCensusNode = function (node) {
+  return {
+    type: actions.COLLAPSE_DIFFING_CENSUS_NODE,
+    node,
+  };
+};
+
+/**
+ * Focus the given node in the snapshot's census's report.
+ *
+ * @param {DominatorTreeNode} node
+ */
+const focusDiffingCensusNode = exports.focusDiffingCensusNode = function (node) {
+  return {
+    type: actions.FOCUS_DIFFING_CENSUS_NODE,
+    node,
+  };
+};
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/actions/dominatorTreeBreakdown.js
@@ -0,0 +1,37 @@
+/* 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 { assert } = require("devtools/shared/DevToolsUtils");
+const { breakdownEquals, createSnapshot } = require("../utils");
+const { actions, snapshotState: states } = require("../constants");
+const { refresh } = require("./refresh");
+
+const setDominatorTreeBreakdownAndRefresh =
+  exports.setDominatorTreeBreakdownAndRefresh =
+  function (heapWorker, breakdown) {
+    return function *(dispatch, getState) {
+      // Clears out all stored census data and sets the breakdown.
+      dispatch(setDominatorTreeBreakdown(breakdown));
+      yield dispatch(refresh(heapWorker));
+    };
+  };
+
+/**
+ * Clears out all census data in the snapshots and sets
+ * a new breakdown.
+ *
+ * @param {Breakdown} breakdown
+ */
+const setDominatorTreeBreakdown = exports.setDominatorTreeBreakdown = function (breakdown) {
+  assert(typeof breakdown === "object"
+         && breakdown
+         && breakdown.by,
+    `Breakdowns must be an object with a \`by\` property, attempted to set: ${uneval(breakdown)}`);
+
+  return {
+    type: actions.SET_DOMINATOR_TREE_BREAKDOWN,
+    breakdown,
+  };
+};
--- a/devtools/client/memory/actions/io.js
+++ b/devtools/client/memory/actions/io.js
@@ -59,17 +59,17 @@ const pickFileAndImportSnapshotAndCensus
     }
 
     yield dispatch(importSnapshotAndCensus(heapWorker, input.path));
   };
 };
 
 const importSnapshotAndCensus = exports.importSnapshotAndCensus = function (heapWorker, path) {
   return function* (dispatch, getState) {
-    const snapshot = immutableUpdate(createSnapshot(), {
+    const snapshot = immutableUpdate(createSnapshot(getState()), {
       path,
       state: states.IMPORTING,
       imported: true,
     });
     const id = snapshot.id;
 
     dispatch({ type: actions.IMPORT_SNAPSHOT_START, snapshot });
     dispatch(selectSnapshot(snapshot.id));
--- a/devtools/client/memory/actions/moz.build
+++ b/devtools/client/memory/actions/moz.build
@@ -2,14 +2,16 @@
 # 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/.
 
 DevToolsModules(
     'allocations.js',
     'breakdown.js',
     'diffing.js',
+    'dominatorTreeBreakdown.js',
     'filter.js',
     'inverted.js',
     'io.js',
     'refresh.js',
     'snapshot.js',
+    'view.js',
 )
--- a/devtools/client/memory/actions/refresh.js
+++ b/devtools/client/memory/actions/refresh.js
@@ -1,22 +1,36 @@
 /* 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 { assert } = require("devtools/shared/DevToolsUtils");
+const { viewState } = require("../constants");
 const { refreshDiffing } = require("./diffing");
-const { refreshSelectedCensus } = require("./snapshot");
+const snapshot = require("./snapshot");
 
 /**
  * Refresh the main thread's data from the heap analyses worker, if needed.
  *
  * @param {HeapAnalysesWorker} heapWorker
  */
 exports.refresh = function (heapWorker) {
   return function* (dispatch, getState) {
-    if (getState().diffing) {
-      yield dispatch(refreshDiffing(heapWorker));
-    } else {
-      yield dispatch(refreshSelectedCensus(heapWorker));
+    switch (getState().view) {
+      case viewState.DIFFING:
+        assert(getState().diffing, "Should have diffing state if in diffing view");
+        yield dispatch(refreshDiffing(heapWorker));
+        return;
+
+      case viewState.CENSUS:
+        yield dispatch(snapshot.refreshSelectedCensus(heapWorker));
+        return;
+
+      case viewState.DOMINATOR_TREE:
+        yield dispatch(snapshot.refreshSelectedDominatorTree(heapWorker));
+        return;
+
+      default:
+        assert(false, `Unexpected view state: ${getState().view}`);
     }
   };
 };
--- a/devtools/client/memory/actions/snapshot.js
+++ b/devtools/client/memory/actions/snapshot.js
@@ -1,17 +1,24 @@
 /* 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 { assert, reportException } = require("devtools/shared/DevToolsUtils");
-const { censusIsUpToDate, getSnapshot, breakdownEquals, createSnapshot } = require("../utils");
-const { actions, snapshotState: states } = require("../constants");
-const { toggleDiffing } = require("./diffing");
+const {
+  censusIsUpToDate,
+  getSnapshot,
+  breakdownEquals,
+  createSnapshot,
+  dominatorTreeIsComputed,
+} = require("../utils");
+const { actions, snapshotState: states, viewState, dominatorTreeState } = require("../constants");
+const view = require("./view");
+const refresh = require("./refresh");
 
 /**
  * A series of actions are fired from this task to save, read and generate the
  * initial census from a snapshot.
  *
  * @param {MemoryFront}
  * @param {HeapAnalysesClient}
  * @param {Object}
@@ -21,51 +28,55 @@ const takeSnapshotAndCensus = exports.ta
     const id = yield dispatch(takeSnapshot(front));
     if (id === null) {
       return;
     }
 
     yield dispatch(readSnapshot(heapWorker, id));
     if (getSnapshot(getState(), id).state === states.READ) {
       yield dispatch(takeCensus(heapWorker, id));
+
+      if (getState().view === viewState.DOMINATOR_TREE) {
+        yield dispatch(computeAndFetchDominatorTree(heapWorker, id));
+      }
     }
   };
 };
 
 /**
  * Selects a snapshot and if the snapshot's census is using a different
  * breakdown, take a new census.
  *
  * @param {HeapAnalysesClient} heapWorker
  * @param {snapshotId} id
  */
 const selectSnapshotAndRefresh = exports.selectSnapshotAndRefresh = function (heapWorker, id) {
   return function *(dispatch, getState) {
     if (getState().diffing) {
-      dispatch(toggleDiffing());
+      dispatch(view.changeView(viewState.CENSUS));
     }
 
     dispatch(selectSnapshot(id));
-    yield dispatch(refreshSelectedCensus(heapWorker));
+    yield dispatch(refresh.refresh(heapWorker));
   };
 };
 
 /**
  * Take a snapshot and return its id on success, or null on failure.
  *
  * @param {MemoryFront} front
  * @returns {Number|null}
  */
 const takeSnapshot = exports.takeSnapshot = function (front) {
   return function *(dispatch, getState) {
     if (getState().diffing) {
-      dispatch(toggleDiffing());
+      dispatch(view.changeView(viewState.CENSUS));
     }
 
-    const snapshot = createSnapshot();
+    const snapshot = createSnapshot(getState());
     const id = snapshot.id;
     dispatch({ type: actions.TAKE_SNAPSHOT_START, snapshot });
     dispatch(selectSnapshot(id));
 
     let path;
     try {
       path = yield front.saveHeapSnapshot();
     } catch (error) {
@@ -192,19 +203,285 @@ const refreshSelectedCensus = exports.re
     // task action will follow through and ensure that a census is taken.
     if (snapshot && snapshot.state === states.SAVED_CENSUS) {
       yield dispatch(takeCensus(heapWorker, snapshot.id));
     }
   };
 };
 
 /**
+ * Request that the `HeapAnalysesWorker` compute the dominator tree for the
+ * snapshot with the given `id`.
+ *
+ * @param {HeapAnalysesClient} heapWorker
+ * @param {SnapshotId} id
+ *
+ * @returns {Promise<DominatorTreeId>}
+ */
+const computeDominatorTree = exports.computeDominatorTree = function (heapWorker, id) {
+  return function*(dispatch, getState) {
+    const snapshot = getSnapshot(getState(), id);
+    assert(!(snapshot.dominatorTree && snapshot.dominatorTree.dominatorTreeId),
+           "Should not re-compute dominator trees");
+
+    dispatch({ type: actions.COMPUTE_DOMINATOR_TREE_START, id });
+
+    let dominatorTreeId;
+    try {
+      dominatorTreeId = yield heapWorker.computeDominatorTree(snapshot.path);
+    } catch (error) {
+      reportException("actions/snapshot/computeDominatorTree", error);
+      dispatch({ type: actions.DOMINATOR_TREE_ERROR, id, error });
+      return null;
+    }
+
+    dispatch({ type: actions.COMPUTE_DOMINATOR_TREE_END, id, dominatorTreeId });
+    return dominatorTreeId;
+  };
+};
+
+/**
+ * Get the partial subtree, starting from the root, of the
+ * snapshot-with-the-given-id's dominator tree.
+ *
+ * @param {HeapAnalysesClient} heapWorker
+ * @param {SnapshotId} id
+ *
+ * @returns {Promise<DominatorTreeNode>}
+ */
+const fetchDominatorTree = exports.fetchDominatorTree = function (heapWorker, id) {
+  return function*(dispatch, getState) {
+    const snapshot = getSnapshot(getState(), id);
+    assert(dominatorTreeIsComputed(snapshot),
+           "Should have dominator tree model and it should be computed");
+
+    let breakdown;
+    let root;
+    do {
+      breakdown = getState().dominatorTreeBreakdown;
+      assert(breakdown, "Should have a breakdown to describe nodes with.");
+
+      dispatch({ type: actions.FETCH_DOMINATOR_TREE_START, id, breakdown });
+
+      try {
+        root = yield heapWorker.getDominatorTree({
+          dominatorTreeId: snapshot.dominatorTree.dominatorTreeId,
+          breakdown,
+        });
+      } catch (error) {
+        reportException("actions/snapshot/fetchDominatorTree", error);
+        dispatch({ type: actions.DOMINATOR_TREE_ERROR, id, error });
+        return null;
+      }
+    }
+    while (!breakdownEquals(breakdown, getState().dominatorTreeBreakdown));
+
+    dispatch({ type: actions.FETCH_DOMINATOR_TREE_END, id, root });
+    return root;
+  };
+};
+
+/**
+ * Fetch the immediately dominated children represented by the placeholder
+ * `lazyChildren` from snapshot-with-the-given-id's dominator tree.
+ *
+ * @param {HeapAnalysesClient} heapWorker
+ * @param {SnapshotId} id
+ * @param {DominatorTreeLazyChildren} lazyChildren
+ */
+const fetchImmediatelyDominated = exports.fetchImmediatelyDominated = function (heapWorker, id, lazyChildren) {
+  return function*(dispatch, getState) {
+    const snapshot = getSnapshot(getState(), id);
+    assert(snapshot.dominatorTree, "Should have dominator tree model");
+    assert(snapshot.dominatorTree.state === dominatorTreeState.LOADED ||
+           snapshot.dominatorTree.state === dominatorTreeState.INCREMENTAL_FETCHING,
+           "Cannot fetch immediately dominated nodes in a dominator tree unless " +
+           " the dominator tree has already been computed");
+
+    let breakdown;
+    let response;
+    do {
+      breakdown = getState().dominatorTreeBreakdown;
+      assert(breakdown, "Should have a breakdown to describe nodes with.");
+
+      dispatch({ type: actions.FETCH_IMMEDIATELY_DOMINATED_START, id });
+
+      try {
+        response = yield heapWorker.getImmediatelyDominated({
+          dominatorTreeId: snapshot.dominatorTree.dominatorTreeId,
+          breakdown,
+          nodeId: lazyChildren.parentNodeId(),
+          startIndex: lazyChildren.siblingIndex(),
+        });
+      } catch (error) {
+        reportException("actions/snapshot/fetchImmediatelyDominated", error);
+        dispatch({ type: actions.DOMINATOR_TREE_ERROR, id, error });
+        return null;
+      }
+    }
+    while (!breakdownEquals(breakdown, getState().dominatorTreeBreakdown));
+
+    dispatch({
+      type: actions.FETCH_IMMEDIATELY_DOMINATED_END,
+      id,
+      path: response.path,
+      nodes: response.nodes,
+      moreChildrenAvailable: response.moreChildrenAvailable,
+    });
+  };
+};
+
+/**
+ * Compute and then fetch the dominator tree of the snapshot with the given
+ * `id`.
+ *
+ * @param {HeapAnalysesClient} heapWorker
+ * @param {SnapshotId} id
+ *
+ * @returns {Promise<DominatorTreeNode>}
+ */
+const computeAndFetchDominatorTree = exports.computeAndFetchDominatorTree = function (heapWorker, id) {
+  return function*(dispatch, getState) {
+    const dominatorTreeId = yield dispatch(computeDominatorTree(heapWorker, id));
+    if (dominatorTreeId === null) {
+      return null;
+    }
+
+    const root = yield dispatch(fetchDominatorTree(heapWorker, id));
+    if (!root) {
+      return null;
+    }
+
+    return root;
+  };
+};
+
+/**
+ * Update the currently selected snapshot's dominator tree.
+ *
+ * @param {HeapAnalysesClient} heapWorker
+ */
+const refreshSelectedDominatorTree = exports.refreshSelectedDominatorTree = function (heapWorker) {
+  return function*(dispatch, getState) {
+    let snapshot = getState().snapshots.find(s => s.selected);
+    if (!snapshot) {
+      return;
+    }
+
+    if (snapshot.dominatorTree &&
+        !(snapshot.dominatorTree.state === dominatorTreeState.COMPUTED ||
+          snapshot.dominatorTree.state === dominatorTreeState.LOADED ||
+          snapshot.dominatorTree.state === dominatorTreeState.INCREMENTAL_FETCHING)) {
+      return;
+    }
+
+    switch (snapshot.state) {
+      case states.READ:
+      case states.SAVING_CENSUS:
+      case states.SAVED_CENSUS:
+        if (snapshot.dominatorTree) {
+          yield dispatch(fetchDominatorTree(heapWorker, snapshot.id));
+        } else {
+          yield dispatch(computeAndFetchDominatorTree(heapWorker, snapshot.id));
+        }
+        return;
+
+      default:
+        // If there was an error, we can't continue. If we are still saving or
+        // reading the snapshot, then takeSnapshotAndCensus will finish the job
+        // for us.
+        return;
+    }
+  };
+};
+
+/**
  * Select the snapshot with the given id.
  *
  * @param {snapshotId} id
  * @see {Snapshot} model defined in devtools/client/memory/models.js
  */
 const selectSnapshot = exports.selectSnapshot = function (id) {
   return {
     type: actions.SELECT_SNAPSHOT,
     id
   };
 };
+
+/**
+ * Expand the given node in the snapshot's census report.
+ *
+ * @param {CensusTreeNode} node
+ */
+const expandCensusNode = exports.expandCensusNode = function (id, node) {
+  return {
+    type: actions.EXPAND_CENSUS_NODE,
+    id,
+    node,
+  };
+};
+
+/**
+ * Collapse the given node in the snapshot's census report.
+ *
+ * @param {CensusTreeNode} node
+ */
+const collapseCensusNode = exports.collapseCensusNode = function (id, node) {
+  return {
+    type: actions.COLLAPSE_CENSUS_NODE,
+    id,
+    node,
+  };
+};
+
+/**
+ * Focus the given node in the snapshot's census's report.
+ *
+ * @param {SnapshotId} id
+ * @param {DominatorTreeNode} node
+ */
+const focusCensusNode = exports.focusCensusNode = function (id, node) {
+  return {
+    type: actions.FOCUS_CENSUS_NODE,
+    id,
+    node,
+  };
+};
+
+/**
+ * Expand the given node in the snapshot's dominator tree.
+ *
+ * @param {DominatorTreeTreeNode} node
+ */
+const expandDominatorTreeNode = exports.expandDominatorTreeNode = function (id, node) {
+  return {
+    type: actions.EXPAND_DOMINATOR_TREE_NODE,
+    id,
+    node,
+  };
+};
+
+/**
+ * Collapse the given node in the snapshot's dominator tree.
+ *
+ * @param {DominatorTreeTreeNode} node
+ */
+const collapseDominatorTreeNode = exports.collapseDominatorTreeNode = function (id, node) {
+  return {
+    type: actions.COLLAPSE_DOMINATOR_TREE_NODE,
+    id,
+    node,
+  };
+};
+
+/**
+ * Focus the given node in the snapshot's dominator tree.
+ *
+ * @param {SnapshotId} id
+ * @param {DominatorTreeNode} node
+ */
+const focusDominatorTreeNode = exports.focusDominatorTreeNode = function (id, node) {
+  return {
+    type: actions.FOCUS_DOMINATOR_TREE_NODE,
+    id,
+    node,
+  };
+};
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/actions/view.js
@@ -0,0 +1,32 @@
+/* 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 refresh = require("./refresh");
+
+/**
+ * Change the currently selected view.
+ *
+ * @param {viewState} view
+ */
+const changeView = exports.changeView = function (view) {
+  return {
+    type: actions.CHANGE_VIEW,
+    view
+  };
+};
+
+/**
+ * Change the currently selected view and ensure all our data is up to date from
+ * the heap worker.
+ *
+ * @param {viewState} view
+ */
+exports.changeViewAndRefresh = function (view, heapWorker) {
+  return function* (dispatch, getState) {
+    dispatch(changeView(view));
+    yield dispatch(refresh.refresh(heapWorker));
+  };
+};
--- a/devtools/client/memory/app.js
+++ b/devtools/client/memory/app.js
@@ -1,31 +1,55 @@
 /* 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/. */
 
+const { assert } = require("devtools/shared/DevToolsUtils");
 const { DOM: dom, createClass, createFactory, PropTypes } = require("devtools/client/shared/vendor/react");
 const { connect } = require("devtools/client/shared/vendor/react-redux");
-const { breakdowns, diffingState } = require("./constants");
+const { breakdowns, diffingState, viewState } = require("./constants");
 const { toggleRecordingAllocationStacks } = require("./actions/allocations");
 const { setBreakdownAndRefresh } = require("./actions/breakdown");
-const { selectSnapshotForDiffingAndRefresh, toggleDiffing } = require("./actions/diffing");
+const { setDominatorTreeBreakdownAndRefresh } = require("./actions/dominatorTreeBreakdown");
+const {
+  selectSnapshotForDiffingAndRefresh,
+  toggleDiffing,
+  expandDiffingCensusNode,
+  collapseDiffingCensusNode,
+  focusDiffingCensusNode,
+} = require("./actions/diffing");
 const { toggleInvertedAndRefresh } = require("./actions/inverted");
 const { setFilterStringAndRefresh } = require("./actions/filter");
 const { pickFileAndExportSnapshot, pickFileAndImportSnapshotAndCensus } = require("./actions/io");
-const { selectSnapshotAndRefresh, takeSnapshotAndCensus } = require("./actions/snapshot");
-const { breakdownNameToSpec, getBreakdownDisplayData } = require("./utils");
+const {
+  selectSnapshotAndRefresh,
+  takeSnapshotAndCensus,
+  fetchImmediatelyDominated,
+  expandCensusNode,
+  collapseCensusNode,
+  focusCensusNode,
+  expandDominatorTreeNode,
+  collapseDominatorTreeNode,
+  focusDominatorTreeNode,
+} = require("./actions/snapshot");
+const { changeViewAndRefresh } = require("./actions/view");
+const {
+  breakdownNameToSpec,
+  getBreakdownDisplayData,
+  dominatorTreeBreakdownNameToSpec,
+  getDominatorTreeBreakdownDisplayData,
+} = require("./utils");
 const Toolbar = createFactory(require("./components/toolbar"));
 const List = createFactory(require("./components/list"));
 const SnapshotListItem = createFactory(require("./components/snapshot-list-item"));
-const HeapView = createFactory(require("./components/heap"));
+const Heap = createFactory(require("./components/heap"));
 const { app: appModel } = require("./models");
 
-const App = createClass({
-  displayName: "memory-tool",
+const MemoryApp = createClass({
+  displayName: "MemoryApp",
 
   propTypes: appModel,
 
   getDefaultProps() {
     return {};
   },
 
   childContextTypes: {
@@ -34,41 +58,46 @@ const App = createClass({
     toolbox: PropTypes.any,
   },
 
   getChildContext() {
     return {
       front: this.props.front,
       heapWorker: this.props.heapWorker,
       toolbox: this.props.toolbox,
-    }
+    };
   },
 
   render() {
     let {
       dispatch,
       snapshots,
       front,
       heapWorker,
       breakdown,
       allocations,
       inverted,
       toolbox,
       filter,
-      diffing
+      diffing,
+      view,
+      dominatorTreeBreakdown
     } = 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));
 
     return (
-      dom.div({ id: "memory-tool" },
+      dom.div(
+        {
+          id: "memory-tool"
+        },
 
         Toolbar({
           snapshots,
           breakdowns: getBreakdownDisplayData(),
           onImportClick: () => dispatch(pickFileAndImportSnapshotAndCensus(heapWorker)),
           onTakeSnapshotClick: () => dispatch(takeSnapshotAndCensus(front, heapWorker)),
           onBreakdownChange: breakdown =>
             dispatch(setBreakdownAndRefresh(heapWorker, breakdownNameToSpec(breakdown))),
@@ -77,41 +106,116 @@ const App = createClass({
           allocations,
           inverted,
           onToggleInverted: () =>
             dispatch(toggleInvertedAndRefresh(heapWorker)),
           filter,
           setFilterString: filterString =>
             dispatch(setFilterStringAndRefresh(filterString, heapWorker)),
           diffing,
-          onToggleDiffing: () => dispatch(toggleDiffing())
+          onToggleDiffing: () => dispatch(toggleDiffing()),
+          view,
+          dominatorTreeBreakdowns: getDominatorTreeBreakdownDisplayData(),
+          onDominatorTreeBreakdownChange: breakdown => {
+            const spec = dominatorTreeBreakdownNameToSpec(breakdown);
+            assert(spec, "Should have a breakdown spec");
+            dispatch(setDominatorTreeBreakdownAndRefresh(heapWorker, spec));
+          },
+          onViewChange: v => dispatch(changeViewAndRefresh(v, heapWorker)),
         }),
 
-        dom.div({ id: "memory-tool-container" },
+        dom.div(
+          {
+            id: "memory-tool-container"
+          },
+
           List({
             itemComponent: SnapshotListItem,
             items: snapshots,
             onSave: snapshot => dispatch(pickFileAndExportSnapshot(snapshot)),
             onClick: onClickSnapshotListItem,
             diffing,
           }),
 
-          HeapView({
+          Heap({
             snapshot: selectedSnapshot,
             diffing,
-            onSnapshotClick: () => dispatch(takeSnapshotAndCensus(front, heapWorker)),
             onViewSourceInDebugger: frame => toolbox.viewSourceInDebugger(frame.source, frame.line),
+            onSnapshotClick: () =>
+              dispatch(takeSnapshotAndCensus(front, heapWorker)),
+            onLoadMoreSiblings: lazyChildren =>
+              dispatch(fetchImmediatelyDominated(heapWorker,
+                                                 selectedSnapshot.id,
+                                                 lazyChildren)),
+            onCensusExpand: (census, node) => {
+              if (diffing) {
+                assert(diffing.census === census,
+                       "Should only expand active census");
+                dispatch(expandDiffingCensusNode(node));
+              } else {
+                assert(selectedSnapshot && selectedSnapshot.census === census,
+                       "If not diffing, should be expanding on selected snapshot's census");
+                dispatch(expandCensusNode(selectedSnapshot.id, node));
+              }
+            },
+            onCensusCollapse: (census, node) => {
+              if (diffing) {
+                assert(diffing.census === census,
+                       "Should only collapse active census");
+                dispatch(collapseDiffingCensusNode(node));
+              } else {
+                assert(selectedSnapshot && selectedSnapshot.census === census,
+                       "If not diffing, should be collapsing on selected snapshot's census");
+                dispatch(collapseCensusNode(selectedSnapshot.id, node));
+              }
+            },
+            onCensusFocus: (census, node) => {
+              if (diffing) {
+                assert(diffing.census === census,
+                       "Should only focus nodes in active census");
+                dispatch(focusDiffingCensusNode(node));
+              } else {
+                assert(selectedSnapshot && selectedSnapshot.census === census,
+                       "If not diffing, should be focusing on nodes in selected snapshot's census");
+                dispatch(focusCensusNode(selectedSnapshot.id, node));
+              }
+            },
+            onDominatorTreeExpand: node => {
+              assert(view === viewState.DOMINATOR_TREE,
+                     "If expanding 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(expandDominatorTreeNode(selectedSnapshot.id, node));
+            },
+            onDominatorTreeCollapse: node => {
+              assert(view === viewState.DOMINATOR_TREE,
+                     "If collapsing 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(collapseDominatorTreeNode(selectedSnapshot.id, node));
+            },
+            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));
+            },
+            view,
           })
         )
       )
     );
   },
 });
 
 /**
  * Passed into react-redux's `connect` method that is called on store change
  * and passed to components.
  */
 function mapStateToProps (state) {
   return state;
 }
 
-module.exports = connect(mapStateToProps)(App);
+module.exports = connect(mapStateToProps)(MemoryApp);
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/components/census-header.js
@@ -0,0 +1,35 @@
+/* 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/. */
+
+const { DOM: dom, createClass } = require("devtools/client/shared/vendor/react");
+const { L10N } = require("../utils");
+
+const CensusHeader = module.exports = createClass({
+  displayName: "CensusHeader",
+
+  propTypes: { },
+
+  render() {
+    return dom.div(
+      {
+        className: "header"
+      },
+
+      dom.span({ className: "heap-tree-item-bytes" },
+               L10N.getStr("heapview.field.bytes")),
+
+      dom.span({ className: "heap-tree-item-count" },
+               L10N.getStr("heapview.field.count")),
+
+      dom.span({ className: "heap-tree-item-total-bytes" },
+               L10N.getStr("heapview.field.totalbytes")),
+
+      dom.span({ className: "heap-tree-item-total-count" },
+               L10N.getStr("heapview.field.totalcount")),
+
+      dom.span({ className: "heap-tree-item-name" },
+               L10N.getStr("heapview.field.name"))
+    );
+  }
+});
rename from devtools/client/memory/components/tree-item.js
rename to devtools/client/memory/components/census-tree-item.js
--- a/devtools/client/memory/components/tree-item.js
+++ b/devtools/client/memory/components/census-tree-item.js
@@ -1,28 +1,21 @@
 /* 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/. */
 
 const { isSavedFrame } = require("devtools/shared/DevToolsUtils");
-const { DOM: dom, createClass, createFactory, PropTypes } = require("devtools/client/shared/vendor/react");
+const { DOM: dom, createClass, createFactory } = require("devtools/client/shared/vendor/react");
 const { L10N } = require("../utils");
-const FrameView = createFactory(require("devtools/client/shared/components/frame"));
+const Frame = createFactory(require("devtools/client/shared/components/frame"));
 const unknownSourceString = L10N.getStr("unknownSource");
-
-const INDENT = 10;
-const MAX_SOURCE_LENGTH = 200;
+const { TREE_ROW_HEIGHT } = require("../constants");
 
-
-/**
- * An arrow that displays whether its node is expanded (▼) or collapsed
- * (▶). When its node has no children, it is hidden.
- */
-const TreeItem = module.exports = createClass({
-  displayName: "tree-item",
+const CensusTreeItem = module.exports = createClass({
+  displayName: "CensusTreeItem",
 
   formatPercent(showSign, percent) {
     return L10N.getFormatStr("tree-item.percent",
                              this.formatNumber(showSign, percent));
   },
 
   formatNumber(showSign, number) {
     const rounded = Math.round(number);
@@ -79,28 +72,29 @@ const TreeItem = module.exports = create
                dom.span({ className: "heap-tree-number" }, count),
                dom.span({ className: "heap-tree-percent" }, percentCount)),
       dom.span({ className: "heap-tree-item-field heap-tree-item-total-bytes" },
                dom.span({ className: "heap-tree-number" }, totalBytes),
                dom.span({ className: "heap-tree-percent" }, percentTotalBytes)),
       dom.span({ className: "heap-tree-item-field heap-tree-item-total-count" },
                dom.span({ className: "heap-tree-number" }, totalCount),
                dom.span({ className: "heap-tree-percent" }, percentTotalCount)),
-      dom.span({ className: "heap-tree-item-field heap-tree-item-name", style: { marginLeft: depth * INDENT }},
+               dom.span({ className: "heap-tree-item-field heap-tree-item-name",
+                          style: { marginLeft: depth * TREE_ROW_HEIGHT }},
         arrow,
         this.toLabel(item.name, onViewSourceInDebugger)
       )
     );
   },
 
   toLabel(name, linkToDebugger) {
     if (isSavedFrame(name)) {
       let onClickTooltipString =
         L10N.getFormatStr("viewsourceindebugger",`${name.source}:${name.line}:${name.column}`);
-      return FrameView({
+      return Frame({
         frame: name,
         onClick: () => linkToDebugger(name),
         onClickTooltipString,
         unknownSourceString
       });
     }
 
     if (name === null) {
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/components/census.js
@@ -0,0 +1,75 @@
+/* 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/. */
+
+const { DOM: dom, createClass, PropTypes, createFactory } = require("devtools/client/shared/vendor/react");
+const Tree = createFactory(require("devtools/client/shared/components/tree"));
+const CensusTreeItem = createFactory(require("./census-tree-item"));
+const { createParentMap } = require("../utils");
+const { TREE_ROW_HEIGHT } = require("../constants");
+const { censusModel, diffingModel } = require("../models");
+
+const Census = module.exports = createClass({
+  displayName: "Census",
+
+  propTypes: {
+    census: censusModel,
+    onExpand: PropTypes.func.isRequired,
+    onCollapse: PropTypes.func.isRequired,
+    onFocus: PropTypes.func.isRequired,
+    onViewSourceInDebugger: PropTypes.func.isRequired,
+    diffing: diffingModel,
+  },
+
+  render() {
+    let {
+      census,
+      onExpand,
+      onCollapse,
+      onFocus,
+      diffing,
+      onViewSourceInDebugger,
+    } = this.props;
+
+    const report = census.report;
+    let parentMap = createParentMap(report);
+    const { totalBytes, totalCount } = report;
+
+    const getPercentBytes = totalBytes === 0
+      ? _ => 0
+      : bytes => (bytes / totalBytes) * 100;
+
+    const getPercentCount = totalCount === 0
+      ? _ => 0
+      : count => (count / totalCount) * 100;
+
+    return Tree({
+      autoExpandDepth: 0,
+      focused: census.focused,
+      getParent: node => {
+        const parent = parentMap[node.id];
+        return parent === report ? null : parent;
+      },
+      getChildren: node => node.children || [],
+      isExpanded: node => census.expanded.has(node.id),
+      onExpand,
+      onCollapse,
+      onFocus,
+      renderItem: (item, depth, focused, arrow, expanded) =>
+        new CensusTreeItem({
+          onViewSourceInDebugger,
+          item,
+          depth,
+          focused,
+          arrow,
+          expanded,
+          getPercentBytes,
+          getPercentCount,
+          showSign: !!diffing,
+        }),
+      getRoots: () => report.children || [],
+      getKey: node => node.id,
+      itemHeight: TREE_ROW_HEIGHT,
+    });
+  }
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/components/dominator-tree-header.js
@@ -0,0 +1,29 @@
+/* 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/. */
+
+const { DOM: dom, createClass, PropTypes } = require("devtools/client/shared/vendor/react");
+const { L10N } = require("../utils");
+
+const DominatorTreeHeader = module.exports = createClass({
+  displayName: "DominatorTreeHeader",
+
+  propTypes: { },
+
+  render() {
+    return dom.div(
+      {
+        className: "header"
+      },
+
+      dom.span({ className: "heap-tree-item-bytes" },
+               L10N.getStr("heapview.field.retainedSize")),
+
+      dom.span({ className: "heap-tree-item-bytes" },
+               L10N.getStr("heapview.field.shallowSize")),
+
+      dom.span({ className: "heap-tree-item-name" },
+               L10N.getStr("heapview.field.name"))
+    );
+  }
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/components/dominator-tree-item.js
@@ -0,0 +1,148 @@
+/* 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/. */
+
+const { assert, isSavedFrame } = require("devtools/shared/DevToolsUtils");
+const { DOM: dom, createClass, createFactory, PropTypes } = require("devtools/client/shared/vendor/react");
+const { L10N } = require("../utils");
+const Frame = createFactory(require("devtools/client/shared/components/frame"));
+const { TREE_ROW_HEIGHT } = require("../constants");
+
+const Separator = createFactory(createClass({
+  displayName: "Separator",
+
+  render() {
+    return dom.span({ className: "separator" }, "›");
+  }
+}));
+
+const DominatorTreeItem = module.exports = createClass({
+  displayName: "DominatorTreeItem",
+
+  propTypes: {
+    item: PropTypes.object.isRequired,
+    depth: PropTypes.number.isRequired,
+    arrow: PropTypes.object.isRequired,
+    focused: PropTypes.bool.isRequired,
+    getPercentSize: PropTypes.func.isRequired,
+    onViewSourceInDebugger: PropTypes.func.isRequired,
+  },
+
+  formatPercent(percent) {
+    return L10N.getFormatStr("tree-item.percent",
+                             this.formatNumber(percent));
+  },
+
+  formatNumber(number) {
+    const rounded = Math.round(number);
+    if (rounded === 0 || rounded === -0) {
+      return "0";
+    }
+
+    return String(Math.abs(rounded));
+  },
+
+  shouldComponentUpdate(nextProps, nextState) {
+    return this.props.item != nextProps.item
+      || this.props.depth != nextProps.depth
+      || this.props.focused != nextProps.focused;
+  },
+
+  render() {
+    let {
+      item,
+      depth,
+      arrow,
+      focused,
+      getPercentSize,
+      onViewSourceInDebugger,
+    } = this.props;
+
+    const retainedSize = this.formatNumber(item.retainedSize);
+    const percentRetainedSize = this.formatPercent(getPercentSize(item.retainedSize));
+
+    const shallowSize = this.formatNumber(item.shallowSize);
+    const percentShallowSize = this.formatPercent(getPercentSize(item.shallowSize));
+
+    // Build up our label UI as an array of each label piece, which is either a
+    // string or a frame, and separators in between them.
+
+    assert(item.label.length > 0,
+           "Our label should not be empty");
+    const label = Array(item.label.length * 2 - 1);
+    label.fill(undefined);
+
+    for (let i = 0, length = item.label.length; i < length; i++) {
+      const piece = item.label[i];
+      const key = `${item.nodeId}-label-${i}`;
+
+      // `i` is the index of the label piece we are rendering, `label[i*2]` is
+      // where the rendered label piece belngs, and `label[i*2+1]` (if it isn't
+      // out of bounds) is where the separator belongs.
+
+      if (isSavedFrame(piece)) {
+        label[i * 2] = Frame({
+          key,
+          onClick: () => onViewSourceInDebugger(piece),
+          frame: piece
+        });
+      } else if (piece === "noStack") {
+        label[i * 2] = dom.span({ key, className: "not-available" },
+                                L10N.getStr("tree-item.nostack"));
+      } else if (piece === "noFilename") {
+        label[i * 2] = dom.span({ key, className: "not-available" },
+                                L10N.getStr("tree-item.nofilename"));
+      } else {
+        label[i * 2] = piece;
+      }
+
+      // If this is not the last piece of the label, add a separator.
+      if (i < length - 1) {
+        label[i * 2 + 1] = Separator({ key: `${item.nodeId}-separator-${i}` });
+      }
+    }
+
+    return dom.div(
+      {
+        className: `heap-tree-item ${focused ? "focused" : ""} node-${item.nodeId}`
+      },
+
+      dom.span(
+        {
+          className: "heap-tree-item-field heap-tree-item-bytes"
+        },
+        dom.span(
+          {
+            className: "heap-tree-number"
+          },
+          retainedSize
+        ),
+        dom.span({ className: "heap-tree-percent" }, percentRetainedSize)
+      ),
+
+      dom.span(
+        {
+          className: "heap-tree-item-field heap-tree-item-bytes"
+        },
+        dom.span(
+          {
+            className: "heap-tree-number"
+          },
+          shallowSize
+        ),
+        dom.span({ className: "heap-tree-percent" }, percentShallowSize)
+      ),
+
+      dom.span(
+        {
+          className: "heap-tree-item-field heap-tree-item-name",
+          style: { marginLeft: depth * TREE_ROW_HEIGHT }
+        },
+        arrow,
+        label,
+        dom.span({ className: "heap-tree-item-address" },
+                 `@ 0x${item.nodeId.toString(16)}`)
+      )
+    );
+  },
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/components/dominator-tree.js
@@ -0,0 +1,217 @@
+/* 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/. */
+
+const { DOM: dom, createClass, PropTypes, createFactory } = require("devtools/client/shared/vendor/react");
+const { assert, safeErrorString } = require("devtools/shared/DevToolsUtils");
+const Tree = createFactory(require("devtools/client/shared/components/tree"));
+const DominatorTreeItem = createFactory(require("./dominator-tree-item"));
+const { createParentMap, L10N } = require("../utils");
+const { TREE_ROW_HEIGHT, dominatorTreeState } = require("../constants");
+const { dominatorTreeModel } = require("../models");
+const DominatorTreeLazyChildren = require("../dominator-tree-lazy-children");
+
+const DOMINATOR_TREE_AUTO_EXPAND_DEPTH = 3;
+
+/**
+ * A throbber that represents a subtree in the dominator tree that is actively
+ * being incrementally loaded and fetched from the `HeapAnalysesWorker`.
+ */
+const DominatorTreeSubtreeFetching = createFactory(createClass({
+  displayName: "DominatorTreeSubtreeFetching",
+
+  shouldComponentUpdate(nextProps, nextState) {
+    return this.props.depth !== nextProps.depth
+      || this.props.focused !== nextProps.focused;
+  },
+
+  propTypes: {
+    depth: PropTypes.number.isRequired,
+    focused: PropTypes.bool.isRequired,
+  },
+
+  render() {
+    let {
+      depth,
+      focused,
+    } = this.props;
+
+    return dom.div(
+      {
+        className: `heap-tree-item subtree-fetching ${focused ? "focused" : ""}`
+      },
+      dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }),
+      dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }),
+      dom.span({
+        className: "heap-tree-item-field heap-tree-item-name devtools-throbber",
+        style: { marginLeft: depth * TREE_ROW_HEIGHT }
+      })
+    );
+  }
+}));
+
+/**
+ * A link to fetch and load more siblings in the dominator tree, when there are
+ * already many loaded above.
+ */
+const DominatorTreeSiblingLink = createFactory(createClass({
+  displayName: "DominatorTreeSiblingLink",
+
+  propTypes: {
+    depth: PropTypes.number.isRequired,
+    focused: PropTypes.bool.isRequired,
+    item: PropTypes.instanceOf(DominatorTreeLazyChildren).isRequired,
+    onLoadMoreSiblings: PropTypes.func.isRequired,
+  },
+
+  shouldComponentUpdate(nextProps, nextState) {
+    return this.props.depth !== nextProps.depth
+      || this.props.focused !== nextProps.focused;
+  },
+
+  render() {
+    let {
+      depth,
+      focused,
+      item,
+      onLoadMoreSiblings,
+    } = this.props;
+
+    return dom.div(
+      {
+        className: `heap-tree-item more-children ${focused ? "focused" : ""}`
+      },
+      dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }),
+      dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }),
+      dom.span(
+        {
+          className: "heap-tree-item-field heap-tree-item-name",
+          style: { marginLeft: depth * TREE_ROW_HEIGHT }
+        },
+        dom.a(
+          {
+            onClick: () => onLoadMoreSiblings(item)
+          },
+          L10N.getStr("tree-item.load-more")
+        )
+      )
+    );
+  }
+}));
+
+/**
+ * The actual dominator tree rendered as an expandable and collapsible tree.
+ */
+const DominatorTree = module.exports = createClass({
+  displayName: "DominatorTree",
+
+  propTypes: {
+    dominatorTree: dominatorTreeModel.isRequired,
+    onLoadMoreSiblings: PropTypes.func.isRequired,
+    onViewSourceInDebugger: PropTypes.func.isRequired,
+    onExpand: PropTypes.func.isRequired,
+    onCollapse: PropTypes.func.isRequired,
+  },
+
+  shouldComponentUpdate(nextProps, nextState) {
+    // Safe to use referential equality here because all of our mutations on
+    // dominator tree models use immutableUpdate in a persistent manner. The
+    // exception to the rule are mutations of the expanded set, however we take
+    // care that the dominatorTree model itself is still re-allocated when
+    // mutations to the expanded set occur. Because of the re-allocations, we
+    // can continue using referential equality here.
+    return this.props.dominatorTree !== nextProps.dominatorTree;
+  },
+
+  render() {
+    const { dominatorTree, onViewSourceInDebugger, onLoadMoreSiblings } = this.props;
+
+    const parentMap = createParentMap(dominatorTree.root, node => node.nodeId);
+
+    return Tree({
+      key: "dominator-tree-tree",
+      autoExpandDepth: DOMINATOR_TREE_AUTO_EXPAND_DEPTH,
+      focused: dominatorTree.focused,
+      getParent: node =>
+        node instanceof DominatorTreeLazyChildren
+          ? parentMap[node.parentNodeId()]
+          : parentMap[node.nodeId],
+      getChildren: node => {
+        const children = node.children ? node.children.slice() : [];
+        if (node.moreChildrenAvailable) {
+          children.push(new DominatorTreeLazyChildren(node.nodeId, children.length));
+        }
+        return children;
+      },
+      isExpanded: node => {
+        return node instanceof DominatorTreeLazyChildren
+          ? false
+          : dominatorTree.expanded.has(node.nodeId)
+      },
+      onExpand: item => {
+        if (item instanceof DominatorTreeLazyChildren) {
+          return;
+        }
+
+        if (item.moreChildrenAvailable && (!item.children || !item.children.length)) {
+          const startIndex = item.children ? item.children.length : 0;
+          onLoadMoreSiblings(new DominatorTreeLazyChildren(item.nodeId, startIndex));
+        }
+
+        this.props.onExpand(item);
+      },
+      onCollapse: item => {
+        if (item instanceof DominatorTreeLazyChildren) {
+          return;
+        }
+
+        this.props.onCollapse(item);
+      },
+      onFocus: item => {
+        if (item instanceof DominatorTreeLazyChildren) {
+          return;
+        }
+
+        this.props.onFocus(item);
+      },
+      renderItem: (item, depth, focused, arrow, expanded) => {
+        if (item instanceof DominatorTreeLazyChildren) {
+          if (item.isFirstChild()) {
+            assert(dominatorTree.state === dominatorTreeState.INCREMENTAL_FETCHING,
+                   "If we are displaying a throbber for loading a subtree, " +
+                   "then we should be INCREMENTAL_FETCHING those children right now");
+            return DominatorTreeSubtreeFetching({
+              key: item.key(),
+              depth,
+              focused,
+            });
+          }
+
+          return DominatorTreeSiblingLink({
+            key: item.key(),
+            item,
+            depth,
+            focused,
+            onLoadMoreSiblings,
+          });
+        }
+
+        return DominatorTreeItem({
+          item,
+          depth,
+          focused,
+          arrow,
+          getPercentSize: size => (size / dominatorTree.root.retainedSize) * 100,
+          onViewSourceInDebugger,
+        })
+      },
+      getRoots: () => [dominatorTree.root],
+      getKey: node =>
+        node instanceof DominatorTreeLazyChildren ? node.key() : node.nodeId,
+      itemHeight: TREE_ROW_HEIGHT,
+      // We can't cache traversals because incremental fetching of children
+      // means the traversal might not be valid.
+      reuseCachedTraversal: _ => false,
+    });
+  }
+});
--- a/devtools/client/memory/components/heap.js
+++ b/devtools/client/memory/components/heap.js
@@ -1,203 +1,291 @@
 /* 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/. */
 
 const { DOM: dom, createClass, PropTypes, createFactory } = require("devtools/client/shared/vendor/react");
 const { assert, safeErrorString } = require("devtools/shared/DevToolsUtils");
-const Tree = createFactory(require("devtools/client/shared/components/tree"));
-const TreeItem = createFactory(require("./tree-item"));
+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 { getStatusTextFull, L10N } = require("../utils");
-const { snapshotState: states, diffingState } = require("../constants");
+const { snapshotState: states, diffingState, viewState, dominatorTreeState } = require("../constants");
 const { snapshot: snapshotModel, diffingModel } = require("../models");
-// If HEAP_TREE_ROW_HEIGHT changes, be sure to change `var(--heap-tree-row-height)`
-// in `devtools/client/themes/memory.css`
-const HEAP_TREE_ROW_HEIGHT = 14;
 
 /**
- * Creates a hash map mapping node IDs to its parent node.
+ * Get the app state's current state atom.
+ *
+ * @see the relevant state string constants in `../constants.js`.
  *
- * @param {CensusTreeNode} node
- * @param {Object<number, CensusTreeNode>} aggregator
+ * @param {viewState} view
+ * @param {snapshotModel} snapshot
+ * @param {diffingModel} diffing
  *
- * @return {Object<number, CensusTreeNode>}
+ * @return {snapshotState|diffingState|dominatorTreeState}
  */
-function createParentMap (node, aggregator=Object.create(null)) {
-  for (let child of (node.children || [])) {
-    aggregator[child.id] = node;
-    createParentMap(child, aggregator);
+function getState(view, snapshot, diffing) {
+  switch (view) {
+    case viewState.CENSUS:
+      return snapshot.state;
+
+    case viewState.DIFFING:
+      return diffing.state;
+
+    case viewState.DOMINATOR_TREE:
+      return snapshot.dominatorTree
+        ? snapshot.dominatorTree.state
+        : snapshot.state;
   }
 
-  return aggregator;
+  assert(false, `Unexpected view state: ${view}`);
+  return null;
+}
+
+/**
+ * Return true if we should display a status message when we are in the given
+ * state. Return false otherwise.
+ *
+ * @param {snapshotState|diffingState|dominatorTreeState} state
+ * @param {viewState} view
+ * @param {snapshotModel} snapshot
+ *
+ * @returns {Boolean}
+ */
+function shouldDisplayStatus(state, view, snapshot) {
+  switch (state) {
+    case states.IMPORTING:
+    case states.SAVING:
+    case states.SAVED:
+    case states.READING:
+    case states.READ:
+    case states.SAVING_CENSUS:
+    case diffingState.SELECTING:
+    case diffingState.TAKING_DIFF:
+    case dominatorTreeState.COMPUTING:
+    case dominatorTreeState.COMPUTED:
+    case dominatorTreeState.FETCHING:
+      return true;
+  }
+  return view === viewState.DOMINATOR_TREE && !snapshot.dominatorTree;
 }
 
 /**
- * Creates properties to be passed into the Tree component.
+ * Get the status text to display for the given state.
  *
- * @param {censusModel} census
- * @param {Function} onViewSourceInDebugger
- * @param {Object} diffing
- * @return {Object}
+ * @param {snapshotState|diffingState|dominatorTreeState} state
+ * @param {diffingModel} diffing
+ *
+ * @returns {String}
  */
-function createTreeProperties(census, onViewSourceInDebugger, diffing) {
-  const report = census.report;
-  let map = createParentMap(report);
-  const { totalBytes, totalCount } = report;
-
-  const getPercentBytes = totalBytes === 0
-    ? _ => 0
-    : bytes => (bytes / totalBytes) * 100;
-
-  const getPercentCount = totalCount === 0
-    ? _ => 0
-    : count => (count / totalCount) * 100;
+function getStateStatusText(state, diffing) {
+  if (state === diffingState.SELECTING) {
+    return L10N.getStr(diffing.firstSnapshotId === null
+                         ? "diffing.prompt.selectBaseline"
+                         : "diffing.prompt.selectComparison");
+  }
 
-  return {
-    autoExpandDepth: 0,
-    getParent: node => {
-      const parent = map[node.id];
-      return parent === report ? null : parent;
-    },
-    getChildren: node => node.children || [],
-    renderItem: (item, depth, focused, arrow, expanded) =>
-      new TreeItem({
-        onViewSourceInDebugger,
-        item,
-        depth,
-        focused,
-        arrow,
-        expanded,
-        getPercentBytes,
-        getPercentCount,
-        showSign: !!diffing,
-      }),
-    getRoots: () => report.children || [],
-    getKey: node => node.id,
-    itemHeight: HEAP_TREE_ROW_HEIGHT,
-    // Because we never add or remove children when viewing the same census, we
-    // can always reuse a cached traversal if one is available.
-    reuseCachedTraversal: _ => true,
-  };
+  return getStatusTextFull(state);
+}
+
+/**
+ * Given that we should display a status message, return true if we should also
+ * display a throbber along with the status message. Return false otherwise.
+ *
+ * @param {diffingModel} diffing
+ *
+ * @returns {Boolean}
+ */
+function shouldDisplayThrobber(diffing) {
+  return !diffing || diffing.state !== diffingState.SELECTING;
 }
 
 /**
- * Main view for the memory tool -- contains several panels for different states;
- * an initial state of only a button to take a snapshot, loading states, and the
- * heap view tree.
+ * Get the current state's error, or return null if there is none.
+ *
+ * @param {snapshotModel} snapshot
+ * @param {diffingModel} diffing
+ *
+ * @returns {Error|null}
  */
+function getError(snapshot, diffing) {
+  if (diffing && diffing.state === diffingState.ERROR) {
+    return diffing.error;
+  }
 
+  if (snapshot) {
+    if (snapshot.state === states.ERROR) {
+      return snapshot.error;
+    }
+
+    if (snapshot.dominatorTree &&
+        snapshot.dominatorTree.state === dominatorTreeState.ERROR) {
+      return snapshot.dominatorTree.error;
+    }
+  }
+
+  return null;
+}
+
+/**
+ * Main view for the memory tool.
+ *
+ * The Heap component contains several panels for different states; an initial
+ * state of only a button to take a snapshot, loading states, the census view
+ * tree, the dominator tree, etc.
+ */
 const Heap = module.exports = createClass({
-  displayName: "heap-view",
+  displayName: "Heap",
 
   propTypes: {
     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,
     snapshot: snapshotModel,
     onViewSourceInDebugger: PropTypes.func.isRequired,
     diffing: diffingModel,
+    view: PropTypes.string.isRequired,
   },
 
   render() {
-    let { snapshot, diffing, onSnapshotClick, onViewSourceInDebugger } = this.props;
+    let {
+      snapshot,
+      diffing,
+      onSnapshotClick,
+      onLoadMoreSiblings,
+      onViewSourceInDebugger,
+      view,
+    } = this.props;
+
+    if (!diffing && !snapshot) {
+      return this._renderInitial(onSnapshotClick);
+    }
+
+    const state = getState(view, snapshot, diffing);
+    const statusText = getStateStatusText(state, diffing);
 
-    let census;
-    let state;
-    let statusText;
-    let error;
-    if (diffing) {
-      census = diffing.census;
-      state = diffing.state;
+    if (shouldDisplayStatus(state, view, snapshot)) {
+      return this._renderStatus(state, statusText, diffing);
+    }
 
-      if (diffing.state === diffingState.SELECTING) {
-        statusText = L10N.getStr(diffing.firstSnapshotId === null
-                                   ? "diffing.prompt.selectBaseline"
-                                   : "diffing.prompt.selectComparison");
-      } else {
-        statusText = getStatusTextFull(diffing.state);
-      }
+    const error = getError(snapshot, diffing);
+    if (error) {
+      return this._renderError(state, statusText, error);
+    }
+
+    if (view === viewState.CENSUS || view === viewState.DIFFING) {
+      const census = view === viewState.CENSUS
+        ? snapshot.census
+        : diffing.census;
+      return this._renderCensus(state, census, diffing, onViewSourceInDebugger);
+    }
 
-      if (diffing.error) {
-        error = diffing.error;
-      }
-    } else {
-      census = snapshot ? snapshot.census : null;
-      state = snapshot ? snapshot.state : "initial";
-      statusText = snapshot ? getStatusTextFull(snapshot.state) : "";
-      if (snapshot && snapshot.error) {
-        error = snapshot.error;
-      }
-    }
-    assert(census !== undefined, "census should have been set");
-    assert(state !== undefined, "state should have been set");
-    assert(statusText !== undefined, "statusText should have been set");
+    assert(view === viewState.DOMINATOR_TREE,
+           "If we aren't in progress, looking at a census, or diffing, then we " +
+           "must be looking at a dominator tree");
+    assert(!diffing, "Should not have diffing");
+    assert(snapshot.dominatorTree, "Should have a dominator tree");
+
+    return this._renderDominatorTree(state, onViewSourceInDebugger, snapshot.dominatorTree,
+                                     onLoadMoreSiblings);
+  },
 
-    let content;
-    switch (state) {
-      case "initial":
-        content = [dom.button({
-          className: "devtools-toolbarbutton take-snapshot",
-          onClick: onSnapshotClick,
-          // Want to use the [standalone] tag to leverage our styles,
-          // but React hates that evidently
-          "data-standalone": true,
-          "data-text-only": true,
-        }, L10N.getStr("take-snapshot"))];
-        break;
+  /**
+   * Render the heap view's container panel with the given contents inside of
+   * it.
+   *
+   * @param {snapshotState|diffingState|dominatorTreeState} state
+   * @param {...Any} contents
+   */
+  _renderHeapView(state, ...contents) {
+    return dom.div(
+      {
+        id: "heap-view",
+        "data-state": state
+      },
+      dom.div(
+        {
+          className: "heap-view-panel",
+          "data-state": state,
+        },
+        ...contents
+      )
+    );
+  },
 
-      case diffingState.ERROR:
-      case states.ERROR:
-        content = [
-          dom.span({ className: "snapshot-status error" }, statusText),
-          dom.pre({}, safeErrorString(error))
-        ];
-        break;
+  _renderInitial(onSnapshotClick) {
+    return this._renderHeapView("initial", dom.button(
+      {
+        className: "devtools-toolbarbutton take-snapshot",
+        onClick: onSnapshotClick,
+        "data-standalone": true,
+        "data-text-only": true,
+      },
+      L10N.getStr("take-snapshot")
+    ));
+  },
 
-      case diffingState.SELECTING:
-      case diffingState.TAKING_DIFF:
-      case states.IMPORTING:
-      case states.SAVING:
-      case states.SAVED:
-      case states.READING:
-      case states.READ:
-      case states.SAVING_CENSUS:
-        const throbber = state === diffingState.SELECTING
-          ? ""
-          : "devtools-throbber";
-        content = [dom.span({ className: `snapshot-status ${throbber}` },
-                            statusText)];
-        break;
+  _renderStatus(state, statusText, diffing) {
+    let throbber = "";
+    if (shouldDisplayThrobber(diffing)) {
+      throbber = "devtools-throbber";
+    }
 
-      case diffingState.TOOK_DIFF:
-      case states.SAVED_CENSUS:
-        content = [];
+    return this._renderHeapView(state, dom.span(
+      {
+        className: `snapshot-status ${throbber}`
+      },
+      statusText
+    ));
+  },
+
+  _renderError(state, statusText, error) {
+    return this._renderHeapView(
+      state,
+      dom.span({ className: "snapshot-status error" }, statusText),
+      dom.pre({}, safeErrorString(error))
+    );
+  },
 
-        if (census.breakdown.by === "allocationStack"
-            && census.report.children.length === 1
-            && census.report.children[0].name === "noStack") {
-          content.push(dom.div({ className: "error no-allocation-stacks" },
-                               L10N.getStr("heapview.noAllocationStacks")));
-        }
+  _renderCensus(state, census, diffing, onViewSourceInDebugger) {
+    const contents = [];
+
+    if (census.breakdown.by === "allocationStack"
+        && census.report.children.length === 1
+        && census.report.children[0].name === "noStack") {
+      contents.push(dom.div({ className: "error no-allocation-stacks" },
+                           L10N.getStr("heapview.noAllocationStacks")));
+    }
 
-        content.push(
-          dom.div({ className: "header" },
-            dom.span({ className: "heap-tree-item-bytes" }, L10N.getStr("heapview.field.bytes")),
-            dom.span({ className: "heap-tree-item-count" }, L10N.getStr("heapview.field.count")),
-            dom.span({ className: "heap-tree-item-total-bytes" }, L10N.getStr("heapview.field.totalbytes")),
-            dom.span({ className: "heap-tree-item-total-count" }, L10N.getStr("heapview.field.totalcount")),
-            dom.span({ className: "heap-tree-item-name" }, L10N.getStr("heapview.field.name"))
-          ),
-          Tree(createTreeProperties(census, onViewSourceInDebugger, diffing))
-        );
-        break;
+    contents.push(CensusHeader());
+    contents.push(Census({
+      onViewSourceInDebugger,
+      diffing,
+      census,
+      onExpand: node => this.props.onCensusExpand(census, node),
+      onCollapse: node => this.props.onCensusCollapse(census, node),
+      onFocus: node => this.props.onCensusFocus(census, node),
+    }));
+
+    return this._renderHeapView(state, ...contents);
+  },
 
-      default:
-        assert(false, "Unexpected state: ${state}");
-    }
-    assert(!!content, "Should have set content in the above switch block");
-
-    let pane = dom.div({ className: "heap-view-panel", "data-state": state },
-                       ...content);
-
-    return (
-      dom.div({ id: "heap-view", "data-state": state }, pane)
+  _renderDominatorTree(state, onViewSourceInDebugger, dominatorTree, onLoadMoreSiblings) {
+    return this._renderHeapView(
+      state,
+      DominatorTreeHeader(),
+      DominatorTree({
+        onViewSourceInDebugger,
+        dominatorTree,
+        onLoadMoreSiblings,
+        onExpand: this.props.onDominatorTreeExpand,
+        onCollapse: this.props.onDominatorTreeCollapse,
+        onFocus: this.props.onDominatorTreeFocus,
+      })
     );
-  }
+  },
 });
--- a/devtools/client/memory/components/list.js
+++ b/devtools/client/memory/components/list.js
@@ -5,17 +5,17 @@
 const { DOM: dom, createClass, PropTypes } = require("devtools/client/shared/vendor/react");
 
 /**
  * Generic list component that takes another react component to represent
  * the children nodes as `itemComponent`, and a list of items to render
  * as that component with a click handler.
  */
 const List = module.exports = createClass({
-  displayName: "list",
+  displayName: "List",
 
   propTypes: {
     itemComponent: PropTypes.any.isRequired,
     onClick: PropTypes.func,
     items: PropTypes.array.isRequired,
   },
 
   render() {
--- a/devtools/client/memory/components/moz.build
+++ b/devtools/client/memory/components/moz.build
@@ -1,12 +1,17 @@
 # vim: set filetype=python:
 # 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/.
 
 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',
     'snapshot-list-item.js',
     'toolbar.js',
-    'tree-item.js',
 )
--- a/devtools/client/memory/components/snapshot-list-item.js
+++ b/devtools/client/memory/components/snapshot-list-item.js
@@ -10,17 +10,17 @@ const {
   getSnapshotTotals,
   getStatusText,
   snapshotIsDiffable
 } = require("../utils");
 const { snapshotState: states, diffingState } = require("../constants");
 const { snapshot: snapshotModel } = require("../models");
 
 const SnapshotListItem = module.exports = createClass({
-  displayName: "snapshot-list-item",
+  displayName: "SnapshotListItem",
 
   propTypes: {
     onClick: PropTypes.func.isRequired,
     onSave: PropTypes.func.isRequired,
     item: snapshotModel.isRequired,
     index: PropTypes.number.isRequired,
   },
 
--- a/devtools/client/memory/components/toolbar.js
+++ b/devtools/client/memory/components/toolbar.js
@@ -1,118 +1,213 @@
 /* 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/. */
 
+const { assert } = require("devtools/shared/DevToolsUtils");
 const { DOM: dom, createClass, PropTypes } = require("devtools/client/shared/vendor/react");
 const { L10N } = require("../utils");
 const models = require("../models");
+const { viewState } = require("../constants");
 
 const Toolbar = module.exports = createClass({
-  displayName: "toolbar",
+  displayName: "Toolbar",
   propTypes: {
     breakdowns: PropTypes.arrayOf(PropTypes.shape({
       name: PropTypes.string.isRequired,
       displayName: PropTypes.string.isRequired,
     })).isRequired,
     onTakeSnapshotClick: PropTypes.func.isRequired,
     onImportClick: PropTypes.func.isRequired,
     onBreakdownChange: PropTypes.func.isRequired,
     onToggleRecordAllocationStacks: PropTypes.func.isRequired,
     allocations: models.allocations,
     onToggleInverted: PropTypes.func.isRequired,
     inverted: PropTypes.bool.isRequired,
     filterString: PropTypes.string,
     setFilterString: PropTypes.func.isRequired,
     diffing: models.diffingModel,
     onToggleDiffing: PropTypes.func.isRequired,
+    view: PropTypes.string.isRequired,
+    onViewChange: PropTypes.func.isRequired,
+    dominatorTreeBreakdowns: PropTypes.arrayOf(PropTypes.shape({
+      name: PropTypes.string.isRequired,
+      displayName: PropTypes.string.isRequired,
+    })).isRequired,
+    onDominatorTreeBreakdownChange: PropTypes.func.isRequired,
+    snapshots: PropTypes.arrayOf(models.snapshot).isRequired,
   },
 
   render() {
     let {
       onTakeSnapshotClick,
       onImportClick,
       onBreakdownChange,
       breakdowns,
+      dominatorTreeBreakdowns,
+      onDominatorTreeBreakdownChange,
       onToggleRecordAllocationStacks,
       allocations,
       onToggleInverted,
       inverted,
       filterString,
       setFilterString,
       snapshots,
       diffing,
       onToggleDiffing,
+      view,
+      onViewChange,
     } = this.props;
 
+    let viewToolbarOptions;
+    if (view == viewState.CENSUS || view === viewState.DIFFING) {
+      viewToolbarOptions = dom.div(
+        {
+          className: "toolbar-group"
+        },
+
+        dom.label(
+          { className: "breakdown-by" },
+          L10N.getStr("toolbar.breakdownBy"),
+          dom.select(
+            {
+              id: "select-breakdown",
+              className: "select-breakdown",
+              onChange: e => onBreakdownChange(e.target.value),
+            },
+            breakdowns.map(({ name, displayName }) => dom.option(
+              {
+                key: name,
+                value: name
+              },
+              displayName
+            ))
+          )
+        ),
+
+        dom.label(
+          {},
+          dom.input({
+            id: "invert-tree-checkbox",
+            type: "checkbox",
+            checked: inverted,
+            onChange: onToggleInverted,
+          }),
+          L10N.getStr("checkbox.invertTree")
+        ),
+
+        dom.div({ id: "toolbar-spacer", className: "spacer" }),
+
+        dom.input({
+          id: "filter",
+          type: "search",
+          className: "devtools-searchinput",
+          placeholder: L10N.getStr("filter.placeholder"),
+          onChange: event => setFilterString(event.target.value),
+          value: !!filterString ? filterString : undefined,
+        })
+      );
+    } else {
+      assert(view === viewState.DOMINATOR_TREE);
+
+      viewToolbarOptions = dom.div(
+        {
+          className: "toolbar-group"
+        },
+
+        dom.label(
+          { className: "label-by" },
+          L10N.getStr("toolbar.labelBy"),
+          dom.select(
+            {
+              id: "select-dominator-tree-breakdown",
+              onChange: e => onDominatorTreeBreakdownChange(e.target.value),
+            },
+            dominatorTreeBreakdowns.map(({ name, displayName }) => dom.option(
+              {
+                key: name,
+                value: name
+              },
+              displayName
+            ))
+          )
+        )
+      );
+    }
+
+    let viewSelect;
+    if (view !== viewState.DIFFING) {
+      viewSelect = dom.label(
+        {},
+        L10N.getStr("toolbar.view"),
+        dom.select(
+          {
+            id: "select-view",
+            onChange: e => onViewChange(e.target.value),
+            defaultValue: viewState.CENSUS,
+          },
+          dom.option({ value: viewState.CENSUS },
+                     L10N.getStr("toolbar.view.census")),
+          dom.option({ value: viewState.DOMINATOR_TREE },
+                     L10N.getStr("toolbar.view.dominators"))
+        )
+      );
+    }
+
     return (
-      dom.div({ className: "devtools-toolbar" },
-        dom.div({ className: "toolbar-group" },
+      dom.div(
+        {
+          className: "devtools-toolbar"
+        },
+
+        dom.div(
+          {
+            className: "toolbar-group"
+          },
+
           dom.button({
             id: "take-snapshot",
             className: "take-snapshot devtools-button",
             onClick: onTakeSnapshotClick,
             title: L10N.getStr("take-snapshot")
           }),
 
-          dom.button({
-            id: "diff-snapshots",
-            className: "devtools-button devtools-monospace" + (!!diffing ? " checked" : ""),
-            disabled: snapshots.length < 2,
-            onClick: onToggleDiffing,
-            title: L10N.getStr("diff-snapshots.tooltip"),
-          }, L10N.getStr("diff-snapshots")),
-
-          dom.button({
-            id: "import-snapshot",
-            className: "devtools-toolbarbutton import-snapshot devtools-button",
-            onClick: onImportClick,
-            title: L10N.getStr("import-snapshot"),
-            "data-text-only": true,
-          }, L10N.getStr("import-snapshot"))
-        ),
-
-        dom.div({ className: "toolbar-group" },
-          dom.label({ className: "breakdown-by" },
-            L10N.getStr("toolbar.breakdownBy"),
-            dom.select({
-              id: "select-breakdown",
-              className: "select-breakdown",
-              onChange: e => onBreakdownChange(e.target.value),
-            }, ...breakdowns.map(({ name, displayName }) => dom.option({ key: name, value: name }, displayName)))
+          dom.button(
+            {
+              id: "diff-snapshots",
+              className: "devtools-button devtools-monospace" + (!!diffing ? " checked" : ""),
+              disabled: snapshots.length < 2,
+              onClick: onToggleDiffing,
+              title: L10N.getStr("diff-snapshots.tooltip"),
+            },
+            L10N.getStr("diff-snapshots")
           ),
 
-          dom.label({},
-            dom.input({
-              id: "invert-tree-checkbox",
-              type: "checkbox",
-              checked: inverted,
-              onChange: onToggleInverted,
-            }),
-            L10N.getStr("checkbox.invertTree")
-          ),
+          dom.button(
+            {
+              id: "import-snapshot",
+              className: "devtools-toolbarbutton import-snapshot devtools-button",
+              onClick: onImportClick,
+              title: L10N.getStr("import-snapshot"),
+              "data-text-only": true,
+            },
+            L10N.getStr("import-snapshot")
+          )
+        ),
 
-          dom.label({},
-            dom.input({
-              id: "record-allocation-stacks-checkbox",
-              type: "checkbox",
-              checked: allocations.recording,
-              disabled: allocations.togglingInProgress,
-              onChange: onToggleRecordAllocationStacks,
-            }),
-            L10N.getStr("checkbox.recordAllocationStacks")
-          ),
+        dom.label(
+          {},
+          dom.input({
+            id: "record-allocation-stacks-checkbox",
+            type: "checkbox",
+            checked: allocations.recording,
+            disabled: allocations.togglingInProgress,
+            onChange: onToggleRecordAllocationStacks,
+          }),
+          L10N.getStr("checkbox.recordAllocationStacks")
+        ),
 
-          dom.div({ id: "toolbar-spacer", className: "spacer" }),
-
-          dom.input({
-            id: "filter",
-            type: "search",
-            className: "devtools-searchinput",
-            placeholder: L10N.getStr("filter.placeholder"),
-            onChange: event => setFilterString(event.target.value),
-            value: !!filterString ? filterString : undefined,
-          })
-        )
+        viewSelect,
+        viewToolbarOptions
       )
     );
   }
 });
--- a/devtools/client/memory/constants.js
+++ b/devtools/client/memory/constants.js
@@ -5,16 +5,20 @@
 "use strict";
 
 // Options passed to MemoryFront's startRecordingAllocations never change.
 exports.ALLOCATION_RECORDING_OPTIONS = {
   probability: 1,
   maxLogLength: 1
 };
 
+// If TREE_ROW_HEIGHT changes, be sure to change `var(--heap-tree-row-height)`
+// in `devtools/client/themes/memory.css`
+exports.TREE_ROW_HEIGHT = 14;
+
 /*** Actions ******************************************************************/
 
 const actions = exports.actions = {};
 
 // Fired by UI to request a snapshot from the actor.
 actions.TAKE_SNAPSHOT_START = "take-snapshot-start";
 actions.TAKE_SNAPSHOT_END = "take-snapshot-end";
 
@@ -44,36 +48,60 @@ actions.IMPORT_SNAPSHOT_END = "import-sn
 actions.IMPORT_SNAPSHOT_ERROR = "import-snapshot-error";
 
 // Fired by UI to select a snapshot to view.
 actions.SELECT_SNAPSHOT = "select-snapshot";
 
 // Fired to toggle tree inversion on or off.
 actions.TOGGLE_INVERTED = "toggle-inverted";
 
-// Fired to toggle diffing mode on or off.
-actions.TOGGLE_DIFFING = "toggle-diffing";
-
 // Fired when a snapshot is selected for diffing.
 actions.SELECT_SNAPSHOT_FOR_DIFFING = "select-snapshot-for-diffing";
 
 // Fired when taking a census diff.
 actions.TAKE_CENSUS_DIFF_START = "take-census-diff-start";
 actions.TAKE_CENSUS_DIFF_END = "take-census-diff-end";
 actions.DIFFING_ERROR = "diffing-error";
 
 // Fired to set a new breakdown.
 actions.SET_BREAKDOWN = "set-breakdown";
 
+// Fired to change the breakdown that controls the dominator tree labels.
+actions.SET_DOMINATOR_TREE_BREAKDOWN = "set-dominator-tree-breakdown";
+
+// Fired when changing between census or dominators view.
+actions.CHANGE_VIEW = "change-view";
+
 // Fired when there is an error processing a snapshot or taking a census.
 actions.SNAPSHOT_ERROR = "snapshot-error";
 
 // Fired when there is a new filter string set.
 actions.SET_FILTER_STRING = "set-filter-string";
 
+// Fired to expand or collapse nodes in census reports.
+actions.EXPAND_CENSUS_NODE = "expand-census-node";
+actions.EXPAND_DIFFING_CENSUS_NODE = "expand-diffing-census-node";
+actions.COLLAPSE_CENSUS_NODE = "collapse-census-node";
+actions.COLLAPSE_DIFFING_CENSUS_NODE = "collapse-diffing-census-node";
+
+// Fired when nodes in various trees are focused.
+actions.FOCUS_CENSUS_NODE = "focus-census-node";
+actions.FOCUS_DIFFING_CENSUS_NODE = "focus-diffing-census-node";
+actions.FOCUS_DOMINATOR_TREE_NODE = "focus-dominator-tree-node";
+
+actions.COMPUTE_DOMINATOR_TREE_START = "compute-dominator-tree-start";
+actions.COMPUTE_DOMINATOR_TREE_END = "compute-dominator-tree-end";
+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";
+
 /*** 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 = {
@@ -103,27 +131,73 @@ const breakdowns = exports.breakdowns = 
   },
 
   internalType: {
     displayName: "Internal Type",
     breakdown: INTERNAL_TYPE,
   },
 };
 
+const DOMINATOR_TREE_LABEL_COARSE_TYPE = {
+  by: "coarseType",
+  objects: OBJECT_CLASS,
+  scripts: {
+    by: "internalType",
+    then: {
+      by: "filename",
+      then: COUNT,
+      noFilename: COUNT,
+    },
+  },
+  strings: INTERNAL_TYPE,
+  other: INTERNAL_TYPE,
+};
+
+const dominatorTreeBreakdowns = exports.dominatorTreeBreakdowns = {
+  coarseType: {
+    displayName: "Coarse Type",
+    breakdown: DOMINATOR_TREE_LABEL_COARSE_TYPE
+  },
+
+  allocationStack: {
+    displayName: "Allocation Stack",
+    breakdown: {
+      by: "allocationStack",
+      then: DOMINATOR_TREE_LABEL_COARSE_TYPE,
+      noStack: DOMINATOR_TREE_LABEL_COARSE_TYPE,
+    },
+  },
+
+  internalType: {
+    displayName: "Internal Type",
+    breakdown: INTERNAL_TYPE,
+  },
+};
+
+/*** View States **************************************************************/
+
+/**
+ * The various main views that the tool can be in.
+ */
+const viewState = exports.viewState = Object.create(null);
+viewState.CENSUS = "view-state-census";
+viewState.DIFFING = "view-state-diffing";
+viewState.DOMINATOR_TREE = "view-state-dominator-tree";
+
 /*** Snapshot States **********************************************************/
 
-const snapshotState = exports.snapshotState = {};
+const snapshotState = exports.snapshotState = Object.create(null);
 
 /**
  * Various states a snapshot can be in.
  * An FSM describing snapshot states:
  *
- *     SAVING -> SAVED    -> READING -> READ     SAVED_CENSUS
- *               IMPORTING ↗                ↘     ↑  ↓
- *                                            SAVING_CENSUS
+ *     SAVING -> SAVED -> READING -> READ     SAVED_CENSUS
+ *                       ↗                ↘     ↑  ↓
+ *              IMPORTING                   SAVING_CENSUS
  *
  * Any of these states may go to the ERROR state, from which they can never
  * leave (mwah ha ha ha!)
  */
 snapshotState.ERROR = "snapshot-state-error";
 snapshotState.IMPORTING = "snapshot-state-importing";
 snapshotState.SAVING = "snapshot-state-saving";
 snapshotState.SAVED = "snapshot-state-saved";
@@ -150,8 +224,25 @@ diffingState.SELECTING = "diffing-state-
 // Currently computing the diff between the two selected snapshots.
 diffingState.TAKING_DIFF = "diffing-state-taking-diff";
 
 // Have the diff between the two selected snapshots.
 diffingState.TOOK_DIFF = "diffing-state-took-diff";
 
 // An error occurred while computing the diff.
 diffingState.ERROR = "diffing-state-error";
+
+/*** Dominator Tree States ****************************************************/
+
+/*
+ * Various states the dominator tree model can be in.
+ *
+ *     COMPUTING -> COMPUTED -> FETCHING -> LOADED <--> INCREMENTAL_FETCHING
+ *
+ * Any state may lead to the ERROR state, from which it can never leave.
+ */
+const dominatorTreeState = exports.dominatorTreeState = Object.create(null);
+dominatorTreeState.COMPUTING = "dominator-tree-state-computing";
+dominatorTreeState.COMPUTED = "dominator-tree-state-computed";
+dominatorTreeState.FETCHING = "dominator-tree-state-fetching";
+dominatorTreeState.LOADED = "dominator-tree-state-loaded";
+dominatorTreeState.INCREMENTAL_FETCHING = "dominator-tree-state-incremental-fetching";
+dominatorTreeState.ERROR = "dominator-tree-state-error";
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/dominator-tree-lazy-children.js
@@ -0,0 +1,58 @@
+/* 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/. */
+
+/**
+ * The `DominatorTreeLazyChildren` is a placeholder that represents a future
+ * subtree in an existing `DominatorTreeNode` tree that is currently being
+ * incrementally fetched from the `HeapAnalysesWorker`.
+ *
+ * @param {NodeId} parentNodeId
+ * @param {Number} siblingIndex
+ */
+function DominatorTreeLazyChildren(parentNodeId, siblingIndex) {
+  this._parentNodeId = parentNodeId;
+  this._siblingIndex = siblingIndex;
+}
+
+/**
+ * Generate a unique key for this `DominatorTreeLazyChildren` instance. This can
+ * be used as the key in a hash table or as the `key` property for a React
+ * component, for example.
+ *
+ * @returns {String}
+ */
+DominatorTreeLazyChildren.prototype.key = function () {
+  return `dominator-tree-lazy-children-${this._parentNodeId}-${this._siblingIndex}`;
+};
+
+/**
+ * Return true if this is a placeholder for the first child of its
+ * parent. Return false if it is a placeholder for loading more of its parent's
+ * children.
+ *
+ * @returns {Boolean}
+ */
+DominatorTreeLazyChildren.prototype.isFirstChild = function () {
+  return this._siblingIndex === 0;
+};
+
+/**
+ * Get this subtree's parent node's identifier.
+ *
+ * @returns {NodeId}
+ */
+DominatorTreeLazyChildren.prototype.parentNodeId = function () {
+  return this._parentNodeId;
+};
+
+/**
+ * Get this subtree's index in its parent's children array.
+ *
+ * @returns {Number}
+ */
+DominatorTreeLazyChildren.prototype.siblingIndex = function () {
+  return this._siblingIndex;
+};
+
+module.exports = DominatorTreeLazyChildren;
--- a/devtools/client/memory/models.js
+++ b/devtools/client/memory/models.js
@@ -1,17 +1,53 @@
 /* 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/. */
 
 const { assert } = require("devtools/shared/DevToolsUtils");
 const { MemoryFront } = require("devtools/server/actors/memory");
 const HeapAnalysesClient = require("devtools/shared/heapsnapshot/HeapAnalysesClient");
 const { PropTypes } = require("devtools/client/shared/vendor/react");
-const { snapshotState: states, diffingState } = require("./constants");
+const {
+  snapshotState: states,
+  diffingState,
+  dominatorTreeState,
+  viewState
+} = require("./constants");
+
+/**
+ * ONLY USE THIS FOR MODEL VALIDATORS IN CONJUCTION WITH assert()!
+ *
+ * React checks that the returned values from validator functions are instances
+ * of Error, but because React is loaded in its own global, that check is always
+ * false and always results in a warning.
+ *
+ * To work around this and still get model validation, just call assert() inside
+ * a function passed to catchAndIgnore. The assert() function will still report
+ * assertion failures, but this funciton will swallow the errors so that React
+ * doesn't go crazy and drown out the real error in irrelevant and incorrect
+ * warnings.
+ *
+ * Example usage:
+ *
+ *     const MyModel = PropTypes.shape({
+ *       someProperty: catchAndIgnore(function (model) {
+ *         assert(someInvariant(model.someProperty), "Should blah blah");
+ *       })
+ *     });
+ */
+function catchAndIgnore(fn) {
+  return function (...args) {
+    try {
+      fn(...args);
+    } catch (err) { }
+
+    return null;
+  };
+}
 
 /**
  * The breakdown object DSL describing how we want
  * the census data to be.
  * @see `js/src/doc/Debugger/Debugger.Memory.md`
  */
 let breakdownModel = exports.breakdown = PropTypes.shape({
   by: PropTypes.oneOf(["coarseType", "allocationStack", "objectClass", "internalType"]).isRequired,
@@ -22,16 +58,107 @@ let censusModel = exports.censusModel = 
   report: PropTypes.object,
   // The breakdown used to generate the current census
   breakdown: breakdownModel,
   // Whether the currently cached report tree is inverted or not.
   inverted: PropTypes.bool,
   // If present, the currently cached report's filter string used for pruning
   // the tree items.
   filter: PropTypes.string,
+  // The Set<CensusTreeNode.id> of expanded node ids in the report tree.
+  expanded: catchAndIgnore(function (census) {
+    if (census.report) {
+      assert(census.expanded,
+             "If we have a report, we should also have the set of expanded nodes");
+    }
+  }),
+  // If a node is currently focused in the report tree, then this is it.
+  focused: PropTypes.object,
+});
+
+/**
+ * Dominator tree model.
+ */
+let dominatorTreeModel = exports.dominatorTreeModel = PropTypes.shape({
+  // The id of this dominator tree.
+  dominatorTreeId: PropTypes.number,
+
+  // The root DominatorTreeNode of this dominator tree.
+  root: PropTypes.object,
+
+  // The Set<NodeId> of expanded nodes in this dominator tree.
+  expanded: PropTypes.object,
+
+  // If a node is currently focused in the dominator tree, then this is it.
+  focused: PropTypes.object,
+
+  // If an error was thrown while getting this dominator tree, the `Error`
+  // instance (or an error string message) is attached here.
+  error: PropTypes.oneOfType([
+    PropTypes.string,
+    PropTypes.object,
+  ]),
+
+  // The breakdown used to generate descriptive labels of nodes in this
+  // dominator tree.
+  breakdown: breakdownModel,
+
+  // The number of active requests to incrementally fetch subtrees. This should
+  // only be non-zero when the state is INCREMENTAL_FETCHING.
+  activeFetchRequestCount: PropTypes.number,
+
+  // The dominatorTreeState that this domintor tree is currently in.
+  state: catchAndIgnore(function (dominatorTree) {
+    switch (dominatorTree.state) {
+      case dominatorTreeState.COMPUTING:
+        assert(dominatorTree.dominatorTreeId == null,
+                "Should not have a dominator tree id yet");
+        assert(!dominatorTree.root,
+               "Should not have the root of the tree yet");
+        assert(!dominatorTree.error,
+               "Should not have an error");
+        break;
+
+      case dominatorTreeState.COMPUTED:
+      case dominatorTreeState.FETCHING:
+        assert(dominatorTree.dominatorTreeId != null,
+               "Should have a dominator tree id");
+        assert(!dominatorTree.root,
+               "Should not have the root of the tree yet");
+        assert(!dominatorTree.error,
+               "Should not have an error");
+        break;
+
+      case dominatorTreeState.INCREMENTAL_FETCHING:
+        assert(typeof dominatorTree.activeFetchRequestCount === "number",
+               "The active fetch request count is a number when we are in the " +
+               "INCREMENTAL_FETCHING state");
+        assert(dominatorTree.activeFetchRequestCount > 0,
+               "We are keeping track of how many active requests are in flight.");
+        // Fall through...
+      case dominatorTreeState.LOADED:
+        assert(dominatorTree.dominatorTreeId != null,
+               "Should have a dominator tree id");
+        assert(dominatorTree.root,
+               "Should have the root of the tree");
+        assert(dominatorTree.expanded,
+               "Should have an expanded set");
+        assert(!dominatorTree.error,
+               "Should not have an error");
+        break;
+
+      case dominatorTreeState.ERROR:
+        assert(dominatorTree.error, "Should have an error");
+        break;
+
+      default:
+        assert(false,
+               `Unexpected dominator tree state: ${dominatorTree.state}`);
+    }
+  }),
 });
 
 /**
  * Snapshot model.
  */
 let stateKeys = Object.keys(states).map(state => states[state]);
 const snapshotId = PropTypes.number;
 let snapshotModel = exports.snapshot = PropTypes.shape({
@@ -39,27 +166,29 @@ let snapshotModel = exports.snapshot = P
   id: snapshotId.isRequired,
   // Whether or not this snapshot is currently selected.
   selected: PropTypes.bool.isRequired,
   // Filesystem path to where the snapshot is stored; used to identify the
   // snapshot for HeapAnalysesClient.
   path: PropTypes.string,
   // Current census data for this snapshot.
   census: censusModel,
+  // Current dominator tree data for this snapshot.
+  dominatorTree: dominatorTreeModel,
   // If an error was thrown while processing this snapshot, the `Error` instance
   // is attached here.
   error: PropTypes.object,
   // Boolean indicating whether or not this snapshot was imported.
   imported: PropTypes.bool.isRequired,
   // The creation time of the snapshot; required after the snapshot has been
   // read.
   creationTime: PropTypes.number,
   // The current state the snapshot is in.
   // @see ./constants.js
-  state: function (snapshot, propName) {
+  state: catchAndIgnore(function (snapshot, propName) {
     let current = snapshot.state;
     let shouldHavePath = [states.IMPORTING, states.SAVED, states.READ, states.SAVING_CENSUS, states.SAVED_CENSUS];
     let shouldHaveCreationTime = [states.READ, states.SAVING_CENSUS, states.SAVED_CENSUS];
     let shouldHaveCensus = [states.SAVED_CENSUS];
 
     if (!stateKeys.includes(current)) {
       throw new Error(`Snapshot state must be one of ${stateKeys}.`);
     }
@@ -67,50 +196,50 @@ let snapshotModel = exports.snapshot = P
       throw new Error(`Snapshots in state ${current} must have a snapshot path.`);
     }
     if (shouldHaveCensus.includes(current) && (!snapshot.census || !snapshot.census.breakdown)) {
       throw new Error(`Snapshots in state ${current} must have a census and breakdown.`);
     }
     if (shouldHaveCreationTime.includes(current) && !snapshot.creationTime) {
       throw new Error(`Snapshots in state ${current} must have a creation time.`);
     }
-  },
+  }),
 });
 
 let allocationsModel = exports.allocations = PropTypes.shape({
   // True iff we are recording allocation stacks right now.
   recording: PropTypes.bool.isRequired,
   // True iff we are in the process of toggling the recording of allocation
   // stacks on or off right now.
   togglingInProgress: PropTypes.bool.isRequired,
 });
 
 let diffingModel = exports.diffingModel = PropTypes.shape({
   // The id of the first snapshot to diff.
   firstSnapshotId: snapshotId,
 
   // The id of the second snapshot to diff.
-  secondSnapshotId: function (diffing, propName) {
+  secondSnapshotId: catchAndIgnore(function (diffing, propName) {
     if (diffing.secondSnapshotId && !diffing.firstSnapshotId) {
       throw new Error("Cannot have second snapshot without already having " +
                       "first snapshot");
     }
     return snapshotId(diffing, propName);
-  },
+  }),
 
   // The current census data for the diffing.
   census: censusModel,
 
   // If an error was thrown while diffing, the `Error` instance is attached
   // here.
   error: PropTypes.object,
 
   // The current state the diffing is in.
   // @see ./constants.js
-  state: function (diffing) {
+  state: catchAndIgnore(function (diffing) {
     switch (diffing.state) {
       case diffingState.TOOK_DIFF:
         assert(diffing.census, "If we took a diff, we should have a census");
         // Fall through...
       case diffingState.TAKING_DIFF:
         assert(diffing.firstSnapshotId, "Should have first snapshot");
         assert(diffing.secondSnapshotId, "Should have second snapshot");
         break;
@@ -120,31 +249,63 @@ let diffingModel = exports.diffingModel 
 
       case diffingState.ERROR:
         assert(diffing.error, "Should have error");
         break;
 
       default:
         assert(false, `Bad diffing state: ${diffing.state}`);
     }
-  }
+  }),
 });
 
 let appModel = exports.app = {
   // {MemoryFront} Used to communicate with platform
   front: PropTypes.instanceOf(MemoryFront),
+
   // Allocations recording related data.
   allocations: allocationsModel.isRequired,
+
   // {HeapAnalysesClient} Used to interface with snapshots
   heapWorker: PropTypes.instanceOf(HeapAnalysesClient),
+
   // The breakdown object DSL describing how we want
   // the census data to be.
   // @see `js/src/doc/Debugger/Debugger.Memory.md`
   breakdown: breakdownModel.isRequired,
+
+  // The breakdown object DSL describing how we want
+  // the dominator tree labels to be computed.
+  // @see `js/src/doc/Debugger/Debugger.Memory.md`
+  dominatorTreeBreakdown: breakdownModel.isRequired,
+
   // List of reference to all snapshots taken
   snapshots: PropTypes.arrayOf(snapshotModel).isRequired,
+
   // True iff we want the tree displayed inverted.
   inverted: PropTypes.bool.isRequired,
+
   // If present, a filter string for pruning the tree items.
   filter: PropTypes.string,
+
   // If present, the current diffing state.
   diffing: diffingModel,
+
+  // The current type of view.
+  view: catchAndIgnore(function (app) {
+    switch (app.view) {
+      case viewState.CENSUS:
+        assert(!app.diffing, "Should not be diffing");
+        break;
+
+      case viewState.DIFFING:
+        assert(app.diffing, "Should be diffing");
+        break;
+
+      case viewState.DOMINATOR_TREE:
+        assert(!app.diffing, "Should not be diffing");
+        break;
+
+      default:
+        assert(false, `Unexpected type of view: ${app.view}`);
+    }
+  }),
 };
--- a/devtools/client/memory/moz.build
+++ b/devtools/client/memory/moz.build
@@ -10,18 +10,20 @@ DIRS += [
     'actions',
     'components',
     'reducers',
 ]
 
 DevToolsModules(
     'app.js',
     'constants.js',
+    'dominator-tree-lazy-children.js',
     'initializer.js',
     'models.js',
     'panel.js',
     'reducers.js',
     'store.js',
     'utils.js',
 )
 
 BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
 XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
+MOCHITEST_CHROME_MANIFESTS += ['test/chrome/chrome.ini']
--- a/devtools/client/memory/reducers.js
+++ b/devtools/client/memory/reducers.js
@@ -1,12 +1,14 @@
 /* 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";
 
 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.snapshots = require("./reducers/snapshots");
+exports.view = require("./reducers/view");
--- a/devtools/client/memory/reducers/diffing.js
+++ b/devtools/client/memory/reducers/diffing.js
@@ -1,30 +1,31 @@
 /* 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 { immutableUpdate, assert } = require("devtools/shared/DevToolsUtils");
-const { actions, diffingState } = require("../constants");
+const { actions, diffingState, viewState } = require("../constants");
 const { snapshotIsDiffable } = require("../utils");
 
 const handlers = Object.create(null);
 
-handlers[actions.TOGGLE_DIFFING] = function (diffing, action) {
-  if (diffing) {
-    return null;
+handlers[actions.CHANGE_VIEW] = function (diffing, { view }) {
+  if (view === viewState.DIFFING) {
+    assert(!diffing, "Should not switch to diffing view when already diffing");
+    return Object.freeze({
+      firstSnapshotId: null,
+      secondSnapshotId: null,
+      census: null,
+      state: diffingState.SELECTING,
+    });
   }
 
-  return Object.freeze({
-    firstSnapshotId: null,
-    secondSnapshotId: null,
-    census: null,
-    state: diffingState.SELECTING,
-  });
+  return null;
 };
 
 handlers[actions.SELECT_SNAPSHOT_FOR_DIFFING] = function (diffing, { snapshot }) {
   assert(diffing,
          "Should never select a snapshot for diffing when we aren't diffing " +
          "anything");
   assert(diffing.state === diffingState.SELECTING,
          "Can't select when not in SELECTING state");
@@ -75,26 +76,67 @@ handlers[actions.TAKE_CENSUS_DIFF_END] =
          "First snapshot's id should match");
   assert(action.second.id === diffing.secondSnapshotId,
          "Second snapshot's id should match");
 
   return immutableUpdate(diffing, {
     state: diffingState.TOOK_DIFF,
     census: {
       report: action.report,
+      expanded: new Set(),
       inverted: action.inverted,
       filter: action.filter,
       breakdown: action.breakdown,
     }
   });
 };
 
-handlers[actions.DIFFFING_ERROR] = function (diffing, action) {
+handlers[actions.DIFFING_ERROR] = function (diffing, action) {
   return {
     state: diffingState.ERROR,
     error: action.error
   };
 };
 
+handlers[actions.EXPAND_DIFFING_CENSUS_NODE] = function (diffing, { node }) {
+  assert(diffing, "Should be diffing if expanding diffing's census nodes");
+  assert(diffing.state === diffingState.TOOK_DIFF,
+         "Should have taken the census diff if expanding nodes");
+  assert(diffing.census, "Should have a census");
+  assert(diffing.census.report, "Should have a census report");
+  assert(diffing.census.expanded, "Should have a census's expanded set");
+
+  // Warning: mutable operations couched in an immutable update ahead :'( This
+  // at least lets us use referential equality on the census model itself,
+  // even though the expanded set is mutated in place.
+  const expanded = diffing.census.expanded;
+  expanded.add(node.id);
+  const census = immutableUpdate(diffing.census, { expanded });
+  return immutableUpdate(diffing, { census });
+};
+
+handlers[actions.COLLAPSE_DIFFING_CENSUS_NODE] = function (diffing, { node }) {
+  assert(diffing, "Should be diffing if expanding diffing's census nodes");
+  assert(diffing.state === diffingState.TOOK_DIFF,
+         "Should have taken the census diff if expanding nodes");
+  assert(diffing.census, "Should have a census");
+  assert(diffing.census.report, "Should have a census report");
+  assert(diffing.census.expanded, "Should have a census's expanded set");
+
+  // Warning: mutable operations couched in an immutable update ahead :'( See
+  // above comment in the EXPAND_DIFFING_CENSUS_NODE handler.
+  const expanded = diffing.census.expanded;
+  expanded.delete(node.id);
+  const census = immutableUpdate(diffing.census, { expanded });
+  return immutableUpdate(diffing, { census });
+};
+
+handlers[actions.FOCUS_DIFFING_CENSUS_NODE] = function (diffing, { node }) {
+  assert(diffing, "Should be diffing.");
+  assert(diffing.census, "Should have a census");
+  const census = immutableUpdate(diffing.census, { focused: node });
+  return immutableUpdate(diffing, { census });
+};
+
 module.exports = function (diffing = null, action) {
   const handler = handlers[action.type];
   return handler ? handler(diffing, action) : diffing;
 };
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/reducers/dominatorTreeBreakdown.js
@@ -0,0 +1,19 @@
+/* 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, dominatorTreeBreakdowns } = require("../constants");
+const DEFAULT_BREAKDOWN = dominatorTreeBreakdowns.coarseType.breakdown;
+
+const handlers = Object.create(null);
+
+handlers[actions.SET_DOMINATOR_TREE_BREAKDOWN] = function (_, { breakdown }) {
+  return breakdown;
+};
+
+module.exports = function (state = DEFAULT_BREAKDOWN, action) {
+  const handler = handlers[action.type];
+  return handler ? handler(state, action) : state;
+};
--- a/devtools/client/memory/reducers/moz.build
+++ b/devtools/client/memory/reducers/moz.build
@@ -2,13 +2,15 @@
 # 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/.
 
 DevToolsModules(
     'allocations.js',
     'breakdown.js',
     'diffing.js',
+    'dominatorTreeBreakdown.js',
     'errors.js',
     'filter.js',
     'inverted.js',
     'snapshots.js',
+    'view.js',
 )
--- a/devtools/client/memory/reducers/snapshots.js
+++ b/devtools/client/memory/reducers/snapshots.js
@@ -1,15 +1,21 @@
 /* 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 { immutableUpdate } = require("devtools/shared/DevToolsUtils");
-const { actions, snapshotState: states } = require("../constants");
+const { immutableUpdate, assert } = require("devtools/shared/DevToolsUtils");
+const {
+  actions,
+  snapshotState: states,
+  dominatorTreeState,
+  viewState,
+} = require("../constants");
+const DominatorTreeNode = require("devtools/shared/heapsnapshot/DominatorTreeNode");
 
 const handlers = Object.create(null);
 
 handlers[actions.SNAPSHOT_ERROR] = function (snapshots, { id, error }) {
   return snapshots.map(snapshot => {
     return snapshot.id === id
       ? immutableUpdate(snapshot, { state: states.ERROR, error })
       : snapshot;
@@ -59,34 +65,301 @@ handlers[actions.TAKE_CENSUS_START] = fu
       ? immutableUpdate(snapshot, { state: states.SAVING_CENSUS, census })
       : snapshot;
   });
 };
 
 handlers[actions.TAKE_CENSUS_END] = function (snapshots, { id, report, breakdown, inverted, filter }) {
   const census = {
     report,
+    expanded: new Set(),
     breakdown,
     inverted,
     filter,
   };
 
   return snapshots.map(snapshot => {
     return snapshot.id === id
       ? immutableUpdate(snapshot, { state: states.SAVED_CENSUS, census })
       : snapshot;
   });
 };
 
+handlers[actions.EXPAND_CENSUS_NODE] = function (snapshots, { id, node }) {
+  return snapshots.map(snapshot => {
+    if (snapshot.id !== id) {
+      return snapshot;
+    }
+
+    assert(snapshot.census, "Should have a census");
+    assert(snapshot.census.report, "Should have a census report");
+    assert(snapshot.census.expanded, "Should have a census's expanded set");
+
+    // Warning: mutable operations couched in an immutable update ahead :'( This
+    // at least lets us use referential equality on the census model itself,
+    // even though the expanded set is mutated in place.
+    const expanded = snapshot.census.expanded;
+    expanded.add(node.id);
+    const census = immutableUpdate(snapshot.census, { expanded });
+    return immutableUpdate(snapshot, { census });
+  });
+};
+
+handlers[actions.COLLAPSE_CENSUS_NODE] = function (snapshots, { id, node }) {
+  return snapshots.map(snapshot => {
+    if (snapshot.id !== id) {
+      return snapshot;
+    }
+
+    assert(snapshot.census, "Should have a census");
+    assert(snapshot.census.report, "Should have a census report");
+    assert(snapshot.census.expanded, "Should have a census's expanded set");
+
+    // Warning: mutable operations couched in an immutable update ahead :'( See
+    // above comment in the EXPAND_CENSUS_NODE handler.
+    const expanded = snapshot.census.expanded;
+    expanded.delete(node.id);
+    const census = immutableUpdate(snapshot.census, { expanded });
+    return immutableUpdate(snapshot, { census });
+  });
+};
+
+handlers[actions.FOCUS_CENSUS_NODE] = function (snapshots, { id, node }) {
+  return snapshots.map(snapshot => {
+    if (snapshot.id !== id) {
+      return snapshot;
+    }
+
+    assert(snapshot.census, "Should have a census");
+    const census = immutableUpdate(snapshot.census, { focused: node });
+    return immutableUpdate(snapshot, { census });
+  });
+};
+
 handlers[actions.SELECT_SNAPSHOT] = function (snapshots, { id }) {
   return snapshots.map(s => immutableUpdate(s, { selected: s.id === id }));
 };
 
-handlers[actions.TOGGLE_DIFFING] = function (snapshots, action) {
-  return snapshots.map(s => immutableUpdate(s, { selected: false }));
+handlers[actions.CHANGE_VIEW] = function (snapshots, { view }) {
+  return view === viewState.DIFFING
+    ? snapshots.map(s => immutableUpdate(s, { selected: false }))
+    : snapshots;
+};
+
+handlers[actions.COMPUTE_DOMINATOR_TREE_START] = function (snapshots, { id }) {
+  const dominatorTree = Object.freeze({
+    state: dominatorTreeState.COMPUTING,
+    dominatorTreeId: undefined,
+    root: undefined,
+  });
+
+  return snapshots.map(snapshot => {
+    if (snapshot.id !== id) {
+      return snapshot;
+    }
+
+    assert(!snapshot.dominatorTree,
+           "Should not have a dominator tree model");
+    return immutableUpdate(snapshot, { dominatorTree });
+  });
+};
+
+handlers[actions.COMPUTE_DOMINATOR_TREE_END] = function (snapshots, { id, dominatorTreeId }) {
+  return snapshots.map(snapshot => {
+    if (snapshot.id !== id) {
+      return snapshot;
+    }
+
+    assert(snapshot.dominatorTree, "Should have a dominator tree model");
+    assert(snapshot.dominatorTree.state == dominatorTreeState.COMPUTING,
+           "Should be in the COMPUTING state");
+
+    const dominatorTree = immutableUpdate(snapshot.dominatorTree, {
+      state: dominatorTreeState.COMPUTED,
+      dominatorTreeId,
+    });
+    return immutableUpdate(snapshot, { dominatorTree });
+  });
+};
+
+handlers[actions.FETCH_DOMINATOR_TREE_START] = function (snapshots, { id, breakdown }) {
+  return snapshots.map(snapshot => {
+    if (snapshot.id !== id) {
+      return snapshot;
+    }
+
+    assert(snapshot.dominatorTree, "Should have a dominator tree model");
+    assert(snapshot.dominatorTree.state !== dominatorTreeState.COMPUTING &&
+           snapshot.dominatorTree.state !== dominatorTreeState.ERROR,
+           `Should have already computed the dominator tree, found state = ${snapshot.dominatorTree.state}`);
+
+    const dominatorTree = immutableUpdate(snapshot.dominatorTree, {
+      state: dominatorTreeState.FETCHING,
+      root: undefined,
+      breakdown,
+    });
+    return immutableUpdate(snapshot, { dominatorTree });
+  });
+};
+
+handlers[actions.FETCH_DOMINATOR_TREE_END] = function (snapshots, { id, root }) {
+  return snapshots.map(snapshot => {
+    if (snapshot.id !== id) {
+      return snapshot;
+    }
+
+    assert(snapshot.dominatorTree, "Should have a dominator tree model");
+    assert(snapshot.dominatorTree.state == dominatorTreeState.FETCHING,
+           "Should be in the FETCHING state");
+
+    const dominatorTree = immutableUpdate(snapshot.dominatorTree, {
+      state: dominatorTreeState.LOADED,
+      root,
+      expanded: new Set(),
+    });
+
+    return immutableUpdate(snapshot, { dominatorTree });
+  });
+};
+
+handlers[actions.EXPAND_DOMINATOR_TREE_NODE] = function (snapshots, { id, node }) {
+  return snapshots.map(snapshot => {
+    if (snapshot.id !== id) {
+      return snapshot;
+    }
+
+    assert(snapshot.dominatorTree, "Should have a dominator tree");
+    assert(snapshot.dominatorTree.expanded,
+           "Should have the dominator tree's expanded set");
+
+    // Warning: mutable operations couched in an immutable update ahead :'( This
+    // at least lets us use referential equality on the dominatorTree model itself,
+    // even though the expanded set is mutated in place.
+    const expanded = snapshot.dominatorTree.expanded;
+    expanded.add(node.nodeId);
+    const dominatorTree = immutableUpdate(snapshot.dominatorTree, { expanded });
+    return immutableUpdate(snapshot, { dominatorTree });
+  });
+};
+
+handlers[actions.COLLAPSE_DOMINATOR_TREE_NODE] = function (snapshots, { id, node }) {
+  return snapshots.map(snapshot => {
+    if (snapshot.id !== id) {
+      return snapshot;
+    }
+
+    assert(snapshot.dominatorTree, "Should have a dominator tree");
+    assert(snapshot.dominatorTree.expanded,
+           "Should have the dominator tree's expanded set");
+
+    // Warning: mutable operations couched in an immutable update ahead :'( See
+    // above comment in the EXPAND_DOMINATOR_TREE_NODE handler.
+    const expanded = snapshot.dominatorTree.expanded;
+    expanded.delete(node.nodeId);
+    const dominatorTree = immutableUpdate(snapshot.dominatorTree, { expanded });
+    return immutableUpdate(snapshot, { dominatorTree });
+  });
+};
+
+handlers[actions.FOCUS_DOMINATOR_TREE_NODE] = function (snapshots, { id, node }) {
+  return snapshots.map(snapshot => {
+    if (snapshot.id !== id) {
+      return snapshot;
+    }
+
+    assert(snapshot.dominatorTree, "Should have a dominator tree");
+    const dominatorTree = immutableUpdate(snapshot.dominatorTree, { focused: node });
+    return immutableUpdate(snapshot, { dominatorTree });
+  });
+};
+
+handlers[actions.FETCH_IMMEDIATELY_DOMINATED_START] = function (snapshots, { id }) {
+  return snapshots.map(snapshot => {
+    if (snapshot.id !== id) {
+      return snapshot;
+    }
+
+    assert(snapshot.dominatorTree, "Should have a dominator tree model");
+    assert(snapshot.dominatorTree.state == dominatorTreeState.INCREMENTAL_FETCHING ||
+           snapshot.dominatorTree.state == dominatorTreeState.LOADED,
+           "The dominator tree should be loaded if we are going to " +
+           "incrementally fetch children.");
+
+    const activeFetchRequestCount = snapshot.dominatorTree.activeFetchRequestCount
+      ? snapshot.dominatorTree.activeFetchRequestCount + 1
+      : 1;
+
+    const dominatorTree = immutableUpdate(snapshot.dominatorTree, {
+      state: dominatorTreeState.INCREMENTAL_FETCHING,
+      activeFetchRequestCount,
+    });
+
+    return immutableUpdate(snapshot, { dominatorTree });
+  });
+};
+
+handlers[actions.FETCH_IMMEDIATELY_DOMINATED_END] =
+  function (snapshots, { id, path, nodes, moreChildrenAvailable}) {
+    return snapshots.map(snapshot => {
+      if (snapshot.id !== id) {
+        return snapshot;
+      }
+
+      assert(snapshot.dominatorTree, "Should have a dominator tree model");
+      assert(snapshot.dominatorTree.root, "Should have a dominator tree model root");
+      assert(snapshot.dominatorTree.state === dominatorTreeState.INCREMENTAL_FETCHING,
+             "The dominator tree state should be INCREMENTAL_FETCHING");
+
+      const root = DominatorTreeNode.insert(snapshot.dominatorTree.root,
+                                            path,
+                                            nodes,
+                                            moreChildrenAvailable);
+
+      const focused = snapshot.dominatorTree.focused
+        ? DominatorTreeNode.getNodeByIdAlongPath(snapshot.dominatorTree.focused.nodeId,
+                                                 root,
+                                                 path)
+        : undefined;
+
+      const activeFetchRequestCount = snapshot.dominatorTree.activeFetchRequestCount === 1
+        ? undefined
+        : snapshot.dominatorTree.activeFetchRequestCount - 1;
+
+      // If there are still outstanding requests, we need to stay in the
+      // INCREMENTAL_FETCHING state until they complete.
+      const state = activeFetchRequestCount
+        ? dominatorTreeState.INCREMENTAL_FETCHING
+        : dominatorTreeState.LOADED;
+
+      const dominatorTree = immutableUpdate(snapshot.dominatorTree, {
+        state,
+        root,
+        focused,
+        activeFetchRequestCount,
+      });
+
+      return immutableUpdate(snapshot, { dominatorTree });
+    });
+  };
+
+handlers[actions.DOMINATOR_TREE_ERROR] = function (snapshots, { id, error }) {
+  assert(error, "actions with DOMINATOR_TREE_ERROR should have an error");
+
+  return snapshots.map(snapshot => {
+    if (snapshot.id !== id) {
+      return snapshot;
+    }
+
+    const dominatorTree = Object.freeze({
+      state: dominatorTreeState.ERROR,
+      error,
+    });
+
+    return immutableUpdate(snapshot, { dominatorTree });
+  });
 };
 
 module.exports = function (snapshots = [], action) {
   const handler = handlers[action.type];
   if (handler) {
     return handler(snapshots, action);
   }
   return snapshots;
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/reducers/view.js
@@ -0,0 +1,17 @@
+/* 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, viewState } = require("../constants");
+
+const handlers = Object.create(null);
+
+handlers[actions.CHANGE_VIEW] = function (_, { view }) {
+  return view;
+};
+
+module.exports = function (view = viewState.CENSUS, action) {
+  const handler = handlers[action.type];
+  return handler ? handler(view, action) : view;
+};
--- a/devtools/client/memory/store.js
+++ b/devtools/client/memory/store.js
@@ -1,27 +1,32 @@
 /* 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/. */
 
 const { combineReducers } = require("../shared/vendor/redux");
 const createStore = require("../shared/redux/create-store");
 const reducers = require("./reducers");
+const { viewState } = require("./constants");
 const DevToolsUtils = require("devtools/shared/DevToolsUtils");
 
 module.exports = function () {
   let shouldLog = false;
   let history;
 
   // If testing, store the action history in an array
   // we'll later attach to the store
   if (DevToolsUtils.testing) {
     history = [];
+    shouldLog = true;
   }
 
-  let store = createStore({ log: shouldLog, history })(combineReducers(reducers), {});
+  let store = createStore({
+    log: shouldLog,
+    history
+  })(combineReducers(reducers), {});
 
   if (history) {
     store.history = history;
   }
 
   return store;
 };
--- a/devtools/client/memory/test/browser/browser.ini
+++ b/devtools/client/memory/test/browser/browser.ini
@@ -1,20 +1,22 @@
 [DEFAULT]
 tags = devtools devtools-memory
 subsuite = devtools
 support-files =
   head.js
+  doc_big_tree.html
   doc_steady_allocation.html
 
 [browser_memory_allocationStackBreakdown_01.js]
 [browser_memory-breakdowns-01.js]
     skip-if = debug # bug 1219554
 [browser_memory_diff_01.js]
     skip-if = debug # bug 1219554
+[browser_memory_dominator_trees_01.js]
 [browser_memory_filter_01.js]
     skip-if = debug # bug 1219554
 [browser_memory_no_allocation_stacks.js]
 [browser_memory_no_auto_expand.js]
     skip-if = debug # bug 1219554
 [browser_memory_percents_01.js]
 [browser_memory-simple-01.js]
     skip-if = debug # bug 1219554
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/browser/browser_memory_dominator_trees_01.js
@@ -0,0 +1,155 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Sanity test for dominator trees, their focused nodes, and keyboard navigating
+// through nodes across incrementally fetching subtrees.
+
+"use strict";
+
+const {
+  dominatorTreeState,
+  snapshotState,
+  viewState,
+} = require("devtools/client/memory/constants");
+const {
+  expandDominatorTreeNode,
+  takeSnapshotAndCensus,
+} = require("devtools/client/memory/actions/snapshot");
+const { toggleInverted } = require("devtools/client/memory/actions/inverted");
+const { changeView } = require("devtools/client/memory/actions/view");
+
+const TEST_URL = "http://example.com/browser/devtools/client/memory/test/browser/doc_big_tree.html";
+
+this.test = makeMemoryTest(TEST_URL, function* ({ tab, panel }) {
+  // Taking snapshots and computing dominator trees is slow :-/
+  requestLongerTimeout(4);
+
+  const heapWorker = panel.panelWin.gHeapAnalysesClient;
+  const front = panel.panelWin.gFront;
+  const store = panel.panelWin.gStore;
+  const { getState, dispatch } = store;
+  const doc = panel.panelWin.document;
+
+  dispatch(changeView(viewState.DOMINATOR_TREE));
+
+  // Take a snapshot.
+
+  const takeSnapshotButton = doc.getElementById("take-snapshot");
+  EventUtils.synthesizeMouseAtCenter(takeSnapshotButton, {}, panel.panelWin);
+
+  // Wait for the dominator tree to be computed and fetched.
+
+  yield waitUntilState(store, state =>
+    state.snapshots[0] &&
+    state.snapshots[0].dominatorTree &&
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+  ok(true, "Computed and fetched the dominator tree.");
+
+  // Expand all the dominator tree nodes that are eagerly fetched, except for
+  // the leaves which will trigger fetching their lazily loaded subtrees.
+
+  const id = getState().snapshots[0].id;
+  const root = getState().snapshots[0].dominatorTree.root;
+  (function expandAllEagerlyFetched(node = root) {
+    if (!node.moreChildrenAvailable || node.children) {
+      dispatch(expandDominatorTreeNode(id, node));
+    }
+
+    if (node.children) {
+      for (let child of node.children) {
+        expandAllEagerlyFetched(child);
+      }
+    }
+  }());
+
+  // Find the deepest eagerly loaded node: one which has more children but none
+  // of them are loaded.
+
+  const deepest = (function findDeepest(node = root) {
+    if (node.moreChildrenAvailable && !node.children) {
+      return node;
+    }
+
+    if (node.children) {
+      for (let child of node.children) {
+        const found = findDeepest(child);
+        if (found) {
+          return found;
+        }
+      }
+    }
+
+    return null;
+  }());
+
+  ok(deepest, "Found the deepest node");
+  ok(!getState().snapshots[0].dominatorTree.expanded.has(deepest.nodeId),
+     "The deepest node should not be expanded");
+
+  // Select the deepest node.
+
+  EventUtils.synthesizeMouseAtCenter(doc.querySelector(`.node-${deepest.nodeId}`),
+                                     {},
+                                     panel.panelWin);
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.focused.nodeId === deepest.nodeId);
+  ok(doc.querySelector(`.node-${deepest.nodeId}`).classList.contains("focused"),
+     "The deepest node should be focused now");
+
+  // Expand the deepest node, which triggers an incremental fetch of its lazily
+  // loaded subtree.
+
+  EventUtils.synthesizeKey("VK_RIGHT", {}, panel.panelWin);
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.expanded.has(deepest.nodeId));
+  is(getState().snapshots[0].dominatorTree.state,
+     dominatorTreeState.INCREMENTAL_FETCHING,
+     "Expanding the deepest node should start an incremental fetch of its subtree");
+  ok(doc.querySelector(`.node-${deepest.nodeId}`).classList.contains("focused"),
+     "The deepest node should still be focused after expansion");
+
+  // Wait for the incremental fetch to complete.
+
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+  ok(true, "And the incremental fetch completes.");
+  ok(doc.querySelector(`.node-${deepest.nodeId}`).classList.contains("focused"),
+     "The deepest node should still be focused after we have loaded its children");
+
+  // Find the most up-to-date version of the node whose children we just
+  // incrementally fetched.
+
+  const newDeepest = (function findNewDeepest(node = getState().snapshots[0].dominatorTree.root) {
+    if (node.nodeId === deepest.nodeId) {
+      return node;
+    }
+
+    if (node.children) {
+      for (let child of node.children) {
+        const found = findNewDeepest(child);
+        if (found) {
+          return found;
+        }
+      }
+    }
+
+    return null;
+  }());
+
+  ok(newDeepest, "We found the up-to-date version of deepest");
+  ok(newDeepest.children, "And its children are loaded");
+  ok(newDeepest.children.length, "And there are more than 0 children");
+
+  const firstChild = newDeepest.children[0];
+  ok(firstChild, "deepest should have a first child");
+  ok(doc.querySelector(`.node-${firstChild.nodeId}`),
+     "and the first child should exist in the dom");
+
+  // Select the newly loaded first child by pressing the right arrow once more.
+
+  EventUtils.synthesizeKey("VK_RIGHT", {}, panel.panelWin);
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.focused === firstChild);
+  ok(doc.querySelector(`.node-${firstChild.nodeId}`).classList.contains("focused"),
+     "The first child should now be focused");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/browser/doc_big_tree.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+    <body>
+        <script>
+         window.big = (function makeBig(depth = 0) {
+           var big = Array(5);
+           big.fill(undefined);
+           if (depth < 5) {
+             big = big.map(_ => makeBig(depth + 1));
+           }
+           return big;
+         }());
+        </script>
+    </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/chrome/chrome.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+support-files =
+  head.js
+
+[test_DominatorTree_01.html]
+[test_DominatorTree_02.html]
+[test_Heap_01.html]
+[test_Heap_02.html]
+[test_Heap_03.html]
+[test_Toolbar_01.html]
+[test_Toolbar_02.html]
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/chrome/head.js
@@ -0,0 +1,198 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+Cu.import("resource://testing-common/Assert.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+Cu.import("resource://devtools/client/shared/browser-loader.js");
+var { require } = BrowserLoader("resource://devtools/client/memory/", this);
+
+var DevToolsUtils = require("devtools/shared/DevToolsUtils");
+DevToolsUtils.testing = true;
+var { immutableUpdate } = DevToolsUtils;
+
+var constants = require("devtools/client/memory/constants");
+var {
+  diffingState,
+  dominatorTreeBreakdowns,
+  dominatorTreeState,
+  snapshotState,
+  viewState
+} = constants;
+
+const {
+  getBreakdownDisplayData,
+  getDominatorTreeBreakdownDisplayData,
+} = 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 DominatorTreeComponent = React.createFactory(require("devtools/client/memory/components/dominator-tree"));
+var Toolbar = React.createFactory(require("devtools/client/memory/components/toolbar"));
+
+// All tests are asynchronous.
+SimpleTest.waitForExplicitFinish();
+
+var noop = () => {};
+
+// Counter for mock DominatorTreeNode ids.
+var TEST_NODE_ID_COUNTER = 0;
+
+/**
+ * Create a mock DominatorTreeNode for testing, with sane defaults. Override any
+ * property by providing it on `opts`. Optionally pass child nodes as well.
+ *
+ * @param {Object} opts
+ * @param {Array<DominatorTreeNode>?} children
+ *
+ * @returns {DominatorTreeNode}
+ */
+function makeTestDominatorTreeNode(opts, children) {
+  const nodeId = TEST_NODE_ID_COUNTER++;
+
+  const node = Object.assign({
+    nodeId,
+    label: ["other", "SomeType"],
+    shallowSize: 1,
+    retainedSize: (children || []).reduce((size, c) => size + c.retainedSize, 1),
+    parentId: undefined,
+    children,
+    moreChildrenAvailable: true,
+  }, opts);
+
+  if (children && children.length) {
+    children.map(c => c.parentId = node.nodeId);
+  }
+
+  return node;
+}
+
+var TEST_DOMINATOR_TREE = Object.freeze({
+  dominatorTreeId: 666,
+  root: (function makeTree(depth = 0) {
+    let children;
+    if (depth <= 3) {
+      children = [
+        makeTree(depth + 1),
+        makeTree(depth + 1),
+        makeTree(depth + 1),
+      ];
+    }
+    return makeTestDominatorTreeNode({}, children);
+  }()),
+  expanded: new Set(),
+  focused: null,
+  error: null,
+  breakdown: dominatorTreeBreakdowns.coarseType.breakdown,
+  activeFetchRequestCount: null,
+  state: dominatorTreeState.LOADED,
+});
+
+var TEST_DOMINATOR_TREE_PROPS = Object.freeze({
+  dominatorTree: TEST_DOMINATOR_TREE,
+  onLoadMoreSiblings: noop,
+  onViewSourceInDebugger: noop,
+  onExpand: noop,
+  onCollapse: noop,
+});
+
+var TEST_HEAP_PROPS = Object.freeze({
+  onSnapshotClick: noop,
+  onLoadMoreSiblings: noop,
+  onCensusExpand: noop,
+  onCensusCollapse: noop,
+  onDominatorTreeExpand: noop,
+  onDominatorTreeCollapse: noop,
+  onCensusFocus: noop,
+  onDominatorTreeFocus: noop,
+  onViewSourceInDebugger: noop,
+  diffing: null,
+  view: viewState.CENSUS,
+  snapshot: Object.freeze({
+    id: 1337,
+    selected: true,
+    path: "/fake/path/to/snapshot",
+    census: Object.freeze({
+      report: Object.freeze({
+        objects: Object.freeze({ count: 4, bytes: 400 }),
+        scripts: Object.freeze({ count: 3, bytes: 300 }),
+        strings: Object.freeze({ count: 2, bytes: 200 }),
+        other: Object.freeze({ count: 1, bytes: 100 }),
+      }),
+      breakdown: Object.freeze({
+        by: "coarseType",
+        objects: Object.freeze({ by: "count", count: true, bytes: true }),
+        scripts: Object.freeze({ by: "count", count: true, bytes: true }),
+        strings: Object.freeze({ by: "count", count: true, bytes: true }),
+        other: Object.freeze({ by: "count", count: true, bytes: true }),
+      }),
+      inverted: false,
+      filter: null,
+      expanded: new Set(),
+      focused: null,
+    }),
+    dominatorTree: TEST_DOMINATOR_TREE,
+    error: null,
+    imported: false,
+    creationTime: 0,
+    state: snapshotState.SAVED_CENSUS,
+  }),
+});
+
+var TEST_TOOLBAR_PROPS = Object.freeze({
+  breakdowns: getBreakdownDisplayData(),
+  onTakeSnapshotClick: noop,
+  onImportClick: noop,
+  onBreakdownChange: noop,
+  onToggleRecordAllocationStacks: noop,
+  allocations: models.allocations,
+  onToggleInverted: noop,
+  inverted: false,
+  filterString: null,
+  setFilterString: noop,
+  diffing: null,
+  onToggleDiffing: noop,
+  view: viewState.CENSUS,
+  onViewChange: noop,
+  dominatorTreeBreakdowns: getDominatorTreeBreakdownDisplayData(),
+  onDominatorTreeBreakdownChange: noop,
+  snapshots: [],
+});
+
+function onNextAnimationFrame(fn) {
+  return () =>
+    requestAnimationFrame(() =>
+      requestAnimationFrame(fn));
+}
+
+function renderComponent(component, container) {
+  return new Promise(resolve => {
+    ReactDOM.render(component, container, onNextAnimationFrame(() => {
+      dumpn("Rendered = " + container.innerHTML);
+      resolve();
+    }));
+  });
+}
+
+function setState(component, newState) {
+  return new Promise(resolve => {
+    component.setState(newState, onNextAnimationFrame(resolve));
+  });
+}
+
+function setProps(component, newProps) {
+  return new Promise(resolve => {
+    component.setProps(newProps, onNextAnimationFrame(resolve));
+  });
+}
+
+function dumpn(msg) {
+  dump(`MEMORY-TEST: ${msg}\n`);
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/chrome/test_DominatorTree_01.html
@@ -0,0 +1,50 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that we show a place holder for a subtree we are lazily fetching.
+-->
+<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">
+</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");
+
+             const root = makeTestDominatorTreeNode({ moreChildrenAvailable: true});
+             ok(!root.children);
+
+             const expanded = new Set();
+             expanded.add(root.nodeId);
+
+             yield renderComponent(DominatorTreeComponent(immutableUpdate(TEST_DOMINATOR_TREE_PROPS, {
+               dominatorTree: immutableUpdate(TEST_DOMINATOR_TREE_PROPS.dominatorTree, {
+                 expanded,
+                 root,
+                 state: dominatorTreeState.INCREMENTAL_FETCHING,
+                 activeFetchRequestCount: 1,
+               }),
+             })), container);
+
+             ok(container.querySelector(".subtree-fetching"),
+                "Expanded nodes with more children available, but no children " +
+                "loaded, should get a placeholder");
+           } 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_DominatorTree_02.html
@@ -0,0 +1,50 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that we show a link to load more children when some (but not all) are loaded.
+-->
+<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">
+</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");
+
+             const root = makeTestDominatorTreeNode({ moreChildrenAvailable: true }, [
+               makeTestDominatorTreeNode({}),
+             ]);
+             ok(root.children);
+             ok(root.moreChildrenAvailable);
+
+             const expanded = new Set();
+             expanded.add(root.nodeId);
+
+             yield renderComponent(DominatorTreeComponent(immutableUpdate(TEST_DOMINATOR_TREE_PROPS, {
+               dominatorTree: immutableUpdate(TEST_DOMINATOR_TREE_PROPS.dominatorTree, {
+                 expanded,
+                 root,
+               }),
+             })), container);
+
+             ok(container.querySelector(".more-children"),
+                "Should get a link to load more children");
+           } 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_Heap_01.html
@@ -0,0 +1,49 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that rendering a dominator tree error is handled correctly.
+-->
+<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">
+</head>
+<body>
+    <div id="container"></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 {
+             ok(React, "Should get React");
+             ok(Heap, "Should get Heap");
+
+             const errorMessage = "Something went wrong!";
+             const container = document.getElementById("container");
+
+             const props = immutableUpdate(TEST_HEAP_PROPS, {
+               view: viewState.DOMINATOR_TREE,
+               snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, {
+                 dominatorTree: {
+                   error: new Error(errorMessage),
+                   state: dominatorTreeState.ERROR,
+                 }
+               })
+             });
+
+             yield renderComponent(Heap(props), container);
+
+             ok(container.querySelector(".error"), "Should render an error view");
+             ok(container.textContent.indexOf(errorMessage) !== -1,
+                "Should see our error message");
+           } 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_Heap_02.html
@@ -0,0 +1,78 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that the currently selected view is rendered.
+-->
+<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">
+</head>
+<body>
+    <div id="container"></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 {
+             ok(React, "Should get React");
+             ok(Heap, "Should get Heap");
+
+             const errorMessage = "Something went wrong!";
+             const container = document.getElementById("container");
+
+             // Dominator tree view.
+
+             yield renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, {
+               view: viewState.DOMINATOR_TREE,
+             })), container);
+
+             ok(container.querySelector(`[data-state=${dominatorTreeState.LOADED}]`),
+                "Should render the dominator tree.");
+
+             // Census view.
+
+             yield renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, {
+               view: viewState.CENSUS,
+             })), container);
+
+             ok(container.querySelector(`[data-state=${snapshotState.SAVED_CENSUS}]`),
+                "Should render the census.");
+
+             // Diffing view.
+
+             yield renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, {
+               view: viewState.DIFFING,
+               snapshot: null,
+               diffing: {
+                 firstSnapshotId: null,
+                 secondSnapshotId: null,
+                 census: null,
+                 error: null,
+                 state: diffingState.SELECTING,
+               },
+             })), container);
+
+             ok(container.querySelector(`[data-state=${diffingState.SELECTING}]`),
+                "Should render the diffing.");
+
+             // Initial view.
+
+             yield renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, {
+               snapshot: null,
+               diffing: null,
+             })), container);
+
+             ok(container.querySelector("[data-state=initial]"),
+                "With no snapshot, nor a diffing, should render initial prompt.");
+           } 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_Heap_03.html
@@ -0,0 +1,74 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that we show a throbber while computing and fetching dominator trees,
+but not in other dominator tree states.
+-->
+<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">
+</head>
+<body>
+    <div id="container"></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");
+
+             for (let state of [dominatorTreeState.COMPUTING, dominatorTreeState.FETCHING]) {
+               yield renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, {
+                 view: viewState.DOMINATOR_TREE,
+                 snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, {
+                   dominatorTree: immutableUpdate(TEST_HEAP_PROPS.snapshot.dominatorTree, {
+                     state,
+                     root: null,
+                     dominatorTreeId: state === dominatorTreeState.FETCHING ? 1 : null,
+                   }),
+                 }),
+               })), container);
+
+               ok(container.querySelector(".devtools-throbber"),
+                  `Should show a throbber for state = ${state}`);
+             }
+
+             for (let state of [dominatorTreeState.LOADED, dominatorTreeState.INCREMENTAL_FETCHING]) {
+               yield renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, {
+                 view: viewState.DOMINATOR_TREE,
+                 snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, {
+                   dominatorTree: immutableUpdate(TEST_HEAP_PROPS.snapshot.dominatorTree, {
+                     state,
+                     activeFetchRequestCount: state === dominatorTreeState.INCREMENTAL_FETCHING ? 1 : undefined,
+                   }),
+                 }),
+               })), container);
+
+               ok(!container.querySelector(".devtools-throbber"),
+                  `Should not show a throbber for state = ${state}`);
+             }
+
+             yield renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, {
+               view: viewState.DOMINATOR_TREE,
+               snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, {
+                 dominatorTree: {
+                   state: dominatorTreeState.ERROR,
+                   error: new Error("example error for testing"),
+                 },
+               }),
+             })), container);
+
+             ok(!container.querySelector(".devtools-throbber"),
+                `Should not show a throbber for ERROR state`);
+           } 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_Toolbar_01.html
@@ -0,0 +1,47 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that the Toolbar component shows the view switcher only at the appropriate times.
+-->
+<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">
+</head>
+<body>
+    <div id="container"></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");
+
+             // Census and dominator tree views.
+
+             for (let view of [viewState.CENSUS, viewState.DOMINATOR_TREE]) {
+               yield renderComponent(Toolbar(immutableUpdate(TEST_TOOLBAR_PROPS, {
+                 view,
+               })), container);
+
+               ok(container.querySelector("#select-view"),
+                  `The view selector is shown in view = ${view}`);
+             }
+
+             yield renderComponent(Toolbar(immutableUpdate(TEST_TOOLBAR_PROPS, {
+               view: viewState.DIFFING,
+             })), container);
+
+             ok(!container.querySelector("#select-view"),
+                "The view selector is NOT shown in the DIFFING view");
+           } 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_Toolbar_02.html
@@ -0,0 +1,47 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that the Toolbar component shows the tree inversion checkbox only at the appropriate times.
+-->
+<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">
+</head>
+<body>
+    <div id="container"></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");
+
+             // Census and dominator tree views.
+
+             for (let view of [viewState.CENSUS, viewState.DIFFING]) {
+               yield renderComponent(Toolbar(immutableUpdate(TEST_TOOLBAR_PROPS, {
+                 view,
+               })), container);
+
+               ok(container.querySelector("#invert-tree-checkbox"),
+                  `The invert checkbox is shown in view = ${view}`);
+             }
+
+             yield renderComponent(Toolbar(immutableUpdate(TEST_TOOLBAR_PROPS, {
+               view: viewState.DOMINATOR_TREE,
+             })), container);
+
+             ok(!container.querySelector("#invert-tree-checkbox"),
+                "The invert checkbox is NOT shown in the DOMINATOR_TREE view");
+           } catch(e) {
+             ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e));
+           } finally {
+             SimpleTest.finish();
+           }
+         });
+        </script>
+    </pre>
+</body>
+</html>
--- a/devtools/client/memory/test/unit/head.js
+++ b/devtools/client/memory/test/unit/head.js
@@ -6,16 +6,18 @@
 var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 var { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
 var { gDevTools } = Cu.import("resource://devtools/client/framework/gDevTools.jsm", {});
 var { console } = Cu.import("resource://gre/modules/Console.jsm", {});
 var { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
 
 var DevToolsUtils = require("devtools/shared/DevToolsUtils");
 DevToolsUtils.testing = true;
+DevToolsUtils.dumpn.wantLogging = true;
+DevToolsUtils.dumpv.wantLogging = true;
 
 var { OS } = require("resource://gre/modules/osfile.jsm");
 var { FileUtils } = require("resource://gre/modules/FileUtils.jsm");
 var { TargetFactory } = require("devtools/client/framework/target");
 var promise = require("promise");
 var { Task } = Cu.import("resource://gre/modules/Task.jsm", {});
 var { expectState } = require("devtools/server/actors/common");
 var HeapSnapshotFileUtils = require("devtools/shared/heapsnapshot/HeapSnapshotFileUtils");
--- a/devtools/client/memory/test/unit/test_action_diffing_05.js
+++ b/devtools/client/memory/test/unit/test_action_diffing_05.js
@@ -45,17 +45,17 @@ add_task(function *() {
   const s3 = yield dispatch(takeSnapshot(front, heapWorker));
   dispatch(readSnapshot(heapWorker, s1));
   dispatch(readSnapshot(heapWorker, s2));
   dispatch(readSnapshot(heapWorker, s3));
   yield waitUntilSnapshotState(store, [snapshotState.READ,
                                        snapshotState.READ,
                                        snapshotState.READ]);
 
-  dispatch(toggleDiffing());
+  yield dispatch(toggleDiffing());
   dispatch(selectSnapshotForDiffingAndRefresh(heapWorker,
                                               getState().snapshots[0]));
   dispatch(selectSnapshotForDiffingAndRefresh(heapWorker,
                                               getState().snapshots[1]));
   yield waitUntilState(store,
                        state => state.diffing.state === diffingState.TOOK_DIFF);
 
   const shouldTriggerRecompute = [
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/unit/test_dominator_trees_01.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that we can compute and fetch the dominator tree for a snapshot.
+
+let {
+  snapshotState: states,
+  dominatorTreeState,
+} = require("devtools/client/memory/constants");
+let {
+  takeSnapshotAndCensus,
+  computeAndFetchDominatorTree,
+} = require("devtools/client/memory/actions/snapshot");
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function *() {
+  let front = new StubbedMemoryFront();
+  let heapWorker = new HeapAnalysesClient();
+  yield front.attach();
+  let store = Store();
+  let { getState, dispatch } = store;
+
+  dispatch(takeSnapshotAndCensus(front, heapWorker));
+  yield waitUntilSnapshotState(store, [states.SAVED_CENSUS]);
+  ok(!getState().snapshots[0].dominatorTree,
+     "There shouldn't be a dominator tree model yet since it is not computed " +
+     "until we switch to the dominators view.");
+
+  // Change to the dominator tree view.
+  dispatch(computeAndFetchDominatorTree(heapWorker, getState().snapshots[0].id));
+  ok(getState().snapshots[0].dominatorTree,
+     "Should now have a dominator tree model for the selected snapshot");
+
+  // Wait for the dominator tree to start being computed.
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.COMPUTING);
+  ok(true, "The dominator tree started computing");
+  ok(!getState().snapshots[0].dominatorTree.root,
+     "When the dominator tree is computing, we should not have its root");
+
+  // Wait for the dominator tree to finish computing and start being fetched.
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.FETCHING);
+  ok(true, "The dominator tree started fetching");
+  ok(!getState().snapshots[0].dominatorTree.root,
+     "When the dominator tree is fetching, we should not have its root");
+
+  // Wait for the dominator tree to finish being fetched.
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+  ok(true, "The dominator tree was fetched");
+  ok(getState().snapshots[0].dominatorTree.root,
+     "When the dominator tree is loaded, we should have its root");
+
+  heapWorker.destroy();
+  yield front.detach();
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/unit/test_dominator_trees_02.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that selecting the dominator tree view automatically kicks off fetching
+// and computing dominator trees.
+
+const {
+  snapshotState: states,
+  dominatorTreeState,
+  viewState,
+} = require("devtools/client/memory/constants");
+const {
+  takeSnapshotAndCensus,
+} = require("devtools/client/memory/actions/snapshot");
+const {
+  changeViewAndRefresh
+} = require("devtools/client/memory/actions/view");
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function *() {
+  let front = new StubbedMemoryFront();
+  let heapWorker = new HeapAnalysesClient();
+  yield front.attach();
+  let store = Store();
+  let { getState, dispatch } = store;
+
+  dispatch(takeSnapshotAndCensus(front, heapWorker));
+  yield waitUntilSnapshotState(store, [states.SAVED_CENSUS]);
+  ok(!getState().snapshots[0].dominatorTree,
+     "There shouldn't be a dominator tree model yet since it is not computed " +
+     "until we switch to the dominators view.");
+
+  dispatch(changeViewAndRefresh(viewState.DOMINATOR_TREE, heapWorker));
+  ok(getState().snapshots[0].dominatorTree,
+     "Should now have a dominator tree model for the selected snapshot");
+
+  // Wait for the dominator tree to start being computed.
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.COMPUTING);
+  ok(true, "The dominator tree started computing");
+  ok(!getState().snapshots[0].dominatorTree.root,
+     "When the dominator tree is computing, we should not have its root");
+
+  // Wait for the dominator tree to finish computing and start being fetched.
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.FETCHING);
+  ok(true, "The dominator tree started fetching");
+  ok(!getState().snapshots[0].dominatorTree.root,
+     "When the dominator tree is fetching, we should not have its root");
+
+  // Wait for the dominator tree to finish being fetched.
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+  ok(true, "The dominator tree was fetched");
+  ok(getState().snapshots[0].dominatorTree.root,
+     "When the dominator tree is loaded, we should have its root");
+
+  heapWorker.destroy();
+  yield front.detach();
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/unit/test_dominator_trees_03.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that selecting the dominator tree view and then taking a snapshot
+// properly kicks off fetching and computing dominator trees.
+
+const {
+  snapshotState: states,
+  dominatorTreeState,
+  viewState,
+} = require("devtools/client/memory/constants");
+const {
+  takeSnapshotAndCensus,
+} = require("devtools/client/memory/actions/snapshot");
+const {
+  changeView
+} = require("devtools/client/memory/actions/view");
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function *() {
+  let front = new StubbedMemoryFront();
+  let heapWorker = new HeapAnalysesClient();
+  yield front.attach();
+  let store = Store();
+  let { getState, dispatch } = store;
+
+  dispatch(changeView(viewState.DOMINATOR_TREE));
+  equal(getState().view, viewState.DOMINATOR_TREE,
+        "We should now be in the DOMINATOR_TREE view");
+
+  dispatch(takeSnapshotAndCensus(front, heapWorker));
+
+  // Wait for the dominator tree to start being computed.
+  yield waitUntilState(store, state =>
+    state.snapshots[0] && state.snapshots[0].dominatorTree);
+  equal(getState().snapshots[0].dominatorTree.state,
+        dominatorTreeState.COMPUTING,
+        "The dominator tree started computing");
+  ok(!getState().snapshots[0].dominatorTree.root,
+     "When the dominator tree is computing, we should not have its root");
+
+  // Wait for the dominator tree to finish computing and start being fetched.
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.FETCHING);
+  ok(true, "The dominator tree started fetching");
+  ok(!getState().snapshots[0].dominatorTree.root,
+     "When the dominator tree is fetching, we should not have its root");
+
+  // Wait for the dominator tree to finish being fetched.
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+  ok(true, "The dominator tree was fetched");
+  ok(getState().snapshots[0].dominatorTree.root,
+     "When the dominator tree is loaded, we should have its root");
+
+  heapWorker.destroy();
+  yield front.detach();
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/unit/test_dominator_trees_04.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that selecting the dominator tree view while in the middle of taking a
+// snapshot properly kicks off fetching and computing dominator trees.
+
+const {
+  snapshotState: states,
+  dominatorTreeState,
+  viewState,
+} = require("devtools/client/memory/constants");
+const {
+  takeSnapshotAndCensus,
+} = require("devtools/client/memory/actions/snapshot");
+const {
+  changeView
+} = require("devtools/client/memory/actions/view");
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function *() {
+  let front = new StubbedMemoryFront();
+  let heapWorker = new HeapAnalysesClient();
+  yield front.attach();
+
+  for (let intermediateSnapshotState of [states.SAVING,
+                                         states.READING,
+                                         states.SAVING_CENSUS]) {
+    dumpn(`Testing switching to the DOMINATOR_TREE view in the middle of the ${intermediateSnapshotState} snapshot state`);
+
+    let store = Store();
+    let { getState, dispatch } = store;
+
+    dispatch(takeSnapshotAndCensus(front, heapWorker));
+    yield waitUntilSnapshotState(store, [intermediateSnapshotState]);
+
+    dispatch(changeView(viewState.DOMINATOR_TREE));
+    equal(getState().view, viewState.DOMINATOR_TREE,
+          "We should now be in the DOMINATOR_TREE view");
+
+    // Wait for the dominator tree to start being computed.
+    yield waitUntilState(store, state =>
+      state.snapshots[0] && state.snapshots[0].dominatorTree);
+    equal(getState().snapshots[0].dominatorTree.state,
+          dominatorTreeState.COMPUTING,
+          "The dominator tree started computing");
+    ok(!getState().snapshots[0].dominatorTree.root,
+       "When the dominator tree is computing, we should not have its root");
+
+    // Wait for the dominator tree to finish computing and start being fetched.
+    yield waitUntilState(store, state =>
+      state.snapshots[0].dominatorTree.state === dominatorTreeState.FETCHING);
+    ok(true, "The dominator tree started fetching");
+    ok(!getState().snapshots[0].dominatorTree.root,
+       "When the dominator tree is fetching, we should not have its root");
+
+    // Wait for the dominator tree to finish being fetched.
+    yield waitUntilState(store, state =>
+      state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+    ok(true, "The dominator tree was fetched");
+    ok(getState().snapshots[0].dominatorTree.root,
+       "When the dominator tree is loaded, we should have its root");
+  }
+
+  heapWorker.destroy();
+  yield front.detach();
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/unit/test_dominator_trees_05.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that changing the currently selected snapshot to a snapshot that does
+// not have a dominator tree will automatically compute and fetch one for it.
+
+let {
+  snapshotState: states,
+  dominatorTreeState,
+  viewState,
+} = require("devtools/client/memory/constants");
+let {
+  takeSnapshotAndCensus,
+  selectSnapshotAndRefresh,
+} = require("devtools/client/memory/actions/snapshot");
+
+let { changeView } = require("devtools/client/memory/actions/view");
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function *() {
+  let front = new StubbedMemoryFront();
+  let heapWorker = new HeapAnalysesClient();
+  yield front.attach();
+  let store = Store();
+  let { getState, dispatch } = store;
+
+  dispatch(takeSnapshotAndCensus(front, heapWorker));
+  dispatch(takeSnapshotAndCensus(front, heapWorker));
+  yield waitUntilSnapshotState(store, [states.SAVED_CENSUS,
+                                       states.SAVED_CENSUS]);
+
+  ok(getState().snapshots[1].selected, "The second snapshot is selected");
+
+  // Change to the dominator tree view.
+  dispatch(changeView(viewState.DOMINATOR_TREE));
+
+  // Wait for the dominator tree to finish being fetched.
+  yield waitUntilState(store, state =>
+    state.snapshots[1].dominatorTree &&
+    state.snapshots[1].dominatorTree.state === dominatorTreeState.LOADED);
+  ok(true, "The second snapshot's dominator tree was fetched");
+
+  // Select the first snapshot.
+  dispatch(selectSnapshotAndRefresh(heapWorker, getState().snapshots[0].id));
+
+  // And now the first snapshot should have its dominator tree fetched and
+  // computed because of the new selection.
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree &&
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+  ok(true, "The first snapshot's dominator tree was fetched");
+
+  heapWorker.destroy();
+  yield front.detach();
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/unit/test_dominator_trees_06.js
@@ -0,0 +1,127 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that we can incrementally fetch a subtree of a dominator tree.
+
+const {
+  snapshotState: states,
+  dominatorTreeState,
+  viewState,
+} = require("devtools/client/memory/constants");
+const {
+  takeSnapshotAndCensus,
+  selectSnapshotAndRefresh,
+  fetchImmediatelyDominated,
+} = require("devtools/client/memory/actions/snapshot");
+const DominatorTreeLazyChildren
+  = require("devtools/client/memory/dominator-tree-lazy-children");
+
+const { changeView } = require("devtools/client/memory/actions/view");
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function *() {
+  let front = new StubbedMemoryFront();
+  let heapWorker = new HeapAnalysesClient();
+  yield front.attach();
+  let store = Store();
+  let { getState, dispatch } = store;
+
+  dispatch(changeView(viewState.DOMINATOR_TREE));
+  dispatch(takeSnapshotAndCensus(front, heapWorker));
+
+  // Wait for the dominator tree to finish being fetched.
+  yield waitUntilState(store, state =>
+    state.snapshots[0] &&
+    state.snapshots[0].dominatorTree &&
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+  ok(getState().snapshots[0].dominatorTree.root,
+     "The dominator tree was fetched");
+
+  // Find a node that has children, but none of them are loaded.
+
+  function findNode(node) {
+    if (node.moreChildrenAvailable && !node.children) {
+      return node;
+    }
+
+    if (node.children) {
+      for (let child of node.children) {
+        const found = findNode(child);
+        if (found) {
+          return found;
+        }
+      }
+    }
+
+    return null;
+  }
+
+  const oldRoot = getState().snapshots[0].dominatorTree.root;
+  const oldNode = findNode(oldRoot);
+  ok(oldNode,
+     "Should have found a node with children that are not loaded since we " +
+     "only send partial dominator trees across initially and load the rest " +
+     "on demand");
+  ok(oldNode !== oldRoot, "But the node should not be the root");
+
+  const lazyChildren = new DominatorTreeLazyChildren(oldNode.nodeId, 0);
+  dispatch(fetchImmediatelyDominated(heapWorker, getState().snapshots[0].id, lazyChildren));
+
+  equal(getState().snapshots[0].dominatorTree.state,
+        dominatorTreeState.INCREMENTAL_FETCHING,
+        "Fetching immediately dominated children should put us in the " +
+        "INCREMENTAL_FETCHING state");
+
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+  ok(true,
+     "The dominator tree should go back to LOADED after the incremental " +
+     "fetching is done.");
+
+  const newRoot = getState().snapshots[0].dominatorTree.root;
+  ok(oldRoot !== newRoot,
+     "When we insert new nodes, we get a new tree");
+  equal(oldRoot.children.length, newRoot.children.length,
+        "The new tree's root should have the same number of children as the " +
+        "old root's");
+
+  let differentChildrenCount = 0;
+  for (let i = 0; i < oldRoot.children.length; i++) {
+    if (oldRoot.children[i] !== newRoot.children[i]) {
+      differentChildrenCount++;
+    }
+  }
+  equal(differentChildrenCount, 1,
+        "All subtrees except the subtree we inserted incrementally fetched " +
+        "children into should be the same because we use persistent updates");
+
+  // Find the new node which has the children inserted.
+
+  function findNewNode(node) {
+    if (node.nodeId === oldNode.nodeId) {
+      return node;
+    }
+
+    if (node.children) {
+      for (let child of node.children) {
+        const found = findNewNode(child);
+        if (found) {
+          return found;
+        }
+      }
+    }
+
+    return null;
+  }
+
+  const newNode = findNewNode(newRoot);
+  ok(newNode, "Should find the node in the new tree again");
+  ok(newNode !== oldNode, "We did not mutate the old node in place, instead created a new node");
+  ok(newNode.children, "And the new node should have the children attached");
+
+  heapWorker.destroy();
+  yield front.detach();
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/unit/test_dominator_trees_07.js
@@ -0,0 +1,146 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that we can incrementally fetch two subtrees in the same dominator tree
+// concurrently. This exercises the activeFetchRequestCount machinery.
+
+const {
+  snapshotState: states,
+  dominatorTreeState,
+  viewState,
+} = require("devtools/client/memory/constants");
+const {
+  takeSnapshotAndCensus,
+  selectSnapshotAndRefresh,
+  fetchImmediatelyDominated,
+} = require("devtools/client/memory/actions/snapshot");
+const DominatorTreeLazyChildren
+  = require("devtools/client/memory/dominator-tree-lazy-children");
+
+const { changeView } = require("devtools/client/memory/actions/view");
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function *() {
+  let front = new StubbedMemoryFront();
+  let heapWorker = new HeapAnalysesClient();
+  yield front.attach();
+  let store = Store();
+  let { getState, dispatch } = store;
+
+  dispatch(changeView(viewState.DOMINATOR_TREE));
+  dispatch(takeSnapshotAndCensus(front, heapWorker));
+
+  // Wait for the dominator tree to finish being fetched.
+  yield waitUntilState(store, state =>
+    state.snapshots[0] &&
+    state.snapshots[0].dominatorTree &&
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+  ok(getState().snapshots[0].dominatorTree.root,
+     "The dominator tree was fetched");
+
+  // Find a node that has more children.
+
+  function findNode(node) {
+    if (node.moreChildrenAvailable && !node.children) {
+      return node;
+    }
+
+    if (node.children) {
+      for (let child of node.children) {
+        const found = findNode(child);
+        if (found) {
+          return found;
+        }
+      }
+    }
+
+    return null;
+  }
+
+  const oldRoot = getState().snapshots[0].dominatorTree.root;
+  const oldNode = findNode(oldRoot);
+  ok(oldNode, "Should have found a node with more children.");
+
+  // Find another node that has more children.
+  function findNodeRev(node) {
+    if (node.moreChildrenAvailable && !node.children) {
+      return node;
+    }
+
+    if (node.children) {
+      for (let child of node.children.slice().reverse()) {
+        const found = findNodeRev(child);
+        if (found) {
+          return found;
+        }
+      }
+    }
+
+    return null;
+  }
+
+  const oldNode2 = findNodeRev(oldRoot);
+  ok(oldNode2, "Should have found another node with more children.");
+  ok(oldNode !== oldNode2,
+     "The second node should not be the same as the first one");
+
+  // Fetch both subtrees concurrently.
+  dispatch(fetchImmediatelyDominated(heapWorker, getState().snapshots[0].id,
+                                     new DominatorTreeLazyChildren(oldNode.nodeId, 0)));
+  dispatch(fetchImmediatelyDominated(heapWorker, getState().snapshots[0].id,
+                                     new DominatorTreeLazyChildren(oldNode2.nodeId, 0)));
+
+  equal(getState().snapshots[0].dominatorTree.state,
+        dominatorTreeState.INCREMENTAL_FETCHING,
+        "Fetching immediately dominated children should put us in the " +
+        "INCREMENTAL_FETCHING state");
+
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+  ok(true,
+     "The dominator tree should go back to LOADED after the incremental " +
+     "fetching is done.");
+
+  const newRoot = getState().snapshots[0].dominatorTree.root;
+  ok(oldRoot !== newRoot,
+     "When we insert new nodes, we get a new tree");
+
+  // Find the new node which has the children inserted.
+
+  function findNodeWithId(id, node) {
+    if (node.nodeId === id) {
+      return node;
+    }
+
+    if (node.children) {
+      for (let child of node.children) {
+        const found = findNodeWithId(id, child);
+        if (found) {
+          return found;
+        }
+      }
+    }
+
+    return null;
+  }
+
+  const newNode = findNodeWithId(oldNode.nodeId, newRoot);
+  ok(newNode, "Should find the node in the new tree again");
+  ok(newNode !== oldNode,
+     "We did not mutate the old node in place, instead created a new node");
+  ok(newNode.children.length,
+     "And the new node should have the new children attached");
+
+  const newNode2 = findNodeWithId(oldNode2.nodeId, newRoot);
+  ok(newNode2, "Should find the second node in the new tree again");
+  ok(newNode2 !== oldNode2,
+     "We did not mutate the second old node in place, instead created a new node");
+  ok(newNode2.children,
+     "And the new node should have the new children attached");
+
+  heapWorker.destroy();
+  yield front.detach();
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/unit/test_dominator_trees_08.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that we can change the breakdown with which we describe a dominator tree
+// and that the dominator tree is re-fetched.
+
+const {
+  snapshotState: states,
+  dominatorTreeState,
+  viewState,
+  dominatorTreeBreakdowns,
+} = require("devtools/client/memory/constants");
+const {
+  setDominatorTreeBreakdownAndRefresh
+} = require("devtools/client/memory/actions/dominatorTreeBreakdown");
+const {
+  changeView,
+} = require("devtools/client/memory/actions/view");
+const {
+  takeSnapshotAndCensus,
+  computeAndFetchDominatorTree,
+} = require("devtools/client/memory/actions/snapshot");
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function *() {
+  let front = new StubbedMemoryFront();
+  let heapWorker = new HeapAnalysesClient();
+  yield front.attach();
+  let store = Store();
+  let { getState, dispatch } = store;
+
+  dispatch(changeView(viewState.DOMINATOR_TREE));
+
+  dispatch(takeSnapshotAndCensus(front, heapWorker));
+  yield waitUntilSnapshotState(store, [states.SAVED_CENSUS]);
+  ok(!getState().snapshots[0].dominatorTree,
+     "There shouldn't be a dominator tree model yet since it is not computed " +
+     "until we switch to the dominators view.");
+
+  // Wait for the dominator tree to finish being fetched.
+  yield waitUntilState(store, state =>
+    state.snapshots[0] &&
+    state.snapshots[0].dominatorTree &&
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+
+  ok(getState().dominatorTreeBreakdown,
+     "We have a default breakdown for describing nodes in a dominator tree");
+  equal(getState().dominatorTreeBreakdown,
+        dominatorTreeBreakdowns.coarseType.breakdown,
+        "and the default is coarse type");
+  equal(getState().dominatorTreeBreakdown,
+        getState().snapshots[0].dominatorTree.breakdown,
+        "and the newly computed dominator tree has that breakdown");
+
+  // Switch to the internalType breakdown.
+  dispatch(setDominatorTreeBreakdownAndRefresh(
+    heapWorker,
+    dominatorTreeBreakdowns.internalType.breakdown));
+
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.FETCHING);
+  ok(true,
+     "switching breakdown types caused the dominator tree to be fetched " +
+     "again.");
+
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+  equal(getState().snapshots[0].dominatorTree.breakdown,
+        dominatorTreeBreakdowns.internalType.breakdown,
+        "The new dominator tree's breakdown is internalType");
+  equal(getState().dominatorTreeBreakdown,
+        dominatorTreeBreakdowns.internalType.breakdown,
+        "as is our requested dominator tree breakdown");
+
+  heapWorker.destroy();
+  yield front.detach();
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/memory/test/unit/test_dominator_trees_09.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that we can change the breakdown with which we describe a dominator tree
+// while the dominator tree is in the middle of being fetched.
+
+const {
+  snapshotState: states,
+  dominatorTreeState,
+  viewState,
+  dominatorTreeBreakdowns,
+} = require("devtools/client/memory/constants");
+const {
+  setDominatorTreeBreakdownAndRefresh
+} = require("devtools/client/memory/actions/dominatorTreeBreakdown");
+const {
+  changeView,
+} = require("devtools/client/memory/actions/view");
+const {
+  takeSnapshotAndCensus,
+  computeAndFetchDominatorTree,
+} = require("devtools/client/memory/actions/snapshot");
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function *() {
+  let front = new StubbedMemoryFront();
+  let heapWorker = new HeapAnalysesClient();
+  yield front.attach();
+  let store = Store();
+  let { getState, dispatch } = store;
+
+  dispatch(changeView(viewState.DOMINATOR_TREE));
+
+  dispatch(takeSnapshotAndCensus(front, heapWorker));
+  yield waitUntilSnapshotState(store, [states.SAVED_CENSUS]);
+  ok(!getState().snapshots[0].dominatorTree,
+     "There shouldn't be a dominator tree model yet since it is not computed " +
+     "until we switch to the dominators view.");
+
+  // Wait for the dominator tree to start fetching.
+  yield waitUntilState(store, state =>
+    state.snapshots[0] &&
+    state.snapshots[0].dominatorTree &&
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.FETCHING);
+
+  ok(getState().dominatorTreeBreakdown,
+     "We have a default breakdown for describing nodes in a dominator tree");
+  equal(getState().dominatorTreeBreakdown,
+        dominatorTreeBreakdowns.coarseType.breakdown,
+        "and the default is coarse type");
+  equal(getState().dominatorTreeBreakdown,
+        getState().snapshots[0].dominatorTree.breakdown,
+        "and the newly computed dominator tree has that breakdown");
+
+  // Switch to the internalType breakdown while we are still fetching the
+  // dominator tree.
+  dispatch(setDominatorTreeBreakdownAndRefresh(
+    heapWorker,
+    dominatorTreeBreakdowns.internalType.breakdown));
+
+  // Wait for the dominator tree to finish being fetched.
+  yield waitUntilState(store, state =>
+    state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED);
+
+  equal(getState().snapshots[0].dominatorTree.breakdown,
+        dominatorTreeBreakdowns.internalType.breakdown,
+        "The new dominator tree's breakdown is internalType");
+  equal(getState().dominatorTreeBreakdown,
+        dominatorTreeBreakdowns.internalType.breakdown,
+        "as is our requested dominator tree breakdown");
+
+  heapWorker.destroy();
+  yield front.detach();
+});
--- a/devtools/client/memory/test/unit/test_utils.js
+++ b/devtools/client/memory/test/unit/test_utils.js
@@ -28,18 +28,18 @@ add_task(function *() {
   }), "utils.breakdownEquals() fails when deep properties do not match");
 
   ok(!utils.breakdownEquals(breakdowns.allocationStack.breakdown, {
     by: "allocationStack",
     then: { by: "count", bytes: true },
     noStack: { by: "count", count: true, bytes: true },
   }), "utils.breakdownEquals() fails when deep properties are missing.");
 
-  let s1 = utils.createSnapshot();
-  let s2 = utils.createSnapshot();
+  let s1 = utils.createSnapshot({});
+  let s2 = utils.createSnapshot({});
   equal(s1.state, states.SAVING, "utils.createSnapshot() creates snapshot in saving state");
   ok(s1.id !== s2.id, "utils.createSnapshot() creates snapshot with unique ids");
 
   ok(utils.breakdownEquals(utils.breakdownNameToSpec("coarseType"), breakdowns.coarseType.breakdown),
     "utils.breakdownNameToSpec() works for presets");
   ok(utils.breakdownEquals(utils.breakdownNameToSpec("coarseType"), breakdowns.coarseType.breakdown),
     "utils.breakdownNameToSpec() works for presets");
 
--- a/devtools/client/memory/test/unit/xpcshell.ini
+++ b/devtools/client/memory/test/unit/xpcshell.ini
@@ -21,10 +21,19 @@ skip-if = toolkit == 'android' || toolki
 [test_action-set-breakdown-and-refresh-02.js]
 [test_action-take-census.js]
 [test_action-take-snapshot.js]
 [test_action-take-snapshot-and-census.js]
 [test_action-toggle-inverted.js]
 [test_action-toggle-inverted-and-refresh-01.js]
 [test_action-toggle-inverted-and-refresh-02.js]
 [test_action-toggle-recording-allocations.js]
+[test_dominator_trees_01.js]
+[test_dominator_trees_02.js]
+[test_dominator_trees_03.js]
+[test_dominator_trees_04.js]
+[test_dominator_trees_05.js]
+[test_dominator_trees_06.js]
+[test_dominator_trees_07.js]
+[test_dominator_trees_08.js]
+[test_dominator_trees_09.js]
 [test_utils.js]
 [test_utils-get-snapshot-totals.js]
--- a/devtools/client/memory/utils.js
+++ b/devtools/client/memory/utils.js
@@ -8,18 +8,25 @@ Cu.import("resource://devtools/client/sh
 const STRINGS_URI = "chrome://devtools/locale/memory.properties"
 const L10N = exports.L10N = new ViewHelpers.L10N(STRINGS_URI);
 
 const { URL } = require("sdk/url");
 const { OS } = require("resource://gre/modules/osfile.jsm");
 const { assert } = require("devtools/shared/DevToolsUtils");
 const { Preferences } = require("resource://gre/modules/Preferences.jsm");
 const CUSTOM_BREAKDOWN_PREF = "devtools.memory.custom-breakdowns";
+const CUSTOM_DOMINATOR_TREE_BREAKDOWN_PREF = "devtools.memory.custom-dominator-tree-breakdowns";
 const DevToolsUtils = require("devtools/shared/DevToolsUtils");
-const { snapshotState: states, diffingState, breakdowns } = require("./constants");
+const {
+  snapshotState: states,
+  diffingState,
+  breakdowns,
+  dominatorTreeBreakdowns,
+  dominatorTreeState
+} = require("./constants");
 
 /**
  * Takes a snapshot object and returns the
  * localized form of its timestamp to be used as a title.
  *
  * @param {Snapshot} snapshot
  * @return {String}
  */
@@ -90,17 +97,16 @@ exports.getCustomBreakdowns = function (
 /**
  * Converts a breakdown preset name, like "allocationStack", and returns the
  * spec for the breakdown. Also checks properties of keys in the `devtools.memory.custom-breakdowns`
  * pref. If not found, returns an empty object.
  *
  * @param {String} name
  * @return {Object}
  */
-
 exports.breakdownNameToSpec = function (name) {
   let customBreakdowns = exports.getCustomBreakdowns();
 
   // If breakdown is already a breakdown, use it
   if (typeof name === "object") {
     return name;
   }
   // If it's in our custom breakdowns, use it
@@ -110,16 +116,91 @@ exports.breakdownNameToSpec = function (
   // If breakdown name is in our presets, use that
   else if (name in breakdowns) {
     return breakdowns[name].breakdown;
   }
   return Object.create(null);
 };
 
 /**
+ * Returns an array of objects with the unique key `name` and `displayName` for
+ * each breakdown for dominator trees.
+ *
+ * @return {Array<Object>}
+ */
+exports.getDominatorTreeBreakdownDisplayData = function () {
+  return exports.getDominatorTreeBreakdownNames().map(name => {
+    // If it's a preset use the display name value
+    let preset = dominatorTreeBreakdowns[name];
+    let displayName = name;
+    if (preset && preset.displayName) {
+      displayName = preset.displayName;
+    }
+    return { name, displayName };
+  });
+};
+
+/**
+ * Returns an array of the unique names for each breakdown in
+ * presets and custom pref.
+ *
+ * @return {Array<Breakdown>}
+ */
+exports.getDominatorTreeBreakdownNames = function () {
+  let custom = exports.getCustomDominatorTreeBreakdowns();
+  return Object.keys(Object.assign({}, dominatorTreeBreakdowns, custom));
+};
+
+/**
+ * Returns custom breakdowns defined in `devtools.memory.custom-dominator-tree-breakdowns` pref.
+ *
+ * @return {Object}
+ */
+exports.getCustomDominatorTreeBreakdowns = function () {
+  let customBreakdowns = Object.create(null);
+  try {
+    customBreakdowns = JSON.parse(Preferences.get(CUSTOM_DOMINATOR_TREE_BREAKDOWN_PREF)) || Object.create(null);
+  } catch (e) {
+    DevToolsUtils.reportException(
+      `String stored in "${CUSTOM_BREAKDOWN_PREF}" pref cannot be parsed by \`JSON.parse()\`.`);
+  }
+  return customBreakdowns;
+}
+
+/**
+ * Converts a dominator tree breakdown preset name, like "allocationStack", and
+ * returns the spec for the breakdown. Also checks properties of keys in the
+ * `devtools.memory.custom-breakdowns` pref. If not found, returns an empty
+ * object.
+ *
+ * @param {String} name
+ * @return {Object}
+ */
+exports.dominatorTreeBreakdownNameToSpec = function (name) {
+  let customBreakdowns = exports.getCustomDominatorTreeBreakdowns();
+
+  // If breakdown is already a breakdown, use it.
+  if (typeof name === "object") {
+    return name;
+  }
+
+  // If it's in our custom breakdowns, use it.
+  if (name in customBreakdowns) {
+    return customBreakdowns[name];
+  }
+
+  // If breakdown name is in our presets, use that.
+  if (name in dominatorTreeBreakdowns) {
+    return dominatorTreeBreakdowns[name].breakdown;
+  }
+
+  return Object.create(null);
+};
+
+/**
  * Returns a string representing a readable form of the snapshot's state. More
  * concise than `getStatusTextFull`.
  *
  * @param {snapshotState | diffingState} state
  * @return {String}
  */
 exports.getStatusText = function (state) {
   assert(state, "Must have a state");
@@ -145,18 +226,32 @@ exports.getStatusText = function (state)
       return L10N.getStr("snapshot.state.saving-census");
 
     case diffingState.TAKING_DIFF:
       return L10N.getStr("diffing.state.taking-diff");
 
     case diffingState.SELECTING:
       return L10N.getStr("diffing.state.selecting");
 
+    case dominatorTreeState.COMPUTING:
+      return L10N.getStr("dominatorTree.state.computing");
+
+    case dominatorTreeState.COMPUTED:
+    case dominatorTreeState.FETCHING:
+      return L10N.getStr("dominatorTree.state.fetching");
+
+    case dominatorTreeState.INCREMENTAL_FETCHING:
+      return L10N.getStr("dominatorTree.state.incrementalFetching");
+
+    case dominatorTreeState.ERROR:
+      return L10N.getStr("dominatorTree.state.error");
+
     // These states do not have any message to show as other content will be
     // displayed.
+    case dominatorTreeState.LOADED:
     case diffingState.TOOK_DIFF:
     case states.READ:
     case states.SAVED_CENSUS:
       return "";
 
     default:
       assert(false, `Unexpected state: ${state}`);
       return "";
@@ -194,18 +289,32 @@ exports.getStatusTextFull = function (st
       return L10N.getStr("snapshot.state.saving-census.full");
 
     case diffingState.TAKING_DIFF:
       return L10N.getStr("diffing.state.taking-diff.full");
 
     case diffingState.SELECTING:
       return L10N.getStr("diffing.state.selecting.full");
 
+    case dominatorTreeState.COMPUTING:
+      return L10N.getStr("dominatorTree.state.computing.full");
+
+    case dominatorTreeState.COMPUTED:
+    case dominatorTreeState.FETCHING:
+      return L10N.getStr("dominatorTree.state.fetching.full");
+
+    case dominatorTreeState.INCREMENTAL_FETCHING:
+      return L10N.getStr("dominatorTree.state.incrementalFetching.full");
+
+    case dominatorTreeState.ERROR:
+      return L10N.getStr("dominatorTree.state.error.full");
+
     // These states do not have any full message to show as other content will
     // be displayed.
+    case dominatorTreeState.LOADED:
     case diffingState.TOOK_DIFF:
     case states.READ:
     case states.SAVED_CENSUS:
       return "";
 
     default:
       assert(false, `Unexpected state: ${state}`);
       return "";
@@ -231,30 +340,42 @@ exports.snapshotIsDiffable = function sn
  * the snapshot passed in.
  *
  * @param {appModel} state
  * @param {snapshotId} id
  * @return {snapshotModel|null}
  */
 exports.getSnapshot = function getSnapshot (state, id) {
   const found = state.snapshots.find(s => s.id === id);
-  assert(found, `No matching snapshot found for ${id}`);
+  assert(found, `No matching snapshot found with id = ${id}`);
   return found;
 };
 
 /**
  * Creates a new snapshot object.
  *
+ * @param {appModel} state
  * @return {Snapshot}
  */
 let ID_COUNTER = 0;
-exports.createSnapshot = function createSnapshot() {
+exports.createSnapshot = function createSnapshot(state) {
+  let dominatorTree = null;
+  if (state.view === dominatorTreeState.DOMINATOR_TREE) {
+    dominatorTree = Object.freeze({
+      dominatorTreeId: null,
+      root: null,
+      error: null,
+      state: dominatorTreeState.COMPUTING,
+    });
+  }
+
   return Object.freeze({
     id: ++ID_COUNTER,
     state: states.SAVING,
+    dominatorTree,
     census: null,
     path: null,
     imported: false,
     selected: false,
     error: null,
   });
 };
 
@@ -312,16 +433,30 @@ const breakdownEquals = exports.breakdow
 exports.censusIsUpToDate = function (inverted, filter, breakdown, census) {
   return census
       && inverted === census.inverted
       && filter === census.filter
       && breakdownEquals(breakdown, census.breakdown);
 };
 
 /**
+ * Returns true if the given snapshot's dominator tree has been computed, false
+ * otherwise.
+ *
+ * @param {SnapshotModel} snapshot
+ * @returns {Boolean}
+ */
+exports.dominatorTreeIsComputed = function (snapshot) {
+  return snapshot.dominatorTree &&
+    (snapshot.dominatorTree.state === dominatorTreeState.COMPUTED ||
+     snapshot.dominatorTree.state === dominatorTreeState.LOADED ||
+     snapshot.dominatorTree.state === dominatorTreeState.INCREMENTAL_FETCHING);
+};
+
+/**
  * Takes a snapshot and returns the total bytes and total count that this
  * snapshot represents.
  *
  * @param {CensusModel} census
  * @return {Object}
  */
 exports.getSnapshotTotals = function (census) {
   let bytes = 0;
@@ -376,8 +511,29 @@ exports.openFilePicker = function({ titl
           resolve(null);
           return;
         }
         resolve(fp.file);
       }
     });
   });
 };
+
+/**
+ * Creates a hash map mapping node IDs to its parent node.
+ *
+ * @param {CensusTreeNode} node
+ * @param {Object<number, TreeNode>} aggregator
+ *
+ * @return {Object<number, TreeNode>}
+ */
+const createParentMap = exports.createParentMap = function (node,
+                                                            getId = node => node.id,
+                                                            aggregator = Object.create(null)) {
+  if (node.children) {
+    for (let child of node.children) {
+      aggregator[getId(child)] = node;
+      createParentMap(child, getId, aggregator);
+    }
+  }
+
+  return aggregator;
+};
--- a/devtools/client/preferences/devtools.js
+++ b/devtools/client/preferences/devtools.js
@@ -98,16 +98,17 @@ pref("devtools.debugger.ui.panes-visible
 pref("devtools.debugger.ui.variables-sorting-enabled", true);
 pref("devtools.debugger.ui.variables-only-enum-visible", false);
 pref("devtools.debugger.ui.variables-searchbox-visible", false);
 
 // Enable the Memory tools
 pref("devtools.memory.enabled", false);
 
 pref("devtools.memory.custom-breakdowns", "{}");
+pref("devtools.memory.custom-dominator-tree-breakdowns", "{}");
 
 // Enable the Performance tools
 pref("devtools.performance.enabled", true);
 
 // The default Performance UI settings
 pref("devtools.performance.memory.sample-probability", "0.05");
 // Can't go higher than this without causing internal allocation overflows while
 // serializing the allocations data over the RDP.
--- a/devtools/client/shared/components/frame.js
+++ b/devtools/client/shared/components/frame.js
@@ -1,17 +1,17 @@
 /* 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/. */
 
 const { DOM: dom, createClass, PropTypes } = require("devtools/client/shared/vendor/react");
 const { getSourceNames } = require("devtools/client/shared/source-utils");
 
 const Frame = module.exports = createClass({
-  displayName: "frame-view",
+  displayName: "Frame",
 
   propTypes: {
     // SavedFrame
     frame: PropTypes.object.isRequired,
     // Clicking on the frame link -- probably should link to the debugger.
     onClick: PropTypes.func.isRequired,
     // Tooltip to display when hovering over the link to the frame;
     // Something like "View source in debugger -> http://foo.com/file.js:100:2".
--- a/devtools/client/shared/components/test/mochitest/head.js
+++ b/devtools/client/shared/components/test/mochitest/head.js
@@ -3,25 +3,16 @@
 "use strict";
 
 var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 Cu.import("resource://testing-common/Assert.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 var { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
 
-// Disable logging for all the tests. Both the debugger server and frontend will
-// be affected by this pref.
-var gEnableLogging = Services.prefs.getBoolPref("devtools.debugger.log");
-Services.prefs.setBoolPref("devtools.debugger.log", false);
-
-// Enable the memory tool for all tests.
-var gMemoryToolEnabled = Services.prefs.getBoolPref("devtools.memory.enabled");
-Services.prefs.setBoolPref("devtools.memory.enabled", true);
-
 var { Promise: promise } = Cu.import("resource://gre/modules/Promise.jsm", {});
 var { require } = Cu.import("resource://gre/modules/devtools/shared/Loader.jsm", {});
 var { gDevTools } = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
 var { BrowserLoader } = Cu.import("resource://devtools/client/shared/browser-loader.js", {});
 var { DebuggerServer } = require("devtools/server/main");
 var { DebuggerClient } = require("devtools/shared/client/main");
 var DevToolsUtils = require("devtools/shared/DevToolsUtils");
 var { TargetFactory } = require("devtools/client/framework/target");
@@ -78,121 +69,40 @@ var TEST_TREE = {
     H: "C",
     I: "C",
     J: "D",
     K: "E",
     L: "E",
     M: null,
     N: "M",
     O: "N"
-  }
+  },
+  expanded: new Set(),
 };
 
 var TEST_TREE_INTERFACE = {
   getParent: x => TEST_TREE.parent[x],
   getChildren: x => TEST_TREE.children[x],
   renderItem: (x, depth, focused, arrow) => "-".repeat(depth) + x + ":" + focused + "\n",
   getRoots: () => ["A", "M"],
   getKey: x => "key-" + x,
   itemHeight: 1,
+  onExpand: x => TEST_TREE.expanded.add(x),
+  onCollapse: x => TEST_TREE.expanded.delete(x),
+  isExpanded: x => TEST_TREE.expanded.has(x),
 };
 
+function forceRender(tree) {
+  return setState(tree, {})
+    .then(() => setState(tree, {}));
+}
+
 // All tests are asynchronous.
 SimpleTest.waitForExplicitFinish();
 
-SimpleTest.registerCleanupFunction(() => {
-  info("finish() was called, cleaning up...");
-  Services.prefs.setBoolPref("devtools.debugger.log", gEnableLogging);
-  Services.prefs.setBoolPref("devtools.memory.enabled", gMemoryToolEnabled);
-});
-
-function addTab(url) {
-  info("Adding tab: " + url);
-
-  var deferred = promise.defer();
-  var tab = gBrowser.selectedTab = gBrowser.addTab(url);
-  var linkedBrowser = tab.linkedBrowser;
-
-  linkedBrowser.addEventListener("load", function onLoad() {
-    linkedBrowser.removeEventListener("load", onLoad, true);
-    info("Tab added and finished loading: " + url);
-    deferred.resolve(tab);
-  }, true);
-
-  return deferred.promise;
-}
-
-function removeTab(tab) {
-  info("Removing tab.");
-
-  var deferred = promise.defer();
-  var tabContainer = gBrowser.tabContainer;
-
-  tabContainer.addEventListener("TabClose", function onClose(aEvent) {
-    tabContainer.removeEventListener("TabClose", onClose, false);
-    info("Tab removed and finished closing.");
-    deferred.resolve();
-  }, false);
-
-  gBrowser.removeTab(tab);
-  return deferred.promise;
-}
-
-var withTab = Task.async(function* (url, generator) {
-  var tab = yield addTab(url);
-  try {
-    yield* generator(tab);
-  } finally {
-    yield removeTab(tab);
-  }
-});
-
-var openMemoryTool = Task.async(function* (tab) {
-  info("Initializing a memory panel.");
-
-  var target = TargetFactory.forTab(tab);
-  var debuggee = target.window.wrappedJSObject;
-
-  yield target.makeRemote();
-
-  var toolbox = yield gDevTools.showToolbox(target, "memory");
-  var panel = toolbox.getCurrentPanel();
-  return [target, debuggee, panel];
-});
-
-var closeMemoryTool = Task.async(function* (panel) {
-  info("Closing a memory panel");
-  yield panel._toolbox.destroy();
-});
-
-var withMemoryTool = Task.async(function* (tab, generator) {
-  var [target, debuggee, panel] = yield openMemoryTool(tab);
-  try {
-    yield* generator(target, debuggee, panel);
-  } finally {
-    yield closeMemoryTool(panel);
-  }
-});
-
-var withTabAndMemoryTool = Task.async(function* (url, generator) {
-  yield withTab(url, function* (tab) {
-    yield withMemoryTool(tab, function* (target, debuggee, panel) {
-      yield* generator(tab, target, debuggee, panel);
-    });
-  });
-});
-
-function reload(target) {
-  info("Reloading tab.");
-  var deferred = promise.defer();
-  target.once("navigate", deferred.resolve);
-  target.activeTab.reload();
-  return deferred.promise;
-}
-
 function onNextAnimationFrame(fn) {
   return () =>
     requestAnimationFrame(() =>
       requestAnimationFrame(fn));
 }
 
 function setState(component, newState) {
   var deferred = promise.defer();
--- a/devtools/client/shared/components/test/mochitest/test_tree_01.html
+++ b/devtools/client/shared/components/test/mochitest/test_tree_01.html
@@ -24,19 +24,18 @@ window.onload = Task.async(function* () 
     ok(Tree, "Should get Tree");
 
     const t = Tree(TEST_TREE_INTERFACE);
     ok(t, "Should be able to create Tree instances");
 
     const tree = ReactDOM.render(t, window.document.body);
     ok(tree, "Should be able to mount Tree instances");
 
-    yield setState(tree, {
-      expanded: new Set("ABCDEFGHIJKLMNO".split(""))
-    });
+    TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split(""));
+    yield forceRender(tree);
 
     isRenderedTree(document.body.textContent, [
       "A:false",
       "-B:false",
       "--E:false",
       "---K:false",
       "---L:false",
       "--F:false",
--- a/devtools/client/shared/components/test/mochitest/test_tree_02.html
+++ b/devtools/client/shared/components/test/mochitest/test_tree_02.html
@@ -16,19 +16,18 @@ Test that collapsed subtrees aren't rend
 window.onload = Task.async(function* () {
   try {
     let ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom");
     let React = browserRequire("devtools/client/shared/vendor/react");
     let Tree = React.createFactory(browserRequire("devtools/client/shared/components/tree"));
 
     const tree = ReactDOM.render(Tree(TEST_TREE_INTERFACE), window.document.body);
 
-    yield setState(tree, {
-      expanded: new Set("MNO".split(""))
-    });
+    TEST_TREE.expanded = new Set("MNO".split(""));
+    yield forceRender(tree);
 
     isRenderedTree(document.body.textContent, [
       "A:false",
       "M:false",
       "-N:false",
       "--O:false",
     ], "Collapsed subtrees shouldn't be rendered");
   } catch(e) {
--- a/devtools/client/shared/components/test/mochitest/test_tree_04.html
+++ b/devtools/client/shared/components/test/mochitest/test_tree_04.html
@@ -15,18 +15,18 @@ Test that we only render visible tree it
 <script type="application/javascript;version=1.8">
 window.onload = Task.async(function* () {
   try {
     const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom");
     const React = browserRequire("devtools/client/shared/vendor/react");
     const Tree = React.createFactory(browserRequire("devtools/client/shared/components/tree"));
     const tree = ReactDOM.render(Tree(TEST_TREE_INTERFACE), window.document.body);
 
+    TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split(""));
     yield setState(tree, {
-      expanded: new Set("ABCDEFGHIJKLMNO".split("")),
       height: 3,
       scroll: 1
     });
 
     isRenderedTree(document.body.textContent, [
       "A:false",
       "-B:false",
       "--E:false",
--- a/devtools/client/shared/components/test/mochitest/test_tree_05.html
+++ b/devtools/client/shared/components/test/mochitest/test_tree_05.html
@@ -15,21 +15,23 @@ Test focusing with the Tree component.
 <script type="application/javascript;version=1.8">
 
 window.onload = Task.async(function* () {
   try {
     const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom");
     const React = browserRequire("devtools/client/shared/vendor/react");
     const { Simulate } = React.addons.TestUtils;
     const Tree = React.createFactory(browserRequire("devtools/client/shared/components/tree"));
-    const tree = ReactDOM.render(Tree(TEST_TREE_INTERFACE), window.document.body);
+    const tree = ReactDOM.render(Tree(Object.assign({}, TEST_TREE_INTERFACE, {
+      onFocus: x => setProps(tree, { focused: x }),
+    })), window.document.body);
 
-    yield setState(tree, {
+    TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split(""));
+    yield setProps(tree, {
       focused: "G",
-      expanded: new Set("ABCDEFGHIJKLMNO".split(""))
     });
 
     isRenderedTree(document.body.textContent, [
       "A:false",
       "-B:false",
       "--E:false",
       "---K:false",
       "---L:false",
@@ -42,19 +44,17 @@ window.onload = Task.async(function* () 
       "--J:false",
       "M:false",
       "-N:false",
       "--O:false",
     ], "G should be focused");
 
     // Click the first tree node
     Simulate.click(document.querySelector(".tree-node"));
-
-    // Let the next render happen.
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     isRenderedTree(document.body.textContent, [
       "A:true",
       "-B:false",
       "--E:false",
       "---K:false",
       "---L:false",
       "--F:false",
--- a/devtools/client/shared/components/test/mochitest/test_tree_06.html
+++ b/devtools/client/shared/components/test/mochitest/test_tree_06.html
@@ -14,32 +14,31 @@ Test keyboard navigation with the Tree c
 <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 ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom");
     const React = browserRequire("devtools/client/shared/vendor/react");
     const { Simulate } = React.addons.TestUtils;
     const Tree = React.createFactory(browserRequire("devtools/client/shared/components/tree"));
-    const tree = ReactDOM.render(Tree(TEST_TREE_INTERFACE), window.document.body);
+    const tree = ReactDOM.render(Tree(Object.assign({}, TEST_TREE_INTERFACE, {
+      onFocus: x => setProps(tree, { focused: x }),
+    })), window.document.body);
 
-    yield setState(tree, {
-      expanded: new Set("ABCDEFGHIJKLMNO".split(""))
-    });
+    TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split(""));
 
     // UP ----------------------------------------------------------------------
 
     info("Up to the previous sibling.");
 
-    yield setState(tree, {
+    yield setProps(tree, {
       focused: "L"
     });
     Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowUp" });
-    // Let the component re-render.
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     isRenderedTree(document.body.textContent, [
       "A:false",
       "-B:false",
       "--E:false",
       "---K:true",
       "---L:false",
       "--F:false",
@@ -52,18 +51,17 @@ window.onload = Task.async(function* () 
       "M:false",
       "-N:false",
       "--O:false",
     ], "After the UP, K should be focused.");
 
     info("Up to the parent.");
 
     Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowUp" });
-    // Let the component re-render.
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     isRenderedTree(document.body.textContent, [
       "A:false",
       "-B:false",
       "--E:true",
       "---K:false",
       "---L:false",
       "--F:false",
@@ -75,22 +73,21 @@ window.onload = Task.async(function* () 
       "--J:false",
       "M:false",
       "-N:false",
       "--O:false",
     ], "After the UP, E should be focused.");
 
     info("Try and navigate up, past the first item.");
 
-    yield setState(tree, {
+    yield setProps(tree, {
       focused: "A"
     });
     Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowUp" });
-    // Let the component re-render.
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     isRenderedTree(document.body.textContent, [
       "A:true",
       "-B:false",
       "--E:false",
       "---K:false",
       "---L:false",
       "--F:false",
@@ -102,25 +99,24 @@ window.onload = Task.async(function* () 
       "--J:false",
       "M:false",
       "-N:false",
       "--O:false",
     ], "After the UP, A should be focused and we shouldn't have overflowed past it.");
 
     // DOWN --------------------------------------------------------------------
 
-    yield setState(tree, {
+    yield setProps(tree, {
       focused: "K"
     });
 
     info("Down to next sibling.");
 
     Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowDown" });
-    // Let the component re-render.
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     isRenderedTree(document.body.textContent, [
       "A:false",
       "-B:false",
       "--E:false",
       "---K:false",
       "---L:true",
       "--F:false",
@@ -133,18 +129,17 @@ window.onload = Task.async(function* () 
       "M:false",
       "-N:false",
       "--O:false",
     ], "After the DOWN, L should be focused.");
 
     info("Down to parent's next sibling.");
 
     Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowDown" });
-    // Let the component re-render.
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     isRenderedTree(document.body.textContent, [
       "A:false",
       "-B:false",
       "--E:false",
       "---K:false",
       "---L:false",
       "--F:true",
@@ -156,22 +151,21 @@ window.onload = Task.async(function* () 
       "--J:false",
       "M:false",
       "-N:false",
       "--O:false",
     ], "After the DOWN, F should be focused.");
 
     info("Try and go down past the last item.");
 
-    yield setState(tree, {
+    yield setProps(tree, {
       focused: "O"
     });
     Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowDown" });
-    // Let the component re-render.
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     isRenderedTree(document.body.textContent, [
       "A:false",
       "-B:false",
       "--E:false",
       "---K:false",
       "---L:false",
       "--F:false",
@@ -185,22 +179,21 @@ window.onload = Task.async(function* () 
       "-N:false",
       "--O:true",
     ], "After the DOWN, O should still be focused and we shouldn't have overflowed past it.");
 
     // LEFT --------------------------------------------------------------------
 
     info("Left to go to parent.");
 
-    yield setState(tree, {
+    yield setProps(tree, {
       focused: "L"
     })
     Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowLeft" });
-    // Let the component re-render.
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     isRenderedTree(document.body.textContent, [
       "A:false",
       "-B:false",
       "--E:true",
       "---K:false",
       "---L:false",
       "--F:false",
@@ -213,18 +206,17 @@ window.onload = Task.async(function* () 
       "M:false",
       "-N:false",
       "--O:false",
     ], "After the LEFT, E should be focused.");
 
     info("Left to collapse children.");
 
     Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowLeft" });
-    // Let the component re-render.
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     isRenderedTree(document.body.textContent, [
       "A:false",
       "-B:false",
       "--E:true",
       "--F:false",
       "--G:false",
       "-C:false",
@@ -237,18 +229,17 @@ window.onload = Task.async(function* () 
       "--O:false",
     ], "After the LEFT, E's children should be collapsed.");
 
     // RIGHT -------------------------------------------------------------------
 
     info("Right to expand children.");
 
     Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowRight" });
-    // Let the component re-render.
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     isRenderedTree(document.body.textContent, [
       "A:false",
       "-B:false",
       "--E:true",
       "---K:false",
       "---L:false",
       "--F:false",
@@ -261,18 +252,17 @@ window.onload = Task.async(function* () 
       "M:false",
       "-N:false",
       "--O:false",
     ], "After the RIGHT, E's children should be expanded again.");
 
     info("Right to go to next item.");
 
     Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowRight" });
-    // Let the component re-render.
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     isRenderedTree(document.body.textContent, [
       "A:false",
       "-B:false",
       "--E:false",
       "---K:true",
       "---L:false",
       "--F:false",
--- a/devtools/client/shared/components/test/mochitest/test_tree_07.html
+++ b/devtools/client/shared/components/test/mochitest/test_tree_07.html
@@ -29,34 +29,31 @@ window.onload = Task.async(function* () 
             style: { marginLeft: depth * 16 + "px" }
           },
           arrow,
           item
         );
       }
     });
 
-    yield setState(tree, {
-      expanded: new Set("ABCDEFGHIJKLMNO".split(""))
-    });
+    TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split(""));
+    yield forceRender(tree);
 
     let arrows = document.querySelectorAll(".arrow");
     for (let a of arrows) {
       ok(a.classList.contains("open"), "Every arrow should be open.");
     }
 
-    yield setState(tree, {
-      expanded: new Set()
-    });
+    TEST_TREE.expanded = new Set();
+    yield forceRender(tree);
 
     arrows = document.querySelectorAll(".arrow");
     for (let a of arrows) {
       ok(!a.classList.contains("open"), "Every arrow should be closed.");
     }
-
   } catch(e) {
     ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e));
   } finally {
     SimpleTest.finish();
   }
 });
 </script>
 </pre>
--- a/devtools/client/shared/components/test/mochitest/test_tree_08.html
+++ b/devtools/client/shared/components/test/mochitest/test_tree_08.html
@@ -16,27 +16,28 @@ other inputs.
 <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 ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom");
     const React = browserRequire("devtools/client/shared/vendor/react");
     const { Simulate } = React.addons.TestUtils;
     const Tree = React.createFactory(browserRequire("devtools/client/shared/components/tree"));
-    const tree = ReactDOM.render(Tree(TEST_TREE_INTERFACE), window.document.body);
+    const tree = ReactDOM.render(Tree(Object.assign({}, TEST_TREE_INTERFACE, {
+      onFocus: x => setProps(tree, { focused: x }),
+    })), window.document.body);
 
     const input = document.createElement("input");
     document.body.appendChild(input);
 
     input.focus();
     is(document.activeElement, input, "The text input should be focused.");
 
     Simulate.click(document.querySelector(".tree-node"));
-    // Let the tree re-render, because focus is dealt with on componentDidUpdate.
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     isnot(document.activeElement, input,
           "The input should have had it's focus stolen by clicking on a tree item.");
   } catch(e) {
     ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e));
   } finally {
     SimpleTest.finish();
   }
--- a/devtools/client/shared/components/test/mochitest/test_tree_09.html
+++ b/devtools/client/shared/components/test/mochitest/test_tree_09.html
@@ -22,44 +22,48 @@ window.onload = Task.async(function* () 
     const Tree = React.createFactory(browserRequire("devtools/client/shared/components/tree"));
 
     let numberOfExpands = 0;
     let lastExpandedItem = null;
 
     let numberOfCollapses = 0;
     let lastCollapsedItem = null;
 
-    const tree = ReactDOM.render(Tree(Object.assign({
+    const tree = ReactDOM.render(Tree(Object.assign({}, TEST_TREE_INTERFACE, {
+      autoExpandDepth: 0,
       onExpand: item => {
         lastExpandedItem = item;
         numberOfExpands++;
+        TEST_TREE.expanded.add(item);
       },
       onCollapse: item => {
         lastCollapsedItem = item;
         numberOfCollapses++;
+        TEST_TREE.expanded.delete(item);
       },
-    }, TEST_TREE_INTERFACE)), window.document.body);
+      onFocus: item => setProps(tree, { focused: item }),
+    })), window.document.body);
 
-    yield setState(tree, {
+    yield setProps(tree, {
       focused: "A"
     });
 
     is(lastExpandedItem, null);
     is(lastCollapsedItem, null);
 
     // Expand "A" via the keyboard and then let the component re-render.
     Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowRight" });
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     is(lastExpandedItem, "A", "Our onExpand callback should have been fired.");
     is(numberOfExpands, 1);
 
     // Now collapse "A" via the keyboard and then let the component re-render.
     Simulate.keyDown(document.querySelector(".tree"), { key: "ArrowLeft" });
-    yield setState(tree, {});
+    yield forceRender(tree);
 
     is(lastCollapsedItem, "A", "Our onCollapsed callback should have been fired.");
     is(numberOfCollapses, 1);
   } catch(e) {
     ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e));
   } finally {
     SimpleTest.finish();
   }
--- a/devtools/client/shared/components/test/mochitest/test_tree_10.html
+++ b/devtools/client/shared/components/test/mochitest/test_tree_10.html
@@ -20,17 +20,17 @@ window.onload = Task.async(function* () 
     const React = browserRequire("devtools/client/shared/vendor/react");
     const { Simulate } = React.addons.TestUtils;
     const Tree = React.createFactory(browserRequire("devtools/client/shared/components/tree"));
 
     const tree = ReactDOM.render(Tree(Object.assign({
       autoExpandDepth: 1
     }, TEST_TREE_INTERFACE)), window.document.body);
 
-    yield setState(tree, {
+    yield setProps(tree, {
       focused: "A"
     });
 
     isRenderedTree(document.body.textContent, [
       "A:true",
       "-B:false",
       "-C:false",
       "-D:false",
--- a/devtools/client/shared/components/tree.js
+++ b/devtools/client/shared/components/tree.js
@@ -38,16 +38,22 @@ const ArrowExpander = createFactory(crea
       };
     }
 
     return dom.div(attrs);
   }
 }));
 
 const TreeNode = createFactory(createClass({
+  componentDidMount() {
+    if (this.props.focused) {
+      this.refs.button.focus();
+    }
+  },
+
   componentDidUpdate() {
     if (this.props.focused) {
       this.refs.button.focus();
     }
   },
 
   render() {
     const arrow = ArrowExpander({
@@ -140,50 +146,52 @@ const Tree = module.exports = createClas
     getChildren: PropTypes.func.isRequired,
     // A function which takes an item and ArrowExpander and returns a
     // component.
     renderItem: PropTypes.func.isRequired,
     // A function which returns the roots of the tree (forest).
     getRoots: PropTypes.func.isRequired,
     // A function to get a unique key for the given item.
     getKey: PropTypes.func.isRequired,
+    // A function to get whether an item is expanded or not. If an item is not
+    // expanded, then it must be collapsed.
+    isExpanded: PropTypes.func.isRequired,
     // The height of an item in the tree including margin and padding, in
     // pixels.
     itemHeight: PropTypes.number.isRequired,
 
     // Optional props
 
+    // The currently focused item, if any such item exists.
+    focused: PropTypes.any,
+    // Handle when a new item is focused.
+    onFocus: PropTypes.func,
     // The depth to which we should automatically expand new items.
     autoExpandDepth: PropTypes.number,
     // A predicate that returns true if the last DFS traversal that was cached
     // can be reused, false otherwise. The predicate function is passed the
     // cached traversal as an array of nodes.
     reuseCachedTraversal: PropTypes.func,
     // Optional event handlers for when items are expanded or collapsed.
     onExpand: PropTypes.func,
     onCollapse: PropTypes.func,
   },
 
   getDefaultProps() {
     return {
-      expanded: new Set(),
-      seen: new Set(),
-      focused: undefined,
       autoExpandDepth: AUTO_EXPAND_DEPTH,
       reuseCachedTraversal: null,
     };
   },
 
   getInitialState() {
     return {
       scroll: 0,
       height: window.innerHeight,
-      expanded: new Set(),
       seen: new Set(),
-      focused: undefined,
       cachedTraversal: undefined,
     };
   },
 
   componentDidMount() {
     window.addEventListener("resize", this._updateHeight);
     this._autoExpand();
     this._updateHeight();
@@ -206,17 +214,17 @@ const Tree = module.exports = createClas
     // not use the usual DFS infrastructure because we don't want to ignore
     // collapsed nodes.
     const autoExpand = (item, currentDepth) => {
       if (currentDepth >= this.props.autoExpandDepth ||
           this.state.seen.has(item)) {
         return;
       }
 
-      this.state.expanded.add(item);
+      this.props.onExpand(item);
       this.state.seen.add(item);
 
       for (let child of this.props.getChildren(item)) {
         autoExpand(item, currentDepth + 1);
       }
     };
 
     for (let root of this.props.getRoots()) {
@@ -247,22 +255,22 @@ const Tree = module.exports = createClas
 
     for (let i = 0; i < toRender.length; i++) {
       let { item, depth } = toRender[i];
       nodes.push(TreeNode({
         key: this.props.getKey(item),
         item: item,
         depth: depth,
         renderItem: this.props.renderItem,
-        focused: this.state.focused === item,
-        expanded: this.state.expanded.has(item),
+        focused: this.props.focused === item,
+        expanded: this.props.isExpanded(item),
         hasChildren: !!this.props.getChildren(item).length,
         onExpand: this._onExpand,
         onCollapse: this._onCollapse,
-        onFocus: () => this._onFocus(item)
+        onFocus: () => this._focus(item)
       }));
     }
 
     nodes.push(dom.div({
       key: "bottom-spacer",
       style: {
         padding: 0,
         margin: 0,
@@ -295,17 +303,17 @@ const Tree = module.exports = createClas
   },
 
   /**
    * Perform a pre-order depth-first search from item.
    */
   _dfs(item, maxDepth = Infinity, traversal = [], _depth = 0) {
     traversal.push({ item, depth: _depth });
 
-    if (!this.state.expanded.has(item)) {
+    if (!this.props.isExpanded(item)) {
       return traversal;
     }
 
     const nextDepth = _depth + 1;
 
     if (nextDepth > maxDepth) {
       return traversal;
     }
@@ -343,75 +351,63 @@ const Tree = module.exports = createClas
 
   /**
    * Expands current row.
    *
    * @param {Object} item
    * @param {Boolean} expandAllChildren
    */
   _onExpand: oncePerAnimationFrame(function (item, expandAllChildren) {
-    this.state.expanded.add(item);
-
     if (this.props.onExpand) {
       this.props.onExpand(item);
-    }
 
-    if (expandAllChildren) {
-      for (let { item: child } of this._dfs(item)) {
-        this.state.expanded.add(child);
-
-        if (this.props.onExpand) {
+      if (expandAllChildren) {
+        for (let { item: child } of this._dfs(item)) {
           this.props.onExpand(child);
         }
       }
     }
 
     this.setState({
-      expanded: this.state.expanded,
       cachedTraversal: null,
     });
   }),
 
   /**
    * Collapses current row.
    *
    * @param {Object} item
    */
   _onCollapse: oncePerAnimationFrame(function (item) {
-    this.state.expanded.delete(item);
-
     if (this.props.onCollapse) {
       this.props.onCollapse(item);
     }
 
     this.setState({
-      expanded: this.state.expanded,
       cachedTraversal: null,
     });
   }),
 
   /**
    * Sets the passed in item to be the focused item.
    *
    * @param {Object} item
    */
-  _onFocus: oncePerAnimationFrame(function (item) {
-    this.setState({
-      focused: item
-    });
-  }),
+  _focus: function (item) {
+    if (this.props.onFocus) {
+      this.props.onFocus(item);
+    }
+  },
 
   /**
    * Sets the state to have no focused item.
    */
-  _onBlur: oncePerAnimationFrame(function () {
-    this.setState({
-      focused: undefined
-    });
-  }),
+  _onBlur: function () {
+    this._focus(undefined);
+  },
 
   /**
    * Fired on a scroll within the tree's container, updates
    * the stored position of the view port to handle virtual view rendering.
    *
    * @param {Event} e
    */
   _onScroll: oncePerAnimationFrame(function (e) {
@@ -422,17 +418,17 @@ const Tree = module.exports = createClas
   }),
 
   /**
    * Handles key down events in the tree's container.
    *
    * @param {Event} e
    */
   _onKeyDown(e) {
-    if (this.state.focused == null) {
+    if (this.props.focused == null) {
       return;
     }
 
     // Prevent scrolling when pressing navigation keys. Guard against mocked
     // events received when testing.
     if (e.nativeEvent && e.nativeEvent.preventDefault) {
       ViewHelpers.preventScrolling(e.nativeEvent);
     }
@@ -442,27 +438,27 @@ const Tree = module.exports = createClas
         this._focusPrevNode();
         return false;
 
       case "ArrowDown":
         this._focusNextNode();
         return false;
 
       case "ArrowLeft":
-        if (this.state.expanded.has(this.state.focused)
-            && this.props.getChildren(this.state.focused).length) {
-          this._onCollapse(this.state.focused);
+        if (this.props.isExpanded(this.props.focused)
+            && this.props.getChildren(this.props.focused).length) {
+          this._onCollapse(this.props.focused);
         } else {
           this._focusParentNode();
         }
         return false;
 
       case "ArrowRight":
-        if (!this.state.expanded.has(this.state.focused)) {
-          this._onExpand(this.state.focused);
+        if (!this.props.isExpanded(this.props.focused)) {
+          this._onExpand(this.props.focused);
         } else {
           this._focusNextNode();
         }
         return false;
       }
   },
 
   /**
@@ -470,62 +466,56 @@ const Tree = module.exports = createClas
    */
   _focusPrevNode: oncePerAnimationFrame(function () {
     // Start a depth first search and keep going until we reach the currently
     // focused node. Focus the previous node in the DFS, if it exists. If it
     // doesn't exist, we're at the first node already.
 
     let prev;
     for (let { item } of this._dfsFromRoots()) {
-      if (item === this.state.focused) {
+      if (item === this.props.focused) {
         break;
       }
       prev = item;
     }
 
     if (prev === undefined) {
       return;
     }
 
-    this.setState({
-      focused: prev
-    });
+    this._focus(prev);
   }),
 
   /**
    * Handles the down arrow key which will focus either the next child
    * or sibling row.
    */
   _focusNextNode: oncePerAnimationFrame(function () {
     // Start a depth first search and keep going until we reach the currently
     // focused node. Focus the next node in the DFS, if it exists. If it
     // doesn't exist, we're at the last node already.
 
     const traversal = this._dfsFromRoots();
 
     let i = 0;
     for (let { item } of traversal) {
-      if (item === this.state.focused) {
+      if (item === this.props.focused) {
         break;
       }
       i++;
     }
 
     if (i + 1 < traversal.length) {
-      this.setState({
-        focused: traversal[i + 1].item
-      });
+      this._focus(traversal[i + 1].item);
     }
   }),
 
   /**
    * Handles the left arrow key, going back up to the current rows'
    * parent row.
    */
   _focusParentNode: oncePerAnimationFrame(function () {
-    const parent = this.props.getParent(this.state.focused);
+    const parent = this.props.getParent(this.props.focused);
     if (parent) {
-      this.setState({
-        focused: parent
-      });
+      this._focus(parent);
     }
   }),
 });
--- a/devtools/client/themes/memory.css
+++ b/devtools/client/themes/memory.css
@@ -85,16 +85,24 @@ html, body, #app, #memory-tool {
   align-items: center;
   margin-inline-end: 5px;
 }
 
 .devtools-toolbar > .toolbar-group > label.breakdown-by > span {
   margin-inline-end: 5px;
 }
 
+.devtools-toolbar > label {
+  margin-inline-end: 5px;
+}
+
+#select-view {
+  margin-inline-start: 5px;
+}
+
 #take-snapshot::before {
   background-image: url(images/command-screenshot.png);
 }
 @media (min-resolution: 1.1dppx) {
   #take-snapshot::before {
     background-image: url(images/command-screenshot@2x.png);
   }
 }
@@ -409,16 +417,24 @@ html, body, #app, #memory-tool {
 .theme-light .error::before {
   background-image: url(chrome://devtools/skin/images/webconsole.svg#light-icons);
 }
 
 /**
  * Frame View components
  */
 
+.separator,
+.not-available,
+.heap-tree-item-address {
+  opacity: .5;
+  margin-left: .5em;
+  margin-right: .5em;
+}
+
 .focused .frame-link-filename,
 .focused .frame-link-column,
 .focused .frame-link-line,
 .focused .frame-link-host,
 .focused .frame-link-colon {
   color: var(--theme-selection-color);
 }