diff --git a/debug-symbols/0.24.25+2/android-debug-symbols.7z b/debug-symbols/0.24.25+2/android-debug-symbols.7z new file mode 100644 index 0000000000..bf0a62cb3d --- /dev/null +++ b/debug-symbols/0.24.25+2/android-debug-symbols.7z @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4906f52ef81b063f50c254f43a0b723da7e191ab1086deaf52a02e69714c8b93 +size 64506770 diff --git a/debug-symbols/0.24.25+2/ios-debug-symbols.7z b/debug-symbols/0.24.25+2/ios-debug-symbols.7z new file mode 100644 index 0000000000..4a88c45a71 --- /dev/null +++ b/debug-symbols/0.24.25+2/ios-debug-symbols.7z @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa8ab176fe0a2e22ba1ccc497b403ee3d5bf1fdd72a2e2712113d3c9e74acc77 +size 94448736 diff --git a/debug-symbols/0.24.25+2/linux-debug-symbols.7z b/debug-symbols/0.24.25+2/linux-debug-symbols.7z new file mode 100644 index 0000000000..398d139df6 --- /dev/null +++ b/debug-symbols/0.24.25+2/linux-debug-symbols.7z @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:809bbda75938905b2b1ff9d02a647da7d2eb48f79819a24e486b9df8e59b1feb +size 17580113 diff --git a/debug-symbols/0.24.25+2/macos-debug-symbols.7z b/debug-symbols/0.24.25+2/macos-debug-symbols.7z new file mode 100644 index 0000000000..76c5b13162 --- /dev/null +++ b/debug-symbols/0.24.25+2/macos-debug-symbols.7z @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55dd1725d7cd7eb79f2949297590da58bf8042d98ff5c16e3e3a21bc043c9845 +size 66374005 diff --git a/debug-symbols/0.24.25+2/windows-debug-symbols.7z b/debug-symbols/0.24.25+2/windows-debug-symbols.7z new file mode 100644 index 0000000000..8802a018c9 --- /dev/null +++ b/debug-symbols/0.24.25+2/windows-debug-symbols.7z @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44c00d1c0390ccb17850e27f5a339eb2f94c521851b223fb8ff0372f4651d8ad +size 19822509 diff --git a/integration_tests/lib/custom_elements/flutter_tooltips.dart b/integration_tests/lib/custom_elements/flutter_tooltips.dart new file mode 100644 index 0000000000..61d4f8259f --- /dev/null +++ b/integration_tests/lib/custom_elements/flutter_tooltips.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:webf/dom.dart' as dom; +import 'package:webf/rendering.dart'; +import 'package:webf/webf.dart'; + +class FlutterToolTipsElement extends WidgetElement { + FlutterToolTipsElement(super.context); + + @override + WebFWidgetElementState createState() => FlutterToolTipsElementState(this); +} + +class FlutterToolTipsElementState extends WebFWidgetElementState { + FlutterToolTipsElementState(super.widgetElement); + + FlutterToolTipsElement get tooltipsElement => + widgetElement as FlutterToolTipsElement; + + @override + Widget build(BuildContext context) { + dom.Element? firstElementChild; + for (final dom.Node node in tooltipsElement.childNodes) { + if (node is dom.Element) { + firstElementChild = node; + break; + } + } + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + widgetElement.dispatchEvent(Event('click', bubbles: true)); + }, + child: firstElementChild != null + ? WebFWidgetElementChild(child: firstElementChild.toWidget()) + : const SizedBox.shrink(), + ); + } +} + +class WebFTestAutoSizeTextElement extends WidgetElement { + WebFTestAutoSizeTextElement(super.context); + + @override + WebFWidgetElementState createState() => WebFTestAutoSizeTextElementState(this); +} + +class WebFTestAutoSizeTextElementState extends WebFWidgetElementState { + WebFTestAutoSizeTextElementState(super.widgetElement); + + WebFTestAutoSizeTextElement get autoSizeTextElement => + widgetElement as WebFTestAutoSizeTextElement; + + @override + Widget build(BuildContext context) { + final String text = autoSizeTextElement.getAttribute('text') ?? ''; + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + textScaler: const TextScaler.linear(1.0), + style: const TextStyle( + color: Colors.white, + fontSize: 24, + height: 1, + ), + ); + }, + ); + } +} diff --git a/integration_tests/lib/custom_elements/main.dart b/integration_tests/lib/custom_elements/main.dart index d2a43470fc..f9c8088829 100644 --- a/integration_tests/lib/custom_elements/main.dart +++ b/integration_tests/lib/custom_elements/main.dart @@ -26,6 +26,7 @@ import 'flutter_cupertino_portal_modal_popup.dart'; import 'flutter_portal_popup_item.dart'; import 'flutter_bottom_sheet.dart'; import 'flutter_ifc_host.dart'; +import 'flutter_tooltips.dart'; void defineWebFCustomElements() { WebF.defineCustomElement('flutter-button', @@ -65,4 +66,6 @@ void defineWebFCustomElements() { WebF.defineCustomElement('flutter-max-height-container', (context) => FlutterMaxHeightContainerElement(context)); WebF.defineCustomElement('flutter-fixed-height-slot', (context) => FlutterFixedHeightSlotElement(context)); WebF.defineCustomElement('flutter-ifc-host', (context) => FlutterIFCHostElement(context)); + WebF.defineCustomElement('flutter-tooltips', (context) => FlutterToolTipsElement(context)); + WebF.defineCustomElement('webf-test-auto-size-text', (context) => WebFTestAutoSizeTextElement(context)); } diff --git a/integration_tests/snapshots/rendering/widget_tooltips_child_paint.ts.85ba810b1.png b/integration_tests/snapshots/rendering/widget_tooltips_child_paint.ts.85ba810b1.png new file mode 100644 index 0000000000..82a2f9b040 Binary files /dev/null and b/integration_tests/snapshots/rendering/widget_tooltips_child_paint.ts.85ba810b1.png differ diff --git a/integration_tests/snapshots/rendering/widget_tooltips_child_paint.ts.85ba810b2.png b/integration_tests/snapshots/rendering/widget_tooltips_child_paint.ts.85ba810b2.png new file mode 100644 index 0000000000..914a2d9132 Binary files /dev/null and b/integration_tests/snapshots/rendering/widget_tooltips_child_paint.ts.85ba810b2.png differ diff --git a/integration_tests/specs/rendering/widget_tooltips_child_paint.ts b/integration_tests/specs/rendering/widget_tooltips_child_paint.ts new file mode 100644 index 0000000000..65da22df44 --- /dev/null +++ b/integration_tests/specs/rendering/widget_tooltips_child_paint.ts @@ -0,0 +1,65 @@ +describe('Widget tooltips child paint', () => { + it('tooltip child widget text still paints after nested widget text update in flex layout', async () => { + document.documentElement.style.margin = '0'; + document.body.style.margin = '0'; + document.body.style.padding = '0'; + document.body.style.backgroundColor = '#000'; + + const root = document.createElement('div'); + root.setAttribute( + 'style', + [ + 'display:flex', + 'width:240px', + 'padding:12px', + 'background:#000', + ].join(';'), + ); + + const column = document.createElement('div'); + column.setAttribute( + 'style', + [ + 'display:flex', + 'flex-direction:column', + 'min-width:0', + 'flex:1', + ].join(';'), + ); + + const row = document.createElement('div'); + row.setAttribute( + 'style', + [ + 'display:flex', + 'width:100%', + 'align-items:center', + ].join(';'), + ); + + const tips = document.createElement('flutter-tooltips'); + tips.id = 'tips'; + tips.setAttribute('style', 'display:block; width:100%;'); + + const amount = document.createElement('webf-test-auto-size-text'); + amount.id = 'amount'; + amount.setAttribute('text', '1,200.686 USDT'); + + tips.appendChild(amount); + row.appendChild(tips); + column.appendChild(row); + root.appendChild(column); + document.body.appendChild(root); + + await waitForOnScreen(root); + await nextFrames(4); + + await snapshot(); + + amount.setAttribute('text', '9,999.999 USDT'); + await nextFrames(4); + await sleep(0.2); + + await snapshot(); + }); +}); diff --git a/webf/CHANGELOG.md b/webf/CHANGELOG.md index 60a0a6d9c0..85fcde3113 100644 --- a/webf/CHANGELOG.md +++ b/webf/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.24.25+2 + +### Bug Fixes + +- **Rendering/Widget/Flex**: preserve hosted Flutter widget text painting after nested updates + in flex layouts by preventing percentage-width widget children from collapsing to a transient + zero-width layout during relayout. + ## 0.24.25+1 ### Bug Fixes diff --git a/webf/ios/webf.podspec b/webf/ios/webf.podspec index f44d47fe35..19cc1a862c 100644 --- a/webf/ios/webf.podspec +++ b/webf/ios/webf.podspec @@ -29,8 +29,8 @@ Pod::Spec.new do |s| 'LLVM_LTO' => 'YES', # Enable Link Time Optimization for release builds 'GCC_OPTIMIZATION_LEVEL' => 's', # Enable optimization for size 'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) ' + - 'APP_REV=\\"e9eee7148\\" ' + - 'APP_VERSION=\\"0.24.25+1\\" ' + + 'APP_REV=\\"218bfc723\\" ' + + 'APP_VERSION=\\"0.24.25+2\\" ' + 'CONFIG_VERSION=\\"2025-04-26\\" ' + 'WEBF_QUICK_JS_ENGINE=1 ' + 'FLUTTER_BACKEND=1 ' + diff --git a/webf/lib/src/rendering/widget.dart b/webf/lib/src/rendering/widget.dart index 954efb79c2..9b4889d1c4 100644 --- a/webf/lib/src/rendering/widget.dart +++ b/webf/lib/src/rendering/widget.dart @@ -199,8 +199,30 @@ class RenderWidget extends RenderBoxModel if (renderStyle.width.type != CSSLengthType.AUTO) { final double? logicalContentWidth = renderStyle.contentBoxLogicalWidth; if (logicalContentWidth != null && logicalContentWidth.isFinite) { - final double clampedWidth = logicalContentWidth.clamp( - contentConstraints!.minWidth, contentConstraints!.maxWidth); + double minWidth = childConstraints.minWidth; + double maxWidth = childConstraints.maxWidth; + + // Percentage-width widget subtrees can briefly inherit a zero max-width + // from flex adjustment even though their used content width has already + // resolved from the containing block. Preserve that resolved width here + // instead of collapsing the hosted Flutter child to 0px. + if (renderStyle.width.type == CSSLengthType.PERCENTAGE && + maxWidth == 0 && + logicalContentWidth > 0) { + maxWidth = logicalContentWidth; + if (minWidth > maxWidth) { + minWidth = maxWidth; + } + childConstraints = BoxConstraints( + minWidth: minWidth, + maxWidth: maxWidth, + minHeight: childConstraints.minHeight, + maxHeight: childConstraints.maxHeight, + ); + } + + final double clampedWidth = + logicalContentWidth.clamp(minWidth, maxWidth).toDouble(); childConstraints = childConstraints.tighten(width: clampedWidth); } } diff --git a/webf/pubspec.yaml b/webf/pubspec.yaml index 65a7a961cc..a7b37453c7 100644 --- a/webf/pubspec.yaml +++ b/webf/pubspec.yaml @@ -1,6 +1,6 @@ name: webf description: W3C standards-compliant web rendering engine based on Flutter, allowing web applications to run natively on Flutter. -version: 0.24.25+1 +version: 0.24.25+2 homepage: https://openwebf.com license: GPL-3.0-only environment: diff --git a/webf/test/src/rendering/widget_tooltips_child_paint_test.dart b/webf/test/src/rendering/widget_tooltips_child_paint_test.dart new file mode 100644 index 0000000000..fc97416bc4 --- /dev/null +++ b/webf/test/src/rendering/widget_tooltips_child_paint_test.dart @@ -0,0 +1,236 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:webf/dom.dart' as dom; +import 'package:webf/rendering.dart'; +import 'package:webf/webf.dart'; + +import '../../setup.dart'; +import '../widget/test_utils.dart'; + +class _FlutterToolTipsElement extends WidgetElement { + _FlutterToolTipsElement(super.context); + + @override + WebFWidgetElementState createState() => _FlutterToolTipsElementState(this); +} + +class _FlutterToolTipsElementState extends WebFWidgetElementState { + _FlutterToolTipsElementState(super.widgetElement); + + _FlutterToolTipsElement get tooltipsElement => + widgetElement as _FlutterToolTipsElement; + + @override + Widget build(BuildContext context) { + dom.Element? firstElementChild; + for (final dom.Node node in tooltipsElement.childNodes) { + if (node is dom.Element) { + firstElementChild = node; + break; + } + } + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () {}, + child: firstElementChild != null + ? WebFWidgetElementChild(child: firstElementChild.toWidget()) + : const SizedBox.shrink(), + ); + } +} + +class _FlutterAutoSizeTextElement extends WidgetElement { + _FlutterAutoSizeTextElement(super.context); + + @override + WebFWidgetElementState createState() => + _FlutterAutoSizeTextElementState(this); +} + +class _FlutterAutoSizeTextElementState extends WebFWidgetElementState { + _FlutterAutoSizeTextElementState(super.widgetElement); + + static int buildCount = 0; + static String lastBuiltText = ''; + + _FlutterAutoSizeTextElement get autoSizeTextElement => + widgetElement as _FlutterAutoSizeTextElement; + + @override + Widget build(BuildContext context) { + final String text = autoSizeTextElement.getAttribute('text') ?? ''; + buildCount++; + lastBuiltText = text; + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + textScaler: const TextScaler.linear(1.0), + style: const TextStyle( + color: Colors.white, + fontSize: 24, + height: 1, + ), + ); + }, + ); + } +} + +Future _capture(WidgetTester tester) async { + final RenderRepaintBoundary boundary = + tester.firstRenderObject(find.byType(RepaintBoundary)); + return boundary.toImage(pixelRatio: 1); +} + +Future _countBrightPixels( + ui.Image image, { + required Rect rect, +}) async { + final ByteData? byteData = + await image.toByteData(format: ui.ImageByteFormat.rawRgba); + if (byteData == null) { + throw TestFailure('Failed to read image byte data'); + } + final Uint8List data = byteData.buffer.asUint8List(); + int count = 0; + for (int y = rect.top.floor(); y < rect.bottom.ceil(); y++) { + for (int x = rect.left.floor(); x < rect.right.ceil(); x++) { + final int offset = (y * image.width + x) * 4; + final int r = data[offset]; + final int g = data[offset + 1]; + final int b = data[offset + 2]; + final int a = data[offset + 3]; + if (a > 0 && r > 180 && g > 180 && b > 180) { + count++; + } + } + } + return count; +} + +void main() { + const String kTooltipsTag = 'flutter-tooltips'; + const String kAutoSizeTextTag = 'webf-test-auto-size-text'; + + setUpAll(() { + setupTest(); + if (!dom.getAllWidgetElements().containsKey(kTooltipsTag.toUpperCase())) { + WebF.defineCustomElement( + kTooltipsTag, + (context) => _FlutterToolTipsElement(context), + ); + } + if (!dom + .getAllWidgetElements() + .containsKey(kAutoSizeTextTag.toUpperCase())) { + WebF.defineCustomElement( + kAutoSizeTextTag, + (context) => _FlutterAutoSizeTextElement(context), + ); + } + }); + + setUp(() { + _FlutterAutoSizeTextElementState.buildCount = 0; + _FlutterAutoSizeTextElementState.lastBuiltText = ''; + WebFControllerManager.instance.initialize( + WebFControllerManagerConfig( + maxAliveInstances: 3, + maxAttachedInstances: 3, + enableDevTools: false, + ), + ); + }); + + tearDown(() async { + WebFControllerManager.instance.disposeAll(); + await Future.delayed(const Duration(milliseconds: 50)); + }); + + testWidgets( + 'tooltip child widget text still paints after nested widget text update in flex layout', + (WidgetTester tester) async { + final PreparedWidgetTest prepared = + await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + controllerName: + 'widget-tooltips-paint-${DateTime.now().millisecondsSinceEpoch}', + viewportWidth: 260, + viewportHeight: 180, + html: ''' + +
+
+
+ + + +
+
+
+ ''', + wrap: (Widget child) => MaterialApp( + home: Scaffold( + backgroundColor: Colors.black, + body: RepaintBoundary(child: child), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final dom.Element amount = prepared.getElementById('amount'); + expect(amount.attachedRenderer, isNotNull); + + ui.Image image = (await tester.runAsync(() => _capture(tester)))!; + addTearDown(image.dispose); + + int brightPixels = (await tester.runAsync(() { + return _countBrightPixels( + image, + rect: const Rect.fromLTWH(8, 8, 220, 60), + ); + }))!; + expect(brightPixels, greaterThan(150)); + expect(_FlutterAutoSizeTextElementState.buildCount, greaterThan(0)); + expect(_FlutterAutoSizeTextElementState.lastBuiltText, '1,200.686 USDT'); + + amount.setAttribute('text', '9,999.999 USDT'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + expect(_FlutterAutoSizeTextElementState.lastBuiltText, '9,999.999 USDT'); + + image = (await tester.runAsync(() => _capture(tester)))!; + addTearDown(image.dispose); + + brightPixels = (await tester.runAsync(() { + return _countBrightPixels( + image, + rect: const Rect.fromLTWH(8, 8, 220, 60), + ); + }))!; + + expect(brightPixels, greaterThan(150)); + }); +}