Fixing React Native WebView’s postMessage for iOS

charlesstover

Charles Stover

Posted on June 4, 2019

Fixing React Native WebView’s postMessage for iOS

In 2016, GitHub user Robert Roskam (raiderrobert) opened an issue on the React Native repository reporting the error “Setting onMessage on a WebView overrides existing values of window.postMessage, but a previous value was defined”. In the two years since then, nothing has been done to resolve it within the internal React Native implementation of WebView.

The React Native community has forked WebView specifically to maintain it as a third party package and fix many of these ongoing issues. However, in order to implement these third party packages, you must be able to link React Native packages — react-native link react-native-webview. If you are able and willing to do this, your problem is solved. The installation instructions for the community edition of WebView are as simple as:

yarn add https://github.com/react-native-community/react-native-webview
react-native link react-native-webview 
Enter fullscreen mode Exit fullscreen mode

Note: In order to react-native link ..., you must first yarn global add react-native.

Unfortunately, if you are unable or unwilling to do this, there has simply been no solution to this problem. For years!

Users of Expo, for example, would have to eject their project and write their own native, non-JavaScript implementation of features. Expo will, theoretically, be using these community edition packages in future releases; but with a launch window only weeks away, my team and myself were not willing to wait.

The Solution 💡

If you care more about getting this solved right now than how it works, this section is for you.

Either npm install rn-webview or yarn add rn-webview to add the rn-webview package to your project.

Wherever you are using import { WebView } from 'react-native', simply replace it with import WebView from 'rn-webview'. Then just use the new WebView component as you would the React Native internal implementation, including the use of the onMessage prop. The rn-webview package is just a wrapper for the internal React Native implementation that intercepts messages through a different channel than the internal onMessage prop, but handles it with its own onMessage prop, giving the illusion that you are actually using the internal onMessage with expected results.

Caveats 🤕

The rn-webview package works by directing window.postMessage traffic to history.pushState instead. While React Native’s iOS implementation cannot handle window.postMessage correctly, it can handle navigation state changes. Because of this, the navigation state change event is the channel through which messages are transferred between the WebView and the native application.

If manipulation of the history state is an important aspect of your application, this solution may not suit your needs. Feel free to fork the project on GitHub to offer alternative solutions.

The Implementation 🔨

Export 🚢

First and foremost, the ref prop of WebView is a particularly important one. Because of this, we don’t want the user to lose access to it. We start the package with a forwardRef implementation, where WebViewPostMessage is the class name used for this package.

export default React.forwardRef((props, ref) =>
  <WebViewPostMessage
    {...props}
    forwardedRef={ref}
  />
);
Enter fullscreen mode Exit fullscreen mode

Render 🎨

The output of this component is going to be the React Native internal implementation of WebView, with a few tweaks. We aren’t going to give it the forwardedRef prop, because that is only used to give the parent access to the ref and is totally meaningless to the internal WebView. Most importantly, we aren’t going to give it the onMessage prop, because that is the source of all of our problems — it’s not supported by iOS!

render() {
  const props = {...this.props};
  delete props.forwardedRef;
  delete props.onMessage;

  return (
    <WebView
      {...this.props}
      onNavigationStateChange={this.handleNavigationStateChange}
      ref={this.handleRef}
    />
  );
}

Enter fullscreen mode Exit fullscreen mode

We have a custom navigation state change listener, because that is the channel through which we will be listening for messages.

We have a custom ref handler, because we both 1) need access to it inside this component and 2) need to pass the ref back to the parent container via the forwardedRef prop.

Ref 👋

When the internal WebView gives us its ref, we store it on the instance (this.ref = ref) for use later. If the parent requested the ref as well, we forward it.

handleRef = ref => {
  this.ref = ref;

  // If the caller also wants this ref, pass it along to them as well.
  if (this.props.forwardedRef) {
    this.props.forwardedRef(ref);
  }
};

Enter fullscreen mode Exit fullscreen mode

Inject window.postMessage 💉

Now, a custom implementation of window.postMessage needs to exist on any page in the WebView. Whenever the navigation state changes, if it has finished loading, we inject JavaScript into it to override what window.postMessage does.

handleNavigationStateChange = e => {

  /* We'll do something here later. */

  // If this navigation state change has completed, listen for messages.
  if (
    !e.loading &&
    this.ref
  ) {
    this.ref.injectJavaScript(injectPostMessage);
  }

  /* We'll do something here later. */
};

