Bug 1635106 - fix spellchecker lifetime handling vs. the context menu, r=nika
authorGijs Kruitbosch <gijskruitbosch@gmail.com>
Fri, 22 May 2020 08:35:57 +0000
changeset 531599 0224aa78cb39c30c8e1778fa8f175061fbe46159
parent 531598 b08a7f6f47bc51ad21324702aa9e9ffdb2020794
child 531600 9133eeb54db8be5f55e51576ae07a1113d48f1f5
push id37441
push userapavel@mozilla.com
push dateFri, 22 May 2020 21:38:53 +0000
treeherdermozilla-central@d6abd35b54ad [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnika
bugs1635106
milestone78.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 1635106 - fix spellchecker lifetime handling vs. the context menu, r=nika This changes both the spellchecker parent code that interfaces with the InlineSpellCheckerParent actor, and the child code interfacing with the ContextMenuChild actor, to ensure they get notified when either actor goes away. It maintains the "uninit" messages to clear out spellcheck data when the context menu goes away (while the window / actors remain intact). It also adds some belts-and-suspenders type checks that allow us to recover if we ever get in a bad state again, instead of stubbornly throwing exceptions and breaking the UI for users. Differential Revision: https://phabricator.services.mozilla.com/D75228
browser/actors/ContextMenuChild.jsm
browser/base/content/test/contextMenu/browser.ini
browser/base/content/test/contextMenu/browser_contextmenu_input.js
browser/base/content/test/contextMenu/browser_contextmenu_spellcheck.js
browser/base/content/test/contextMenu/contextmenu_common.js
toolkit/actors/InlineSpellCheckerParent.jsm
toolkit/modules/InlineSpellChecker.jsm
toolkit/modules/InlineSpellCheckerContent.jsm
--- a/browser/actors/ContextMenuChild.jsm
+++ b/browser/actors/ContextMenuChild.jsm
@@ -1222,9 +1222,25 @@ class ContextMenuChild extends JSWindowA
         context.hasBGImage = false;
         context.isDesignMode = true;
         context.onEditable = true;
         context.onSpellcheckable = true;
         context.shouldInitInlineSpellCheckerUIWithChildren = true;
       }
     }
   }
+
+  _destructionObservers = new Set();
+  registerDestructionObserver(obj) {
+    this._destructionObservers.add(obj);
+  }
+
+  unregisterDestructionObserver(obj) {
+    this._destructionObservers.delete(obj);
+  }
+
+  didDestroy() {
+    for (let obs of this._destructionObservers) {
+      obs.actorDestroyed(this);
+    }
+    this._destructionObservers = null;
+  }
 }
--- a/browser/base/content/test/contextMenu/browser.ini
+++ b/browser/base/content/test/contextMenu/browser.ini
@@ -11,16 +11,18 @@ support-files =
   ../general/head.js
   ../general/video.ogg
   ../general/audio.ogg
   ../../../../extensions/pdfjs/test/file_pdfjs_test.pdf
   contextmenu_common.js
 
 [browser_contextmenu_loadblobinnewtab.js]
 support-files = browser_contextmenu_loadblobinnewtab.html
+[browser_contextmenu_spellcheck.js]
+skip-if = toolkit == "gtk" || (os == "win" && processor == "aarch64") # disabled on Linux due to bug 513558, aarch64 due to 1533161
 [browser_view_image.js]
 support-files =
   test_view_image_revoked_cached_blob.html
 [browser_contextmenu_touch.js]
 skip-if = true # Bug 1424433, disable due to very high frequency failure rate also on Windows 10
 [browser_contextmenu_linkopen.js]
 [browser_contextmenu_iframe.js]
 support-files =
--- a/browser/base/content/test/contextMenu/browser_contextmenu_input.js
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_input.js
@@ -40,156 +40,16 @@ add_task(async function test_text_input(
     false,
     "---",
     null,
     "spell-check-enabled",
     true,
   ]);
 });
 
