From 27971c8b82e720be5d6f1e0b38bdceea54e8d1f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 19 Jun 2026 11:09:48 +0000 Subject: [PATCH] fix(webview): render inline HTML on web instead of permanent Loading state PR #2291 added html/htmlBaseUrl/injectedJavaScript support to the native InAppWebView path but left the web iframe implementation unchanged. On web, setting html clears url, so buildWidget always showed Loading... Load inline HTML via iframe srcdoc with the same base URL and injected script behavior as native initialData, and add unit tests for the shared document/origin helpers. Co-authored-by: Sharjeel Yunus --- .../lib/widget/webview/web/webviewstate.dart | 45 ++++++++++++++-- .../ensemble/lib/widget/webview/webview.dart | 42 +++++++++++++++ .../test/webview_html_content_test.dart | 54 +++++++++++++++++++ 3 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 modules/ensemble/test/webview_html_content_test.dart diff --git a/modules/ensemble/lib/widget/webview/web/webviewstate.dart b/modules/ensemble/lib/widget/webview/web/webviewstate.dart index 73a8685ba..eb4dc6154 100644 --- a/modules/ensemble/lib/widget/webview/web/webviewstate.dart +++ b/modules/ensemble/lib/widget/webview/web/webviewstate.dart @@ -30,6 +30,18 @@ class WebViewState extends EWidgetState { // widget.controller.webViewController = ControllerImpl(_iframeElement); } + @override + void didUpdateWidget(covariant EnsembleWebView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller.url != widget.controller.url || + oldWidget.controller.html != widget.controller.html || + oldWidget.controller.htmlBaseUrl != widget.controller.htmlBaseUrl || + oldWidget.controller.injectedJavaScript != + widget.controller.injectedJavaScript) { + _loadIframeContent(); + } + } + @override void dispose() { _cleanupIFrame(); @@ -45,10 +57,9 @@ class WebViewState extends EWidgetState { HtmlElementView buildIFrameWidget() { _iframeElement.style.width = '100%'; _iframeElement.style.height = '100%'; - - _iframeElement.src = widget.controller.url ?? ''; _iframeElement.style.border = 'none'; + _loadIframeContent(); _setupJavaScriptChannels(); // ignore: undefined_prefixed_name @@ -63,6 +74,22 @@ class WebViewState extends EWidgetState { ); } + void _loadIframeContent() { + final html = widget.controller.html; + if (html != null && html.isNotEmpty) { + _iframeElement.removeAttribute('src'); + _iframeElement.srcdoc = buildIframeHtmlDocument( + html: html, + htmlBaseUrl: widget.controller.htmlBaseUrl, + injectedJavaScript: widget.controller.injectedJavaScript, + ); + return; + } + + _iframeElement.removeAttribute('srcdoc'); + _iframeElement.src = widget.controller.url ?? ''; + } + void _setupJavaScriptChannels() { window.addEventListener('message', (event) { if (event is MessageEvent) { @@ -72,7 +99,13 @@ class WebViewState extends EWidgetState { } void _handleWebMessage(MessageEvent event) { - if (event.origin != Uri.parse(widget.controller.url ?? '').origin) { + final expectedOrigin = resolvedWebViewOrigin( + url: widget.controller.url, + htmlBaseUrl: widget.controller.htmlBaseUrl, + ); + if (expectedOrigin != null && + expectedOrigin.isNotEmpty && + event.origin != expectedOrigin) { return; } @@ -106,10 +139,14 @@ class WebViewState extends EWidgetState { } } + bool get _hasContent => + (widget.controller.url != null && widget.controller.url!.isNotEmpty) || + (widget.controller.html != null && widget.controller.html!.isNotEmpty); + @override Widget buildWidget(BuildContext context) { // WebView's height will be the same as the HTML height - if (widget.controller.url == null) { + if (!_hasContent) { return const Text('Loading...'); } return SizedBox(height: widget.controller.height, child: htmlView!); diff --git a/modules/ensemble/lib/widget/webview/webview.dart b/modules/ensemble/lib/widget/webview/webview.dart index 410824afc..874784964 100644 --- a/modules/ensemble/lib/widget/webview/webview.dart +++ b/modules/ensemble/lib/widget/webview/webview.dart @@ -219,6 +219,48 @@ Map parseYamlMap(value) { return result; } +/// Default base URL for inline HTML WebViews when [htmlBaseUrl] is not set. +const String kWebViewHtmlDefaultBaseUrl = 'https://ensemble.local/'; + +/// Resolve the expected postMessage origin for a WebView URL or inline HTML base. +@visibleForTesting +String? resolvedWebViewOrigin({String? url, String? htmlBaseUrl}) { + if (url != null && url.isNotEmpty) { + return Uri.tryParse(url)?.origin; + } + final base = htmlBaseUrl?.trim(); + return Uri.tryParse( + (base == null || base.isEmpty) ? kWebViewHtmlDefaultBaseUrl : base, + )?.origin; +} + +/// Build a full HTML document for iframe `srcdoc` rendering on web. +@visibleForTesting +String buildIframeHtmlDocument({ + required String html, + String? htmlBaseUrl, + String? injectedJavaScript, +}) { + final base = htmlBaseUrl?.trim(); + final resolvedBase = (base == null || base.isEmpty) + ? kWebViewHtmlDefaultBaseUrl + : base; + final buffer = StringBuffer() + ..write('') + ..write(html); + final script = injectedJavaScript?.trim(); + if (script != null && script.isNotEmpty) { + buffer.write(''); + } + buffer.write(''); + return buffer.toString(); +} + +String _escapeHtmlAttribute(String value) => + value.replaceAll('&', '&').replaceAll('"', '"'); + enum HeaderMatchType { CONTAINS, EXACT, REGEX } class HeaderOverrideRule { final String urlPattern; diff --git a/modules/ensemble/test/webview_html_content_test.dart b/modules/ensemble/test/webview_html_content_test.dart new file mode 100644 index 000000000..32e373cfb --- /dev/null +++ b/modules/ensemble/test/webview_html_content_test.dart @@ -0,0 +1,54 @@ +import 'package:ensemble/widget/webview/webview.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('inline WebView HTML helpers', () { + test('resolvedWebViewOrigin prefers url over html base', () { + expect( + resolvedWebViewOrigin( + url: 'https://example.com/page', + htmlBaseUrl: 'https://ignored.test/', + ), + 'https://example.com', + ); + }); + + test('resolvedWebViewOrigin falls back to html base url', () { + expect( + resolvedWebViewOrigin(htmlBaseUrl: 'https://cdn.example/app/'), + 'https://cdn.example', + ); + }); + + test('resolvedWebViewOrigin uses default base for inline html', () { + expect( + resolvedWebViewOrigin(), + Uri.parse(kWebViewHtmlDefaultBaseUrl).origin, + ); + }); + + test('buildIframeHtmlDocument wraps html with base and injected script', () { + final document = buildIframeHtmlDocument( + html: '

Hello

', + htmlBaseUrl: 'https://app.example/', + injectedJavaScript: 'console.log("ready");', + ); + + expect(document, contains('')); + expect(document, contains('

Hello

')); + expect(document, contains('')); + }); + + test('buildIframeHtmlDocument escapes base url attribute characters', () { + final document = buildIframeHtmlDocument( + html: '

Hi

', + htmlBaseUrl: 'https://app.example/path?a=1&b=2"', + ); + + expect( + document, + contains(''), + ); + }); + }); +}