Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String, StaticDefinedSyncBindingObjectMethod> 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<StaticDefinedSyncBindingObjectMethodMap> 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<void> show() async {
if (_isShowing) return;
if (!mounted) return;
_isShowing = true;

try {
await showCupertinoModalPopup<void>(
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();
}
}

Original file line number Diff line number Diff line change
@@ -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<String, dynamic> 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(),
),
);
}
}

5 changes: 5 additions & 0 deletions integration_tests/lib/custom_elements/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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));
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
53 changes: 47 additions & 6 deletions webf/lib/src/css/render_style.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading