Bug 1287007 - Fix "onclick" in contextMenus, to child. r=billm
authorRob Wu <rob@robwu.nl>
Mon, 12 Sep 2016 18:26:03 -0700
changeset 428721 f2f75e6e2d607e8a123e2444c2fd9fe9a4e276c8
parent 428720 4c43afedf60f8852f0a1729123af998da103f50b
child 428722 44c68913ebf56daf3aef3454c615597fef3d62b9
push id33405
push userbcampen@mozilla.com
push dateMon, 24 Oct 2016 15:32:53 +0000
reviewersbillm
bugs1287007
milestone52.0a1
Bug 1287007 - Fix "onclick" in contextMenus, to child. r=billm Main thing: Making contextMenus implementation webext-oop compatible. Preparation: - Add getParentEvent to ChildAPIManager to allow use of remote events. - Introduce `addon_parent_only` to "allowedContexts" to only generate a schema API in the main process. - Do not fill in `null` for missing keys if the schema declares a key as `"optional": "omit-key-if-missing"`. This is needed for the second point in the next list. Drive-by fixes: - Ensure that the "onclick" handler is erased when a context closes. - Do not clear the "onclick" handler in `contextMenus.update` if the onclick key has been omitted (parity with Chrome). - Remove some unnecessary `Promise.resolve()` - Add extensive set of tests that check the behavior of the contextMenus APIs with regards to the onclick attribute in various scenarios. MozReview-Commit-ID: A5f3AUQzU8T
browser/components/extensions/ext-c-contextMenus.js
browser/components/extensions/ext-contextMenus.js
browser/components/extensions/extensions-browser.manifest
browser/components/extensions/jar.mn
browser/components/extensions/schemas/context_menus.json
browser/components/extensions/test/browser/browser.ini
browser/components/extensions/test/browser/browser_ext_contextMenus_icons.js
browser/components/extensions/test/browser/browser_ext_contextMenus_onclick.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionUtils.jsm
toolkit/components/extensions/Schemas.jsm
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/ext-c-contextMenus.js
@@ -0,0 +1,158 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// If id is not specified for an item we use an integer.
+// This ID need only be unique within a single addon. Since all addon code that
+// can use this API runs in the same process, this local variable suffices.
+var gNextMenuItemID = 0;
+
+// Map[Extension -> Map[string or id, ContextMenusClickPropHandler]]
+var gPropHandlers = new Map();
+
+// The contextMenus API supports an "onclick" attribute in the create/update
+// methods to register a callback. This class manages these onclick properties.
+class ContextMenusClickPropHandler {
+  constructor(context) {
+    this.context = context;
+    // Map[string or integer -> callback]
+    this.onclickMap = new Map();
+    this.dispatchEvent = this.dispatchEvent.bind(this);
+  }
+
+  // A listener on contextMenus.onClicked that forwards the event to the only
+  // listener, if any.
+  dispatchEvent(info, tab) {
+    let onclick = this.onclickMap.get(info.menuItemId);
+    if (onclick) {
+      // No need for runSafe or anything because we are already being run inside
+      // an event handler -- the event is just being forwarded to the actual
+      // handler.
+      onclick(info, tab);
+    }
+  }
+
+  // Sets the `onclick` handler for the given menu item.
+  // The `onclick` function MUST be owned by `this.context`.
+  setListener(id, onclick) {
+    if (this.onclickMap.size === 0) {
+      this.context.childManager.getParentEvent("contextMenus.onClicked").addListener(this.dispatchEvent);
+      this.context.callOnClose(this);
+    }
+    this.onclickMap.set(id, onclick);
+
+    let propHandlerMap = gPropHandlers.get(this.context.extension);
+    if (!propHandlerMap) {
+      propHandlerMap = new Map();
+    } else {
+      // If the current callback was created in a different context, remove it
+      // from the other context.
+      let propHandler = propHandlerMap.get(id);
+      if (propHandler && propHandler !== this) {
+        propHandler.unsetListener(id);
+      }
+    }
+    propHandlerMap.set(id, this);
+    gPropHandlers.set(this.context.extension, propHandlerMap);
+  }
+
+  // Deletes the `onclick` handler for the given menu item.
+  // The `onclick` function MUST be owned by `this.context`.
+  unsetListener(id) {
+    if (!this.onclickMap.delete(id)) {
+      return;
+    }
+    if (this.onclickMap.size === 0) {
+      this.context.childManager.getParentEvent("contextMenus.onClicked").removeListener(this.dispatchEvent);
+      this.context.forgetOnClose(this);
+    }
+    let propHandlerMap = gPropHandlers.get(this.context.extension);
+    propHandlerMap.delete(id);
+    if (propHandlerMap.size === 0) {
+      gPropHandlers.delete(this.context.extension);
+    }
+  }
+
+  // Deletes the `onclick` handler for the given menu item, if any, regardless
+  // of the context where it was created.
+  unsetListenerFromAnyContext(id) {
+    let propHandlerMap = gPropHandlers.get(this.context.extension);
+    let propHandler = propHandlerMap && propHandlerMap.get(id);
+    if (propHandler) {
+      propHandler.unsetListener(id);
+    }
+  }
+
+  // Remove all `onclick` handlers of the extension.
+  deleteAllListenersFromExtension() {
+    let propHandlerMap = gPropHandlers.get(this.context.extension);
+    if (propHandlerMap) {
+      for (let [id, propHandler] of propHandlerMap) {
+        propHandler.unsetListener(id);
+      }
+    }
+  }
+
+  // Removes all `onclick` handlers from this context.
+  close() {
+    for (let id of this.onclickMap.keys()) {
+      this.unsetListener(id);
+    }
+  }
+}
+
+extensions.registerSchemaAPI("contextMenus", "addon_child", context => {
+  let onClickedProp = new ContextMenusClickPropHandler(context);
+
+  return {
+    contextMenus: {
+      create(createProperties, callback) {
+        if (createProperties.id === null) {
+          createProperties.id = ++gNextMenuItemID;
+        }
+        let {onclick} = createProperties;
+        delete createProperties.onclick;
+        context.childManager.callParentAsyncFunction("contextMenus.createInternal", [
+          createProperties,
+        ]).then(() => {
+          if (onclick) {
+            onClickedProp.setListener(createProperties.id, onclick);
+          }
+          if (callback) {
+            callback();
+          }
+        });
+        return createProperties.id;
+      },
+
+      update(id, updateProperties) {
+        let {onclick} = updateProperties;
+        delete updateProperties.onclick;
+        return context.childManager.callParentAsyncFunction("contextMenus.update", [
+          id,
+          updateProperties,
+        ]).then(() => {
+          if (onclick) {
+            onClickedProp.setListener(id, onclick);
+          } else if (onclick === null) {
+            onClickedProp.unsetListenerFromAnyContext(id);
+          }
+          // else onclick is not set so it should not be changed.
+        });
+      },
+
+      remove(id) {
+        onClickedProp.unsetListenerFromAnyContext(id);
+        return context.childManager.callParentAsyncFunction("contextMenus.remove", [
+          id,
+        ]);
+      },
+
+      removeAll() {
+        onClickedProp.deleteAllListenersFromExtension();
+
+        return context.childManager.callParentAsyncFunction("contextMenus.removeAll", []);
+      },
+    },
+  };
+});
--- a/browser/components/extensions/ext-contextMenus.js
+++ b/browser/components/extensions/ext-contextMenus.js
@@ -5,17 +5,16 @@
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 Cu.import("resource://gre/modules/MatchPattern.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 var {
   EventManager,
   IconDetails,
-  runSafe,
 } = ExtensionUtils;
 
 // Map[Extension -> Map[ID -> MenuItem]]
 // Note: we want to enumerate all the menu items so
 // this cannot be a weak map.
 var gContextMenuMap = new Map();
 
 // Map[Extension -> MenuItem]
