Bug 1574192 - Initial watchpoints front end commit. r=nchevobbe
authorMiriam <bmiriam1230@gmail.com>
Sat, 14 Sep 2019 14:13:54 +0000
changeset 493155 f26512d3707796059727bf53542e473c1fa01a17
parent 493154 173761e4fd6620f93d5b8b1c54f657524f3c4152
child 493156 54f895ee30ef8150582654d153bf674eaea1f68d
push id36574
push userbtara@mozilla.com
push dateSat, 14 Sep 2019 21:21:09 +0000
treeherdermozilla-central@0765fd605a48 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnchevobbe
bugs1574192
milestone71.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1574192 - Initial watchpoints front end commit. r=nchevobbe Differential Revision: https://phabricator.services.mozilla.com/D43487
devtools/client/debugger/packages/devtools-reps/src/object-inspector/actions.js
devtools/client/debugger/packages/devtools-reps/src/object-inspector/components/ObjectInspector.css
devtools/client/debugger/packages/devtools-reps/src/object-inspector/components/ObjectInspector.js
devtools/client/debugger/packages/devtools-reps/src/object-inspector/components/ObjectInspectorItem.js
devtools/client/debugger/packages/devtools-reps/src/object-inspector/index.js
devtools/client/debugger/packages/devtools-reps/src/object-inspector/reducer.js
devtools/client/debugger/packages/devtools-reps/src/object-inspector/tests/component/__snapshots__/classnames.js.snap
devtools/client/debugger/packages/devtools-reps/src/object-inspector/tests/component/__snapshots__/window.js.snap
devtools/client/debugger/packages/devtools-reps/src/object-inspector/tests/component/release-actors.js
devtools/client/debugger/packages/devtools-reps/src/object-inspector/tests/utils/make-numerical-buckets.js
devtools/client/debugger/packages/devtools-reps/src/object-inspector/utils/node.js
devtools/client/debugger/src/actions/index.js
devtools/client/debugger/src/client/firefox/commands.js
devtools/client/debugger/src/client/firefox/types.js
devtools/client/debugger/src/components/SecondaryPanes/Scopes.js
devtools/client/debugger/src/reducers/pause.js
devtools/client/debugger/src/utils/pause/why.js
devtools/client/debugger/src/utils/prefs.js
devtools/client/debugger/test/mochitest/browser.ini
devtools/client/debugger/test/mochitest/browser_dbg-watchpoints.js
devtools/client/debugger/test/mochitest/examples/doc-watchpoints.html
devtools/client/debugger/test/mochitest/helpers.js
devtools/client/locales/en-US/debugger.properties
devtools/client/preferences/debugger.js
devtools/client/shared/components/reps/reps.css
devtools/client/shared/components/reps/reps.js
devtools/server/actors/object.js
devtools/server/actors/root.js
devtools/server/tests/unit/test_watchpoint-01.js
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/actions.js
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/actions.js
@@ -2,17 +2,18 @@
  * 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/>. */
 
 // @flow
 
 import type { GripProperties, Node, Props, ReduxAction } from "./types";
 
 const { loadItemProperties } = require("./utils/load-properties");
-const { getLoadedProperties, getActors } = require("./reducer");
+const { getPathExpression, getValue } = require("./utils/node");
+const { getLoadedProperties, getActors, getWatchpoints } = require("./reducer");
 
 type Dispatch = ReduxAction => void;
 
 type ThunkArg = {
   getState: () => {},
   dispatch: Dispatch,
 };
 
@@ -68,16 +69,60 @@ function nodePropertiesLoaded(
   properties: GripProperties
 ) {
   return {
     type: "NODE_PROPERTIES_LOADED",
     data: { node, actor, properties },
   };
 }
 
+/*
+ * This action adds a property watchpoint to an object
+ */
+function addWatchpoint(item, watchpoint: string) {
+  return async function({ dispatch, client }: ThunkArgs) {
+    const { parent, name } = item;
+    const object = getValue(parent);
+    if (!object) {
+      return;
+    }
+
+    const path = parent.path;
+    const property = name;
+    const label = getPathExpression(item);
+    const actor = object.actor;
+
+    await client.addWatchpoint(object, property, label, watchpoint);
+
+    dispatch({
+      type: "SET_WATCHPOINT",
+      data: { path, watchpoint, property, actor },
+    });
+  };
+}
+
+/*
+ * This action removes a property watchpoint from an object
+ */
+function removeWatchpoint(item) {
+  return async function({ dispatch, client }: ThunkArgs) {
+    const object = getValue(item.parent);
+    const property = item.name;
+    const path = item.parent.path;
+    const actor = object.actor;
+
+    await client.removeWatchpoint(object, property);
+
+    dispatch({
+      type: "REMOVE_WATCHPOINT",
+      data: { path, property, actor },
+    });
+  };
+}
+
 function closeObjectInspector() {
   return async ({ getState, client }: ThunkArg) => {
     releaseActors(getState(), client);
   };
 }
 
 /*
  * This action is dispatched when the `roots` prop, provided by a consumer of
@@ -94,18 +139,24 @@ function rootsChanged(props: Props) {
       type: "ROOTS_CHANGED",
       data: props,
     });
   };
 }
 
 function releaseActors(state, client) {
   const actors = getActors(state);
+  const watchpoints = getWatchpoints(state);
+
   for (const actor of actors) {
-    client.releaseActor(actor);
+    // Watchpoints are stored in object actors.
+    // If we release the actor we lose the watchpoint.
+    if (!watchpoints.has(actor)) {
+      client.releaseActor(actor);
+    }
   }
 }
 
 function invokeGetter(
   node: Node,
   targetGrip: object,
   receiverId: string | null,
   getterName: string
@@ -133,9 +184,11 @@ function invokeGetter(
 module.exports = {
   closeObjectInspector,
   invokeGetter,
   nodeExpand,
   nodeCollapse,
   nodeLoadProperties,
   nodePropertiesLoaded,
   rootsChanged,
+  addWatchpoint,
+  removeWatchpoint,
 };
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/components/ObjectInspector.css
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/components/ObjectInspector.css
@@ -50,8 +50,36 @@
 .tree.object-inspector .tree-node.focused * {
   color: inherit;
 }
 
 .tree-node.focused button.open-inspector,
 .tree-node.focused button.invoke-getter {
   background-color: currentColor;
 }
+
+button.remove-set-watchpoint {
+  mask: url("resource://devtools/client/debugger/images/webconsole-logpoint.svg")
+  no-repeat;
+  display: inline-block;
+  vertical-align: top;
+  height: 15px;
+  width: 15px;
+  margin: 0 4px 0px 20px;
+  padding: 0;
+  border: none;
+  background-color: var(--breakpoint-fill);
+  cursor: pointer;
+}
+
+button.remove-get-watchpoint {
+  mask: url("resource://devtools/client/debugger/images/webconsole-logpoint.svg")
+  no-repeat;
+  display: inline-block;
+  vertical-align: top;
+  height: 15px;
+  width: 15px;
+  margin: 0 4px 0px 20px;
+  padding: 0;
+  border: none;
+  background-color: var(--purple-60);
+  cursor: pointer;
+}
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/components/ObjectInspector.js
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/components/ObjectInspector.js
@@ -105,16 +105,22 @@ class ObjectInspector extends Component<
 
   removeOutdatedNodesFromCache(nextProps) {
     // When the roots changes, we can wipe out everything.
     if (this.roots !== nextProps.roots) {
       this.cachedNodes.clear();
       return;
     }
 
+    for (const [path, properties] of nextProps.loadedProperties) {
+      if (properties !== this.props.loadedProperties.get(path)) {
+        this.cachedNodes.delete(path);
+      }
+    }
+
     // If there are new evaluations, we want to remove the existing cached
     // nodes from the cache.
     if (nextProps.evaluations > this.props.evaluations) {
       for (const key of nextProps.evaluations.keys()) {
         if (!this.props.evaluations.has(key)) {
           this.cachedNodes.delete(key);
         }
       }
@@ -129,16 +135,17 @@ class ObjectInspector extends Component<
     // - OR there are new evaluations
     // - OR the expanded paths number changed, and all of them have properties
     //      loaded
     // - OR the expanded paths number did not changed, but old and new sets
     //      differ
     // - OR the focused node changed.
     // - OR the active node changed.
     return (
+      loadedProperties !== nextProps.loadedProperties ||
       loadedProperties.size !== nextProps.loadedProperties.size ||
       evaluations.size !== nextProps.evaluations.size ||
       (expandedPaths.size !== nextProps.expandedPaths.size &&
         [...nextProps.expandedPaths].every(path =>
           nextProps.loadedProperties.has(path)
         )) ||
       (expandedPaths.size === nextProps.expandedPaths.size &&
         [...nextProps.expandedPaths].some(key => !expandedPaths.has(key))) ||
@@ -266,17 +273,16 @@ class ObjectInspector extends Component<
         inline,
         nowrap: disableWrap,
         "object-inspector": true,
       }),
 
       autoExpandAll,
       autoExpandDepth,
       initiallyExpanded,
-
       isExpanded: item => expandedPaths && expandedPaths.has(item.path),
       isExpandable: this.isNodeExpandable,
       focused: this.focusedItem,
       active: this.activeItem,
 
       getRoots: this.getRoots,
       getParent,
       getChildren: this.getItemChildren,
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/components/ObjectInspectorItem.js
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/components/ObjectInspectorItem.js
@@ -75,16 +75,22 @@ type Props = {
       focused: boolean,
       expanded: boolean,
       setExpanded: (Node, boolean) => any,
     }
   ) => any,
 };
 
 class ObjectInspectorItem extends Component<Props> {
+  static get defaultProps() {
+    return {
+      onContextMenu: () => {},
+    };
+  }
+
   // eslint-disable-next-line complexity
   getLabelAndValue(): {
     value?: string | Element,
     label?: string,
   } {
     const { item, depth, expanded, mode } = this.props;
 
     const label = item.name;
@@ -198,16 +204,17 @@ class ObjectInspectorItem extends Compon
     const {
       item,
       depth,
       focused,
       expanded,
       onCmdCtrlClick,
       onDoubleClick,
       dimTopLevelWindow,
+      onContextMenu,
     } = this.props;
 
     const parentElementProps: Object = {
       className: classnames("node object-node", {
         focused,
         lessen:
           !expanded &&
           (nodeIsDefaultProperties(item) ||
@@ -240,16 +247,17 @@ class ObjectInspectorItem extends Compon
         // So we need to also check if the arrow was clicked.
         if (
           Utils.selection.documentHasSelection() &&
           !(e.target && e.target.matches && e.target.matches(".arrow"))
         ) {
           e.stopPropagation();
         }
       },
+      onContextMenu: e => onContextMenu(e, item),
     };
 
     if (onDoubleClick) {
       parentElementProps.onDoubleClick = e => {
         e.stopPropagation();
         onDoubleClick(item, {
           depth,
           focused,
@@ -287,29 +295,45 @@ class ObjectInspectorItem extends Compon
               });
             }
           : undefined,
       },
       label
     );
   }
 
+  renderWatchpointButton() {
+    const { item, removeWatchpoint } = this.props;
+
+    if (!item || !item.contents || !item.contents.watchpoint) {
+      return;
+    }
+
+    const watchpoint = item.contents.watchpoint;
+    return dom.button({
+      className: `remove-${watchpoint}-watchpoint`,
+      title: L10N.getStr("watchpoints.removeWatchpoint"),
+      onClick: () => removeWatchpoint(item),
+    });
+  }
+
   render() {
     const { arrow } = this.props;
 
     const { label, value } = this.getLabelAndValue();
     const labelElement = this.renderLabel(label);
     const delimiter =
       value && labelElement
         ? dom.span({ className: "object-delimiter" }, ": ")
         : null;
 
     return dom.div(
       this.getTreeItemProps(),
       arrow,
       labelElement,
       delimiter,
-      value
+      value,
+      this.renderWatchpointButton()
     );
   }
 }
 
 module.exports = ObjectInspectorItem;
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/index.js
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/index.js
@@ -1,9 +1,10 @@
 /* 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 ObjectInspector = require("./components/ObjectInspector");
 const utils = require("./utils");
 const reducer = require("./reducer");
+const actions = require("./actions");
 
-module.exports = { ObjectInspector, utils, reducer };
+module.exports = { ObjectInspector, utils, actions, reducer };
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/reducer.js
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/reducer.js
@@ -1,20 +1,22 @@
 /* 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/>. */
 
 import type { ReduxAction, State } from "./types";
 
