Skip to content

Commit

Permalink
[webview_flutter_web] Avoids XHR when possible. (#7090)
Browse files Browse the repository at this point in the history
* update

* Request body must be empty too to skip XHR request. Add test.

* Add ContentType class to parse response headers.

* Use content-type in response to encode iframe contents.

* Attempt to run integration_tests. Do they ever fail?

* Update docs.

* Set Widget styles in a way the flutter engine likes.

* Add bash to codeblocks in readme.

---------

Co-authored-by: David Iglesias Teixeira <ditman@gmail.com>
  • Loading branch information
bparrishMines and ditman authored Feb 6, 2023
1 parent 634c140 commit 0d9b408
Show file tree
Hide file tree
Showing 10 changed files with 317 additions and 33 deletions.
11 changes: 9 additions & 2 deletions packages/webview_flutter/webview_flutter_web/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
## NEXT

## 0.2.2

* Updates `WebWebViewController.loadRequest` to only set the src of the iFrame
when `LoadRequestParams.headers` and `LoadRequestParams.body` are empty and is
using the HTTP GET request method. [#118573](https://github.com/flutter/flutter/issues/118573).
* Parses the `content-type` header of XHR responses to extract the correct
MIME-type and charset. [#118090](https://github.com/flutter/flutter/issues/118090).
* Sets `width` and `height` of widget the way the Engine wants, to remove distracting
warnings from the development console.
* Updates minimum Flutter version to 3.0.

## 0.2.1
Expand Down
16 changes: 16 additions & 0 deletions packages/webview_flutter/webview_flutter_web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,19 @@ yet, so it currently requires extra setup to use:

Once the step above is complete, the APIs from `webview_flutter` listed
above can be used as normal on web.

## Tests

Tests are contained in the `test` directory. You can run all tests from the root
of the package with the following command:

```bash
$ flutter test --platform chrome
```

This package uses `package:mockito` in some tests. Mock files can be updated
from the root of the package like so:

```bash
$ flutter pub run build_runner build --delete-conflicting-outputs
```
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
import 'dart:html' as html;
import 'dart:io';

// FIX (dit): Remove these integration tests, or make them run. They currently never fail.
// (They won't run because they use `dart:io`. If you remove all `dart:io` bits from
// this file, they start failing with `fail()`, for example.)

import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
Expand Down
22 changes: 22 additions & 0 deletions packages/webview_flutter/webview_flutter_web/example/run_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/bash
# Copyright 2013 The Flutter Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

if pgrep -lf chromedriver > /dev/null; then
echo "chromedriver is running."

if [ $# -eq 0 ]; then
echo "No target specified, running all tests..."
find integration_test/ -iname *_test.dart | xargs -n1 -i -t flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target='{}'
else
echo "Running test target: $1..."
set -x
flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target=$1
fi

else
echo "chromedriver is not running."
echo "Please, check the README.md for instructions on how to use run_test.sh"
fi

Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/// Class to represent a content-type header value.
class ContentType {
/// Creates a [ContentType] instance by parsing a "content-type" response [header].
///
/// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type
/// See: https://httpwg.org/specs/rfc9110.html#media.type
ContentType.parse(String header) {
final Iterable<String> chunks =
header.split(';').map((String e) => e.trim().toLowerCase());

for (final String chunk in chunks) {
if (!chunk.contains('=')) {
_mimeType = chunk;
} else {
final List<String> bits =
chunk.split('=').map((String e) => e.trim()).toList();
assert(bits.length == 2);
switch (bits[0]) {
case 'charset':
_charset = bits[1];
break;
case 'boundary':
_boundary = bits[1];
break;
default:
throw StateError('Unable to parse "$chunk" in content-type.');
}
}
}
}

String? _mimeType;
String? _charset;
String? _boundary;

/// The MIME-type of the resource or the data.
String? get mimeType => _mimeType;

/// The character encoding standard.
String? get charset => _charset;

/// The separation boundary for multipart entities.
String? get boundary => _boundary;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
// found in the LICENSE file.

import 'dart:convert';
import 'dart:html';
import 'dart:html' as html;

import 'package:flutter/cupertino.dart';
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';

import 'content_type.dart';
import 'http_request_factory.dart';
import 'shims/dart_ui.dart' as ui;

Expand Down Expand Up @@ -37,10 +38,10 @@ class WebWebViewControllerCreationParams

/// The underlying element used as the WebView.
@visibleForTesting
final IFrameElement iFrame = IFrameElement()
final html.IFrameElement iFrame = html.IFrameElement()
..id = 'webView${_nextIFrameId++}'
..width = '100%'
..height = '100%'
..style.width = '100%'
..style.height = '100%'
..style.border = 'none';
}

Expand Down Expand Up @@ -72,20 +73,37 @@ class WebWebViewController extends PlatformWebViewController {
throw ArgumentError(
'LoadRequestParams#uri is required to have a scheme.');
}
final HttpRequest httpReq =

if (params.headers.isEmpty &&
(params.body == null || params.body!.isEmpty) &&
params.method == LoadRequestMethod.get) {
// ignore: unsafe_html
_webWebViewParams.iFrame.src = params.uri.toString();
} else {
await _updateIFrameFromXhr(params);
}
}

/// Performs an AJAX request defined by [params].
Future<void> _updateIFrameFromXhr(LoadRequestParams params) async {
final html.HttpRequest httpReq =
await _webWebViewParams.httpRequestFactory.request(
params.uri.toString(),
method: params.method.serialize(),
requestHeaders: params.headers,
sendData: params.body,
);
final String contentType =

final String header =
httpReq.getResponseHeader('content-type') ?? 'text/html';
final ContentType contentType = ContentType.parse(header);
final Encoding encoding = Encoding.getByName(contentType.charset) ?? utf8;

// ignore: unsafe_html
_webWebViewParams.iFrame.src = Uri.dataFromString(
httpReq.responseText ?? '',
mimeType: contentType,
encoding: utf8,
mimeType: contentType.mimeType,
encoding: encoding,
).toString();
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/webview_flutter/webview_flutter_web/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: webview_flutter_web
description: A Flutter plugin that provides a WebView widget on web.
repository: https://github.com/flutter/plugins/tree/main/packages/webview_flutter/webview_flutter_web
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22
version: 0.2.1
version: 0.2.2

environment:
sdk: ">=2.14.0 <3.0.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter_test/flutter_test.dart';
import 'package:webview_flutter_web/src/content_type.dart';

void main() {
group('ContentType.parse', () {
test('basic content-type (lowers case)', () {
final ContentType contentType = ContentType.parse('text/pLaIn');

expect(contentType.mimeType, 'text/plain');
expect(contentType.boundary, isNull);
expect(contentType.charset, isNull);
});

test('with charset', () {
final ContentType contentType =
ContentType.parse('text/pLaIn; charset=utf-8');

expect(contentType.mimeType, 'text/plain');
expect(contentType.boundary, isNull);
expect(contentType.charset, 'utf-8');
});

test('with boundary', () {
final ContentType contentType =
ContentType.parse('text/pLaIn; boundary=---xyz');

expect(contentType.mimeType, 'text/plain');
expect(contentType.boundary, '---xyz');
expect(contentType.charset, isNull);
});

test('with charset and boundary', () {
final ContentType contentType =
ContentType.parse('text/pLaIn; charset=utf-8; boundary=---xyz');

expect(contentType.mimeType, 'text/plain');
expect(contentType.boundary, '---xyz');
expect(contentType.charset, 'utf-8');
});

test('with boundary and charset', () {
final ContentType contentType =
ContentType.parse('text/pLaIn; boundary=---xyz; charset=utf-8');

expect(contentType.mimeType, 'text/plain');
expect(contentType.boundary, '---xyz');
expect(contentType.charset, 'utf-8');
});

test('with a bunch of whitespace, boundary and charset', () {
final ContentType contentType = ContentType.parse(
' text/pLaIn ; boundary=---xyz; charset=utf-8 ');

expect(contentType.mimeType, 'text/plain');
expect(contentType.boundary, '---xyz');
expect(contentType.charset, 'utf-8');
});

test('empty string', () {
final ContentType contentType = ContentType.parse('');

expect(contentType.mimeType, '');
expect(contentType.boundary, isNull);
expect(contentType.charset, isNull);
});

test('unknown parameter (throws)', () {
expect(() {
ContentType.parse('text/pLaIn; wrong=utf-8');
}, throwsStateError);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:convert';
import 'dart:html';
// TODO(a14n): remove this import once Flutter 3.1 or later reaches stable (including flutter/flutter#104231)
// ignore: unnecessary_import
Expand All @@ -17,9 +18,9 @@ import 'package:webview_flutter_web/webview_flutter_web.dart';

import 'web_webview_controller_test.mocks.dart';

@GenerateMocks(<Type>[
HttpRequest,
HttpRequestFactory,
@GenerateMocks(<Type>[], customMocks: <MockSpec<Object>>[
MockSpec<HttpRequest>(onMissingStub: OnMissingStub.returnDefault),
MockSpec<HttpRequestFactory>(onMissingStub: OnMissingStub.returnDefault),
])
void main() {
WidgetsFlutterBinding.ensureInitialized();
Expand All @@ -31,8 +32,8 @@ void main() {
WebWebViewControllerCreationParams();

expect(params.iFrame.id, contains('webView'));
expect(params.iFrame.width, '100%');
expect(params.iFrame.height, '100%');
expect(params.iFrame.style.width, '100%');
expect(params.iFrame.style.height, '100%');
expect(params.iFrame.style.border, 'none');
});
});
Expand Down Expand Up @@ -62,7 +63,7 @@ void main() {
});

group('loadRequest', () {
test('loadRequest throws ArgumentError on missing scheme', () async {
test('throws ArgumentError on missing scheme', () async {
final WebWebViewController controller =
WebWebViewController(WebWebViewControllerCreationParams());

Expand All @@ -73,8 +74,33 @@ void main() {
throwsA(const TypeMatcher<ArgumentError>()));
});

test('loadRequest makes request and loads response into iframe',
() async {
test('skips XHR for simple GETs (no headers, no data)', () async {
final MockHttpRequestFactory mockHttpRequestFactory =
MockHttpRequestFactory();
final WebWebViewController controller =
WebWebViewController(WebWebViewControllerCreationParams(
httpRequestFactory: mockHttpRequestFactory,
));

when(mockHttpRequestFactory.request(
any,
method: anyNamed('method'),
requestHeaders: anyNamed('requestHeaders'),
sendData: anyNamed('sendData'),
)).thenThrow(
StateError('The `request` method should not have been called.'));

await controller.loadRequest(LoadRequestParams(
uri: Uri.parse('https://flutter.dev'),
));

expect(
(controller.params as WebWebViewControllerCreationParams).iFrame.src,
'https://flutter.dev/',
);
});

test('makes request and loads response into iframe', () async {
final MockHttpRequestFactory mockHttpRequestFactory =
MockHttpRequestFactory();
final WebWebViewController controller =
Expand Down Expand Up @@ -114,7 +140,41 @@ void main() {
);
});

test('loadRequest escapes "#" correctly', () async {
test('parses content-type response header correctly', () async {
final MockHttpRequestFactory mockHttpRequestFactory =
MockHttpRequestFactory();
final WebWebViewController controller =
WebWebViewController(WebWebViewControllerCreationParams(
httpRequestFactory: mockHttpRequestFactory,
));

final Encoding iso = Encoding.getByName('latin1')!;

final MockHttpRequest mockHttpRequest = MockHttpRequest();
when(mockHttpRequest.responseText)
.thenReturn(String.fromCharCodes(iso.encode('España')));
when(mockHttpRequest.getResponseHeader('content-type'))
.thenReturn('Text/HTmL; charset=latin1');

when(mockHttpRequestFactory.request(
any,
method: anyNamed('method'),
requestHeaders: anyNamed('requestHeaders'),
sendData: anyNamed('sendData'),
)).thenAnswer((_) => Future<HttpRequest>.value(mockHttpRequest));

await controller.loadRequest(LoadRequestParams(
uri: Uri.parse('https://flutter.dev'),
method: LoadRequestMethod.post,
));

expect(
(controller.params as WebWebViewControllerCreationParams).iFrame.src,
'data:text/html;charset=iso-8859-1,Espa%F1a',
);
});

test('escapes "#" correctly', () async {
final MockHttpRequestFactory mockHttpRequestFactory =
MockHttpRequestFactory();
final WebWebViewController controller =
Expand Down
Loading

0 comments on commit 0d9b408

Please # to comment.