diff --git a/integration_tests/lib/custom_elements/flutter_cupertino_portal_modal_popup.dart b/integration_tests/lib/custom_elements/flutter_cupertino_portal_modal_popup.dart new file mode 100644 index 0000000000..ccdf04e3b6 --- /dev/null +++ b/integration_tests/lib/custom_elements/flutter_cupertino_portal_modal_popup.dart @@ -0,0 +1,104 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/widgets.dart'; +import 'package:webf/bridge.dart'; +import 'package:webf/rendering.dart'; +import 'package:webf/webf.dart'; +import 'package:webf/widget.dart'; + +/// A repro custom element that shows its children inside a Cupertino modal popup. +/// +/// The popup content uses Align + SingleChildScrollView (loose width constraints), +/// then bridges those constraints into the WebF subtree via [WebFWidgetElementChild]. +/// +/// Without the core fix, auto-width WidgetElements inside the popup could incorrectly +/// resolve their used width against the original DOM containing block (e.g. 36px), +/// causing the popup viewport width to shrink to 36. +class FlutterCupertinoPortalModalPopup extends WidgetElement { + FlutterCupertinoPortalModalPopup(super.context); + + static Map syncMethods = { + 'show': StaticDefinedSyncBindingObjectMethod(call: (element, args) { + (element as FlutterCupertinoPortalModalPopup).show(); + return null; + }), + 'hide': StaticDefinedSyncBindingObjectMethod(call: (element, args) { + (element as FlutterCupertinoPortalModalPopup).hide(); + return null; + }), + }; + + @override + List get methods => + [...super.methods, syncMethods]; + + FlutterCupertinoPortalModalPopupState? get _state => + state as FlutterCupertinoPortalModalPopupState?; + + void show() => _state?.show(); + + void hide() => _state?.hide(); + + @override + WebFWidgetElementState createState() => FlutterCupertinoPortalModalPopupState(this); +} + +class FlutterCupertinoPortalModalPopupState extends WebFWidgetElementState { + FlutterCupertinoPortalModalPopupState(super.widgetElement); + + bool _isShowing = false; + + @override + FlutterCupertinoPortalModalPopup get widgetElement => + super.widgetElement as FlutterCupertinoPortalModalPopup; + + Future show() async { + if (_isShowing) return; + if (!mounted) return; + _isShowing = true; + + try { + await showCupertinoModalPopup( + context: context, + barrierDismissible: true, + builder: (BuildContext dialogContext) => _buildPopupContent(dialogContext), + ); + } finally { + _isShowing = false; + } + } + + void hide() { + if (!_isShowing) return; + if (!mounted) return; + Navigator.of(context, rootNavigator: true).pop(); + } + + Widget _buildPopupContent(BuildContext dialogContext) { + return Align( + alignment: Alignment.bottomCenter, + child: SingleChildScrollView( + child: WebFWidgetElementChild( + child: WebFHTMLElement( + tagName: 'DIV', + controller: widgetElement.controller, + parentElement: widgetElement, + children: widgetElement.childNodes.toWidgetList(), + ), + ), + ), + ); + } + + @override + void dispose() { + hide(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Host element itself does not render anything; the popup is shown modally. + return const SizedBox.shrink(); + } +} + diff --git a/integration_tests/lib/custom_elements/flutter_portal_popup_item.dart b/integration_tests/lib/custom_elements/flutter_portal_popup_item.dart new file mode 100644 index 0000000000..af31f13b06 --- /dev/null +++ b/integration_tests/lib/custom_elements/flutter_portal_popup_item.dart @@ -0,0 +1,40 @@ +import 'package:flutter/widgets.dart'; +import 'package:webf/rendering.dart'; +import 'package:webf/webf.dart'; + +/// A WidgetElement used as modal popup content for reproducing width resolution bugs. +/// +/// This element renders its children through a nested WebF subtree so it participates +/// in WebF's box model sizing (RenderWidget) while being hosted inside a Flutter +/// modal popup (portal subtree). +class FlutterPortalPopupItem extends WidgetElement { + FlutterPortalPopupItem(super.context); + + @override + Map get defaultStyle => const { + 'display': 'block', + }; + + @override + WebFWidgetElementState createState() => FlutterPortalPopupItemState(this); +} + +class FlutterPortalPopupItemState extends WebFWidgetElementState { + FlutterPortalPopupItemState(super.widgetElement); + + @override + FlutterPortalPopupItem get widgetElement => super.widgetElement as FlutterPortalPopupItem; + + @override + Widget build(BuildContext context) { + return WebFWidgetElementChild( + child: WebFHTMLElement( + tagName: 'DIV', + controller: widgetElement.controller, + parentElement: widgetElement, + children: widgetElement.childNodes.toWidgetList(), + ), + ); + } +} + diff --git a/integration_tests/lib/custom_elements/main.dart b/integration_tests/lib/custom_elements/main.dart index a0a2ba9f92..f8da2451f3 100644 --- a/integration_tests/lib/custom_elements/main.dart +++ b/integration_tests/lib/custom_elements/main.dart @@ -22,6 +22,8 @@ import 'sample_container.dart'; import 'native_flex_container.dart'; import 'flutter_max_height_container.dart'; import 'flutter_fixed_height_slot.dart'; +import 'flutter_cupertino_portal_modal_popup.dart'; +import 'flutter_portal_popup_item.dart'; void defineWebFCustomElements() { WebF.defineCustomElement('flutter-button', @@ -50,6 +52,9 @@ void defineWebFCustomElements() { WebF.defineCustomElement('flutter-nest-scroller-item-top-area', (context) => FlutterNestScrollerSkeletonItemTopArea(context)); WebF.defineCustomElement('flutter-nest-scroller-item-persistent-header', (context) => FlutterNestScrollerSkeletonItemPersistentHeader(context)); WebF.defineCustomElement('flutter-modal-popup', (context) => FlutterModalPopup(context)); + WebF.defineCustomElement( + 'flutter-cupertino-portal-modal-popup', (context) => FlutterCupertinoPortalModalPopup(context)); + WebF.defineCustomElement('flutter-portal-popup-item', (context) => FlutterPortalPopupItem(context)); WebF.defineCustomElement('flutter-intrinsic-container', (context) => FlutterIntrinsicContainer(context)); WebF.defineCustomElement('sample-container', (context) => SampleContainer(context)); WebF.defineCustomElement('native-flex', (context) => NativeFlexContainer(context)); diff --git a/integration_tests/snapshots/rendering/widget_portal_cupertino_modal_popup_width_not_clamped.ts.51d24c161.png b/integration_tests/snapshots/rendering/widget_portal_cupertino_modal_popup_width_not_clamped.ts.51d24c161.png new file mode 100644 index 0000000000..e2b48de614 Binary files /dev/null and b/integration_tests/snapshots/rendering/widget_portal_cupertino_modal_popup_width_not_clamped.ts.51d24c161.png differ diff --git a/integration_tests/specs/rendering/widget_flex_modal_popup_width.ts b/integration_tests/specs/rendering/widget_flex_modal_popup_width.ts index 27df6642ed..979edf3d5f 100644 --- a/integration_tests/specs/rendering/widget_flex_modal_popup_width.ts +++ b/integration_tests/specs/rendering/widget_flex_modal_popup_width.ts @@ -43,5 +43,8 @@ describe('RenderWidget flex + modal popup inner width', () => { expect(bug).not.toBeNull(); await snapshot(bug); + + // Show the modal popup via the exposed sync method. + (popup as any).hide(); }); }); diff --git a/integration_tests/specs/rendering/widget_portal_cupertino_modal_popup_width_not_clamped.ts b/integration_tests/specs/rendering/widget_portal_cupertino_modal_popup_width_not_clamped.ts new file mode 100644 index 0000000000..a754a7c0c6 --- /dev/null +++ b/integration_tests/specs/rendering/widget_portal_cupertino_modal_popup_width_not_clamped.ts @@ -0,0 +1,102 @@ +describe('Portal Cupertino modal popup width', () => { + it('does not clamp WidgetElement used width to the original 36px DOM containing block', async () => { + await resizeViewport(370, 700); + + try { + document.documentElement.style.margin = '0'; + document.body.style.margin = '0'; + document.body.style.padding = '0'; + (document.body.style as any).backgroundColor = '#ffffff'; + + const wrapper = createElement( + 'div', + { + id: 'wrapper', + style: { + width: '36px', + height: '36px', + border: '1px solid #ef4444', + boxSizing: 'border-box', + overflow: 'hidden', + }, + }, + [], + ); + + const popup = createElement( + 'flutter-cupertino-portal-modal-popup', + { id: 'popup' }, + [ + createElement( + 'flutter-portal-popup-item', + { + id: 'item', + style: { + display: 'block', + backgroundColor: '#dbeafe', + border: '2px solid #93c5fd', + borderRadius: '12px', + padding: '16px', + boxSizing: 'border-box', + fontFamily: 'system-ui, sans-serif', + }, + }, + [ + createElement('div', { style: { fontSize: '14px', fontWeight: '700', marginBottom: '8px' } }, [ + createText('Portal width probe'), + ]), + createElement('div', { style: { fontSize: '12px', color: '#1d4ed8' } }, [ + createText('Should expand to popup width, not 36px'), + ]), + ], + ), + ], + ); + + wrapper.appendChild(popup); + document.body.appendChild(wrapper); + + await sleep(0.2); + + // Guard: fail fast when running against an old integration test binary + // that does not include the Dart-side custom element registration. + expect(typeof (popup as any).show).toBe('function'); + expect(typeof (popup as any).hide).toBe('function'); + + (popup as any).show(); + + // Wait for Cupertino modal animation + layout. + await sleep(1.2); + await nextFrames(4); + + const item = document.getElementById('item') as HTMLElement; + expect(item).not.toBeNull(); + + // Force layout. + item.offsetHeight; + await nextFrames(2); + + const rect = item.getBoundingClientRect(); + + // Regression guard: + // Previously this could become ~36 due to width:auto resolving against the + // original DOM containing block instead of the popup viewport constraints. + expect(rect.width).toBeGreaterThan(120); + + // Include Flutter overlay in snapshot for debugging. + await snapshotFlutter(); + } finally { + try { + await dismissFlutterOverlays(); + } catch (_) {} + try { + const popup = document.getElementById('popup') as any; + popup?.hide?.(); + } catch (_) {} + try { + document.getElementById('wrapper')?.remove(); + } catch (_) {} + await resizeViewport(-1, -1); + } + }); +}); diff --git a/webf/lib/src/css/render_style.dart b/webf/lib/src/css/render_style.dart index f793a0fb58..234af5999d 100644 --- a/webf/lib/src/css/render_style.dart +++ b/webf/lib/src/css/render_style.dart @@ -2723,6 +2723,16 @@ class CSSRenderStyle extends RenderStyle } else if (logicalWidth == null && (renderStyle.isSelfRouterLinkElement() && root != null)) { logicalWidth = root.boxSize!.width; } else if (logicalWidth == null && parentStyle != null) { + bool isRenderSubtreeAncestor(flutter.RenderObject? ancestor, flutter.RenderObject? node) { + if (ancestor == null || node == null) return false; + flutter.RenderObject? current = node.parent; + while (current != null) { + if (identical(current, ancestor)) return true; + current = current.parent; + } + return false; + } + // Resolve whether the direct parent is a flex item (its render box's parent is a flex container). // Determine if our direct parent is a flex item: i.e., the parent's parent is a flex container. final bool parentIsFlexItem = parentStyle.isParentRenderFlexLayout(); @@ -2758,9 +2768,27 @@ class CSSRenderStyle extends RenderStyle // is mounted into multiple Flutter subtrees simultaneously. // - Widget elements may also apply CSS padding/max-width, making the logical // content width smaller than the raw Flutter constraints. - final double? parentContentLogicalWidth = parentStyle.contentBoxLogicalWidth; - if (parentContentLogicalWidth != null && parentContentLogicalWidth.isFinite) { - logicalWidth = math.min(maxConstraintWidth, parentContentLogicalWidth); + // + // However, in portal/modal scenarios the DOM/style-tree parent (parentStyle) + // may not be an ancestor of the current render subtree. In that case, the + // parentContentLogicalWidth does NOT represent the real containing block for + // this layout pass and must not clamp the widget constraints. + final RenderBoxModel? currentLayoutBoxForAncestor = + renderBoxModelInLayoutStack.isNotEmpty ? renderBoxModelInLayoutStack.last : null; + final bool parentIsAncestorInCurrentTree = currentLayoutBoxForAncestor == null + ? true + : isRenderSubtreeAncestor( + parentStyle.attachedRenderBoxModel, + currentLayoutBoxForAncestor, + ); + + if (parentIsAncestorInCurrentTree) { + final double? parentContentLogicalWidth = parentStyle.contentBoxLogicalWidth; + if (parentContentLogicalWidth != null && parentContentLogicalWidth.isFinite) { + logicalWidth = math.min(maxConstraintWidth, parentContentLogicalWidth); + } else { + logicalWidth = maxConstraintWidth; + } } else { logicalWidth = maxConstraintWidth; } @@ -2801,9 +2829,22 @@ class CSSRenderStyle extends RenderStyle childWrapper != null && maxConstraintWidth != null && maxConstraintWidth.isFinite) { - final double? ancestorContentLogicalWidth = ancestorRenderStyle.contentBoxLogicalWidth; - if (ancestorContentLogicalWidth != null && ancestorContentLogicalWidth.isFinite) { - logicalWidth = math.min(maxConstraintWidth, ancestorContentLogicalWidth); + final RenderBoxModel? currentLayoutBoxForAncestor = + renderBoxModelInLayoutStack.isNotEmpty ? renderBoxModelInLayoutStack.last : null; + final bool ancestorIsAncestorInCurrentTree = currentLayoutBoxForAncestor == null + ? true + : isRenderSubtreeAncestor( + ancestorRenderStyle.attachedRenderBoxModel, + currentLayoutBoxForAncestor, + ); + + if (ancestorIsAncestorInCurrentTree) { + final double? ancestorContentLogicalWidth = ancestorRenderStyle.contentBoxLogicalWidth; + if (ancestorContentLogicalWidth != null && ancestorContentLogicalWidth.isFinite) { + logicalWidth = math.min(maxConstraintWidth, ancestorContentLogicalWidth); + } else { + logicalWidth = maxConstraintWidth; + } } else { logicalWidth = maxConstraintWidth; } diff --git a/webf/test/src/rendering/render_widget_portal_constraints_test.dart b/webf/test/src/rendering/render_widget_portal_constraints_test.dart new file mode 100644 index 0000000000..c704197435 --- /dev/null +++ b/webf/test/src/rendering/render_widget_portal_constraints_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter/widgets.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 'package:webf/widget.dart'; +import '../../setup.dart'; +import '../widget/test_utils.dart'; + +class _PortalMarker extends InheritedWidget { + const _PortalMarker({required super.child}); + + static bool isPortal(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_PortalMarker>() != null; + + @override + bool updateShouldNotify(_PortalMarker oldWidget) => false; +} + +class _PortalProbeWidgetElement extends WidgetElement { + _PortalProbeWidgetElement(super.context); + + static BoxConstraints? lastPortalConstraints; + + @override + WebFWidgetElementState createState() => _PortalProbeWidgetElementState(this); +} + +class _PortalProbeWidgetElementState extends WebFWidgetElementState { + _PortalProbeWidgetElementState(super.widgetElement); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + if (_PortalMarker.isPortal(context)) { + _PortalProbeWidgetElement.lastPortalConstraints = constraints; + } + return const SizedBox.shrink(); + }, + ); + } +} + +class _PortalEmbedder extends StatefulWidget { + const _PortalEmbedder({ + required this.controllerName, + required this.webf, + }); + + final String controllerName; + final Widget webf; + + @override + State<_PortalEmbedder> createState() => _PortalEmbedderState(); +} + +class _PortalEmbedderState extends State<_PortalEmbedder> { + WidgetElement? _probe; + + @override + void initState() { + super.initState(); + _scheduleProbeMount(); + } + + void _scheduleProbeMount() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _probe != null) return; + + final WebFController? controller = + WebFControllerManager.instance.getControllerSync(widget.controllerName); + final dom.Element? element = controller?.view.document.getElementById(const ['probe']); + if (element is! WidgetElement) { + _scheduleProbeMount(); + return; + } + + setState(() { + _PortalProbeWidgetElement.lastPortalConstraints = null; + _probe = element; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill(child: widget.webf), + if (_probe != null) + Positioned( + left: 0, + top: 0, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 300, + maxHeight: 200, + ), + child: _PortalMarker( + child: WebFWidgetElementChild( + // Mount the same WidgetElement into a different Flutter subtree (portal) + // while it remains in the DOM. Use a unique key to avoid duplicate-key + // collisions with the DOM-mounted instance. + child: _probe!.toWidget(key: UniqueKey()), + ), + ), + ), + ), + ], + ); + } +} + +void main() { + const String kProbeTagName = 'WEBF-TEST-PORTAL-PROBE'; + + setUpAll(() { + setupTest(); + if (!dom.getAllWidgetElements().containsKey(kProbeTagName)) { + dom.defineWidgetElement( + kProbeTagName, + (context) => _PortalProbeWidgetElement(context), + ); + } + }); + + testWidgets('WidgetElement width is not clamped by DOM parent in portal subtree', + (WidgetTester tester) async { + final String controllerName = 'portal-probe-${DateTime.now().millisecondsSinceEpoch}'; + _PortalProbeWidgetElement.lastPortalConstraints = null; + + await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + controllerName: controllerName, + viewportWidth: 360, + viewportHeight: 640, + html: ''' + +
+ <$kProbeTagName id="probe"> +
+ + ''', + wrap: (Widget webf) => Directionality( + textDirection: TextDirection.ltr, + child: _PortalEmbedder(controllerName: controllerName, webf: webf), + ), + ); + + for (int i = 0; i < 10 && _PortalProbeWidgetElement.lastPortalConstraints == null; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + + final BoxConstraints? portalConstraints = _PortalProbeWidgetElement.lastPortalConstraints; + expect(portalConstraints, isNotNull); + expect(portalConstraints!.maxWidth, closeTo(300.0, 0.01)); + }); +}