Bug 1245921 - Monkey patch ReactDOM event system for XUL; r+miker draft
authorGreg Tatum <tatum.creative@gmail.com>
Fri, 23 Dec 2016 15:57:03 -0600
changeset 454824 ec73912c759c342d1f876d6646c17f8e6ff69949
parent 454222 a6d29e9432f5f88a941c0ea5284cb082f34bd097
child 454825 51c4bc9d06b4cd84786a5b1542902dafb81393ac
push id40064
push userbmo:gtatum@mozilla.com
push dateFri, 30 Dec 2016 14:26:59 +0000
bugs1245921
milestone53.0a1
Bug 1245921 - Monkey patch ReactDOM event system for XUL; r+miker XUL iframes inside of a privileged XUL document propagate events between the documents. This breaks React's event model, as React captures all events at the document level. In the XUL document containing a XUL iframe, these events end up being dispatched twice. This fix tricks react into thinking the toolbox.xul document is the only root document, thus fixing the event system. MozReview-Commit-ID: B3XF3L6rax1
devtools/client/shared/vendor/REACT_UPGRADING
devtools/client/shared/vendor/react-dom.js
--- a/devtools/client/shared/vendor/REACT_UPGRADING
+++ b/devtools/client/shared/vendor/REACT_UPGRADING
@@ -48,14 +48,45 @@ MOVE OFF XUL and we don't need to do thi
 
 After patching `build/react-with-addons.js` again, copy the production
 version over:
 
 * cp build/react-with-addons.js <gecko-dev>/devtools/client/shared/vendor/react.js
 
 You also need to copy the ReactDOM and ReactDOMServer package. It requires React, so
 right now we are just manually changing the path from `react` to
-`devtools/client/shared/vendor/react`.
+`devtools/client/shared/vendor/react`. Also, to have React's event system working
+correctly in certain XUL situations, ReactDOM must be monkey patched with a fix. This
+fix is currently applied in devtools/client/shared/vendor/react-dom.js. When upgrading,
+copy and paste the existing block of code into the new file in the same location. It is
+delimited by a header and footer, and then the monkeyPatchReactDOM() needs to be applied
+to the returned value.
+
+e.g. Turn this:
+
+```
+})(function(React) {
+  return React.__SECRET_DOM_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
+});
+```
+
+Into this:
+
+```
+})(function(React) {
+  //--------------------------------------------------------------------------------------
+  // START MONKEY PATCH
+
+  ...
+
+  // END MONKEY PATCH
+  //--------------------------------------------------------------------------------------
+
+  return monkeyPatchReactDOM(React.__SECRET_DOM_DO_NOT_USE_OR_YOU_WILL_BE_FIRED);
+});
+```
 
 * cp build/react-dom.js <gecko-dev>/devtools/client/shared/vendor/react-dom.js
 * (change `require('react')` at the top of the file to the right path)
+* Copy/paste existing monkey patch
+* Apply monkeyPatchReactDOM() to the returned object ReactDOM object.
 * cp build/react-dom.js <gecko-dev>/devtools/client/shared/vendor/react-dom-server.js
 * (change `require('react')` at the top of the file to the right path)