@@ -188,19 +187,16 @@ var gMenuBuilder = {
         item.checked = true;
       }
 
       item.tabManager.addActiveTabPermission();
 
       let tab = item.tabManager.convert(contextData.tab);
       let info = item.getClickInfo(contextData, wasChecked);
       item.extension.emit("webext-contextmenu-menuitem-click", info, tab);
-      if (item.onclick) {
-        runSafe(item.extContext, item.onclick, info, tab);
-      }
     });
 
     return element;
   },
 
   handleEvent: function(event) {
     if (this.xulMenu != event.target || event.type != "popuphidden") {
       return;
@@ -254,19 +250,18 @@ function getContexts(contextData) {
 
   if (contextData.onAudio) {
     contexts.add("audio");
   }
 
   return contexts;
 }
 
-function MenuItem(extension, extContext, createProperties, isRoot = false) {
+function MenuItem(extension, createProperties, isRoot = false) {
   this.extension = extension;
-  this.extContext = extContext;
   this.children = [];
   this.parent = null;
   this.tabManager = TabManager.for(extension);
 
   this.setDefaults();
   this.setProps(createProperties);
   if (!this.hasOwnProperty("_id")) {
     this.id = gNextMenuItemID++;
@@ -370,17 +365,17 @@ MenuItem.prototype = {
     }
     this.children.splice(idx, 1);
     child.parent = null;
   },
 
   get root() {
     let extension = this.extension;
     if (!gRootItems.has(extension)) {
-      let root = new MenuItem(extension, this.context,
+      let root = new MenuItem(extension,
                               {title: extension.name},
                               /* isRoot = */ true);
       gRootItems.set(extension, root);
     }
 
     return gRootItems.get(extension);
   },
 
@@ -490,47 +485,43 @@ extensions.on("shutdown", (type, extensi
   }
 });
 /* eslint-enable mozilla/balanced-listeners */
 
 extensions.registerSchemaAPI("contextMenus", "addon_parent", context => {
   let {extension} = context;
   return {
     contextMenus: {
-      create: function(createProperties, callback) {
-        let menuItem = new MenuItem(extension, context, createProperties);
+      createInternal: function(createProperties) {
+        // Note that the id is required by the schema. If the addon did not set
+        // it, the implementation of contextMenus.create in the child should
+        // have added it.
+        let menuItem = new MenuItem(extension, createProperties);
         gContextMenuMap.get(extension).set(menuItem.id, menuItem);
-        if (callback) {
-          runSafe(context, callback);
-        }
-        return menuItem.id;
       },
 
       update: function(id, updateProperties) {
         let menuItem = gContextMenuMap.get(extension).get(id);
         if (menuItem) {
           menuItem.setProps(updateProperties);
         }
-        return Promise.resolve();
       },
 
       remove: function(id) {
         let menuItem = gContextMenuMap.get(extension).get(id);
         if (menuItem) {
           menuItem.remove();
         }
-        return Promise.resolve();
       },
 
       removeAll: function() {
         let root = gRootItems.get(extension);
         if (root) {
           root.remove();
         }
-        return Promise.resolve();
       },
 
       onClicked: new EventManager(context, "contextMenus.onClicked", fire => {
         let listener = (event, info, tab) => {
           fire(info, tab);
         };
 
         extension.on("webext-contextmenu-menuitem-click", listener);
--- a/browser/components/extensions/extensions-browser.manifest
+++ b/browser/components/extensions/extensions-browser.manifest
@@ -7,16 +7,17 @@ category webextension-scripts desktop-ru
 category webextension-scripts history chrome://browser/content/ext-history.js
 category webextension-scripts pageAction chrome://browser/content/ext-pageAction.js
 category webextension-scripts tabs chrome://browser/content/ext-tabs.js
 category webextension-scripts utils chrome://browser/content/ext-utils.js
 category webextension-scripts windows chrome://browser/content/ext-windows.js
 
 # scripts that must run in the same process as addon code.
 category webextension-scripts-addon browserAction chrome://browser/content/ext-c-browserAction.js
+category webextension-scripts-addon contextMenus chrome://browser/content/ext-c-contextMenus.js
 category webextension-scripts-addon pageAction chrome://browser/content/ext-c-pageAction.js
 category webextension-scripts-addon tabs chrome://browser/content/ext-c-tabs.js
 
 # schemas
 category webextension-schemas bookmarks chrome://browser/content/schemas/bookmarks.json
 category webextension-schemas browser_action chrome://browser/content/schemas/browser_action.json
 category webextension-schemas commands chrome://browser/content/schemas/commands.json
 category webextension-schemas context_menus chrome://browser/content/schemas/context_menus.json
--- a/browser/components/extensions/jar.mn
+++ b/browser/components/extensions/jar.mn
@@ -18,10 +18,11 @@ browser.jar:
     content/browser/ext-contextMenus.js
     content/browser/ext-desktop-runtime.js
     content/browser/ext-history.js
     content/browser/ext-pageAction.js
     content/browser/ext-tabs.js
     content/browser/ext-utils.js
     content/browser/ext-windows.js
     content/browser/ext-c-browserAction.js
+    content/browser/ext-c-contextMenus.js
     content/browser/ext-c-pageAction.js
     content/browser/ext-c-tabs.js
--- a/browser/components/extensions/schemas/context_menus.json
+++ b/browser/components/extensions/schemas/context_menus.json
@@ -202,16 +202,84 @@
             "name": "callback",
             "optional": true,
             "description": "Called when the item has been created in the browser. If there were any problems creating the item, details will be available in $(ref:runtime.lastError).",
             "parameters": []
           }
         ]
       },
       {
+        "name": "createInternal",
+        "type": "function",
+        "allowedContexts": ["addon_parent_only"],
+        "async": "callback",
+        "description": "Identical to contextMenus.create, except: the 'id' field is required and allows an integer, 'onclick' is not allowed, and the method is async (and the return value is not a menu item ID).",
+        "parameters": [
+          {
+            "type": "object",
+            "name": "createProperties",
+            "properties": {
+              "type": {
+                "$ref": "ItemType",
+                "optional": true
+              },
+              "id": {
+                "choices": [
+                  { "type": "integer" },
+                  { "type": "string" }
+                ]
+              },
+              "title": {
+                "type": "string",
+                "optional": true
+              },
+              "checked": {
+                "type": "boolean",
+                "optional": true
+              },
+              "contexts": {
+                "type": "array",
+                "items": {
+                  "$ref": "ContextType"
+                },
+                "minItems": 1,
+                "optional": true
+              },
+              "parentId": {
+                "choices": [
+                  { "type": "integer" },
+                  { "type": "string" }
+                ],
+                "optional": true
+              },
+              "documentUrlPatterns": {
+                "type": "array",
+                "items": {"type": "string"},
+                "optional": true
+              },
+              "targetUrlPatterns": {
+                "type": "array",
+                "items": {"type": "string"},
+                "optional": true
+              },
+              "enabled": {
+                "type": "boolean",
+                "optional": true
+              }
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
         "name": "update",
         "type": "function",
         "description": "Updates a previously created context menu item.",
         "async": "callback",
         "parameters": [
           {
             "choices": [
               { "type": "integer" },
@@ -242,17 +310,17 @@
                 "items": {
                   "$ref": "ContextType"
                 },
                 "minItems": 1,
                 "optional": true
               },
               "onclick": {
                 "type": "function",
-                "optional": true,
+                "optional": "omit-key-if-missing",
                 "parameters": [
                   {
                     "name": "info",
                     "$ref": "contextMenusInternal.OnClickData"
                   },
                   {
                     "name": "tab",
                     "$ref": "tabs.Tab",
--- a/browser/components/extensions/test/browser/browser.ini
+++ b/browser/components/extensions/test/browser/browser.ini
@@ -30,16 +30,17 @@ tags = webextensions
 [browser_ext_commands_execute_browser_action.js]
 [browser_ext_commands_execute_page_action.js]
 [browser_ext_commands_getAll.js]
 [browser_ext_commands_onCommand.js]
 [browser_ext_contentscript_connect.js]
 [browser_ext_contextMenus.js]
 [browser_ext_contextMenus_checkboxes.js]
 [browser_ext_contextMenus_icons.js]
+[browser_ext_contextMenus_onclick.js]
 [browser_ext_contextMenus_radioGroups.js]
 [browser_ext_contextMenus_uninstall.js]
 [browser_ext_contextMenus_urlPatterns.js]
 [browser_ext_currentWindow.js]
 [browser_ext_getViews.js]
 [browser_ext_incognito_popup.js]
 [browser_ext_lastError.js]
 [browser_ext_legacy_extension_context_contentscript.js]
--- a/browser/components/extensions/test/browser/browser_ext_contextMenus_icons.js
+++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_icons.js
@@ -29,16 +29,19 @@ add_task(function* () {
           browser.contextMenus.remove(menuitemId);
         },
       });
 
       browser.contextMenus.create({
         title: "child",
       });
 
+      browser.test.onMessage.addListener(() => {
+        browser.test.sendMessage("pong");
+      });
       browser.test.notifyPass("contextmenus-icons");
     },
   });
 
   let confirmContextMenuIcon = (rootElement) => {
     let expectedURL = new RegExp(String.raw`^moz-extension://[^/]+/extension\.png$`);
     let imageUrl = rootElement.getAttribute("image");
     ok(expectedURL.test(imageUrl), "The context menu should display the extension icon next to the root element");
@@ -50,16 +53,20 @@ add_task(function* () {
   let extensionMenu = yield openExtensionContextMenu();
 
   let contextMenu = document.getElementById("contentAreaContextMenu");
   let topLevelMenuItem = contextMenu.getElementsByAttribute("ext-type", "top-level-menu")[0];
   confirmContextMenuIcon(topLevelMenuItem);
 
   let childToDelete = extensionMenu.getElementsByAttribute("label", "child-to-delete")[0];
   yield closeExtensionContextMenu(childToDelete);
+  // Now perform a roundtrip to the extension process to make sure that the
+  // click event has had a chance to fire.
+  extension.sendMessage("ping");
+  yield extension.awaitMessage("pong");
 
   yield openExtensionContextMenu();
 
   contextMenu = document.getElementById("contentAreaContextMenu");
   topLevelMenuItem = contextMenu.getElementsByAttribute("label", "child")[0];
 
   confirmContextMenuIcon(topLevelMenuItem);
   yield closeContextMenu();
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_onclick.js
@@ -0,0 +1,196 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// Loaded both as a background script and a tab page.
+function testScript() {
+  let page = location.pathname.includes("tab.html") ? "tab" : "background";
+  let clickCounts = {
+    old: 0,
+    new: 0,
+  };
+  browser.contextMenus.onClicked.addListener(() => {
+    // Async to give other onclick handlers a chance to fire.
+    setTimeout(() => {
+      browser.test.sendMessage("onClicked-fired", page);
+    });
+  });
+  browser.test.onMessage.addListener((toPage, msg) => {
+    if (toPage !== page) {
+      return;
+    }
+    browser.test.log(`Received ${msg} for ${toPage}`);
+    if (msg == "get-click-counts") {
+      browser.test.sendMessage("click-counts", clickCounts);
+    } else if (msg == "clear-click-counts") {
+      clickCounts.old = clickCounts.new = 0;
+      browser.test.sendMessage("next");
+    } else if (msg == "create-with-onclick") {
+      browser.contextMenus.create({
+        id: "iden",
+        title: "tifier",
+        onclick() {
+          ++clickCounts.old;
+          browser.test.log(`onclick fired for original onclick property in ${page}`);
+        },
+      }, () => browser.test.sendMessage("next"));
+    } else if (msg == "create-without-onclick") {
+      browser.contextMenus.create({
+        id: "iden",
+        title: "tifier",
+      }, () => browser.test.sendMessage("next"));
+    } else if (msg == "update-without-onclick") {
+      browser.contextMenus.update("iden", {
+        enabled: true,  // Already enabled, so this does nothing.
+      }, () => browser.test.sendMessage("next"));
+    } else if (msg == "update-with-onclick") {
+      browser.contextMenus.update("iden", {
+        onclick() {
+          ++clickCounts.new;
+          browser.test.log(`onclick fired for updated onclick property in ${page}`);
+        },
+      }, () => browser.test.sendMessage("next"));
+    } else if (msg == "remove") {
+      browser.contextMenus.remove("iden", () => browser.test.sendMessage("next"));
+    } else if (msg == "removeAll") {
+      browser.contextMenus.removeAll(() => browser.test.sendMessage("next"));
+    }
+  });
+
+  if (page == "background") {
+    browser.test.log("Opening tab.html");
+    browser.tabs.create({
+      url: "tab.html",
+      active: false,  // To not interfere with the context menu tests.
+    });
+  } else {
+    // Sanity check - the pages must be in the same process.
+    let pages = browser.extension.getViews();
+    browser.test.assertTrue(pages.includes(window),
+        "Expected this tab to be an extension view");
+    pages = pages.filter(w => w !== window);
+    browser.test.assertEq(pages[0], browser.extension.getBackgroundPage(),
+        "Expected the other page to be a background page");
+    browser.test.sendMessage("tab.html ready");
+  }
+}
+
+add_task(function* () {
+  let tab1 = yield BrowserTestUtils.openNewForegroundTab(gBrowser,
+    "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html");
+
+  gBrowser.selectedTab = tab1;
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["contextMenus"],
+    },
+    background: testScript,
+    files: {
+      "tab.html": `<!DOCTYPE html><meta charset="utf-8"><script src="tab.js"></script>`,
+      "tab.js": testScript,
+    },
+  });
+  yield extension.startup();
+  yield extension.awaitMessage("tab.html ready");
+
+  function* clickContextMenu() {
+    // Using openContextMenu instead of openExtensionContextMenu because the
+    // test extension has only one context menu item.
+    let extensionMenuRoot = yield openContextMenu();
+    let items = extensionMenuRoot.getElementsByAttribute("label", "tifier");
+    is(items.length, 1, "Expected one context menu item");
+    yield closeExtensionContextMenu(items[0]);
+    // One of them is "tab", the other is "background".
+    info(`onClicked from: ${yield extension.awaitMessage("onClicked-fired")}`);
+    info(`onClicked from: ${yield extension.awaitMessage("onClicked-fired")}`);
+  }
+
+  function* getCounts(page) {
+    extension.sendMessage(page, "get-click-counts");
+    return yield extension.awaitMessage("click-counts");
+  }
+  function* resetCounts() {
+    extension.sendMessage("tab", "clear-click-counts");
+    extension.sendMessage("background", "clear-click-counts");
+    yield extension.awaitMessage("next");
+    yield extension.awaitMessage("next");
+  }
+
+  // During this test, at most one "onclick" attribute is expected at any time.
+  for (let pageOne of ["background", "tab"]) {
+    for (let pageTwo of ["background", "tab"]) {
+      info(`Testing with menu created by ${pageOne} and updated by ${pageTwo}`);
+      extension.sendMessage(pageOne, "create-with-onclick");
+      yield extension.awaitMessage("next");
+
+      // Test that update without onclick attribute does not clear the existing
+      // onclick handler.
+      extension.sendMessage(pageTwo, "update-without-onclick");
+      yield extension.awaitMessage("next");
+      yield clickContextMenu();
+      let clickCounts = yield getCounts(pageOne);
+      is(clickCounts.old, 1, `Original onclick should still be present in ${pageOne}`);
+      is(clickCounts.new, 0, `Not expecting any new handlers in ${pageOne}`);
+      if (pageOne !== pageTwo) {
+        clickCounts = yield getCounts(pageTwo);
+        is(clickCounts.old, 0, `Not expecting any handlers in ${pageTwo}`);
+        is(clickCounts.new, 0, `Not expecting any new handlers in ${pageTwo}`);
+      }
+      yield resetCounts();
+
+      // Test that update with onclick handler in a different page clears the
+      // existing handler and activates the new onclick handler.
+      extension.sendMessage(pageTwo, "update-with-onclick");
+      yield extension.awaitMessage("next");
+      yield clickContextMenu();
+      clickCounts = yield getCounts(pageOne);
+      is(clickCounts.old, 0, `Original onclick should be gone from ${pageOne}`);
+      if (pageOne !== pageTwo) {
+        is(clickCounts.new, 0, `Still not expecting new handlers in ${pageOne}`);
+      }
+      clickCounts = yield getCounts(pageTwo);
+      if (pageOne !== pageTwo) {
+        is(clickCounts.old, 0, `Not expecting an old onclick in ${pageTwo}`);
+      }
+      is(clickCounts.new, 1, `New onclick should be triggered in ${pageTwo}`);
+      yield resetCounts();
+
+      // Test that updating the handler (different again from the last `update`
+      // call, but the same as the `create` call) clears the existing handler
+      // and activates the new onclick handler.
+      extension.sendMessage(pageOne, "update-with-onclick");
+      yield extension.awaitMessage("next");
+      yield clickContextMenu();
+      clickCounts = yield getCounts(pageOne);
+      is(clickCounts.new, 1, `onclick should be triggered in ${pageOne}`);
+      if (pageOne !== pageTwo) {
+        clickCounts = yield getCounts(pageTwo);
+        is(clickCounts.new, 0, `onclick should be gone from ${pageTwo}`);
+      }
+      yield resetCounts();
+
+      // Test that removing the context menu and recreating it with the same ID
+      // (in a different context) does not leave behind any onclick handlers.
+      extension.sendMessage(pageTwo, "remove");
+      yield extension.awaitMessage("next");
+      extension.sendMessage(pageTwo, "create-without-onclick");
+      yield extension.awaitMessage("next");
+      yield clickContextMenu();
+      clickCounts = yield getCounts(pageOne);
+      is(clickCounts.new, 0, `Did not expect any click handlers in ${pageOne}`);
+      if (pageOne !== pageTwo) {
+        clickCounts = yield getCounts(pageTwo);
+        is(clickCounts.new, 0, `Did not expect any click handlers in ${pageTwo}`);
+      }
+      yield resetCounts();
+
+      // Remove context menu for the next iteration of the test. And just to get
+      // more coverage, let's use removeAll instead of remove.
+      extension.sendMessage(pageOne, "removeAll");
+      yield extension.awaitMessage("next");
+    }
+  }
+  yield extension.unload();
+  yield BrowserTestUtils.removeTab(tab1);
+});
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -735,16 +735,20 @@ GlobalManager = {
         return context.extension.hasPermission(permission);
       },
 
       shouldInject(namespace, name, allowedContexts) {
         // Do not generate content script APIs, unless explicitly allowed.
         if (context.envType === "content_parent" && !allowedContexts.includes("content")) {
           return false;
         }
+        if (context.envType !== "addon_parent" &&
+            allowedContexts.includes("addon_parent_only")) {
+          return false;
+        }
         return findPathInObject(apis, namespace, false) !== null;
       },
 
       getImplementation(namespace, name) {
         let pathObj = findPathInObject(apis, namespace);
         return new LocalAPIImplementation(pathObj, name, context);
       },
     };
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -1851,31 +1851,62 @@ class ChildAPIManager {
       callId,
       path,
       args,
     });
 
     return this.context.wrapPromise(deferred.promise, callback);
   }
 
+  /**
+   * Create a proxy for an event in the parent process. The returned event
+   * object shares its internal state with other instances. For instance, if
+   * `removeListener` is used on a listener that was added on another object
+   * through `addListener`, then the event is unregistered.
+   *
+   * @param {string} path The full name of the event, e.g. "tabs.onCreated".
+   * @returns {object} An object with the addListener, removeListener and
+   *   hasListener methods. See SchemaAPIInterface for documentation.
+   */
+  getParentEvent(path) {
+    let parsed = /^(.+)\.(on[A-Z][^.]+)$/.exec(path);
+    if (!parsed) {
+      throw new Error("getParentEvent: Invalid event name: " + path);
+    }
+    let [, namespace, name] = parsed;
+    let impl = new ProxyAPIImplementation(namespace, name, this);
+    return {
+      addListener: (listener, ...args) => impl.addListener(listener, args),
+      removeListener: (listener) => impl.removeListener(listener),
+      hasListener: (listener) => impl.hasListener(listener),
+    };
+  }
+
   close() {
     this.messageManager.sendAsyncMessage("API:CloseProxyContext", {childId: this.id});
   }
 
   get cloneScope() {
     return this.context.cloneScope;
   }
 
   get principal() {
     return this.context.principal;
   }
 
   shouldInject(namespace, name, allowedContexts) {
     // Do not generate content script APIs, unless explicitly allowed.
-    return this.context.envType !== "content_child" || allowedContexts.includes("content");
+    if (this.context.envType === "content_child" &&
+        !allowedContexts.includes("content")) {
+      return false;
+    }
+    if (allowedContexts.includes("addon_parent_only")) {
+      return false;
+    }
+    return true;
   }
 
   getImplementation(namespace, name) {
     let pathObj = this.localApis;
     if (pathObj) {
       for (let part of namespace.split(".")) {
         pathObj = pathObj[part];
         if (!pathObj) {
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -1030,17 +1030,17 @@ class ObjectType extends Type {
           result[prop] = r.value;
           properties[prop] = r.value;
         }
       }
       remainingProps.delete(prop);
     } else if (!optional) {
       error = context.error(`Property "${prop}" is required`,
                             `contain the required "${prop}" property`);
-    } else {
+    } else if (optional !== "omit-key-if-missing") {
       result[prop] = null;
     }
 
     if (error) {
       if (onError == "warn") {
         context.logError(error.error);
       } else if (onError != "ignore") {
         throw error;