-add_task(async function test_text_input_spellcheck() {
-  await test_contextmenu(
-    "#input_spellcheck_no_value",
-    [
-      "context-undo",
-      false,
-      "---",
-      null,
-      "context-cut",
-      true,
-      "context-copy",
-      true,
-      "context-paste",
-      null, // ignore clipboard state
-      "context-delete",
-      false,
-      "---",
-      null,
-      "context-selectall",
-      false,
-      "---",
-      null,
-      "spell-check-enabled",
-      true,
-      "spell-dictionaries",
-      true,
-      [
-        "spell-check-dictionary-en-US",
-        true,
-        "---",
-        null,
-        "spell-add-dictionaries",
-        true,
-      ],
-      null,
-    ],
-    {
-      waitForSpellCheck: true,
-      async preCheckContextMenuFn() {
-        await SpecialPowers.spawn(
-          gBrowser.selectedBrowser,
-          [],
-          async function() {
-            let doc = content.document;
-            let input = doc.getElementById("input_spellcheck_no_value");
-            input.setAttribute("spellcheck", "true");
-            input.clientTop; // force layout flush
-          }
-        );
-      },
-    }
-  );
-});
-
-add_task(async function test_text_input_spellcheckwrong() {
-  await test_contextmenu(
-    "#input_spellcheck_incorrect",
-    [
-      "*prodigality",
-      true, // spelling suggestion
-      "spell-add-to-dictionary",
-      true,
-      "---",
-      null,
-      "context-undo",
-      false,
-      "---",
-      null,
-      "context-cut",
-      true,
-      "context-copy",
-      true,
-      "context-paste",
-      null, // ignore clipboard state
-      "context-delete",
-      false,
-      "---",
-      null,
-      "context-selectall",
-      true,
-      "---",
-      null,
-      "spell-check-enabled",
-      true,
-      "spell-dictionaries",
-      true,
-      [
-        "spell-check-dictionary-en-US",
-        true,
-        "---",
-        null,
-        "spell-add-dictionaries",
-        true,
-      ],
-      null,
-    ],
-    { waitForSpellCheck: true }
-  );
-});
-
-add_task(async function test_text_input_spellcheckcorrect() {
-  await test_contextmenu(
-    "#input_spellcheck_correct",
-    [
-      "context-undo",
-      false,
-      "---",
-      null,
-      "context-cut",
-      true,
-      "context-copy",
-      true,
-      "context-paste",
-      null, // ignore clipboard state
-      "context-delete",
-      false,
-      "---",
-      null,
-      "context-selectall",
-      true,
-      "---",
-      null,
-      "spell-check-enabled",
-      true,
-      "spell-dictionaries",
-      true,
-      [
-        "spell-check-dictionary-en-US",
-        true,
-        "---",
-        null,
-        "spell-add-dictionaries",
-        true,
-      ],
-      null,
-    ],
-    { waitForSpellCheck: true }
-  );
-});
-
 add_task(async function test_text_input_disabled() {
   await test_contextmenu(
     "#input_disabled",
     [
       "context-undo",
       false,
       "---",
       null,
@@ -199,17 +59,17 @@ add_task(async function test_text_input_
       true,
       "context-paste",
       null, // ignore clipboard state
       "context-delete",
       false,
       "---",
       null,
       "context-selectall",
-      true,
+      false,
       "---",
       null,
       "spell-check-enabled",
       true,
     ],
     { skipFocusChange: true }
   );
 });
copy from browser/base/content/test/contextMenu/browser_contextmenu_input.js
copy to browser/base/content/test/contextMenu/browser_contextmenu_spellcheck.js
--- a/browser/base/content/test/contextMenu/browser_contextmenu_input.js
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_spellcheck.js
@@ -1,75 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
 "use strict";
 
 let contextMenu;
-let hasPocket = Services.prefs.getBoolPref("extensions.pocket.enabled");
+
+const example_base =
+  "http://example.com/browser/browser/base/content/test/contextMenu/";
+const MAIN_URL = example_base + "subtst_contextmenu_input.html";
 
 add_task(async function test_setup() {
-  const example_base =
-    "http://example.com/browser/browser/base/content/test/contextMenu/";
-  const url = example_base + "subtst_contextmenu_input.html";
-  await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+  await BrowserTestUtils.openNewForegroundTab(gBrowser, MAIN_URL);
 
   const chrome_base =
     "chrome://mochitests/content/browser/browser/base/content/test/contextMenu/";
   const contextmenu_common = chrome_base + "contextmenu_common.js";
   /* import-globals-from contextmenu_common.js */
   Services.scriptloader.loadSubScript(contextmenu_common, this);
 
   // Ensure screenshots is really disabled (bug 1498738)
   const addon = await AddonManager.getAddonByID("screenshots@mozilla.org");
   await addon.disable({ allowSystemAddons: true });
 });
 
-add_task(async function test_text_input() {
-  await test_contextmenu("#input_text", [
-    "context-undo",
-    false,
-    "---",
-    null,
-    "context-cut",
-    true,
-    "context-copy",
-    true,
-    "context-paste",
-    null, // ignore clipboard state
-    "context-delete",
-    false,
-    "---",
-    null,
-    "context-selectall",
-    false,
-    "---",
-    null,
-    "spell-check-enabled",
-    true,
-  ]);
-});
-
 add_task(async function test_text_input_spellcheck() {
   await test_contextmenu(
     "#input_spellcheck_no_value",
     [
       "context-undo",
       false,
       "---",
       null,
       "context-cut",
-      true,
+      null, // ignore the enabled/disabled states; there are race conditions
+      // in the edit commands but they're not relevant for what we're testing.
       "context-copy",
-      true,
+      null,
       "context-paste",
       null, // ignore clipboard state
       "context-delete",
-      false,
+      null,
       "---",
       null,
       "context-selectall",
-      false,
+      null,
       "---",
       null,
       "spell-check-enabled",
       true,
       "spell-dictionaries",
       true,
       [
         "spell-check-dictionary-en-US",
@@ -105,71 +84,31 @@ add_task(async function test_text_input_
     [
       "*prodigality",
       true, // spelling suggestion
       "spell-add-to-dictionary",
       true,
       "---",
       null,
       "context-undo",
-      false,
+      null,
       "---",
       null,
       "context-cut",
-      true,
+      null,
       "context-copy",
-      true,
+      null,
       "context-paste",
       null, // ignore clipboard state
       "context-delete",
-      false,
+      null,
       "---",
       null,
       "context-selectall",
-      true,
-      "---",
       null,
-      "spell-check-enabled",
-      true,
-      "spell-dictionaries",
-      true,
-      [
-        "spell-check-dictionary-en-US",
-        true,
-        "---",
-        null,
-        "spell-add-dictionaries",
-        true,
-      ],
-      null,
-    ],
-    { waitForSpellCheck: true }
-  );
-});
-
-add_task(async function test_text_input_spellcheckcorrect() {
-  await test_contextmenu(
-    "#input_spellcheck_correct",
-    [
-      "context-undo",
-      false,
-      "---",
-      null,
-      "context-cut",
-      true,
-      "context-copy",
-      true,
-      "context-paste",
-      null, // ignore clipboard state
-      "context-delete",
-      false,
-      "---",
-      null,
-      "context-selectall",
-      true,
       "---",
       null,
       "spell-check-enabled",
       true,
       "spell-dictionaries",
       true,
       [
         "spell-check-dictionary-en-US",
@@ -180,293 +119,133 @@ add_task(async function test_text_input_
         true,
       ],
       null,
     ],
     { waitForSpellCheck: true }
   );
 });
 
-add_task(async function test_text_input_disabled() {
-  await test_contextmenu(
-    "#input_disabled",
-    [
-      "context-undo",
-      false,
-      "---",
-      null,
-      "context-cut",
-      true,
-      "context-copy",
-      true,
-      "context-paste",
-      null, // ignore clipboard state
-      "context-delete",
-      false,
-      "---",
-      null,
-      "context-selectall",
-      true,
-      "---",
-      null,
-      "spell-check-enabled",
-      true,
-    ],
-    { skipFocusChange: true }
-  );
-});
+const kCorrectItems = [
+  "context-undo",
+  false,
+  "---",
+  null,
+  "context-cut",
+  null,
+  "context-copy",
+  null,
+  "context-paste",
+  null, // ignore clipboard state
+  "context-delete",
+  null,
+  "---",
+  null,
+  "context-selectall",
+  null,
+  "---",
+  null,
+  "spell-check-enabled",
+  true,
+  "spell-dictionaries",
+  true,
+  [
+    "spell-check-dictionary-en-US",
+    true,
+    "---",
+    null,
+    "spell-add-dictionaries",
+    true,
+  ],
+  null,
+];
 
-add_task(async function test_password_input() {
-  await SpecialPowers.pushPrefEnv({
-    set: [["signon.generation.enabled", false]],
+add_task(async function test_text_input_spellcheckcorrect() {
+  await test_contextmenu("#input_spellcheck_correct", kCorrectItems, {
+    waitForSpellCheck: true,
   });
-  todo(
-    false,
-    "context-selectall is enabled on osx-e10s, and windows when" +
-      " it should be disabled"
-  );
-  await test_contextmenu(
-    "#input_password",
-    [
-      "fill-login",
-      null,
-      [
-        "fill-login-no-logins",
-        false,
-        "---",
-        null,
-        "fill-login-saved-passwords",
-        true,
-      ],
-      null,
-      "---",
-      null,
-      "context-undo",
-      false,
-      "---",
-      null,
-      "context-cut",
-      true,
-      "context-copy",
-      true,
-      "context-paste",
-      null, // ignore clipboard state
-      "context-delete",
-      false,
-      "---",
-      null,
-      "context-selectall",
-      null,
-    ],
-    {
-      skipFocusChange: true,
-      // Need to dynamically add the "password" type or LoginManager
-      // will think that the form inputs on the page are part of a login form
-      // and will add fill-login context menu items. The element needs to be
-      // re-created as type=text afterwards since it uses hasBeenTypePassword.
-      async preCheckContextMenuFn() {
-        await SpecialPowers.spawn(
-          gBrowser.selectedBrowser,
-          [],
-          async function() {
-            let doc = content.document;
-            let input = doc.getElementById("input_password");
-            input.type = "password";
-            input.clientTop; // force layout flush
-          }
-        );
-      },
-      async postCheckContextMenuFn() {
-        await SpecialPowers.spawn(
-          gBrowser.selectedBrowser,
-          [],
-          async function() {
-            let doc = content.document;
-            let input = doc.getElementById("input_password");
-            input.outerHTML = `<input id=\"input_password\">`;
-            input.clientTop; // force layout flush
-          }
-        );
-      },
-    }
-  );
-  await SpecialPowers.popPrefEnv();
 });
 
-add_task(async function test_tel_email_url_number_input() {
-  todo(
-    false,
-    "context-selectall is enabled on osx-e10s, and windows when" +
-      " it should be disabled"
+add_task(async function test_text_input_spellcheck_deadactor() {
+  await test_contextmenu("#input_spellcheck_correct", kCorrectItems, {
+    waitForSpellCheck: true,
+    keepMenuOpen: true,
+  });
+  let wgp = gBrowser.selectedBrowser.browsingContext.currentWindowGlobal;
+
+  // Now the menu is open, and spellcheck is running, switch to another tab and
+  // close the original:
+  let tab = gBrowser.selectedTab;
+  await BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.org");
+  BrowserTestUtils.removeTab(tab);
+  // Ensure we've invalidated the actor
+  await TestUtils.waitForCondition(
+    () => wgp.isClosed,
+    "Waiting for actor to be dead after tab closes"
   );
-  for (let selector of [
-    "#input_email",
-    "#input_url",
-    "#input_tel",
-    "#input_number",
-  ]) {
-    await test_contextmenu(
-      selector,
-      [
-        "context-undo",
-        false,
-        "---",
-        null,
-        "context-cut",
-        true,
-        "context-copy",
-        true,
-        "context-paste",
-        null, // ignore clipboard state
-        "context-delete",
-        false,
-        "---",
-        null,
-        "context-selectall",
-        null,
-      ],
-      {
-        skipFocusChange: true,
-      }
-    );
-  }
-});
+  contextMenu.hidePopup();
+
+  // Now go back to the input testcase:
+  BrowserTestUtils.loadURI(gBrowser.selectedBrowser, MAIN_URL);
+  await BrowserTestUtils.browserLoaded(
+    gBrowser.selectedBrowser,
+    false,
+    MAIN_URL
+  );
+
+  // Check the menu still looks the same, keep it open again:
+  await test_contextmenu("#input_spellcheck_correct", kCorrectItems, {
+    waitForSpellCheck: true,
+    keepMenuOpen: true,
+  });
+
+  // Now navigate the tab, after ensuring there's an unload listener, so
+  // we don't end up in bfcache:
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function() {
+    content.document.body.setAttribute("onunload", "");
+  });
+  wgp = gBrowser.selectedBrowser.browsingContext.currentWindowGlobal;
 
-add_task(
-  async function test_date_time_color_range_month_week_datetimelocal_input() {
-    for (let selector of [
-      "#input_date",
-      "#input_time",
-      "#input_color",
-      "#input_range",
-      "#input_month",
-      "#input_week",
-      "#input_datetime-local",
-    ]) {
-      await test_contextmenu(
-        selector,
-        [
-          "context-navigation",
-          null,
-          [
-            "context-back",
-            false,
-            "context-forward",
-            false,
-            "context-reload",
-            true,
-            "context-bookmarkpage",
-            true,
-          ],
-          null,
-          "---",
-          null,
-          "context-savepage",
-          true,
-          ...(hasPocket ? ["context-pocket", true] : []),
-          "---",
-          null,
-          "context-sendpagetodevice",
-          null,
-          [],
-          null,
-          "---",
-          null,
-          "context-viewbgimage",
-          false,
-          "context-selectall",
-          null,
-          "---",
-          null,
-          "context-viewsource",
-          true,
-          "context-viewinfo",
-          true,
-        ],
-        {
-          // XXX Bug 1345081. Currently the Screenshots menu option is shown for
-          // various text elements even though it is set to type "page". That bug
-          // should remove the need for next line.
-          maybeScreenshotsPresent: true,
-          skipFocusChange: true,
-        }
-      );
-    }
-  }
-);
-
-add_task(async function test_search_input() {
-  todo(
+  const NEW_URL = MAIN_URL.replace(".com", ".org");
+  BrowserTestUtils.loadURI(gBrowser.selectedBrowser, NEW_URL);
+  await BrowserTestUtils.browserLoaded(
+    gBrowser.selectedBrowser,
     false,
-    "context-selectall is enabled on osx-e10s, and windows when" +
-      " it should be disabled"
+    NEW_URL
+  );
+  // Ensure we've invalidated the actor
+  await TestUtils.waitForCondition(
+    () => wgp.isClosed,
+    "Waiting for actor to be dead after onunload"
   );
-  await test_contextmenu(
-    "#input_search",
-    [
-      "context-undo",
-      false,
-      "---",
-      null,
-      "context-cut",
-      true,
-      "context-copy",
-      true,
-      "context-paste",
-      null, // ignore clipboard state
-      "context-delete",
-      false,
-      "---",
-      null,
-      "context-selectall",
-      null,
-      "---",
-      null,
-      "spell-check-enabled",
-      true,
-    ],
-    { skipFocusChange: true }
-  );
-});
+  contextMenu.hidePopup();
+
+  // Check the menu *still* looks the same (and keep it open again):
+  await test_contextmenu("#input_spellcheck_correct", kCorrectItems, {
+    waitForSpellCheck: true,
+    keepMenuOpen: true,
+  });
 
-add_task(async function test_text_input_readonly() {
-  todo(
+  // Check what happens if the actor stays alive by loading the same page
+  // again; now the context menu stuff should be destroyed by the menu
+  // hiding, nothing else.
+  wgp = gBrowser.selectedBrowser.browsingContext.currentWindowGlobal;
+  BrowserTestUtils.loadURI(gBrowser.selectedBrowser, NEW_URL);
+  await BrowserTestUtils.browserLoaded(
+    gBrowser.selectedBrowser,
     false,
-    "context-selectall is enabled on osx-e10s, and windows when" +
-      " it should be disabled"
-  );
-  todo(
-    false,
-    "spell-check should not be enabled for input[readonly]. see bug 1246296"
+    NEW_URL
   );
-  await test_contextmenu(
-    "#input_readonly",
-    [
-      "context-undo",
-      false,
-      "---",
-      null,
-      "context-cut",
-      true,
-      "context-copy",
-      true,
-      "context-paste",
-      null, // ignore clipboard state
-      "context-delete",
-      false,
-      "---",
-      null,
-      "context-selectall",
-      null,
-    ],
-    {
-      // XXX Bug 1345081. Currently the Screenshots menu option is shown for
-      // various text elements even though it is set to type "page". That bug
-      // should remove the need for next line.
-      maybeScreenshotsPresent: true,
-      skipFocusChange: true,
-    }
-  );
+  contextMenu.hidePopup();
+
+  // Check the menu still looks the same:
+  await test_contextmenu("#input_spellcheck_correct", kCorrectItems, {
+    waitForSpellCheck: true,
+  });
+  // And test it a last time without any navigation:
+  await test_contextmenu("#input_spellcheck_correct", kCorrectItems, {
+    waitForSpellCheck: true,
+  });
 });
 
 add_task(async function test_cleanup() {
   BrowserTestUtils.removeTab(gBrowser.selectedTab);
 });
--- a/browser/base/content/test/contextMenu/contextmenu_common.js
+++ b/browser/base/content/test/contextMenu/contextmenu_common.js
@@ -347,16 +347,18 @@ let lastElementSelector = null;
  *        waitForSpellCheck: wait until spellcheck is initialized before
  *                           starting test
  *        maybeScreenshotsPresent: if true, the screenshots menu entry is
  *                                 expected to be present in the menu if
  *                                 screenshots is enabled, optional
  *        preCheckContextMenuFn: callback to run before opening menu
  *        onContextMenuShown: callback to run when the context menu is shown
  *        postCheckContextMenuFn: callback to run after opening menu
+ *        keepMenuOpen: if true, we do not call hidePopup, the consumer is
+ *                      responsible for calling it.
  * @return {Promise} resolved after the test finishes
  */
 async function test_contextmenu(selector, menuItems, options = {}) {
   contextMenu = document.getElementById("contentAreaContextMenu");
   is(contextMenu.state, "closed", "checking if popup is closed");
 
   // Default to centered if no positioning is defined.
   if (!options.offsetX && !options.offsetY) {
@@ -467,11 +469,13 @@ async function test_contextmenu(selector
     "popuphidden"
   );
 
   if (options.postCheckContextMenuFn) {
     await options.postCheckContextMenuFn();
     info("Completed postCheckContextMenuFn");
   }
 
-  contextMenu.hidePopup();
-  await awaitPopupHidden;
+  if (!options.keepMenuOpen) {
+    contextMenu.hidePopup();
+    await awaitPopupHidden;
+  }
 }
--- a/toolkit/actors/InlineSpellCheckerParent.jsm
+++ b/toolkit/actors/InlineSpellCheckerParent.jsm
@@ -23,11 +23,30 @@ class InlineSpellCheckerParent extends J
     this.sendAsyncMessage("InlineSpellChecker:toggleEnabled", {});
   }
 
   recheckSpelling() {
     this.sendAsyncMessage("InlineSpellChecker:recheck", {});
   }
 
   uninit() {
+    // This method gets called by InlineSpellChecker when the context menu
+    // goes away and the InlineSpellChecker instance is still alive.
+    // Stop referencing it and tidy the child end of us.
     this.sendAsyncMessage("InlineSpellChecker:uninit", {});
   }
+
+  _destructionObservers = new Set();
+  registerDestructionObserver(obj) {
+    this._destructionObservers.add(obj);
+  }
+
+  unregisterDestructionObserver(obj) {
+    this._destructionObservers.delete(obj);
+  }
+
+  didDestroy() {
+    for (let obs of this._destructionObservers) {
+      obs.actorDestroyed(this);
+    }
+    this._destructionObservers = null;
+  }
 }
--- a/toolkit/modules/InlineSpellChecker.jsm
+++ b/toolkit/modules/InlineSpellChecker.jsm
@@ -22,17 +22,25 @@ InlineSpellChecker.prototype = {
       // note: this might have been NULL if there is no chance we can spellcheck
     } catch (e) {
       this.mInlineSpellChecker = null;
     }
   },
 
   initFromRemote(aSpellInfo, aWindowGlobalParent) {
     if (this.mRemote) {
-      throw new Error("Unexpected state");
+      // We shouldn't get here, but let's just recover instead of bricking the
+      // menu by throwing exceptions:
+      Cu.reportError(new Error("Unexpected remote spellchecker present!"));
+      try {
+        this.mRemote.uninit();
+      } catch (ex) {
+        Cu.reportError(ex);
+      }
+      this.mRemote = null;
     }
     this.uninit();
 
     if (!aSpellInfo) {
       return;
     }
     this.mInlineSpellChecker = this.mRemote = new RemoteSpellChecker(
       aSpellInfo,
@@ -485,16 +493,17 @@ var SpellCheckHelper = {
     return flags;
   },
 };
 
 function RemoteSpellChecker(aSpellInfo, aWindowGlobalParent) {
   this._spellInfo = aSpellInfo;
   this._suggestionGenerator = null;
   this._actor = aWindowGlobalParent.getActor("InlineSpellChecker");
+  this._actor.registerDestructionObserver(this);
 }
 
 RemoteSpellChecker.prototype = {
   get canSpellCheck() {
     return this._spellInfo.canSpellCheck;
   },
   get spellCheckPending() {
     return this._spellInfo.initialSpellCheckPending;
@@ -566,11 +575,20 @@ RemoteSpellChecker.prototype = {
   ignoreWord() {
     let dictionary = Cc[
       "@mozilla.org/spellchecker/personaldictionary;1"
     ].getService(Ci.mozIPersonalDictionary);
     dictionary.ignoreWord(this._spellInfo.misspelling);
     this._actor.recheckSpelling();
   },
   uninit() {
-    this._actor.uninit();
+    if (this._actor) {
+      this._actor.uninit();
+      this._actor.unregisterDestructionObserver(this);
+    }
+  },
+
+  actorDestroyed() {
+    // The actor lets us know if it gets destroyed, so we don't
+    // later try to call `.uninit()` on it.
+    this._actor = null;
   },
 };
--- a/toolkit/modules/InlineSpellCheckerContent.jsm
+++ b/toolkit/modules/InlineSpellCheckerContent.jsm
@@ -12,16 +12,17 @@ var { InlineSpellChecker, SpellCheckHelp
 var EXPORTED_SYMBOLS = ["InlineSpellCheckerContent"];
 
 var InlineSpellCheckerContent = {
   _spellChecker: null,
   _actor: null,
 
   initContextMenu(event, editFlags, actor) {
     this._actor = actor;
+    this._actor.registerDestructionObserver(this);
 
     let spellChecker;
     if (!(editFlags & (SpellCheckHelper.TEXTAREA | SpellCheckHelper.INPUT))) {
       // Get the editor off the window.
       let win = event.target.ownerGlobal;
       let editingSession = win.docShell.editingSession;
       spellChecker = this._spellChecker = new InlineSpellChecker(
         editingSession.getEditorForWindow(win)
@@ -70,20 +71,27 @@ var InlineSpellCheckerContent = {
       misspelling: spellChecker.mMisspelling,
       spellSuggestions: this._generateSpellSuggestions(),
       currentDictionary: spellChecker.mInlineSpellChecker.spellChecker.GetCurrentDictionary(),
       dictionaryList,
     };
   },
 
   uninitContextMenu() {
+    if (this._actor) {
+      this._actor.unregisterDestructionObserver(this);
+    }
     this._actor = null;
     this._spellChecker = null;
   },
 
+  actorDestroyed() {
+    this.uninitContextMenu();
+  },
+
   _generateSpellSuggestions() {
     let spellChecker = this._spellChecker.mInlineSpellChecker.spellChecker;
     try {
       spellChecker.CheckCurrentWord(this._spellChecker.mMisspelling);
     } catch (e) {
       return [];
     }