--- a/devtools/client/shared/vendor/react-dom.js
+++ b/devtools/client/shared/vendor/react-dom.js
@@ -33,10 +33,160 @@
       // needed for Java 8 Nashorn
       // see https://github.com/facebook/react/issues/3037
       g = this;
     }
     g.ReactDOM = f(g.React);
   }
 
 })(function(React) {
-  return React.__SECRET_DOM_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
+  //--------------------------------------------------------------------------------------
+  // START MONKEY PATCH
+  /**
+   * This section contains a monkey patch for React DOM, so that it functions correctly in
+   * certain XUL situations. React centralizes events to specific DOM nodes by only
+   * binding a single listener to the document of the page. It then captures these events,
+   * and then uses a SyntheticEvent system to dispatch these throughout the page.
+   *
+   * In privileged XUL with a XUL iframe, and React in both documents, this system breaks.
+   * By design, these XUL frames can still talk to each other, while in a normal HTML
+   * situation, they would not be able to. The events from the XUL iframe propagate to the
+   * parent document as well. This leads to the React event system incorrectly dispatching
+   * TWO SyntheticEvents for for every ONE action.
+   *
+   * The fix here is trick React into thinking that the owning document for every node in
+   * a XUL iframe to be the toolbox.xul. This is done by creating a Proxy object that
+   * captures any usage of HTMLElement.ownerDocument, and then passing in the toolbox.xul
+   * document rather than (for example) the netmonitor.xul document. React will then hook
+   * up the event system correctly on the top level controlling document.
+   *
+   * @return {object} The proxied and monkey patched ReactDOM
+   */
+  function monkeyPatchReactDOM(ReactDOM) {
+    // This is the actual monkey patched function.
+    const reactDomRender = monkeyPatchRender(ReactDOM);
+
+    // Proxied method calls might need to be bound, but do this lazily with caching.
+    const lazyFunctionBinding = functionLazyBinder();
+
+    // Create a proxy, but the render property is not writable on the ReactDOM object, so
+    // a direct proxy will fail with an error. Instead, create a proxy on a a blank object.
+    // Pass on getting and setting behaviors.
+    return new Proxy({}, {
+      get: (target, name) => {
+        return name === "render"
+          ? reactDomRender
+          : lazyFunctionBinding(ReactDOM, name);
+      },
+      set: (target, name, value) => {
+        ReactDOM[name] = value;
+      }
+    });
+  };
+
+  /**
+   * Creates a function that replaces the ReactDOM.render method. It does this by
+   * creating a proxy for the dom node being mounted. This proxy rewrites the
+   * "ownerDocument" property to point to the toolbox.xul document. This function
+   * is only used for XUL iframes inside of a XUL document.
+   *
+   * @param {object} ReactDOM
+   * @return {function} The patched ReactDOM.render function.
+   */
+  function monkeyPatchRender(ReactDOM) {
+    const elementProxyCache = new WeakMap();
+
+    return (...args) => {
+      const container = args[1];
+      const toolboxDoc = getToolboxDocIfXulOnly(container);
+
+      if (toolboxDoc) {
+        // Re-use any existing cached HTMLElement proxies.
+        let proxy = elementProxyCache.get(container);
+
+        if (!proxy) {
+          // Proxied method calls need to be bound, but do this lazily.
+          const lazyFunctionBinding = functionLazyBinder();
+
+          // Create a proxy to the container HTMLElement. If React tries to access the
+          // ownerDocument, pass in the toolbox's document, as the event dispatching system
+          // is rooted from the toolbox document.
+          proxy = new Proxy(container, {
+            get: function (target, name) {
+              if (name === "ownerDocument") {
+                return toolboxDoc;
+              }
+              return lazyFunctionBinding(target, name);
+            }
+          });
+
+          elementProxyCache.set(container, proxy);
+        }
+
+        // Update the args passed to ReactDOM.render.
+        args[1] = proxy;
+      }
+
+      return ReactDOM.render.apply(this, args);
+    };
+  }
+
+  /**
+   * Try to access the containing toolbox XUL document, but only if all of the iframes
+   * in the heirarchy are XUL documents. Events dispatch differently in the case of all
+   * privileged XUL documents. Events that fire in an iframe propagate up to the parent
+   * frame. This does not happen when HTML is in the mix. Only return the toolbox if
+   * it matches the proper case of a XUL iframe inside of a XUL document.
+   *
+   * @param {HTMLElement} node - The DOM node inside of an iframe.
+   * @return {XULDocument|null} The toolbox.xul document, or null.
+   */
+  function getToolboxDocIfXulOnly(node) {
+    // This execution context doesn't know about XULDocuments, so don't get the toolbox.
+    if (typeof XULDocument !== "function") {
+      return null;
+    }
+
+    let doc = node.ownerDocument;
+
+    while (doc instanceof XULDocument) {
+      const {frameElement} = doc.defaultView;
+
+      if (!frameElement) {
+        // We're at the root element, and no toolbox was found.
+        return null;
+      }
+
+      doc = frameElement.parentElement.ownerDocument;
+      if (doc.documentURI === "about:devtools-toolbox") {
+        return doc;
+      }
+    }
+
+    return null;
+  }
+
+  /**
+   * When accessing proxied functions, the instance becomes unbound to the method. This
+   * utility either passes a value through if it's not a function, or automatically binds a
+   * function and caches that bound function for repeated calls.
+   */
+  function functionLazyBinder() {
+    const boundFunctions = {};
+
+    return (target, name) => {
+      if (typeof target[name] === "function") {
+        // Lazily cache function bindings.
+        if (boundFunctions[name]) {
+          return boundFunctions[name];
+        }
+        boundFunctions[name] = target[name].bind(target);
+        return boundFunctions[name];
+      }
+      return target[name];
+    };
+  }
+
+  // END MONKEY PATCH
+  //--------------------------------------------------------------------------------------
+
+  return monkeyPatchReactDOM(React.__SECRET_DOM_DO_NOT_USE_OR_YOU_WILL_BE_FIRED);
 });