[webview_flutter_web] Avoids XHR when possible. (#7090)
* 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>
diff --git a/packages/webview_flutter/webview_flutter_web/CHANGELOG.md b/packages/webview_flutter/webview_flutter_web/CHANGELOG.md
index 028e03d..3ada124 100644
--- a/packages/webview_flutter/webview_flutter_web/CHANGELOG.md
+++ b/packages/webview_flutter/webview_flutter_web/CHANGELOG.md
@@ -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
diff --git a/packages/webview_flutter/webview_flutter_web/README.md b/packages/webview_flutter/webview_flutter_web/README.md
index 51a0223..03bb6a8 100644
--- a/packages/webview_flutter/webview_flutter_web/README.md
+++ b/packages/webview_flutter/webview_flutter_web/README.md
@@ -21,3 +21,19 @@
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
+```
diff --git a/packages/webview_flutter/webview_flutter_web/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter_web/example/integration_test/webview_flutter_test.dart
index 1736d47..f71d2d3 100644
--- a/packages/webview_flutter/webview_flutter_web/example/integration_test/webview_flutter_test.dart
+++ b/packages/webview_flutter/webview_flutter_web/example/integration_test/webview_flutter_test.dart
@@ -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';
diff --git a/packages/webview_flutter/webview_flutter_web/example/run_test.sh b/packages/webview_flutter/webview_flutter_web/example/run_test.sh
new file mode 100755
index 0000000..aa52974
--- /dev/null
+++ b/packages/webview_flutter/webview_flutter_web/example/run_test.sh
@@ -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
+
diff --git a/packages/webview_flutter/webview_flutter_web/lib/src/content_type.dart b/packages/webview_flutter/webview_flutter_web/lib/src/content_type.dart
new file mode 100644
index 0000000..0aa18ce
--- /dev/null
+++ b/packages/webview_flutter/webview_flutter_web/lib/src/content_type.dart
@@ -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;
+}
diff --git a/packages/webview_flutter/webview_flutter_web/lib/src/web_webview_controller.dart b/packages/webview_flutter/webview_flutter_web/lib/src/web_webview_controller.dart
index 7ef7225..52f93f9 100644
--- a/packages/webview_flutter/webview_flutter_web/lib/src/web_webview_controller.dart
+++ b/packages/webview_flutter/webview_flutter_web/lib/src/web_webview_controller.dart
@@ -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;
@@ -37,10 +38,10 @@
/// 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';
}
@@ -72,20 +73,37 @@
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();
}
}
diff --git a/packages/webview_flutter/webview_flutter_web/pubspec.yaml b/packages/webview_flutter/webview_flutter_web/pubspec.yaml
index 66b67f4..f3ea67d 100644
--- a/packages/webview_flutter/webview_flutter_web/pubspec.yaml
+++ b/packages/webview_flutter/webview_flutter_web/pubspec.yaml
@@ -2,7 +2,7 @@
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"
diff --git a/packages/webview_flutter/webview_flutter_web/test/content_type_test.dart b/packages/webview_flutter/webview_flutter_web/test/content_type_test.dart
new file mode 100644
index 0000000..936eeae
--- /dev/null
+++ b/packages/webview_flutter/webview_flutter_web/test/content_type_test.dart
@@ -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);
+ });
+ });
+}
diff --git a/packages/webview_flutter/webview_flutter_web/test/web_webview_controller_test.dart b/packages/webview_flutter/webview_flutter_web/test/web_webview_controller_test.dart
index 6a8f7379..0a995cb 100644
--- a/packages/webview_flutter/webview_flutter_web/test/web_webview_controller_test.dart
+++ b/packages/webview_flutter/webview_flutter_web/test/web_webview_controller_test.dart
@@ -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
@@ -17,9 +18,9 @@
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();
@@ -31,8 +32,8 @@
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');
});
});
@@ -62,7 +63,7 @@
});
group('loadRequest', () {
- test('loadRequest throws ArgumentError on missing scheme', () async {
+ test('throws ArgumentError on missing scheme', () async {
final WebWebViewController controller =
WebWebViewController(WebWebViewControllerCreationParams());
@@ -73,8 +74,33 @@
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 =
@@ -114,7 +140,41 @@
);
});
- 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 =
diff --git a/packages/webview_flutter/webview_flutter_web/test/web_webview_controller_test.mocks.dart b/packages/webview_flutter/webview_flutter_web/test/web_webview_controller_test.mocks.dart
index f74359a..5cb259a 100644
--- a/packages/webview_flutter/webview_flutter_web/test/web_webview_controller_test.mocks.dart
+++ b/packages/webview_flutter/webview_flutter_web/test/web_webview_controller_test.mocks.dart
@@ -55,24 +55,23 @@
///
/// See the documentation for Mockito's code generation for more information.
class MockHttpRequest extends _i1.Mock implements _i2.HttpRequest {
- MockHttpRequest() {
- _i1.throwOnMissingStub(this);
- }
-
@override
Map<String, String> get responseHeaders => (super.noSuchMethod(
Invocation.getter(#responseHeaders),
returnValue: <String, String>{},
+ returnValueForMissingStub: <String, String>{},
) as Map<String, String>);
@override
int get readyState => (super.noSuchMethod(
Invocation.getter(#readyState),
returnValue: 0,
+ returnValueForMissingStub: 0,
) as int);
@override
String get responseType => (super.noSuchMethod(
Invocation.getter(#responseType),
returnValue: '',
+ returnValueForMissingStub: '',
) as String);
@override
set responseType(String? value) => super.noSuchMethod(
@@ -97,6 +96,10 @@
this,
Invocation.getter(#upload),
),
+ returnValueForMissingStub: _FakeHttpRequestUpload_0(
+ this,
+ Invocation.getter(#upload),
+ ),
) as _i2.HttpRequestUpload);
@override
set withCredentials(bool? value) => super.noSuchMethod(
@@ -110,41 +113,49 @@
_i3.Stream<_i2.Event> get onReadyStateChange => (super.noSuchMethod(
Invocation.getter(#onReadyStateChange),
returnValue: _i3.Stream<_i2.Event>.empty(),
+ returnValueForMissingStub: _i3.Stream<_i2.Event>.empty(),
) as _i3.Stream<_i2.Event>);
@override
_i3.Stream<_i2.ProgressEvent> get onAbort => (super.noSuchMethod(
Invocation.getter(#onAbort),
returnValue: _i3.Stream<_i2.ProgressEvent>.empty(),
+ returnValueForMissingStub: _i3.Stream<_i2.ProgressEvent>.empty(),
) as _i3.Stream<_i2.ProgressEvent>);
@override
_i3.Stream<_i2.ProgressEvent> get onError => (super.noSuchMethod(
Invocation.getter(#onError),
returnValue: _i3.Stream<_i2.ProgressEvent>.empty(),
+ returnValueForMissingStub: _i3.Stream<_i2.ProgressEvent>.empty(),
) as _i3.Stream<_i2.ProgressEvent>);
@override
_i3.Stream<_i2.ProgressEvent> get onLoad => (super.noSuchMethod(
Invocation.getter(#onLoad),
returnValue: _i3.Stream<_i2.ProgressEvent>.empty(),
+ returnValueForMissingStub: _i3.Stream<_i2.ProgressEvent>.empty(),
) as _i3.Stream<_i2.ProgressEvent>);
@override
_i3.Stream<_i2.ProgressEvent> get onLoadEnd => (super.noSuchMethod(
Invocation.getter(#onLoadEnd),
returnValue: _i3.Stream<_i2.ProgressEvent>.empty(),
+ returnValueForMissingStub: _i3.Stream<_i2.ProgressEvent>.empty(),
) as _i3.Stream<_i2.ProgressEvent>);
@override
_i3.Stream<_i2.ProgressEvent> get onLoadStart => (super.noSuchMethod(
Invocation.getter(#onLoadStart),
returnValue: _i3.Stream<_i2.ProgressEvent>.empty(),
+ returnValueForMissingStub: _i3.Stream<_i2.ProgressEvent>.empty(),
) as _i3.Stream<_i2.ProgressEvent>);
@override
_i3.Stream<_i2.ProgressEvent> get onProgress => (super.noSuchMethod(
Invocation.getter(#onProgress),
returnValue: _i3.Stream<_i2.ProgressEvent>.empty(),
+ returnValueForMissingStub: _i3.Stream<_i2.ProgressEvent>.empty(),
) as _i3.Stream<_i2.ProgressEvent>);
@override
_i3.Stream<_i2.ProgressEvent> get onTimeout => (super.noSuchMethod(
Invocation.getter(#onTimeout),
returnValue: _i3.Stream<_i2.ProgressEvent>.empty(),
+ returnValueForMissingStub: _i3.Stream<_i2.ProgressEvent>.empty(),
) as _i3.Stream<_i2.ProgressEvent>);
@override
_i2.Events get on => (super.noSuchMethod(
@@ -153,6 +164,10 @@
this,
Invocation.getter(#on),
),
+ returnValueForMissingStub: _FakeEvents_1(
+ this,
+ Invocation.getter(#on),
+ ),
) as _i2.Events);
@override
void open(
@@ -192,13 +207,16 @@
[],
),
returnValue: '',
+ returnValueForMissingStub: '',
) as String);
@override
- String? getResponseHeader(String? name) =>
- (super.noSuchMethod(Invocation.method(
- #getResponseHeader,
- [name],
- )) as String?);
+ String? getResponseHeader(String? name) => (super.noSuchMethod(
+ Invocation.method(
+ #getResponseHeader,
+ [name],
+ ),
+ returnValueForMissingStub: null,
+ ) as String?);
@override
void overrideMimeType(String? mime) => super.noSuchMethod(
Invocation.method(
@@ -271,6 +289,7 @@
[event],
),
returnValue: false,
+ returnValueForMissingStub: false,
) as bool);
}
@@ -279,10 +298,6 @@
/// See the documentation for Mockito's code generation for more information.
class MockHttpRequestFactory extends _i1.Mock
implements _i4.HttpRequestFactory {
- MockHttpRequestFactory() {
- _i1.throwOnMissingStub(this);
- }
-
@override
_i3.Future<_i2.HttpRequest> request(
String? url, {
@@ -324,5 +339,22 @@
},
),
)),
+ returnValueForMissingStub:
+ _i3.Future<_i2.HttpRequest>.value(_FakeHttpRequest_2(
+ this,
+ Invocation.method(
+ #request,
+ [url],
+ {
+ #method: method,
+ #withCredentials: withCredentials,
+ #responseType: responseType,
+ #mimeType: mimeType,
+ #requestHeaders: requestHeaders,
+ #sendData: sendData,
+ #onProgress: onProgress,
+ },
+ ),
+ )),
) as _i3.Future<_i2.HttpRequest>);
}