Fixing React Native WebView’s postMessage for iOS
Charles Stover
Posted on June 4, 2019
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
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}
/>
);
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}
/>
);
}
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);
}
};
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. */
};
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]) +
'\\\')'
);
};
})();
`;
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;
}
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, '\'');
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;
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.
Posted on June 4, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.