Enter fullscreen mode Exit fullscreen mode

I defined and importedinjectPostMessage from a different file for readability.

export default `
(function() {
  var EMPTY_STATE = Object.create(null);
  var escape = function(str) {
    return str.replace(/'/g, '\\\\\'');
  };
  var postMessage = window.postMessage;
  window.postMessage = function() {
    if (postMessage) {
      postMessage.apply(window, arguments);
    }
    history.pushState(
      EMPTY_STATE,
      document.title,
      location.href +
      '#window.postMessage(\\\'' +
      escape(arguments[0]) +
      '\\\')'
    );
  };
})();
`;

Enter fullscreen mode Exit fullscreen mode

It is an immediately-invoked function expression to make sure none of our variables conflict with the web page.

The EMPTY_STATE is what is pushed to history, since we won’t be using a state object for our event listener.

The escape function escapes apostrophes in a string so that we can place that string in apostrophes. Since the navigation state that we push is not real JavaScript and won’t be passed through any sort of JavaScript interpreter, this step is not exactly necessary. It just allows the state we push to more closely mimic real JavaScript.

The postMessage variable checks to see if a postMessage function already exists. If so, we’ll want to execute it also during any window.postMessage calls.

We define our own window.postMessage function. The first thing it does is executes the previous window.postMessage function, if it existed.

Next, we push to the history state. We have no state object, so we use the aforementioned empty one. The title of the document is not changing, so we just use the current one. The location of the document is also not changing per se: we are merely appending a hash.

That hash, which we’ll be listening for later, is window.postMessage('the message'). It looks like JavaScript, by design, but is not going to be evaluated by any real JavaScript interpreter. We just need a unique hash that won’t collide with real, in-document hashes.

postMessage Listener 📬

Now that we have our own window.postMessage event emitter, we need to listen for it. This is the code that goes at the top of the handleNavigationStateChange method.

const postMessage = e.url.match(/\#window\.postMessage\('(.+)'\)$/);
if (postMessage) {
  if (
    e.loading &&
    this.props.onMessage
  ) {
    this.props.onMessage({
      nativeEvent: {
        data: unescape(postMessage[1])
      }
    });
  }
  return;
}

Enter fullscreen mode Exit fullscreen mode

We check if the new URL matches the postMessage hash we defined earlier. If it does, we’re going to return so that the rest of the navigation state change event listener doesn’t fire. This is a message event, not a navigation state change (technicalities aside).

Each postMessage event will fire the navigation state change twice — once for loading: true and one, almost immediately after, for loading: false. We are only listening for the loading: true event, because it occurs first. The loading: false event is ignored, because it is just a duplicate.

Only if the parent component passed an onMessage event handler, we call that handler with a mock event that contains the message. We unescape the message before passing it, because we escaped the apostrophes earlier.

The unescape function is defined at the top of the document, because it is constant (does not depend on the instance) and does not need to be a method of the component. You may import it if you prefer to code split it.

const unescape = str =>
  str.replace(/\\'/g, '\'');
Enter fullscreen mode Exit fullscreen mode

onNavigationStateChange 🕵

The above covers everything we need for intercepting window.postMessage and handling it with one’s own onMessage event listener. Our original problem is already solved — onMessage works with this WebView. However, since we have overwritten the internal onNavigationStateChange listener, the parent is no longer receiving navigation state change events.

At the bottom of the handleNavigationStateChange event listener, add the following:

if (this.props.onNavigationStateChange) {
  return this.props.onNavigationStateChange(e);
}
return;

Enter fullscreen mode Exit fullscreen mode

If the parent has included an onNavigationStateChange prop, call it, and give it this navigation state change event.

The empty return is simply personal preference — I don’t believe functions should conditionally return, even if it’s functionally equivalent to an implicit return.

Conclusion 🔚

As a reminder, you can include the component just outlined by installing the rn-webview package from NPM. You may also fork it on GitHub.

If you liked this article, feel free to give it a heart or unicorn. It’s quick, it’s easy, and it’s free! If you have any relevant commentary, please leave it in the comments below.

To read more of my columns, you may follow me on LinkedIn, Medium, and Twitter, or check out my portfolio on CharlesStover.com.

💖 💪 🙅 🚩
charlesstover
Charles Stover

Posted on June 4, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related