-function initialState() {
+function initialState(overrides) {
   return {
     expandedPaths: new Set(),
     loadedProperties: new Map(),
     evaluations: new Map(),
     actors: new Set(),
+    watchpoints: new Map(),
+    ...overrides,
   };
 }
 
 function reducer(
   state: State = initialState(),
   action: ReduxAction = {}
 ): State {
   const { type, data } = action;
@@ -28,16 +30,44 @@ function reducer(
   }
 
   if (type === "NODE_COLLAPSE") {
     const expandedPaths = new Set(state.expandedPaths);
     expandedPaths.delete(data.node.path);
     return cloneState({ expandedPaths });
   }
 
+  if (type == "SET_WATCHPOINT") {
+    const { watchpoint, property, path } = data;
+    const obj = state.loadedProperties.get(path);
+
+    return cloneState({
+      loadedProperties: new Map(state.loadedProperties).set(
+        path,
+        updateObject(obj, property, watchpoint)
+      ),
+      watchpoints: new Map(state.watchpoints).set(data.actor, data.watchpoint),
+    });
+  }
+
+  if (type === "REMOVE_WATCHPOINT") {
+    const { path, property, actor } = data;
+    const obj = state.loadedProperties.get(path);
+    const watchpoints = new Map(state.watchpoints);
+    watchpoints.delete(actor);
+
+    return cloneState({
+      loadedProperties: new Map(state.loadedProperties).set(
+        path,
+        updateObject(obj, property, null)
+      ),
+      watchpoints: watchpoints,
+    });
+  }
+
   if (type === "NODE_PROPERTIES_LOADED") {
     return cloneState({
       actors: data.actor
         ? new Set(state.actors || []).add(data.actor)
         : state.actors,
       loadedProperties: new Map(state.loadedProperties).set(
         data.node.path,
         action.data.properties
@@ -61,52 +91,70 @@ function reducer(
           (data.result.value.throw || data.result.value.return),
       }),
     });
   }
 
   // NOTE: we clear the state on resume because otherwise the scopes pane
   // would be out of date. Bug 1514760
   if (type === "RESUME" || type == "NAVIGATE") {
-    return initialState();
+    return initialState({ watchpoints: state.watchpoints });
   }
 
   return state;
 }
 
+function updateObject(obj, property, watchpoint) {
+  return {
+    ...obj,
+    ownProperties: {
+      ...obj.ownProperties,
+      [property]: {
+        ...obj.ownProperties[property],
+        watchpoint,
+      },
+    },
+  };
+}
+
 function getObjectInspectorState(state) {
   return state.objectInspector;
 }
 
 function getExpandedPaths(state) {
   return getObjectInspectorState(state).expandedPaths;
 }
 
 function getExpandedPathKeys(state) {
   return [...getExpandedPaths(state).keys()];
 }
 
 function getActors(state) {
   return getObjectInspectorState(state).actors;
 }
 
+function getWatchpoints(state) {
+  return getObjectInspectorState(state).watchpoints;
+}
+
 function getLoadedProperties(state) {
   return getObjectInspectorState(state).loadedProperties;
 }
 
 function getLoadedPropertyKeys(state) {
   return [...getLoadedProperties(state).keys()];
 }
 
 function getEvaluations(state) {
   return getObjectInspectorState(state).evaluations;
 }
 
 const selectors = {
   getActors,
+  getWatchpoints,
   getEvaluations,
   getExpandedPathKeys,
   getExpandedPaths,
   getLoadedProperties,
   getLoadedPropertyKeys,
 };
 
 Object.defineProperty(module.exports, "__esModule", {
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/tests/component/__snapshots__/classnames.js.snap
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/tests/component/__snapshots__/classnames.js.snap
@@ -19,16 +19,17 @@ exports[`ObjectInspector - classnames ha
     id="root"
     onClick={[Function]}
     onKeyDownCapture={null}
     role="treeitem"
   >
     <div
       className="node object-node"
       onClick={[Function]}
+      onContextMenu={[Function]}
     >
       <span
         className="object-label"
       >
         root
       </span>
       <span
         className="object-delimiter"
@@ -64,16 +65,17 @@ exports[`ObjectInspector - classnames ha
     id="root"
     onClick={[Function]}
     onKeyDownCapture={null}
     role="treeitem"
   >
     <div
       className="node object-node"
       onClick={[Function]}
+      onContextMenu={[Function]}
     >
       <span
         className="object-label"
       >
         root
       </span>
       <span
         className="object-delimiter"
@@ -109,16 +111,17 @@ exports[`ObjectInspector - classnames ha
     id="root"
     onClick={[Function]}
     onKeyDownCapture={null}
     role="treeitem"
   >
     <div
       className="node object-node"
       onClick={[Function]}
+      onContextMenu={[Function]}
     >
       <span
         className="object-label"
       >
         root
       </span>
       <span
         className="object-delimiter"
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/tests/component/__snapshots__/window.js.snap
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/tests/component/__snapshots__/window.js.snap
@@ -20,16 +20,17 @@ exports[`ObjectInspector - dimTopLevelWi
     id="window"
     onClick={[Function]}
     onKeyDownCapture={null}
     role="treeitem"
   >
     <div
       className="node object-node"
       onClick={[Function]}
+      onContextMenu={[Function]}
     >
       <button
         className="arrow"
       />
       <span
         className="object-label"
       >
         window
@@ -75,16 +76,17 @@ exports[`ObjectInspector - dimTopLevelWi
     id="root"
     onClick={[Function]}
     onKeyDownCapture={null}
     role="treeitem"
   >
     <div
       className="node object-node focused"
       onClick={[Function]}
+      onContextMenu={[Function]}
     >
       <button
         className="arrow expanded"
       />
       <span
         className="object-label"
       >
         root
@@ -104,16 +106,17 @@ exports[`ObjectInspector - dimTopLevelWi
     <span
       className="tree-indent tree-last-indent"
     >

     </span>
     <div
       className="node object-node"
       onClick={[Function]}
+      onContextMenu={[Function]}
     >
       <button
         className="arrow"
       />
       <span
         className="object-label"
       >
         window
@@ -158,16 +161,17 @@ exports[`ObjectInspector - dimTopLevelWi
     id="window"
     onClick={[Function]}
     onKeyDownCapture={null}
     role="treeitem"
   >
     <div
       className="node object-node lessen"
       onClick={[Function]}
+      onContextMenu={[Function]}
     >
       <button
         className="arrow"
       />
       <span
         className="object-label"
       >
         window
@@ -213,16 +217,17 @@ exports[`ObjectInspector - dimTopLevelWi
     id="window"
     onClick={[Function]}
     onKeyDownCapture={null}
     role="treeitem"
   >
     <div
       className="node object-node focused"
       onClick={[Function]}
+      onContextMenu={[Function]}
     >
       <button
         className="arrow expanded"
       />
       <span
         className="object-label"
       >
         window
@@ -256,16 +261,17 @@ exports[`ObjectInspector - dimTopLevelWi
     <span
       className="tree-indent tree-last-indent"
     >

     </span>
     <div
       className="node object-node lessen"
       onClick={[Function]}
+      onContextMenu={[Function]}
     >
       <span
         className="object-label"
       >
         &lt;prototype&gt;
       </span>
       <span
         className="object-delimiter"
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/tests/component/release-actors.js
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/tests/component/release-actors.js
@@ -56,16 +56,17 @@ function mount(props, { initialState } =
 
 describe("release actors", () => {
   it("calls release actors when unmount", () => {
     const { wrapper, client } = mount(
       {},
       {
         initialState: {
           actors: new Set(["actor 1", "actor 2"]),
+          watchpoints: new Map(),
         },
       }
     );
 
     wrapper.unmount();
 
     expect(client.releaseActor.mock.calls).toHaveLength(2);
     expect(client.releaseActor.mock.calls[0][0]).toBe("actor 1");
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/tests/utils/make-numerical-buckets.js
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/tests/utils/make-numerical-buckets.js
@@ -15,21 +15,17 @@ describe("makeNumericalBuckets", () => {
     });
     const nodes = makeNumericalBuckets(node);
 
     const names = nodes.map(n => n.name);
     const paths = nodes.map(n => n.path.toString());
 
     expect(names).toEqual(["[0…99]", "[100…199]", "[200…233]"]);
 
-    expect(paths).toEqual([
-      "root◦[0…99]",
-      "root◦[100…199]",
-      "root◦[200…233]",
-    ]);
+    expect(paths).toEqual(["root◦[0…99]", "root◦[100…199]", "root◦[200…233]"]);
   });
 
   // TODO: Re-enable when we have support for lonely node.
   it.skip("does not create a numerical bucket for a single node", () => {
     const node = createNode({
       name: "root",
       contents: {
         value: gripArrayStubs.get("Array(101)"),
@@ -55,20 +51,17 @@ describe("makeNumericalBuckets", () => {
     });
     const nodes = makeNumericalBuckets(node);
 
     const names = nodes.map(n => n.name);
     const paths = nodes.map(n => n.path.toString());
 
     expect(names).toEqual(["[0…99]", "[100…101]"]);
 
-    expect(paths).toEqual([
-      "root◦bucket_0-99",
-      "root◦bucket_100-101",
-    ]);
+    expect(paths).toEqual(["root◦bucket_0-99", "root◦bucket_100-101"]);
   });
 
   it("creates sub-buckets when needed", () => {
     const node = createNode({
       name: "root",
       contents: {
         value: gripArrayStubs.get("Array(23456)"),
       },
@@ -129,16 +122,14 @@ describe("makeNumericalBuckets", () => {
     const lastBucketPaths = lastBucketNodes.map(n => n.path.toString());
     expect(lastBucketNames).toEqual([
       "[23000…23099]",
       "[23100…23199]",
       "[23200…23299]",
       "[23300…23399]",
       "[23400…23455]",
     ]);
-    expect(lastBucketPaths[0]).toEqual(
-      "root◦[23000…23455]◦[23000…23099]"
-    );
+    expect(lastBucketPaths[0]).toEqual("root◦[23000…23455]◦[23000…23099]");
     expect(lastBucketPaths[lastBucketPaths.length - 1]).toEqual(
       "root◦[23000…23455]◦[23400…23455]"
     );
   });
 });
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/utils/node.js
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/utils/node.js
@@ -811,16 +811,25 @@ function getChildren(options: {
 
   if (!hasLoadedProps) {
     return [];
   }
 
   return addToCache(makeNodesForProperties(loadedProps, item));
 }
 
+// Builds an expression that resolves to the value of the item in question
+// e.g. `b` in { a: { b: 2 } } resolves to `a.b`
+function getPathExpression(item) {
+  if (item && item.parent) {
+    return `${getPathExpression(item.parent)}.${item.name}`;
+  }
+  return item.name;
+}
+
 function getParent(item: Node): Node | null {
   return item.parent;
 }
 
 function getNumericalPropertiesCount(item: Node): number {
   if (nodeIsBucket(item)) {
     return item.meta.endIndex - item.meta.startIndex + 1;
   }
@@ -917,16 +926,17 @@ module.exports = {
   createNode,
   createGetterNode,
   createSetterNode,
   getActor,
   getChildren,
   getChildrenWithEvaluations,
   getClosestGripNode,
   getClosestNonBucketNode,
+  getPathExpression,
   getParent,
   getParentGripValue,
   getNonPrototypeParentGripValue,
   getNumericalPropertiesCount,
   getValue,
   makeNodesForEntries,
   makeNodesForPromiseProperties,
   makeNodesForProperties,
--- a/devtools/client/debugger/src/actions/index.js
+++ b/devtools/client/debugger/src/actions/index.js
@@ -17,27 +17,31 @@ import * as quickOpen from "./quick-open
 import * as sourceTree from "./source-tree";
 import * as sources from "./sources";
 import * as sourcesActors from "./source-actors";
 import * as tabs from "./tabs";
 import * as threads from "./threads";
 import * as toolbox from "./toolbox";
 import * as preview from "./preview";
 
+// eslint-disable-next-line import/named
+import { objectInspector } from "devtools-reps";
+
 export default {
   ...ast,
   ...navigation,
   ...breakpoints,
   ...expressions,
   ...eventListeners,
   ...sources,
   ...sourcesActors,
   ...tabs,
   ...pause,
   ...ui,
   ...fileSearch,
+  ...objectInspector.actions,
   ...projectTextSearch,
   ...quickOpen,
   ...sourceTree,
   ...threads,
   ...toolbox,
   ...preview,
 };
--- a/devtools/client/debugger/src/client/firefox/commands.js
+++ b/devtools/client/debugger/src/client/firefox/commands.js
@@ -183,16 +183,35 @@ async function sourceContents({
 function setXHRBreakpoint(path: string, method: string) {
   return currentThreadFront.setXHRBreakpoint(path, method);
 }
 
 function removeXHRBreakpoint(path: string, method: string) {
   return currentThreadFront.removeXHRBreakpoint(path, method);
 }
 
+function addWatchpoint(
+  object: Grip,
+  property: string,
+  label: string,
+  watchpointType: string
+) {
+  if (currentTarget.traits.watchpoints) {
+    const objectClient = createObjectClient(object);
+    return objectClient.addWatchpoint(property, label, watchpointType);
+  }
+}
+
+function removeWatchpoint(object: Grip, property: string) {
+  if (currentTarget.traits.watchpoints) {
+    const objectClient = createObjectClient(object);
+    return objectClient.removeWatchpoint(property);
+  }
+}
+
 // Get the string key to use for a breakpoint location.
 // See also duplicate code in breakpoint-actor-map.js :(
 function locationKey(location: BreakpointLocation) {
   const { sourceUrl, line, column } = location;
   const sourceId = location.sourceId || "";
   // $FlowIgnore
   return `${sourceUrl}:${sourceId}:${line}:${column}`;
 }
@@ -509,16 +528,18 @@ const clientCommands = {
   sourceContents,
   getSourceForActor,
   getSourceActorBreakpointPositions,
   getSourceActorBreakableLines,
   hasBreakpoint,
   setBreakpoint,
   setXHRBreakpoint,
   removeXHRBreakpoint,
+  addWatchpoint,
+  removeWatchpoint,
   removeBreakpoint,
   evaluate,
   evaluateInFrame,
   evaluateExpressions,
   navigate,
   reload,
   getProperties,
   getFrameScopes,
--- a/devtools/client/debugger/src/client/firefox/types.js
+++ b/devtools/client/debugger/src/client/firefox/types.js
@@ -255,17 +255,17 @@ export type DebuggerClient = {
   mainRoot: {
     traits: any,
     getFront: string => Promise<*>,
     listProcesses: () => Promise<{ processes: ProcessDescriptor }>,
   },
   connect: () => Promise<*>,
   request: (packet: Object) => Promise<*>,
   attachConsole: (actor: String, listeners: Array<*>) => Promise<*>,
-  createObjectClient: (grip: Grip) => {},
+  createObjectClient: (grip: Grip) => ObjectClient,
   release: (actor: String) => {},
 };
 
 type ProcessDescriptor = Object;
 
 /**
  * A grip is a JSON value that refers to a specific JavaScript value in the
  * debuggee. Grips appear anywhere an arbitrary value from the debuggee needs
@@ -333,16 +333,22 @@ export type SourceClient = {
 
 /**
  * ObjectClient
  * @memberof firefox
  * @static
  */
 export type ObjectClient = {
   getPrototypeAndProperties: () => any,
+  addWatchpoint: (
+    property: string,
+    label: string,
+    watchpointType: string
+  ) => {},
+  removeWatchpoint: (property: string) => {},
 };
 
 /**
  * ThreadFront
  * @memberof firefox
  * @static
  */
 export type ThreadFront = {
--- a/devtools/client/debugger/src/components/SecondaryPanes/Scopes.js
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.js
@@ -1,17 +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/>. */
 
 // @flow
 import React, { PureComponent } from "react";
+import { showMenu } from "devtools-contextmenu";
 import { connect } from "../../utils/connect";
 import actions from "../../actions";
 import { createObjectClient } from "../../client/firefox";
+import { features } from "../../utils/prefs";
 
 import {
   getSelectedSource,
   getSelectedFrame,
   getGeneratedFrameScope,
   getOriginalFrameScope,
   getPauseReason,
   isMapScopesEnabled,
@@ -40,16 +42,18 @@ type Props = {
   why: Why,
   mapScopesEnabled: boolean,
   openLink: typeof actions.openLink,
   openElementInInspector: typeof actions.openElementInInspectorCommand,
   highlightDomElement: typeof actions.highlightDomElement,
   unHighlightDomElement: typeof actions.unHighlightDomElement,
   toggleMapScopes: typeof actions.toggleMapScopes,
   setExpandedScope: typeof actions.setExpandedScope,
+  addWatchpoint: typeof actions.addWatchpoint,
+  removeWatchpoint: typeof actions.removeWatchpoint,
   expandedScopes: string[],
 };
 
 type State = {
   originalScopes: ?(NamedValue[]),
   generatedScopes: ?(NamedValue[]),
   showOriginal: boolean,
 };
@@ -106,16 +110,60 @@ class Scopes extends PureComponent<Props
       });
     }
   }
 
   onToggleMapScopes = () => {
     this.props.toggleMapScopes();
   };
 
+  onContextMenu = (event, item) => {
+    const { addWatchpoint, removeWatchpoint } = this.props;
+
+    if (!features.watchpoints || !item.parent || !item.parent.contents) {
+      return;
+    }
+
+    if (!item.contents || item.contents.watchpoint) {
+      const removeWatchpointItem = {
+        id: "node-menu-remove-watchpoint",
+        // NOTE: we're going to update the UI to add a "break on..."
+        // sub menu. At that point we'll translate the strings. bug 1580591
+        label: "Remove watchpoint",
+        disabled: false,
+        click: () => removeWatchpoint(item),
+      };
+
+      const menuItems = [removeWatchpointItem];
+      return showMenu(event, menuItems);
+    }
+
+    // NOTE: we're going to update the UI to add a "break on..."
+    // sub menu. At that point we'll translate the strings. bug 1580591
+    const addSetWatchpointLabel = "Pause on set";
+    const addGetWatchpointLabel = "Pause on get";
+
+    const addSetWatchpoint = {
+      id: "node-menu-add-set-watchpoint",
+      label: addSetWatchpointLabel,
+      disabled: false,
+      click: () => addWatchpoint(item, "set"),
+    };
+
+    const addGetWatchpoint = {
+      id: "node-menu-add-get-watchpoint",
+      label: addGetWatchpointLabel,
+      disabled: false,
+      click: () => addWatchpoint(item, "get"),
+    };
+
+    const menuItems = [addGetWatchpoint, addSetWatchpoint];
+    showMenu(event, menuItems);
+  };
+
   renderScopesList() {
     const {
       cx,
       isLoading,
       openLink,
       openElementInInspector,
       highlightDomElement,
       unHighlightDomElement,
@@ -142,16 +190,17 @@ class Scopes extends PureComponent<Props
             disableWrap={true}
             dimTopLevelWindow={true}
             openLink={openLink}
             createObjectClient={grip => createObjectClient(grip)}
             onDOMNodeClick={grip => openElementInInspector(grip)}
             onInspectIconClick={grip => openElementInInspector(grip)}
             onDOMNodeMouseOver={grip => highlightDomElement(grip)}
             onDOMNodeMouseOut={grip => unHighlightDomElement(grip)}
+            onContextMenu={this.onContextMenu}
             setExpanded={(path, expand) => setExpandedScope(cx, path, expand)}
             initiallyExpanded={initiallyExpanded}
           />
         </div>
       );
     }
 
     let stateText = L10N.getStr("scopes.notPaused");
@@ -218,10 +267,12 @@ export default connect(
   mapStateToProps,
   {
     openLink: actions.openLink,
     openElementInInspector: actions.openElementInInspectorCommand,
     highlightDomElement: actions.highlightDomElement,
     unHighlightDomElement: actions.unHighlightDomElement,
     toggleMapScopes: actions.toggleMapScopes,
     setExpandedScope: actions.setExpandedScope,
+    addWatchpoint: actions.addWatchpoint,
+    removeWatchpoint: actions.removeWatchpoint,
   }
 )(Scopes);
--- a/devtools/client/debugger/src/reducers/pause.js
+++ b/devtools/client/debugger/src/reducers/pause.js
@@ -639,16 +639,26 @@ export function getInlinePreviews(
   thread: ThreadId,
   frameId: string
 ): Previews {
   return getThreadPauseState(state.pause, thread).inlinePreview[
     getGeneratedFrameId(frameId)
   ];
 }
 
+export function getSelectedInlinePreviews(state: State) {
+  const thread = getCurrentThread(state);
+  const frameId = getSelectedFrameId(state, thread);
+  if (!frameId) {
+    return null;
+  }
+
+  return getInlinePreviews(state, thread, frameId);
+}
+
 export function getInlinePreviewExpression(
   state: State,
   thread: ThreadId,
   frameId: string,
   line: number,
   expression: string
 ) {
   const previews = getThreadPauseState(state.pause, thread).inlinePreview[
--- a/devtools/client/debugger/src/utils/pause/why.js
+++ b/devtools/client/debugger/src/utils/pause/why.js
@@ -12,16 +12,18 @@ import type { Why } from "../../types";
 // "interrupted", "attached"
 const reasons = {
   debuggerStatement: "whyPaused.debuggerStatement",
   breakpoint: "whyPaused.breakpoint",
   exception: "whyPaused.exception",
   resumeLimit: "whyPaused.resumeLimit",
   breakpointConditionThrown: "whyPaused.breakpointConditionThrown",
   eventBreakpoint: "whyPaused.eventBreakpoint",
+  getWatchpoint: "whyPaused.getWatchpoint",
+  setWatchpoint: "whyPaused.setWatchpoint",
   mutationBreakpoint: "whyPaused.mutationBreakpoint",
   interrupted: "whyPaused.interrupted",
   replayForcedPause: "whyPaused.replayForcedPause",
 
   // V8
   DOM: "whyPaused.breakpoint",
   EventListener: "whyPaused.pauseOnDOMEvents",
   XHR: "whyPaused.xhr",
--- a/devtools/client/debugger/src/utils/prefs.js
+++ b/devtools/client/debugger/src/utils/prefs.js
@@ -67,16 +67,17 @@ if (isDevelopment()) {
   pref("devtools.debugger.features.original-blackbox", true);
   pref("devtools.debugger.features.event-listeners-breakpoints", true);
   pref("devtools.debugger.features.dom-mutation-breakpoints", true);
   pref("devtools.debugger.features.log-points", true);
   pref("devtools.debugger.features.inline-preview", true);
   pref("devtools.debugger.log-actions", true);
   pref("devtools.debugger.features.overlay-step-buttons", true);
   pref("devtools.debugger.features.log-event-breakpoints", false);
+  pref("devtools.debugger.features.watchpoints", false);
 }
 
 export const prefs = new PrefsHelper("devtools", {
   fission: ["Bool", "browsertoolbox.fission"],
   logging: ["Bool", "debugger.logging"],
   editorWrapping: ["Bool", "debugger.ui.editor-wrapping"],
   alphabetizeOutline: ["Bool", "debugger.alphabetize-outline"],
   autoPrettyPrint: ["Bool", "debugger.auto-pretty-print"],
@@ -135,16 +136,17 @@ export const features = new PrefsHelper(
   xhrBreakpoints: ["Bool", "xhr-breakpoints"],
   originalBlackbox: ["Bool", "original-blackbox"],
   eventListenersBreakpoints: ["Bool", "event-listeners-breakpoints"],
   domMutationBreakpoints: ["Bool", "dom-mutation-breakpoints"],
   logPoints: ["Bool", "log-points"],
   showOverlayStepButtons: ["Bool", "overlay-step-buttons"],
   inlinePreview: ["Bool", "inline-preview"],
   logEventBreakpoints: ["Bool", "log-event-breakpoints"],
+  watchpoints: ["Bool", "watchpoints"],
 });
 
 export const asyncStore = asyncStoreHelper("debugger", {
   pendingBreakpoints: ["pending-breakpoints", {}],
   tabs: ["tabs", []],
   xhrBreakpoints: ["xhr-breakpoints", []],
   eventListenerBreakpoints: ["event-listener-breakpoints", undefined],
 });
--- a/devtools/client/debugger/test/mochitest/browser.ini
+++ b/devtools/client/debugger/test/mochitest/browser.ini
@@ -162,8 +162,10 @@ skip-if = (os == 'linux' && debug) || (o
 [browser_dbg-inline-script-offset.js]
 [browser_dbg-scopes-xrays.js]
 [browser_dbg-merge-scopes.js]
 [browser_dbg-message-run-to-completion.js]
 [browser_dbg-remember-expanded-scopes.js]
 [browser_dbg-bfcache.js]
 [browser_dbg-gc-breakpoint-positions.js]
 [browser_dbg-gc-sources.js]
+[browser_dbg-watchpoints.js]
+skip-if = debug
new file mode 100644
--- /dev/null
+++ b/devtools/client/debugger/test/mochitest/browser_dbg-watchpoints.js
@@ -0,0 +1,38 @@
+/* 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/>. */
+
+// Tests adding a watchpoint
+
+add_task(async function() {
+  pushPref("devtools.debugger.features.watchpoints", true);
+  const dbg = await initDebugger("doc-sources.html");
+
+  await navigate(dbg, "doc-watchpoints.html", "doc-watchpoints.html");
+  await selectSource(dbg, "doc-watchpoints.html");
+  await waitForPaused(dbg);
+
+  info(`Add a get watchpoint at b`);
+  await toggleScopeNode(dbg, 3);
+  const addedWatchpoint = waitForDispatch(dbg, "SET_WATCHPOINT");
+  await rightClickScopeNode(dbg, 5);
+  selectContextMenuItem(dbg, selectors.addGetWatchpoint);
+  await addedWatchpoint;
+
+  info(`Resume and wait to pause at the access to b on line 12`);
+  resume(dbg);
+  await waitForPaused(dbg);
+  await waitForState(dbg, () => dbg.selectors.getSelectedInlinePreviews());
+  assertPausedAtSourceAndLine(
+    dbg,
+    findSource(dbg, "doc-watchpoints.html").id,
+    12
+  );
+
+  const removedWatchpoint = waitForDispatch(dbg, "REMOVE_WATCHPOINT");
+  await rightClickScopeNode(dbg, 5);
+  selectContextMenuItem(dbg, selectors.removeWatchpoint);
+  await removedWatchpoint;
+  resume(dbg);
+  await waitForRequestsToSettle(dbg);
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/debugger/test/mochitest/examples/doc-watchpoints.html
@@ -0,0 +1,21 @@
+<!DOCTYPE HTML>
+<html>
+
+<script>
+  const obj = { a: { b: 2, c: 3 }, b: 2 };
+  debugger
+  obj.b = 3;
+
+  obj.a.b = 3;
+  obj.a.b = 4;
+
+  console.log(obj.b);
+  obj.b = 4;
+
+  debugger;
+</script>
+
+<body>
+Hello World!
+</body>
+</html>
--- a/devtools/client/debugger/test/mochitest/helpers.js
+++ b/devtools/client/debugger/test/mochitest/helpers.js
@@ -414,17 +414,17 @@ function isPaused(dbg) {
 // Make sure the debugger is paused at a certain source ID and line.
 function assertPausedAtSourceAndLine(dbg, expectedSourceId, expectedLine) {
   assertPaused(dbg);
 
   const frames = dbg.selectors.getCurrentThreadFrames();
   ok(frames.length >= 1, "Got at least one frame");
   const { sourceId, line } = frames[0].location;
   ok(sourceId == expectedSourceId, "Frame has correct source");
-  ok(line == expectedLine, "Frame has correct line");
+  ok(line == expectedLine, `Frame paused at ${line}, but expected ${expectedLine}`);
 }
 
 // Get any workers associated with the debugger.
 async function getThreads(dbg) {
   await dbg.actions.updateThreads();
   return dbg.selectors.getThreads();
 }
 
@@ -1286,16 +1286,19 @@ const selectors = {
   projectSerchExpandedResults: ".project-text-search .result",
   threadsPaneItems: ".threads-pane .thread",
   threadsPaneItem: i => `.threads-pane .thread:nth-child(${i})`,
   threadsPaneItemPause: i => `${selectors.threadsPaneItem(i)} .pause-badge`,
   CodeMirrorLines: ".CodeMirror-lines",
   inlinePreviewLables: ".CodeMirror-linewidget .inline-preview-label",
   inlinePreviewValues: ".CodeMirror-linewidget .inline-preview-value",
   inlinePreviewOpenInspector: ".inline-preview-value button.open-inspector",
+  addGetWatchpoint: "#node-menu-add-get-watchpoint",
+  addSetWatchpoint: "#node-menu-add-set-watchpoint",
+  removeWatchpoint: "#node-menu-remove-watchpoint"
 };
 
 function getSelector(elementName, ...args) {
   let selector = selectors[elementName];
   if (!selector) {
     throw new Error(`The selector ${elementName} is not defined`);
   }
 
@@ -1386,16 +1389,17 @@ function shiftClickElement(dbg, elementN
 function rightClickElement(dbg, elementName, ...args) {
   const selector = getSelector(elementName, ...args);
   const doc = dbg.win.document;
   return rightClickEl(dbg, doc.querySelector(selector));
 }
 
 function rightClickEl(dbg, el) {
   const doc = dbg.win.document;
+  el.scrollIntoView();
   EventUtils.synthesizeMouseAtCenter(el, { type: "contextmenu" }, dbg.win);
 }
 
 async function clickGutter(dbg, line) {
   const el = await codeMirrorGutterElement(dbg, line);
   clickDOMElement(dbg, el);
 }
 
@@ -1438,16 +1442,20 @@ function toggleScopes(dbg) {
 function toggleExpressionNode(dbg, index) {
   return toggleObjectInspectorNode(findElement(dbg, "expressionNode", index));
 }
 
 function toggleScopeNode(dbg, index) {
   return toggleObjectInspectorNode(findElement(dbg, "scopeNode", index));
 }
 
+function rightClickScopeNode(dbg, index) {
+  rightClickObjectInspectorNode(dbg, findElement(dbg, "scopeNode", index));
+}
+
 function getScopeLabel(dbg, index) {
   return findElement(dbg, "scopeNode", index).innerText;
 }
 
 function getScopeValue(dbg, index) {
   return findElement(dbg, "scopeValue", index).innerText;
 }
 
@@ -1457,16 +1465,28 @@ function toggleObjectInspectorNode(node)
 
   log(`Toggling node ${node.innerText}`);
   node.click();
   return waitUntil(
     () => objectInspector.querySelectorAll(".node").length !== properties
   );
 }
 
+function rightClickObjectInspectorNode(dbg, node) {
+  const objectInspector = node.closest(".object-inspector");
+  const properties = objectInspector.querySelectorAll(".node").length;
+
+  log(`Right clicking node ${node.innerText}`);
+  rightClickEl(dbg, node);
+
+  return waitUntil(
+    () => objectInspector.querySelectorAll(".node").length !== properties
+  );
+}
+
 function getCM(dbg) {
   const el = dbg.win.document.querySelector(".CodeMirror");
   return el.CodeMirror;
 }
 
 function getCoordsFromPosition(cm, { line, ch }) {
   return cm.charCoords({ line: ~~line, ch: ~~ch });
 }
--- a/devtools/client/locales/en-US/debugger.properties
+++ b/devtools/client/locales/en-US/debugger.properties
@@ -486,16 +486,20 @@ xhrBreakpoints.label=Add XHR breakpoint
 
 # LOCALIZATION NOTE (xhrBreakpoints.item.label): message displayed when reaching a breakpoint for XHR requests. %S is replaced by the path provided as condition for the breakpoint.
 xhrBreakpoints.item.label=URL contains “%S”
 
 # LOCALIZATION NOTE (pauseOnAnyXHR): The pause on any XHR checkbox description
 # when the debugger will pause on any XHR requests.
 pauseOnAnyXHR=Pause on any URL
 
+# LOCALIZATION NOTE (watchpoints.removeWatchpoint): This is the text that appears in the
+# context menu to delete a watchpoint on an object property.
+watchpoints.removeWatchpoint=Remove watchpoint
+
 # LOCALIZATION NOTE (sourceTabs.closeTab): Editor source tab context menu item
 # for closing the selected tab below the mouse.
 sourceTabs.closeTab=Close tab
 sourceTabs.closeTab.accesskey=c
 sourceTabs.closeTab.key=CmdOrCtrl+W
 
 # LOCALIZATION NOTE (sourceTabs.closeOtherTabs): Editor source tab context menu item
 # for closing the other tabs.
@@ -771,16 +775,21 @@ whyPaused.breakpointConditionThrown=Erro
 # xml http request
 whyPaused.xhr=Paused on XMLHttpRequest
 
 # LOCALIZATION NOTE (whyPaused.promiseRejection): The text that is displayed
 # in a info block explaining how the debugger is currently paused on a
 # promise rejection
 whyPaused.promiseRejection=Paused on promise rejection
 
+# LOCALIZATION NOTE (whyPaused.getWatchpoint): The text that is displayed
+# in a info block explaining how the debugger is currently paused at a
+# watchpoint on an object property
+whyPaused.getWatchpoint=Paused on property access
+
 # LOCALIZATION NOTE (whyPaused.assert): The text that is displayed
 # in a info block explaining how the debugger is currently paused on an
 # assert
 whyPaused.assert=Paused on assertion
 
 # LOCALIZATION NOTE (whyPaused.debugCommand): The text that is displayed
 # in a info block explaining how the debugger is currently paused on a
 # debugger statement
--- a/devtools/client/preferences/debugger.js
+++ b/devtools/client/preferences/debugger.js
@@ -81,8 +81,9 @@ pref("devtools.debugger.features.xhr-bre
 pref("devtools.debugger.features.original-blackbox", true);
 pref("devtools.debugger.features.event-listeners-breakpoints", true);
 pref("devtools.debugger.features.dom-mutation-breakpoints", true);
 pref("devtools.debugger.features.log-points", true);
 pref("devtools.debugger.features.overlay-step-buttons", false);
 pref("devtools.debugger.features.overlay-step-buttons", true);
 pref("devtools.debugger.features.inline-preview", true);
 pref("devtools.debugger.features.log-event-breakpoints", false);
+pref("devtools.debugger.features.watchpoints", false);
--- a/devtools/client/shared/components/reps/reps.css
+++ b/devtools/client/shared/components/reps/reps.css
@@ -453,8 +453,36 @@ button.invoke-getter {
 .tree.object-inspector .tree-node.focused * {
   color: inherit;
 }
 
 .tree-node.focused button.open-inspector,
 .tree-node.focused button.invoke-getter {
   background-color: currentColor;
 }
+
+button.remove-set-watchpoint {
+  mask: url("resource://devtools/client/debugger/images/webconsole-logpoint.svg")
+  no-repeat;
+  display: inline-block;
+  vertical-align: top;
+  height: 15px;
+  width: 15px;
+  margin: 0 4px 0px 20px;
+  padding: 0;
+  border: none;
+  background-color: var(--breakpoint-fill);
+  cursor: pointer;
+}
+
+button.remove-get-watchpoint {
+  mask: url("resource://devtools/client/debugger/images/webconsole-logpoint.svg")
+  no-repeat;
+  display: inline-block;
+  vertical-align: top;
+  height: 15px;
+  width: 15px;
+  margin: 0 4px 0px 20px;
+  padding: 0;
+  border: none;
+  background-color: var(--purple-60);
+  cursor: pointer;
+}
--- a/devtools/client/shared/components/reps/reps.js
+++ b/devtools/client/shared/components/reps/reps.js
@@ -2316,16 +2316,26 @@ function getChildren(options) {
     return [];
   }
 
   if (!hasLoadedProps) {
     return [];
   }
 
   return addToCache(makeNodesForProperties(loadedProps, item));
+} // Builds an expression that resolves to the value of the item in question
+// e.g. `b` in { a: { b: 2 } } resolves to `a.b`
+
+
+function getPathExpression(item) {
+  if (item && item.parent) {
+    return `${getPathExpression(item.parent)}.${item.name}`;
+  }
+
+  return item.name;
 }
 
 function getParent(item) {
   return item.parent;
 }
 
 function getNumericalPropertiesCount(item) {
   if (nodeIsBucket(item)) {
@@ -2426,16 +2436,17 @@ module.exports = {
   createNode,
   createGetterNode,
   createSetterNode,
   getActor,
   getChildren,
   getChildrenWithEvaluations,
   getClosestGripNode,
   getClosestNonBucketNode,
+  getPathExpression,
   getParent,
   getParentGripValue,
   getNonPrototypeParentGripValue,
   getNumericalPropertiesCount,
   getValue,
   makeNodesForEntries,
   makeNodesForPromiseProperties,
   makeNodesForProperties,
@@ -2479,22 +2490,24 @@ module.exports = {
 /***/ }),
 
 /***/ 115:
 /***/ (function(module, exports) {
 
 /* 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/>. */
-function initialState() {
+function initialState(overrides) {
   return {
     expandedPaths: new Set(),
     loadedProperties: new Map(),
     evaluations: new Map(),
-    actors: new Set()
+    actors: new Set(),
+    watchpoints: new Map(),
+    ...overrides
   };
 }
 
 function reducer(state = initialState(), action = {}) {
   const {
     type,
     data
   } = action;
@@ -2512,16 +2525,44 @@ function reducer(state = initialState(),
   if (type === "NODE_COLLAPSE") {
     const expandedPaths = new Set(state.expandedPaths);
     expandedPaths.delete(data.node.path);
     return cloneState({
       expandedPaths
     });
   }
 
+  if (type == "SET_WATCHPOINT") {
+    const {
+      watchpoint,
+      property,
+      path
+    } = data;
+    const obj = state.loadedProperties.get(path);
+    return cloneState({
+      loadedProperties: new Map(state.loadedProperties).set(path, updateObject(obj, property, watchpoint)),
+      watchpoints: new Map(state.watchpoints).set(data.actor, data.watchpoint)
+    });
+  }
+
+  if (type === "REMOVE_WATCHPOINT") {
+    const {
+      path,
+      property,
+      actor
+    } = data;
+    const obj = state.loadedProperties.get(path);
+    const watchpoints = new Map(state.watchpoints);
+    watchpoints.delete(actor);
+    return cloneState({
+      loadedProperties: new Map(state.loadedProperties).set(path, updateObject(obj, property, null)),
+      watchpoints: watchpoints
+    });
+  }
+
   if (type === "NODE_PROPERTIES_LOADED") {
     return cloneState({
       actors: data.actor ? new Set(state.actors || []).add(data.actor) : state.actors,
       loadedProperties: new Map(state.loadedProperties).set(data.node.path, action.data.properties)
     });
   }
 
   if (type === "ROOTS_CHANGED") {
@@ -2535,52 +2576,69 @@ function reducer(state = initialState(),
         getterValue: data.result && data.result.value && (data.result.value.throw || data.result.value.return)
       })
     });
   } // NOTE: we clear the state on resume because otherwise the scopes pane
   // would be out of date. Bug 1514760
 
 
   if (type === "RESUME" || type == "NAVIGATE") {
-    return initialState();
+    return initialState({
+      watchpoints: state.watchpoints
+    });
   }
 
   return state;
 }
 
+function updateObject(obj, property, watchpoint) {
+  return { ...obj,
+    ownProperties: { ...obj.ownProperties,
+      [property]: { ...obj.ownProperties[property],
+        watchpoint
+      }
+    }
+  };
+}
+
 function getObjectInspectorState(state) {
   return state.objectInspector;
 }
 
 function getExpandedPaths(state) {
   return getObjectInspectorState(state).expandedPaths;
 }
 
 function getExpandedPathKeys(state) {
   return [...getExpandedPaths(state).keys()];
 }
 
 function getActors(state) {
   return getObjectInspectorState(state).actors;
 }
 
+function getWatchpoints(state) {
+  return getObjectInspectorState(state).watchpoints;
+}
+
 function getLoadedProperties(state) {
   return getObjectInspectorState(state).loadedProperties;
 }
 
 function getLoadedPropertyKeys(state) {
   return [...getLoadedProperties(state).keys()];
 }
 
 function getEvaluations(state) {
   return getObjectInspectorState(state).evaluations;
 }
 
 const selectors = {
   getActors,
+  getWatchpoints,
   getEvaluations,
   getExpandedPathKeys,
   getExpandedPaths,
   getLoadedProperties,
   getLoadedPropertyKeys
 };
 Object.defineProperty(module.exports, "__esModule", {
   value: true
@@ -7390,19 +7448,22 @@ module.exports = {
  * 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 ObjectInspector = __webpack_require__(483);
 
 const utils = __webpack_require__(116);
 
 const reducer = __webpack_require__(115);
 
+const actions = __webpack_require__(485);
+
 module.exports = {
   ObjectInspector,
   utils,
+  actions,
   reducer
 };
 
 /***/ }),
 
 /***/ 483:
 /***/ (function(module, exports, __webpack_require__) {
 
@@ -7516,16 +7577,22 @@ class ObjectInspector extends Component 
     }
   }
 
   removeOutdatedNodesFromCache(nextProps) {
     // When the roots changes, we can wipe out everything.
     if (this.roots !== nextProps.roots) {
       this.cachedNodes.clear();
       return;
+    }
+
+    for (const [path, properties] of nextProps.loadedProperties) {
+      if (properties !== this.props.loadedProperties.get(path)) {
+        this.cachedNodes.delete(path);
+      }
     } // If there are new evaluations, we want to remove the existing cached
     // nodes from the cache.
 
 
     if (nextProps.evaluations > this.props.evaluations) {
       for (const key of nextProps.evaluations.keys()) {
         if (!this.props.evaluations.has(key)) {
           this.cachedNodes.delete(key);
@@ -7544,17 +7611,17 @@ class ObjectInspector extends Component 
     // - OR there are new evaluations
     // - OR the expanded paths number changed, and all of them have properties
     //      loaded
     // - OR the expanded paths number did not changed, but old and new sets
     //      differ
     // - OR the focused node changed.
     // - OR the active node changed.
 
-    return loadedProperties.size !== nextProps.loadedProperties.size || evaluations.size !== nextProps.evaluations.size || expandedPaths.size !== nextProps.expandedPaths.size && [...nextProps.expandedPaths].every(path => nextProps.loadedProperties.has(path)) || expandedPaths.size === nextProps.expandedPaths.size && [...nextProps.expandedPaths].some(key => !expandedPaths.has(key)) || this.focusedItem !== nextProps.focusedItem || this.activeItem !== nextProps.activeItem || this.roots !== nextProps.roots;
+    return loadedProperties !== nextProps.loadedProperties || loadedProperties.size !== nextProps.loadedProperties.size || evaluations.size !== nextProps.evaluations.size || expandedPaths.size !== nextProps.expandedPaths.size && [...nextProps.expandedPaths].every(path => nextProps.loadedProperties.has(path)) || expandedPaths.size === nextProps.expandedPaths.size && [...nextProps.expandedPaths].some(key => !expandedPaths.has(key)) || this.focusedItem !== nextProps.focusedItem || this.activeItem !== nextProps.activeItem || this.roots !== nextProps.roots;
   }
 
   componentWillUnmount() {
     this.props.closeObjectInspector();
   }
 
   getItemChildren(item) {
     const {
@@ -7742,18 +7809,24 @@ module.exports = __WEBPACK_EXTERNAL_MODU
 /* 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 {
   loadItemProperties
 } = __webpack_require__(196);
 
 const {
+  getPathExpression,
+  getValue
+} = __webpack_require__(114);
+
+const {
   getLoadedProperties,
-  getActors
+  getActors,
+  getWatchpoints
 } = __webpack_require__(115);
 
 /**
  * This action is responsible for expanding a given node, which also means that
  * it will call the action responsible to fetch properties.
  */
 function nodeExpand(node, actor) {
   return async ({
@@ -7812,16 +7885,77 @@ function nodePropertiesLoaded(node, acto
     type: "NODE_PROPERTIES_LOADED",
     data: {
       node,
       actor,
       properties
     }
   };
 }
+/*
+ * This action adds a property watchpoint to an object
+ */
+
+
+function addWatchpoint(item, watchpoint) {
+  return async function ({
+    dispatch,
+    client
+  }) {
+    const {
+      parent,
+      name
+    } = item;
+    const object = getValue(parent);
+
+    if (!object) {
+      return;
+    }
+
+    const path = parent.path;
+    const property = name;
+    const label = getPathExpression(item);
+    const actor = object.actor;
+    await client.addWatchpoint(object, property, label, watchpoint);
+    dispatch({
+      type: "SET_WATCHPOINT",
+      data: {
+        path,
+        watchpoint,
+        property,
+        actor
+      }
+    });
+  };
+}
+/*
+ * This action removes a property watchpoint from an object
+ */
+
+
+function removeWatchpoint(item) {
+  return async function ({
+    dispatch,
+    client
+  }) {
+    const object = getValue(item.parent);
+    const property = item.name;
+    const path = item.parent.path;
+    const actor = object.actor;
+    await client.removeWatchpoint(object, property);
+    dispatch({
+      type: "REMOVE_WATCHPOINT",
+      data: {
+        path,
+        property,
+        actor
+      }
+    });
+  };
+}
 
 function closeObjectInspector() {
   return async ({
     getState,
     client
   }) => {
     releaseActors(getState(), client);
   };
@@ -7847,19 +7981,24 @@ function rootsChanged(props) {
       type: "ROOTS_CHANGED",
       data: props
     });
   };
 }
 
 function releaseActors(state, client) {
   const actors = getActors(state);
+  const watchpoints = getWatchpoints(state);
 
   for (const actor of actors) {
-    client.releaseActor(actor);
+    // Watchpoints are stored in object actors.
+    // If we release the actor we lose the watchpoint.
+    if (!watchpoints.has(actor)) {
+      client.releaseActor(actor);
+    }
   }
 }
 
 function invokeGetter(node, targetGrip, receiverId, getterName) {
   return async ({
     dispatch,
     client,
     getState
@@ -7882,17 +8021,19 @@ function invokeGetter(node, targetGrip, 
 
 module.exports = {
   closeObjectInspector,
   invokeGetter,
   nodeExpand,
   nodeCollapse,
   nodeLoadProperties,
   nodePropertiesLoaded,
-  rootsChanged
+  rootsChanged,
+  addWatchpoint,
+  removeWatchpoint
 };
 
 /***/ }),
 
 /***/ 486:
 /***/ (function(module, exports) {
 
 // removed by extract-text-webpack-plugin
@@ -7952,17 +8093,23 @@ const {
   nodeIsLongString,
   nodeHasFullText,
   nodeHasGetter,
   getNonPrototypeParentGripValue,
   getParentGripValue
 } = Utils.node;
 
 class ObjectInspectorItem extends Component {
-  // eslint-disable-next-line complexity
+  static get defaultProps() {
+    return {
+      onContextMenu: () => {}
+    };
+  } // eslint-disable-next-line complexity
+
+
   getLabelAndValue() {
     const {
       item,
       depth,
       expanded,
       mode
     } = this.props;
     const label = item.name;
@@ -8067,17 +8214,18 @@ class ObjectInspectorItem extends Compon
   getTreeItemProps() {
     const {
       item,
       depth,
       focused,
       expanded,
       onCmdCtrlClick,
       onDoubleClick,
-      dimTopLevelWindow
+      dimTopLevelWindow,
+      onContextMenu
     } = this.props;
     const parentElementProps = {
       className: classnames("node object-node", {
         focused,
         lessen: !expanded && (nodeIsDefaultProperties(item) || nodeIsPrototype(item) || nodeIsGetter(item) || nodeIsSetter(item) || dimTopLevelWindow === true && nodeIsWindow(item) && depth === 0),
         block: nodeIsBlock(item)
       }),
       onClick: e => {
@@ -8096,17 +8244,18 @@ class ObjectInspectorItem extends Compon
         // user clicked on the arrow itself. Indeed because the arrow is an
         // image, clicking on it does not remove any existing text selection.
         // So we need to also check if the arrow was clicked.
 
 
         if (Utils.selection.documentHasSelection() && !(e.target && e.target.matches && e.target.matches(".arrow"))) {
           e.stopPropagation();
         }
-      }
+      },
+      onContextMenu: e => onContextMenu(e, item)
     };
 
     if (onDoubleClick) {
       parentElementProps.onDoubleClick = e => {
         e.stopPropagation();
         onDoubleClick(item, {
           depth,
           focused,
@@ -8144,29 +8293,47 @@ class ObjectInspectorItem extends Compon
           focused,
           expanded,
           setExpanded: this.props.setExpanded
         });
       } : undefined
     }, label);
   }
 
+  renderWatchpointButton() {
+    const {
+      item,
+      removeWatchpoint
+    } = this.props;
+
+    if (!item || !item.contents || !item.contents.watchpoint) {
+      return;
+    }
+
+    const watchpoint = item.contents.watchpoint;
+    return dom.button({
+      className: `remove-${watchpoint}-watchpoint`,
+      title: L10N.getStr("watchpoints.removeWatchpoint"),
+      onClick: () => removeWatchpoint(item)
+    });
+  }
+
   render() {
     const {
       arrow
     } = this.props;
     const {
       label,
       value
     } = this.getLabelAndValue();
     const labelElement = this.renderLabel(label);
     const delimiter = value && labelElement ? dom.span({
       className: "object-delimiter"
     }, ": ") : null;
-    return dom.div(this.getTreeItemProps(), arrow, labelElement, delimiter, value);
+    return dom.div(this.getTreeItemProps(), arrow, labelElement, delimiter, value, this.renderWatchpointButton());
   }
 
 }
 
 module.exports = ObjectInspectorItem;
 
 /***/ }),
 
--- a/devtools/server/actors/object.js
+++ b/devtools/server/actors/object.js
@@ -118,44 +118,44 @@ const proto = {
     const desc = this.obj.getOwnPropertyDescriptor(property);
 
     if (desc.set || desc.get) {
       return;
     }
 
     this._originalDescriptors.set(property, { desc, watchpointType });
 
-    const pauseAndRespond = () => {
+    const pauseAndRespond = type => {
       const frame = this.thread.dbg.getNewestFrame();
       this.thread._pauseAndRespond(frame, {
-        type: "watchpoint",
+        type: type,
         message: label,
       });
     };
 
     if (watchpointType === "get") {
       this.obj.defineProperty(property, {
         configurable: desc.configurable,
         enumerable: desc.enumerable,
         set: this.obj.makeDebuggeeValue(v => {
           desc.value = v;
         }),
         get: this.obj.makeDebuggeeValue(() => {
-          pauseAndRespond();
+          pauseAndRespond("getWatchpoint");
           return desc.value;
         }),
       });
     }
 
     if (watchpointType === "set") {
       this.obj.defineProperty(property, {
         configurable: desc.configurable,
         enumerable: desc.enumerable,
         set: this.obj.makeDebuggeeValue(v => {
-          pauseAndRespond();
+          pauseAndRespond("setWatchpoint");
           desc.value = v;
         }),
         get: this.obj.makeDebuggeeValue(v => {
           return desc.value;
         }),
       });
     }
   },
--- a/devtools/server/actors/root.js
+++ b/devtools/server/actors/root.js
@@ -175,16 +175,18 @@ RootActor.prototype = {
     // Version 1 - Firefox 65: Introduces a duration-based buffer. It can be controlled
     // by adding a `duration` property (in seconds) to the options passed to
     // `front.startProfiler`. This is an optional parameter but it will throw an error if
     // the profiled Firefox doesn't accept it.
     perfActorVersion: 1,
     // Supports native log points and modifying the condition/log of an existing
     // breakpoints. Fx66+
     nativeLogpoints: true,
+    // Supports watchpoints in the server for Fx71+
+    watchpoints: true,
     // support older browsers for Fx69+
     hasThreadFront: true,
   },
 
   /**
    * Return a 'hello' packet as specified by the Remote Debugging Protocol.
    */
   sayHello: function() {
--- a/devtools/server/tests/unit/test_watchpoint-01.js
+++ b/devtools/server/tests/unit/test_watchpoint-01.js
@@ -46,17 +46,17 @@ async function testSetWatchpoint({ threa
   const args = packet.frame.arguments;
   const obj = args[0];
   const objClient = threadFront.pauseGrip(obj);
   await objClient.addWatchpoint("a", "obj.a", "set");
 
   //Test that watchpoint triggers pause on set.
   const packet2 = await resumeAndWaitForPause(threadFront);
   Assert.equal(packet2.frame.where.line, 4);
-  Assert.equal(packet2.why.type, "watchpoint");
+  Assert.equal(packet2.why.type, "setWatchpoint");
   Assert.equal(obj.preview.ownProperties.a.value, 1);
   
   await resume(threadFront);
 }
 
 async function testGetWatchpoint({ threadFront, debuggee }) {
   function evaluateTestCode(debuggee) {
     /* eslint-disable */
@@ -86,17 +86,17 @@ async function testGetWatchpoint({ threa
   const args = packet.frame.arguments;
   const obj = args[0];
   const objClient = threadFront.pauseGrip(obj);
   await objClient.addWatchpoint("a", "obj.a", "get");
 
   //Test that watchpoint triggers pause on get.
   const packet2 = await resumeAndWaitForPause(threadFront);
   Assert.equal(packet2.frame.where.line, 4);
-  Assert.equal(packet2.why.type, "watchpoint");
+  Assert.equal(packet2.why.type, "getWatchpoint");
   Assert.equal(obj.preview.ownProperties.a.value, 1);
   
   await resume(threadFront);
 }
 
 async function testRemoveWatchpoint({ threadFront, debuggee }) {
   function evaluateTestCode(debuggee) {
     /* eslint-disable */