Skip to content

Commit 9cb2f6e

Browse files
authored
feat(iOS): WKUserScripts (e.g. injectedJavaScript) can now update upon props change; and can be configured to inject into all frames. (#1119)
BREAKING CHANGE: • Props updates to `injectedJavaScript` are no longer immutable. • `injectedJavaScript` no longer attaches a `jsEvaluationValue` property to the `onLoadingFinish` event. Check out: #1119 (comment) to migrate with the same behavior.
1 parent e9ad1df commit 9cb2f6e

File tree

11 files changed

+435
-129
lines changed

11 files changed

+435
-129
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,7 @@ android/gradle
5353
android/gradlew
5454
android/gradlew.bat
5555

56-
lib/
56+
lib/
57+
.classpath
58+
.project
59+
.settings/

docs/Guide.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,11 +293,13 @@ export default class App extends Component {
293293

294294
This runs the JavaScript in the `runFirst` string once the page is loaded. In this case, you can see that both the body style was changed to red and the alert showed up after 2 seconds.
295295

296+
By setting `injectedJavaScriptForMainFrameOnly: false`, the JavaScript injection will occur on all frames (not just the top frame) if supported for the given platform.
297+
296298
<img alt="screenshot of Github repo" width="200" src="https://user-images.githubusercontent.com/1479215/53609254-e5dc9c00-3b7a-11e9-9118-bc4e520ce6ca.png" />
297299

298300
_Under the hood_
299301

300-
> On iOS, `injectedJavaScript` runs a method on WebView called `evaluateJavaScript:completionHandler:`
302+
> On iOS, ~~`injectedJavaScript` runs a method on WebView called `evaluateJavaScript:completionHandler:`~~ – this is no longer true as of version `8.2.0`. Instead, we use a `WKUserScript` with injection time `WKUserScriptInjectionTimeAtDocumentEnd`. As a consequence, `injectedJavaScript` no longer returns an evaluation value nor logs a warning to the console. In the unlikely event that your app depended upon this behaviour, please see migration steps [here](https://github.com/react-native-community/react-native-webview/pull/1119#issuecomment-574919464) to retain equivalent behaviour.
301303
> On Android, `injectedJavaScript` runs a method on the Android WebView called `evaluateJavascriptWithFallback`
302304
303305
#### The `injectedJavaScriptBeforeContentLoaded` prop
@@ -332,6 +334,11 @@ export default class App extends Component {
332334

333335
This runs the JavaScript in the `runFirst` string before the page is loaded. In this case, the value of `window.isNativeApp` will be set to true before the web code executes.
334336

337+
By setting `injectedJavaScriptBeforeContentLoadedForMainFrameOnly: false`, the JavaScript injection will occur on all frames (not just the top frame) if supported for the given platform. Howver, although support for `injectedJavaScriptBeforeContentLoadedForMainFrameOnly: false` has been implemented for iOS and macOS, [it is not clear](https://github.com/react-native-community/react-native-webview/pull/1119#issuecomment-600275750) that it is actually possible to inject JS into iframes at this point in the page lifecycle, and so relying on the expected behaviour of this prop when set to `false` is not recommended.
338+
339+
> On iOS, ~~`injectedJavaScriptBeforeContentLoaded` runs a method on WebView called `evaluateJavaScript:completionHandler:`~~ – this is no longer true as of version `8.2.0`. Instead, we use a `WKUserScript` with injection time `WKUserScriptInjectionTimeAtDocumentStart`. As a consequence, `injectedJavaScriptBeforeContentLoaded` no longer returns an evaluation value nor logs a warning to the console. In the unlikely event that your app depended upon this behaviour, please see migration steps [here](https://github.com/react-native-community/react-native-webview/pull/1119#issuecomment-574919464) to retain equivalent behaviour.
340+
> On Android, `injectedJavaScript` runs a method on the Android WebView called `evaluateJavascriptWithFallback`
341+
335342
#### The `injectJavaScript` method
336343

337344
While convenient, the downside to the previously mentioned `injectedJavaScript` prop is that it only runs once. That's why we also expose a method on the webview ref called `injectJavaScript` (note the slightly different name!).

docs/Reference.md

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ This document lays out the current public properties and methods for the React N
77
- [`source`](Reference.md#source)
88
- [`automaticallyAdjustContentInsets`](Reference.md#automaticallyadjustcontentinsets)
99
- [`injectedJavaScript`](Reference.md#injectedjavascript)
10-
- [`injectedJavaScriptBeforeContentLoaded`](Reference.md#injectedJavaScriptBeforeContentLoaded)
10+
- [`injectedJavaScriptBeforeContentLoaded`](Reference.md#injectedjavascriptbeforecontentloaded)
11+
- [`injectedJavaScriptForMainFrameOnly`](Reference.md#injectedjavascriptformainframeonly)
12+
- [`injectedJavaScriptBeforeContentLoadedForMainFrameOnly`](Reference.md#injectedjavascriptbeforecontentloadedformainframeonly)
1113
- [`mediaPlaybackRequiresUserAction`](Reference.md#mediaplaybackrequiresuseraction)
1214
- [`nativeConfig`](Reference.md#nativeconfig)
1315
- [`onError`](Reference.md#onerror)
@@ -120,11 +122,15 @@ Controls whether to adjust the content inset for web views that are placed behin
120122

121123
### `injectedJavaScript`
122124

123-
Set this to provide JavaScript that will be injected into the web page when the view loads. Make sure the string evaluates to a valid type (`true` works) and doesn't otherwise throw an exception.
125+
Set this to provide JavaScript that will be injected into the web page after the document finishes loading, but before other subresources finish loading.
126+
127+
Make sure the string evaluates to a valid type (`true` works) and doesn't otherwise throw an exception.
128+
129+
On iOS, see [`WKUserScriptInjectionTimeAtDocumentEnd`](https://developer.apple.com/documentation/webkit/wkuserscriptinjectiontime/wkuserscriptinjectiontimeatdocumentend?language=objc)
124130

125131
| Type | Required | Platform |
126132
| ------ | -------- | -------- |
127-
| string | No | iOS, Andrdoid, macOS
133+
| string | No | iOS, Android, macOS
128134

129135
To learn more, read the [Communicating between JS and Native](Guide.md#communicating-between-js-and-native) guide.
130136

@@ -148,18 +154,21 @@ const INJECTED_JAVASCRIPT = `(function() {
148154

149155
### `injectedJavaScriptBeforeContentLoaded`
150156

151-
Set this to provide JavaScript that will be injected into the web page after the document element is created, but before any other content is loaded. Make sure the string evaluates to a valid type (`true` works) and doesn't otherwise throw an exception.
152-
On iOS, see [WKUserScriptInjectionTimeAtDocumentStart](https://developer.apple.com/documentation/webkit/wkuserscriptinjectiontime/wkuserscriptinjectiontimeatdocumentstart?language=objc)
157+
Set this to provide JavaScript that will be injected into the web page after the document element is created, but before other subresources finish loading.
158+
159+
Make sure the string evaluates to a valid type (`true` works) and doesn't otherwise throw an exception.
160+
161+
On iOS, see [`WKUserScriptInjectionTimeAtDocumentStart`](https://developer.apple.com/documentation/webkit/wkuserscriptinjectiontime/wkuserscriptinjectiontimeatdocumentstart?language=objc)
153162

154163
| Type | Required | Platform |
155164
| ------ | -------- | -------- |
156-
| string | No | iOS, Android, macOS |
165+
| string | No | iOS, macOS |
157166

158167
To learn more, read the [Communicating between JS and Native](Guide.md#communicating-between-js-and-native) guide.
159168

160169
Example:
161170

162-
Post message a JSON object of `window.location` to be handled by [`onMessage`](Reference.md#onmessage)
171+
Post message a JSON object of `window.location` to be handled by [`onMessage`](Reference.md#onmessage). `window.ReactNativeWebView.postMessage` *will* be available at this time.
163172

164173
```jsx
165174
const INJECTED_JAVASCRIPT = `(function() {
@@ -175,6 +184,32 @@ const INJECTED_JAVASCRIPT = `(function() {
175184

176185
---
177186

187+
### `injectedJavaScriptForMainFrameOnly`
188+
189+
If `true` (default), loads the `injectedJavaScript` only into the main frame.
190+
191+
If `false`, loads it into all frames (e.g. iframes).
192+
193+
| Type | Required | Platform |
194+
| ------ | -------- | -------- |
195+
| bool | No | iOS, macOS |
196+
197+
---
198+
199+
### `injectedJavaScriptBeforeContentLoadedForMainFrameOnly`
200+
201+
If `true` (default), loads the `injectedJavaScriptBeforeContentLoaded` only into the main frame.
202+
203+
If `false`, loads it into all frames (e.g. iframes).
204+
205+
Warning: although support for `injectedJavaScriptBeforeContentLoadedForMainFrameOnly: false` has been implemented for iOS and macOS, [it is not clear](https://github.com/react-native-community/react-native-webview/pull/1119#issuecomment-600275750) that it is actually possible to inject JS into iframes at this point in the page lifecycle, and so relying on the expected behaviour of this prop when set to `false` is not recommended.
206+
207+
| Type | Required | Platform |
208+
| ------ | -------- | -------- |
209+
| bool | No | iOS, macOS |
210+
211+
---
212+
178213
### `mediaPlaybackRequiresUserAction`
179214

180215
Boolean that determines whether HTML5 audio and video requires the user to tap them before they start playing. The default value is `true`. (Android API minimum version 17).

example/App.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Alerts from './examples/Alerts';
1414
import Scrolling from './examples/Scrolling';
1515
import Background from './examples/Background';
1616
import Uploads from './examples/Uploads';
17+
import Injection from './examples/Injection';
1718

1819
const TESTS = {
1920
Alerts: {
@@ -48,6 +49,14 @@ const TESTS = {
4849
return <Uploads />;
4950
},
5051
},
52+
Injection: {
53+
title: 'Injection',
54+
testId: 'injection',
55+
description: 'Injection test',
56+
render() {
57+
return <Injection />;
58+
},
59+
},
5160
};
5261

5362
type Props = {};
@@ -101,6 +110,11 @@ export default class App extends Component<Props, State> {
101110
title="Background"
102111
onPress={() => this._changeTest('Background')}
103112
/>
113+
<Button
114+
testID="testType_injection"
115+
title="Injection"
116+
onPress={() => this._changeTest('Injection')}
117+
/>
104118
{Platform.OS === 'android' && <Button
105119
testID="testType_uploads"
106120
title="Uploads"

example/examples/Injection.tsx

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import React, {Component} from 'react';
2+
import {Text, View, ScrollView} from 'react-native';
3+
4+
import WebView from 'react-native-webview';
5+
6+
// const HTML = `
7+
// <!DOCTYPE html>
8+
// <html>
9+
// <head>
10+
// <meta charset="utf-8">
11+
// <meta name="viewport" content="width=device-width, initial-scale=1">
12+
// <title>iframe test</title>
13+
// </head>
14+
// <body>
15+
// <p style="">beforeContentLoaded on the top frame <span id="before_failed" style="display: inline-block;">failed</span><span id="before_succeeded" style="display: none;">succeeded</span>!</p>
16+
// <p style="">afterContentLoaded on the top frame <span id="after_failed" style="display: inline-block;">failed</span><span id="after_succeeded" style="display: none;">succeeded</span>!</p>
17+
// <iframe src="https://birchlabs.co.uk/linguabrowse/infopages/obsol/iframe.html?v=1" name="iframe_0" style="width: 100%; height: 25px;"></iframe>
18+
// <iframe src="https://birchlabs.co.uk/linguabrowse/infopages/obsol/iframe2.html?v=1" name="iframe_1" style="width: 100%; height: 25px;"></iframe>
19+
// <iframe src="https://www.ebay.co.uk" name="iframe_2" style="width: 100%; height: 25px;"></iframe>
20+
// </body>
21+
// </html>
22+
// `;
23+
24+
type Props = {};
25+
type State = {
26+
backgroundColor: string,
27+
};
28+
29+
export default class Injection extends Component<Props, State> {
30+
state = {
31+
backgroundColor: '#FF00FF00'
32+
};
33+
34+
render() {
35+
return (
36+
<ScrollView>
37+
<View style={{ }}>
38+
<View style={{ height: 300 }}>
39+
<WebView
40+
/**
41+
* This HTML is a copy of a multi-frame JS injection test that I had lying around.
42+
* @see https://birchlabs.co.uk/linguabrowse/infopages/obsol/iframeTest.html
43+
*/
44+
// source={{ html: HTML }}
45+
source={{ uri: "https://birchlabs.co.uk/linguabrowse/infopages/obsol/rnw_iframe_test.html" }}
46+
automaticallyAdjustContentInsets={false}
47+
style={{backgroundColor:'#00000000'}}
48+
49+
/* Must be populated in order for `messagingEnabled` to be `true` to activate the
50+
* JS injection user scripts, consistent with current behaviour. This is undesirable,
51+
* so needs addressing in a follow-up PR. */
52+
onMessage={() => {}}
53+
54+
/* We set this property in each frame */
55+
injectedJavaScriptBeforeContentLoaded={`
56+
console.log("executing injectedJavaScriptBeforeContentLoaded...");
57+
if(typeof window.top.injectedIframesBeforeContentLoaded === "undefined"){
58+
window.top.injectedIframesBeforeContentLoaded = [];
59+
}
60+
window.self.colourToUse = "orange";
61+
if(window.self === window.top){
62+
console.log("Was window.top. window.frames.length is:", window.frames.length);
63+
window.self.numberOfFramesAtBeforeContentLoaded = window.frames.length;
64+
function declareSuccessOfBeforeContentLoaded(head){
65+
var style = window.self.document.createElement('style');
66+
style.type = 'text/css';
67+
style.innerHTML = "#before_failed { display: none !important; }#before_succeeded { display: inline-block !important; }";
68+
head.appendChild(style);
69+
}
70+
71+
const head = (window.self.document.head || window.self.document.getElementsByTagName('head')[0]);
72+
73+
if(head){
74+
declareSuccessOfBeforeContentLoaded(head);
75+
} else {
76+
window.self.document.addEventListener("DOMContentLoaded", function (event) {
77+
const head = (window.self.document.head || window.self.document.getElementsByTagName('head')[0]);
78+
declareSuccessOfBeforeContentLoaded(head);
79+
});
80+
}
81+
} else {
82+
window.top.injectedIframesBeforeContentLoaded.push(window.self.name);
83+
console.log("wasn't window.top.");
84+
console.log("wasn't window.top. Still going...");
85+
}
86+
`}
87+
88+
injectedJavaScriptForMainFrameOnly={false}
89+
90+
/* We read the colourToUse property in each frame to recolour each frame */
91+
injectedJavaScript={`
92+
console.log("executing injectedJavaScript...");
93+
if(typeof window.top.injectedIframesAfterContentLoaded === "undefined"){
94+
window.top.injectedIframesAfterContentLoaded = [];
95+
}
96+
97+
if(window.self.colourToUse){
98+
window.self.document.body.style.backgroundColor = window.self.colourToUse;
99+
} else {
100+
window.self.document.body.style.backgroundColor = "cyan";
101+
}
102+
103+
if(window.self === window.top){
104+
function declareSuccessOfAfterContentLoaded(head){
105+
var style = window.self.document.createElement('style');
106+
style.type = 'text/css';
107+
style.innerHTML = "#after_failed { display: none !important; }#after_succeeded { display: inline-block !important; }";
108+
head.appendChild(style);
109+
}
110+
111+
declareSuccessOfAfterContentLoaded(window.self.document.head || window.self.document.getElementsByTagName('head')[0]);
112+
113+
// var numberOfFramesAtBeforeContentLoadedEle = document.createElement('p');
114+
// numberOfFramesAtBeforeContentLoadedEle.textContent = "Number of iframes upon the main frame's beforeContentLoaded: " +
115+
// window.self.numberOfFramesAtBeforeContentLoaded;
116+
117+
// var numberOfFramesAtAfterContentLoadedEle = document.createElement('p');
118+
// numberOfFramesAtAfterContentLoadedEle.textContent = "Number of iframes upon the main frame's afterContentLoaded: " + window.frames.length;
119+
// numberOfFramesAtAfterContentLoadedEle.id = "numberOfFramesAtAfterContentLoadedEle";
120+
121+
var namedFramesAtBeforeContentLoadedEle = document.createElement('p');
122+
namedFramesAtBeforeContentLoadedEle.textContent = "Names of iframes that called beforeContentLoaded: " + JSON.stringify(window.top.injectedIframesBeforeContentLoaded);
123+
namedFramesAtBeforeContentLoadedEle.id = "namedFramesAtBeforeContentLoadedEle";
124+
125+
var namedFramesAtAfterContentLoadedEle = document.createElement('p');
126+
namedFramesAtAfterContentLoadedEle.textContent = "Names of iframes that called afterContentLoaded: " + JSON.stringify(window.top.injectedIframesAfterContentLoaded);
127+
namedFramesAtAfterContentLoadedEle.id = "namedFramesAtAfterContentLoadedEle";
128+
129+
// document.body.appendChild(numberOfFramesAtBeforeContentLoadedEle);
130+
// document.body.appendChild(numberOfFramesAtAfterContentLoadedEle);
131+
document.body.appendChild(namedFramesAtBeforeContentLoadedEle);
132+
document.body.appendChild(namedFramesAtAfterContentLoadedEle);
133+
} else {
134+
window.top.injectedIframesAfterContentLoaded.push(window.self.name);
135+
window.top.document.getElementById('namedFramesAtAfterContentLoadedEle').textContent = "Names of iframes that called afterContentLoaded: " + JSON.stringify(window.top.injectedIframesAfterContentLoaded);
136+
}
137+
`}
138+
/>
139+
</View>
140+
</View>
141+
<Text>This test presents three iframes: iframe_0 (yellow); iframe_1 (pink); and iframe_2 (transparent, because its 'X-Frame-Options' is set to 'SAMEORIGIN').</Text>
142+
<Text>Before injection, the main frame's background is the browser's default value (transparent or white) and each frame has its natural colour.</Text>
143+
{/*<Text>1a) At injection time "beforeContentLoaded", a variable will be set in each frame to set 'orange' as the "colour to be used".</Text>*/}
144+
{/*<Text>1b) Also upon "beforeContentLoaded", a style element to change the text "beforeContentLoaded failed" -> "beforeContentLoaded succeeded" will be applied as soon as the head has loaded.</Text>*/}
145+
{/*<Text>2a) At injection time "afterContentLoaded", that variable will be read – if present, the colour orange will be injected into all frames. Otherwise, cyan.</Text>*/}
146+
{/*<Text>2b) Also upon "afterContentLoaded", a style element to change the text "afterContentLoaded failed" -> "afterContentLoaded succeeded" will be applied as soon as the head has loaded.</Text>*/}
147+
<Text>✅ If the main frame becomes orange, then top-frame injection both beforeContentLoaded and afterContentLoaded is supported.</Text>
148+
<Text>✅ If iframe_0, and iframe_1 become orange, then multi-frame injection beforeContentLoaded and afterContentLoaded is supported.</Text>
149+
<Text>✅ If the two texts say "beforeContentLoaded on the top frame succeeded!" and "afterContentLoaded on the top frame succeeded!", then both injection times are supported at least on the main frame.</Text>
150+
<Text>⚠️ If either of the two iframes become coloured cyan, then for that given frame, JS injection succeeded after the content loaded, but didn't occur before the content loaded - please note that for iframes, this may not be a test failure, as it is not clear whether we would expect iframes to support an injection time of beforeContentLoaded anyway.</Text>
151+
<Text>⚠️ If "Names of iframes that called beforeContentLoaded: " is [], then see above.</Text>
152+
<Text>❌ If "Names of iframes that called afterContentLoaded: " is [], then afterContentLoaded is not supported in iframes.</Text>
153+
<Text>❌ If the main frame becomes coloured cyan, then JS injection succeeded after the content loaded, but didn't occur before the content loaded.</Text>
154+
<Text>❌ If the text "beforeContentLoaded on the top frame failed" remains unchanged, then JS injection has failed on the main frame before the content loaded.</Text>
155+
<Text>❌ If the text "afterContentLoaded on the top frame failed" remains unchanged, then JS injection has failed on the main frame after the content loaded.</Text>
156+
<Text>❌ If the iframes remain their original colours (yellow and pink), then multi-frame injection is not supported at all.</Text>
157+
</ScrollView>
158+
);
159+
}
160+
}

0 commit comments

Comments
 (0)