Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(iOS): WKUserScripts (e.g. injectedJavaScript) can now update upon props change; and can be configured to inject into all frames. #1119

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b53f6b6
Extract wkWebViewConfig setup to setUpWkWebViewConfig function
shirakaba Jan 5, 2020
ec0ed51
feat(iOS): user scripts can be updated on prop change
shirakaba Jan 5, 2020
8c08261
fix(iOS): run injectedJavaScript and injectedJavaScriptBeforeContentL…
shirakaba Jan 5, 2020
9207312
fix(iOS): resetupScripts now takes a WKWebViewConfiguration
shirakaba Jan 5, 2020
bd19f68
fix(iOS): got 'start' and 'end' mixed up again
shirakaba Jan 5, 2020
6204edb
Guard against nil _webView
shirakaba Jan 5, 2020
db45cd0
chore(iOS): refactor script -> source; and refactor inject -> mainFra…
shirakaba Jan 5, 2020
813c230
chore(iOS): Reverted forMainFrameOnly policy of the messaging script,…
shirakaba Jan 5, 2020
eb67ce7
fix(iOS): resetupScripts now actually uses the WKWebViewConfiguration…
shirakaba Jan 5, 2020
14de90b
fix(iOS) exported view property "injectedJavaScriptBeforeContentLoade…
shirakaba Jan 5, 2020
a6c5acc
Merge branch 'master' into shirakaba/updatable-user-scripts
shirakaba Jan 15, 2020
75ec36f
fix(iOS): Restore "name:HistoryShimName];" that was cut off.
shirakaba Jan 16, 2020
a517aaa
Merge commit '118663287acec269203c3850891cf145dc12f06e' into shirakab…
shirakaba Feb 19, 2020
8703c57
Update Podfile.lock to use [email protected]
shirakaba Feb 19, 2020
a641a15
Merge branch 'shirakaba/updatable-user-scripts' of github.com:shiraka…
shirakaba Feb 19, 2020
6409d60
Example to test JS injection
shirakaba Feb 19, 2020
05ac6cd
web page -> main frame
shirakaba Feb 19, 2020
a6fdfcc
Clarify wording of test pass
shirakaba Feb 19, 2020
c555ac8
gitignore
shirakaba Mar 17, 2020
fa5667e
Merge commit '5f7f4a19f2b3d4d7f0c6680a3b6be064e19e7965' into shirakab…
shirakaba Mar 17, 2020
201639e
pod install
shirakaba Mar 17, 2020
d17318d
chore: clarify docs
shirakaba Mar 17, 2020
6d47715
Extra dimension to tests
shirakaba Feb 20, 2020
cecb5df
Don't call successBeforeContentLoaded(head) redundantly
shirakaba Feb 20, 2020
532fab3
Use real hosted example and give better test result diagnostics
shirakaba Mar 17, 2020
c7c9099
Orange is more readable
shirakaba Mar 17, 2020
2a394b7
Mentions of "green" -> "orange"
shirakaba Mar 17, 2020
c89f210
Warning on injectedJavaScriptBeforeContentLoadedForMainFrameOnly beha…
shirakaba Mar 17, 2020
e8404f5
Merge branch 'master' into shirakaba/updatable-user-scripts
safaiyeh Mar 17, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,7 @@ android/gradle
android/gradlew
android/gradlew.bat

