[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>);
 }