lib/
lib/
.classpath
.project
.settings/
9 changes: 8 additions & 1 deletion docs/Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,11 +293,13 @@ export default class App extends Component {

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.

By setting `injectedJavaScriptForMainFrameOnly: false`, the JavaScript injection will occur on all frames (not just the top frame) if supported for the given platform.

<img alt="screenshot of Github repo" width="200" src="https://user-images.githubusercontent.com/1479215/53609254-e5dc9c00-3b7a-11e9-9118-bc4e520ce6ca.png" />

_Under the hood_

> On iOS, `injectedJavaScript` runs a method on WebView called `evaluateJavaScript:completionHandler:`
> 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.
> On Android, `injectedJavaScript` runs a method on the Android WebView called `evaluateJavascriptWithFallback`

#### The `injectedJavaScriptBeforeContentLoaded` prop
Expand Down Expand Up @@ -332,6 +334,11 @@ export default class App extends Component {

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.

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.

> 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.
> On Android, `injectedJavaScript` runs a method on the Android WebView called `evaluateJavascriptWithFallback`

#### The `injectJavaScript` method

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!).
Expand Down
49 changes: 42 additions & 7 deletions docs/Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ This document lays out the current public properties and methods for the React N
- [`source`](Reference.md#source)
- [`automaticallyAdjustContentInsets`](Reference.md#automaticallyadjustcontentinsets)
- [`injectedJavaScript`](Reference.md#injectedjavascript)
- [`injectedJavaScriptBeforeContentLoaded`](Reference.md#injectedJavaScriptBeforeContentLoaded)
- [`injectedJavaScriptBeforeContentLoaded`](Reference.md#injectedjavascriptbeforecontentloaded)
- [`injectedJavaScriptForMainFrameOnly`](Reference.md#injectedjavascriptformainframeonly)
- [`injectedJavaScriptBeforeContentLoadedForMainFrameOnly`](Reference.md#injectedjavascriptbeforecontentloadedformainframeonly)
- [`mediaPlaybackRequiresUserAction`](Reference.md#mediaplaybackrequiresuseraction)
- [`nativeConfig`](Reference.md#nativeconfig)
- [`onError`](Reference.md#onerror)
Expand Down Expand Up @@ -120,11 +122,15 @@ Controls whether to adjust the content inset for web views that are placed behin

### `injectedJavaScript`

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.
Set this to provide JavaScript that will be injected into the web page after the document finishes loading, but before other subresources finish loading.

Make sure the string evaluates to a valid type (`true` works) and doesn't otherwise throw an exception.

On iOS, see [`WKUserScriptInjectionTimeAtDocumentEnd`](https://developer.apple.com/documentation/webkit/wkuserscriptinjectiontime/wkuserscriptinjectiontimeatdocumentend?language=objc)

| Type | Required | Platform |
| ------ | -------- | -------- |
| string | No | iOS, Andrdoid, macOS
| string | No | iOS, Android, macOS

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

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

### `injectedJavaScriptBeforeContentLoaded`

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.
On iOS, see [WKUserScriptInjectionTimeAtDocumentStart](https://developer.apple.com/documentation/webkit/wkuserscriptinjectiontime/wkuserscriptinjectiontimeatdocumentstart?language=objc)
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.

Make sure the string evaluates to a valid type (`true` works) and doesn't otherwise throw an exception.

On iOS, see [`WKUserScriptInjectionTimeAtDocumentStart`](https://developer.apple.com/documentation/webkit/wkuserscriptinjectiontime/wkuserscriptinjectiontimeatdocumentstart?language=objc)

| Type | Required | Platform |
| ------ | -------- | -------- |
| string | No | iOS, Android, macOS |
| string | No | iOS, macOS |

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

Example:

Post message a JSON object of `window.location` to be handled by [`onMessage`](Reference.md#onmessage)
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.

```jsx
const INJECTED_JAVASCRIPT = `(function() {
Expand All @@ -175,6 +184,32 @@ const INJECTED_JAVASCRIPT = `(function() {

---

### `injectedJavaScriptForMainFrameOnly`

If `true` (default), loads the `injectedJavaScript` only into the main frame.

If `false`, loads it into all frames (e.g. iframes).

| Type | Required | Platform |
| ------ | -------- | -------- |
| bool | No | iOS, macOS |

---

### `injectedJavaScriptBeforeContentLoadedForMainFrameOnly`

If `true` (default), loads the `injectedJavaScriptBeforeContentLoaded` only into the main frame.

If `false`, loads it into all frames (e.g. iframes).

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.

| Type | Required | Platform |
| ------ | -------- | -------- |
| bool | No | iOS, macOS |

---

### `mediaPlaybackRequiresUserAction`

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).
Expand Down
14 changes: 14 additions & 0 deletions example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Alerts from './examples/Alerts';
import Scrolling from './examples/Scrolling';
import Background from './examples/Background';
import Uploads from './examples/Uploads';
import Injection from './examples/Injection';

const TESTS = {
Alerts: {
Expand Down Expand Up @@ -48,6 +49,14 @@ const TESTS = {
return <Uploads />;
},
},
Injection: {
title: 'Injection',
testId: 'injection',
description: 'Injection test',
render() {
return <Injection />;
},
},
};

type Props = {};
Expand Down Expand Up @@ -101,6 +110,11 @@ export default class App extends Component<Props, State> {
title="Background"
onPress={() => this._changeTest('Background')}
/>
<Button
testID="testType_injection"
title="Injection"
onPress={() => this._changeTest('Injection')}
/>
{Platform.OS === 'android' && <Button
testID="testType_uploads"
title="Uploads"
Expand Down
160 changes: 160 additions & 0 deletions example/examples/Injection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import React, {Component} from 'react';
import {Text, View, ScrollView} from 'react-native';

import WebView from 'react-native-webview';

// const HTML = `
// <!DOCTYPE html>
// <html>
// <head>
// <meta charset="utf-8">
// <meta name="viewport" content="width=device-width, initial-scale=1">
// <title>iframe test</title>
// </head>
// <body>
// <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>
// <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>
// <iframe src="https://birchlabs.co.uk/linguabrowse/infopages/obsol/iframe.html?v=1" name="iframe_0" style="width: 100%; height: 25px;"></iframe>
// <iframe src="https://birchlabs.co.uk/linguabrowse/infopages/obsol/iframe2.html?v=1" name="iframe_1" style="width: 100%; height: 25px;"></iframe>
// <iframe src="https://www.ebay.co.uk" name="iframe_2" style="width: 100%; height: 25px;"></iframe>
// </body>
// </html>
// `;

type Props = {};
type State = {
backgroundColor: string,
};

export default class Injection extends Component<Props, State> {
state = {
backgroundColor: '#FF00FF00'
};

render() {
return (
<ScrollView>
<View style={{ }}>
<View style={{ height: 300 }}>
<WebView
/**
* This HTML is a copy of a multi-frame JS injection test that I had lying around.
* @see https://birchlabs.co.uk/linguabrowse/infopages/obsol/iframeTest.html
*/
// source={{ html: HTML }}
source={{ uri: "https://birchlabs.co.uk/linguabrowse/infopages/obsol/rnw_iframe_test.html" }}
automaticallyAdjustContentInsets={false}
style={{backgroundColor:'#00000000'}}

/* Must be populated in order for `messagingEnabled` to be `true` to activate the
* JS injection user scripts, consistent with current behaviour. This is undesirable,
* so needs addressing in a follow-up PR. */
onMessage={() => {}}

/* We set this property in each frame */
injectedJavaScriptBeforeContentLoaded={`
console.log("executing injectedJavaScriptBeforeContentLoaded...");
if(typeof window.top.injectedIframesBeforeContentLoaded === "undefined"){
window.top.injectedIframesBeforeContentLoaded = [];
}
window.self.colourToUse = "orange";
if(window.self === window.top){
console.log("Was window.top. window.frames.length is:", window.frames.length);
window.self.numberOfFramesAtBeforeContentLoaded = window.frames.length;
function declareSuccessOfBeforeContentLoaded(head){
var style = window.self.document.createElement('style');
style.type = 'text/css';
style.innerHTML = "#before_failed { display: none !important; }#before_succeeded { display: inline-block !important; }";
head.appendChild(style);
}

const head = (window.self.document.head || window.self.document.getElementsByTagName('head')[0]);

if(head){
declareSuccessOfBeforeContentLoaded(head);
} else {
window.self.document.addEventListener("DOMContentLoaded", function (event) {
const head = (window.self.document.head || window.self.document.getElementsByTagName('head')[0]);
declareSuccessOfBeforeContentLoaded(head);
});
}
} else {
window.top.injectedIframesBeforeContentLoaded.push(window.self.name);
console.log("wasn't window.top.");
console.log("wasn't window.top. Still going...");
}
`}

injectedJavaScriptForMainFrameOnly={false}

/* We read the colourToUse property in each frame to recolour each frame */
injectedJavaScript={`
console.log("executing injectedJavaScript...");
if(typeof window.top.injectedIframesAfterContentLoaded === "undefined"){
window.top.injectedIframesAfterContentLoaded = [];
}

if(window.self.colourToUse){
window.self.document.body.style.backgroundColor = window.self.colourToUse;
} else {
window.self.document.body.style.backgroundColor = "cyan";
}

if(window.self === window.top){
function declareSuccessOfAfterContentLoaded(head){
var style = window.self.document.createElement('style');
style.type = 'text/css';
style.innerHTML = "#after_failed { display: none !important; }#after_succeeded { display: inline-block !important; }";
head.appendChild(style);
}

declareSuccessOfAfterContentLoaded(window.self.document.head || window.self.document.getElementsByTagName('head')[0]);

// var numberOfFramesAtBeforeContentLoadedEle = document.createElement('p');
// numberOfFramesAtBeforeContentLoadedEle.textContent = "Number of iframes upon the main frame's beforeContentLoaded: " +
// window.self.numberOfFramesAtBeforeContentLoaded;

// var numberOfFramesAtAfterContentLoadedEle = document.createElement('p');
// numberOfFramesAtAfterContentLoadedEle.textContent = "Number of iframes upon the main frame's afterContentLoaded: " + window.frames.length;
// numberOfFramesAtAfterContentLoadedEle.id = "numberOfFramesAtAfterContentLoadedEle";

var namedFramesAtBeforeContentLoadedEle = document.createElement('p');
namedFramesAtBeforeContentLoadedEle.textContent = "Names of iframes that called beforeContentLoaded: " + JSON.stringify(window.top.injectedIframesBeforeContentLoaded);
namedFramesAtBeforeContentLoadedEle.id = "namedFramesAtBeforeContentLoadedEle";

var namedFramesAtAfterContentLoadedEle = document.createElement('p');
namedFramesAtAfterContentLoadedEle.textContent = "Names of iframes that called afterContentLoaded: " + JSON.stringify(window.top.injectedIframesAfterContentLoaded);
namedFramesAtAfterContentLoadedEle.id = "namedFramesAtAfterContentLoadedEle";

// document.body.appendChild(numberOfFramesAtBeforeContentLoadedEle);
// document.body.appendChild(numberOfFramesAtAfterContentLoadedEle);
document.body.appendChild(namedFramesAtBeforeContentLoadedEle);
document.body.appendChild(namedFramesAtAfterContentLoadedEle);
} else {
window.top.injectedIframesAfterContentLoaded.push(window.self.name);
window.top.document.getElementById('namedFramesAtAfterContentLoadedEle').textContent = "Names of iframes that called afterContentLoaded: " + JSON.stringify(window.top.injectedIframesAfterContentLoaded);
}
`}
/>
</View>
</View>
<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>
<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>
{/*<Text>1a) At injection time "beforeContentLoaded", a variable will be set in each frame to set 'orange' as the "colour to be used".</Text>*/}
{/*<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>*/}
{/*<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>*/}
{/*<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>*/}
<Text>✅ If the main frame becomes orange, then top-frame injection both beforeContentLoaded and afterContentLoaded is supported.</Text>
<Text>✅ If iframe_0, and iframe_1 become orange, then multi-frame injection beforeContentLoaded and afterContentLoaded is supported.</Text>
<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>
<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>
<Text>⚠️ If "Names of iframes that called beforeContentLoaded: " is [], then see above.</Text>
<Text>❌ If "Names of iframes that called afterContentLoaded: " is [], then afterContentLoaded is not supported in iframes.</Text>
<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>
<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>
<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>
<Text>❌ If the iframes remain their original colours (yellow and pink), then multi-frame injection is not supported at all.</Text>
</ScrollView>
);
}
}
Loading