From c25696fe6b35920e3aa20256b92ba21fdf7a0198 Mon Sep 17 00:00:00 2001 From: andycall Date: Mon, 2 Feb 2026 09:14:29 -0800 Subject: [PATCH] fix(css): avoid clamping portal/modal widget width to DOM parent --- .../flutter_cupertino_portal_modal_popup.dart | 104 ++++++++++++ .../flutter_portal_popup_item.dart | 40 +++++ .../lib/custom_elements/main.dart | 5 + ...l_popup_width_not_clamped.ts.51d24c161.png | Bin 0 -> 16362 bytes .../widget_flex_modal_popup_width.ts | 3 + ...cupertino_modal_popup_width_not_clamped.ts | 102 +++++++++++ webf/lib/src/css/render_style.dart | 53 +++++- ...render_widget_portal_constraints_test.dart | 159 ++++++++++++++++++ 8 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 integration_tests/lib/custom_elements/flutter_cupertino_portal_modal_popup.dart create mode 100644 integration_tests/lib/custom_elements/flutter_portal_popup_item.dart create mode 100644 integration_tests/snapshots/rendering/widget_portal_cupertino_modal_popup_width_not_clamped.ts.51d24c161.png create mode 100644 integration_tests/specs/rendering/widget_portal_cupertino_modal_popup_width_not_clamped.ts create mode 100644 webf/test/src/rendering/render_widget_portal_constraints_test.dart 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 0000000000000000000000000000000000000000..e2b48de61414c89cb7662452f31d4d9e4beeea87 GIT binary patch literal 16362 zcmeIYXHZjJ^gbF3U_(?y6e$)^0g+dwNJ)YqVo-YTD$=DBN+l~D zIRXNK_+bz38G}IkK7l|76b|tMcM@;`V!+=%e`6g@P;sXy6}UL)uL(0f1pGn|J$nZN zT>!!E-8BvVwm23PdK%7cUJgQB)wpo*&N3J^yg3rcHtZdw0-mB3DAqf`}TppocP~yvMFd)|Fkwd8EkWodhRO1{BdPS z+nJsD2Bzysl_zlh;^zvu=k{NhPo)>{-iB^k?&I!5Y(06m=KMFs56mQ z=^00F6!{^YopbC%SXguxvDv`%=UaYMFa)x@aujFqp!>3bde(2}&u~K5I0?y`l9d+= zBuu(saOt`HIC!$6$G}2#NrUMl`q{DIpZxGtZ&8J%Y`5Wrs98`h4e4VF#c#9ZBmocM(3R>*j7USf3_raP8Ukk}&!_Ucmml*jF!n(MGtT@Icki1J6gq=Cd)2ApEg?Cb~vR$tFf_JRt}8o z>_gRT^g60S(F&{6U#;r`-rrE))x-obYMZw=4Z-`31(NUIFK~)JQ!LjUbH3oRHFF;L%$7d;;3Rma`! z$Jr>OMy&DA;qCL%;LBP-Rl~#}=FNy3{U2ZS$L-tUbEWLo_Yhcgh3&-(u$NlEQRoeb zz00xlbITQGg?4>w)Un*A`D?OrYEwe_6-AHGGqYhE95|!jD4fgHS=%so%63{=Sud!z z6sMN^s$%>SRrH?7$Vn%lN(@NtI&j)40s5*XgHq-BC;`jE!@Jjdc;NtTG~b^D^$xq< z?6_?C_nrm~R+36uV*yS1U?y8QS{2%O1lF7XD$2EI$`WTyYQ216XlRIW@nve%F(J>9 zD!u5Z!furUi_*RF%ah*ddq|^c#c~6Cv>AX)xOi}1Zd#Z5We+qp=X*Z-$h$@_>C+dpUeYx{n-|;W4@yO| zMpm5s?ugO`vJ3B{M5UlmC~0{;#eJff)HI=}F9U@rP?uZw{7f)TypJeRmpdzK@q5x| zVWOly$GoRlZt2@M(T?qBIa3sziLqb#%l!w)xDl9$(pmtDy@}hc%ie&~XMFBR!ussP zVNaes`QRp)Nxb?fUNVfuveYb&H=^)DDk|KSGR>jL^kWZ zQq9!qFE!VkY zI0MTP05a~UPNsKaR6Y91nWqK&=Dv{T8btOR8xWG1O=_aYv$M0MWn^^GmB^ri-rl~` zr_bhj+(-3fG?rO!jTobF88PRTb$vzq$kQ;R`-~mu2h2PuL|Gjk9SEm zGkp9wA>?a=Pvn`V8-BYZlRmENqbkYZpjh#+g_9U{pFcOPkB~>=YIOh%Bv(Ix!YF&E=F~5$x?Q;TkD*aVM&)IvTwt*KGXO8`5_T}ZcklJ_8L_{X zZ#Q{(7!gS-cE}MJtcAe*&Kbp~f0&$Ymx>l5J}XNNU0k+iuNeg$%t}4T!&j)oW_)m{ z)(4y;fKT$qIQ+Te-?Z-^Vq?usE3Rc{=epVSD!~`J?-m4n%yO6rXEe8Ub`E5s3=$y!@5!ESa#4o&YJStMs2JNbjnN z?Y_;$X#+gqQC{3;96GB6!qE&52ihVnE7#|5f~6Gqo8n{M_q--H$>!*N>}6-sF}qxg zhe%3IIg`jR?yT^ZPg6il&5ZFT z-v&LB!$^{hsbyFL#Zj&UIh9$aC@3o{Fgs_DEt2ney;KccxEAGzb?(9x3b;I?ocAW> zJAeKx(jB3Zz>&9GPf7i<_64!NnBU(1sAFf=C2X5|b#GjJeSK-7`>|`mT#1O} z?CqEPS@aPDfl>dGmoHx>V6i2XiH+)T4Ah{{rTxMM*6SDjne`-&O!$~8&ZeOg98l($ z;IUCK1zrDi_tjb>#lg+f_M6%TkAb2DNfsmIFLY%}Dtb)oI>22?p<#C3X3eo;tijB< z0J>PU+j4JqYfFofrRA6A(AQB>^hhMrBJ+NXBup;T#(f(zT^Ys+TzW8+KbT!cpnWJT zXy5HmvX2i^Y}Wnp^XDEOP>9V+!0dud5hf)3AD@3Ucz6%e(qX7;Po{ZQFrBKN{8zmV z255>Ndl3ePdSu*pMNbmek(o?SeIMj8`fIsZPy|p6e?XM+fnKMFQZS4fd;n8DF=?Ob zInuNciDZyBkXI1T$0TUu@7W7$>1GCw;)r248l@ULjQ6BNZf3yZt`N2g3R#;;4iC@( z77J`BUS&5PX=xY6xTq0@=L=L(fqb3oEeO+PiL$3(_B<>)iC}I^LJ*oZWY=$* z$450z4l>0vsKeQXoH8Zvq3bYJ`0o?!h<}gt^z^vfLDb{EeKU1}=tGwjg4AUW9^`f1 z_&bTt!r2ryJdg`)c`EnO&U6X8)lGCMk>SIRR6_2EhyD3CQWe}euoAt9U@gwZ>b&{) zUkw(MGNEXfO{sOu^xz4dJLh6*m3sI4cM+xj)vUK#tis7bqw3-@g|vtWR0U1Z5^B@T&lB4DrUTTVMna#WTF62ZH9ylTXM1W4onE+zzcMI>37y@ z_4p=g95D}7gv<|k!pz>@9d0s>0IB4}csSXWDI)WlB`}LOO-;XdHPE`Q%e-aDREYl4 zd-N#2HOl!0VQtDfXJm?t{L#dZ&Ke*Wy^xa5(W6^qE1q5}QY|#c^}f_Tiuc;tx=gUo zD6Wcc7*8IjEF>0F0P2tb`ZfPb)ICEJ9&9|JyB(Q1E{k0 z;q%H1fb&0?{jTo$J6|NU^#V`SLCVIRC=o@!_<>)|fD~KUbTa|GTs5Hk&t0v6t?qR9 z;kAuaG}q<62JLe-s(On}FL_fIT?ybPJ8KawiMsu4dwWOmS@+|{8&*Z|!-pAk=+@fu z=#P$)fEbMi_o^@E#a6g*62}zTlV0M@k_~l`pbSLl*=&K4Yj?bIm&IdV{&>YVSZJdJ zv@TCOI#Jw)Ze(Os{Hl+$ZpQgy)kHrGNmkJUW*vh&%&MC=-A7A5YiemJM)YX*If@tL zFCg7EF35qgW3T1*AHBkboGClp>*{MjU+HZdBp~&U3|qy=_}mvxO{hu+C4FUAmEspS(2j94{5KKsYI zidQiQjdr?%2ya^nl*xjXiHhS(Y76rY5k2Q}1^lsG#2q_gfDxeS21*R_)@7}xTAMpi z_rmQ{)x;^W_4kGCeIlDvTY(OUyBxFHV^IHyO)D78EJ{|&&7#0VOKOkBKX=4#ZN?_D zDn{x;j>d?n=mO!o7tjd!$CLXzw$}s!HrbiS1g&j2q9~hXKYsiO;D*f8w^utYp4?40 z$Jyt1)o<9Q2nwjRFVATZC2JZ&%v6zDk^5pl%(r+#6*Xf(Hq3s&($s`zSmR73b#W zWTd5G#J)gcu3L9nbAIoMnRFTKFm7eq00x8U>gonUgESt$;N=%7ijoK& z1dkQlDPsSLB&aQ8tDs+jS?UM(GqVuBo&LM83cp#MK6x@7@IQtduYic{EHr$#zWBiR zJ#lbOqRZ6R=(A}!536K#j@o%Rh&s;4pwx$%McD()O(RD%cSTR7ZopJjIIOjJ7xy}c z=@miI#O*56#>OZKKX>82&YiD-z*-${n85d5mz6VFoN7#7F7uFg{r%~p&bCPG8^C;5 zq&)jDf=$#pn`ZYh0_%U|k-xyQI3dd3cW$zLR#sgpj>WkkooCxJWVaB`MokEOrw{&hzwIOGXW;eSYxHL=!sGJfn zoZ8oJ02u%d^W5WN@%1h!P6l39z9D1Schbv_K%;D~uFh^^&Z1#aqL>sn|0ef!(ixJ;Y;%A)=- z83t^OKyBBxh(v}UJ$~2~A00U(7fa6RNEy8<_zkiafKhe*lp5~Uda}cDx+#WSsAGVO zLG`Wn<otB-9+F^XYsLK*}x3LEOQ1SEePh)jn1e>;W`~f?z06N}XVgO|)I(DU!9UcYJK3zm; zu<1Yy8dwpMt*qUC{tcK6iiD8Ks`U2T;GB}0x6>%RL_Ut(_A z>;igb$;_>5KCk3K;Mhdl$f|E>YZsBdQb71=GhzY>o_LkK+486{(ZRxS6BOLGkcgOT z$z?6EC){UgWQIxltNjPyL)n)DsM(hbzMVJ$Sc;E9bkKgIJzN=*dF04Zp|SGpWM%Jk zr?wWtZP>Og>A{){zx0|dVH%z-!XB82gqaRya!U**+>2g~(`R!oj>EX1ycn7CdH?w{l3ZhS4_`gKaN@+alquIM=lm{{7vn$R^XB#2YjSdWvgWvc zVEQ2qOWyvSNw)9cPm|gIvf-n!L%2~xyV?fE;%;Q5xWjPp_mFwFMLkE4?Sep1>Y+ni z<8X|!0ko5A1S~C@gx`$>lsY-v@hZlzHBF|WnMY#@C{qpunA014ciw`w?R~|7>vw!U z0f!EO{(b!d1bXzpc2XBEcIR&pi=*+wK4Rxy*M87;`kiR~_SG#~P-Smt?NXH>_$cUw z>eE9xK%grJ5B~q=WJ(sMQau)!F@FH`mv0*XwDHLN`_#jr7nq8LU+VH&mLQPe z{{5hTE?)TW~-tFZA{HMhJQ=$Kt z6rULOy1wR>MjHR@7tc%AC_{bFm6~5pQ3n0%J0sOHp>t$e&a5UVRV4A*wX|muF?|;2 zKWQ>~b7ejuVrK?dMI^x#okUp~GAFd5DZjy8~ zdPCK&Q8m7pipR#Dv_Db=|8x}e*JW~6if2nVGTpesP#*sAQ`NIt&2p)RGToQk{Y4jg zz$DHEM56ckAa#Nw%NXVS>i~$`x<4#iyrZrkm+!G4Ee(BS73n!zjV#>$(wul8CVM5g z(knQ_Ss3&e*+)npJqc^~F4COfMN4|Se6DO8((PP%B0IGYbjOtbw)p-XsqC#vv}B0z zmXaYNlK#9f_N3@VK9Ffi#z`sbbY$R_#Ba}nnTL4smA#s-C*yPiX3ENwDCd*JgN*y(N_^g3X$uw zMU+0=DcDr89*H}ZP%)8i?l~-7_aswLRUX8xp}_@a%Kdq1A6EHlcWwR5p)C68^cMEm zjplli4r|aMVm+8Iu!+%~o&xN@UUf-45x0_kiek^9QA>!Jtg2tN|DCo|~*p{20JiO}c1RxNz zlDeI;rZM&xNw*>NCOWIU{1$0-OTi8i7yJ}7-km8lq)tLZQ#r5Hbd(YiR5@U4rhSFbekXt*4FT-T6 zU+-Q;O_cca!h2@tQ;TY$;nT|-0(v!8R$tGd0i%!6%P8@mQ*$D=!(QuC&j4F=-|~T% z$(@eIQ&;vHj95wc-P+huhR<%kf>VcfSd(fc1w}y0%-V3Y0d-5Zi@eBe=zlfs-8<+Z z|5@uGY^pqdv%^oglkP}ouB5M{c`sDX0Qz)vo4_>(|BqSsrIJAF*erJyFD$u*^mFa_M1y&0mYu!V>#(N_EMD9HH zU0>?Q0Pxx`h1uCTnLw^+V<$HAE0d^s@*G*yEnnJr^MmL7x}@0ejvlA^WAT!}a*wo} zqPA~glE?B7EQ>WNgzq$ITTuFdf$E^w7H*fgF43GA5#IXmEHd9+*_zGumV)(o4!hG6 zWA=(@y~W7E5YMJ8!*+(E7|qc(q|xbR*wY zeJ`(Aq>5=3#47<@J7YTP-{6%Kp=hH@*}$E)#*>zM|;2tbxc;QFf@8?mLsIxr3z5beHS)^=kAt-PKhUr@KUdiiQ zQOPT<5%9)^^O7*O0Yaax7q0JNv6BN}nELWzaqkDcuUi|$H$alWx{pokk{aCXo*;!X z=&k#%kAN>6t;V4ovRS^x5m^84hc-EU!J|PIt${)6jFjzfPp5;ZYZaaBFLT5Yk5Sa_ zcVT}xLpzD+?J-gx_jKBLZK$XbopqpAWf-5iPRenyN7$^IOVvuu&(2@5_&PUyp4mMk zz1v%-GC`bP-Yk-`uIQMw50qD5e$gDJPaCo*^OfG3%AFWpUD=^v7vcmadI8h07H?}5 zRx|nx$_}Hm9KyHEZIp^j;P!xxMB`qxr>v472sT`9n{_omR67*tb*0m^DqF$gWBpwq z*^3amwp$xfb@O89v@e7kn0}-F=_9<$5q@z*m3;*BsCKnip-pkErE+ZDzhO3R*|)zJ zOwZ0{PYTym{hFo}8IBe>i3ALP1@>_Y6J^6YbYZ0ycZ_#w{r)a zg;yr~wmcBF^-H*vX`L6;U~>8tZA)woSlv|AmpCPoBilThm)PSOF!DMoYei@$ycxn) zP@V`cxdP@Gz?ozNdh2x1@VWT{i-tI%QzCPz4OzI9F{BV$VQb4q2=I9@=UBJ)<@>tm zQ_9}mbNs~*7CSn7cY?Us9Pmyl2jXFV1GnnI^LIY*(c981>Kj>m#=LkjoiX4tFz>ZTdH9jorUn! zW|Js>O0z^=_Uywm?j5b!gw^r?NdTkGMw9Pk8s5bVT^zMhUH%zX?`YxazTVq3|Wuz@ATiQL;dY+gbVu zQJsHzGI{5x1%OSGKSdk}Dt34_?#`KI>RQn09?4!3S**eN4VS|dW-ar1*XGKpwVz0o zq`1|F@z$V(1L!@?jtd?eJeLbd4k-Jby&mp$wt_dXzeVdUwR$kbEa&L)!)E5rZ|%ba z(TutvV?4^tB52enoA9l_Ev$5Vt8Cii+j?DtUAoJaF&qG7kzryCflbTZgm$4N$3i(^ z@jmX~j@;ooUyPssIIdmbce$3yw#Dz{Cz(Z)naLQ z(8lVWE65A>_-((>^Si8K^HeK!Ybj zA$F?~soC$Yd`7JP^gcas!4`A@X21JE4zAl9B8Fmm$G5h&8@jh1SM(lgb+pV@YSh*q zZiv9Xe5G~zwCJS-?7;c)(q&(VvV_D$cOByl5p9M1hWvx%oidGt_!|(={Ke* zMcn|$nU1Vu6p$_Vf#4sPlw^TPG6NXiIB4mUqj%NQVffme@Tp~0UEO$sN0fEtbGunt zmk-e&D#6lw39lc##s25@Bz50{0xLslp}-JDegJs6)j~1KELjGb9!o zH>g+(M@Y!Zqar%myE97|sy9|GMmVK%*@kiU2ILNuH|0Eb$w(LRnN#%|$Ag&a7$a~$ zKLfKT6lQ0P)VDYLZ8TPnwkc5YrkK&&_a;cgcl)rOg{hdAtbQ9};bKgG)U8|-_(ty- zv`E5{P3Q1%X#dPgjKG#&tu59hMqwED@i;5AIXBUn|NJ63sIDKVE9|Zm~$2<071GA8Nnm zxJP*9r-ph|D_s%FdpM%81UJR~F^C2>hbL^2Uf$vLI?4_MmBX`wXa5ZC2GzIrN7mi) z9JWuxsU0N~{Y%B`vul66B;lderUoREO`-CP>_7iLbiDDr=<~dy`C_u|PU*ktX&T;g z)|kS)Lr^^ezIIrfZ{a7iymD9Lt5?G?>u42ZL+Rn>8z&K@w#nFSIc?1FLHEf5(WN1q zjE9}Bc{U}_3tAnn*FAPBhHT$>=xyM2IoE{1`1}kL;eH@SwV`aPdWN1rJtq>Rmo>gA zV^RVcBs8&?yJs)LtYVGzf}i9nJ(o2E`kQ>?nFY z%Bo`C==~^B4y1TPA5ap--Vl8632t%_ZaUUR37d<3QkG+CyagtYUUZ%pwVz&5>0cky z-rAyssb;&p6Biq?rB~076b?GkDx+Wb=Pjz0lwS#V*DPm@pi~Z$rzO|-Y zWPjaztjOC<*Jj2gZRJ%;n0)raTi6KR*)jF)+F=~Lmb`XOQhWnkSPeemAR$lU-1}I+ z<%9)BhG}o~Ell?SIC%QIH##g_vl8gy?2;6CCoox(N_P+?{Hf$6tY4x~GIn>S4u$<~ zxIPwO6{iYj|1eWgU7h)7%qQPtYeR06tk0$zAQkgm-W3xES?UyrQ|YMi-vgM;ORV1= z9q@^V(jO+!B_@cs3zhZh!x*Zy)p&%~Z5H)ZQRJA`8hu3+H~cAA|7K*ekLPWST1{lR zsFeHI4~O${$GKjs3(j1Ee>^?qm0*>SE7JY*H+?s&?R{hZcDFG=+jo}Md@yX(FPYVI zLxUC_BAoovyhona8+2|`p#wy)7`H`cEZOkXjkym6Bw+vPShe%_|`qQR=gTmdrb zRYPPsJibBmWt~v9ibHKkw)^mJ3!9)b1zG+1d;HXy#tRUSfSowH|5mSo;4TBPLqByi zKHfNZa5Eu`tt6_Zmx=C$_6M>6w5vf}9Wlcv8583f#JaJP7@h-t(l zwCb;Lcm7c7*bLDk#gqc^jYl2V2byN~V?z3z*qj zZ_3#=@b|PmbnodmC{&us4@!D0^}K`9&+av|o-;LkQ7sZBA*$(ik~+^tx|UWS`Oq3) zS!k%;sL!H_E+V`nl7m?u!^CYv|5j$rr;9bla@r%}^n+COfy70}v_qUi9U-w)N zU$u~^hh+7KM(rS>etCIU>KTsz zpyR(;!z~}6HTRR?-2BNcF8=u5k$f{7XJ1EQrSMEE&3lX#YionZx>Zx88LoAQGc7zqi-M9O*iKj!zb^X7aiW4jl1K%m1(e^tY1&cmy zx#gn=_)z)JhwZOLK;`jX_N-k$~H1q4mylWC%mw`QN>JTi@Cu3&$4Q_4ySp-9EG}PPWn5 z3OHQRPo0?{Rsd1 zXH<2PbE9g!h2b0|)+g^a?_0NlOHpW(=t?JKx_MCv_w0k+xR*l2D;CJ|hkMfJ>clWE zsBNBIrfdv%#~OkIZTxo#>dn*r9R}gUg#gg%xp1s*+cyy(<9C7C!ud}QNWeXX^GF=K zX7%Gio|=I75`GBp(ux?UAIZfwL90?`uj#t&?-CM zCHDdkPjI0JQ6p@_GtuvOynB#y*{P~CH4>MBzO5z_#|zIvpAp}dCvWA4m_Ts`?Uc^S zOSp&al((L#NGS(yodM4qCu{7N5`4-EfMT*ks^Xul$9vK$tvi3cXP?L*_@0My?OWI5 zW+NQSkdS_7ATI8BkAKGC%tlsl)DUFA5##N|(4Erm^dA|ouras!-nzLq{3Bo`(t@>7X%$nF(o`DjxP1=Rudg_OFcvrtiTk z#G7k@dR{SXl87e_JmB+B_0w># zvpx3&>dPNqZt_lOz$PUtFBh(}v)J@AMONM_YXHf!P=(3n#Lxo4Q;Rqc30-t6dq1@Xh3v2?Ze`;@hb!6T_PI5PMbs$6uVepKx?> zm^l#JGEzbqH)n+7sV_(DN*aLBXQxsJNDsFYw9LfM;qIagS$X+#y^ zFFu)W)>T#T#f#f&i?fW-5Ek&TTw2LTG$cbKq9ERlpW<6l%wr6(f_!GK2aQ7oblHGj zlA_VHkHg;lA+brU8)FhkNcx^-Bfv;EuO8)^{B-$Sk-s!E-}X6R8$#wV?{&#v?~5JN zKEfY}yF)7|dNlmmJisU0+AYwpHOlFSraMHhqOw1Ki5Pu)Xs`=hZzq=9r+C`LbkTGq zaUVW)wp^{k(oy_DuX0JH1t8f(l>aW@fe%pQ0~c=?w>gKeu$GNGBaJsV5oRi+bwfZs z>vmi-2Sn})&S~~joJ9@@D!<7IArnGn9wESH{FPT%**Vz{?RgNlHd-bTitiI$t!gLqs0TC|8m9*<>d4%DKD+G-SMk<;ViGH zLGxlz1|I-M|3^z&=H%sdbNudM_!l2jAF)rxVWZZwd_M%n(8X<-<96pP)Xirr{Asta zG7k5n&O0V$mFmiN4<~k|?>|sS&Tu2YEu{zo`G6ZI%=RSTSZ)x~w z^ze9dskPEEnd`O*bckh`cGz#X2i27ylV47TB|=zY1CwB;iw3)OP>R-%}9PyIT1HO_d9T%76xPp{+&0! z`MQx(d*J_`jre=FI!H(Eu-22a&DaiVRZ;xwgO@c+nY(T)S1y_!yCq|1s-xmz_YGro zO}j^Lb9XGJj2^YhsAC=lZJgYH5cKylIY4HhU_YpSs^Ze8(@L%3Ib`TKgAcSJ29Xs} zlzx7n;a00cIuCjw5NfWFY5PPrKoB}_&|eAyVOOnsj$nnVmTb`qZIX@+8>c{}6(8YN z7h#@i7H3Bujs22i)YeU(-VYk=bh)Q&C0q5C*fi3Xd!*Im_Y9I43<9Y=F84WiGXCqu z3BFS!k6!i5X^M=Wd3^|!cFU^f`KPSAh>}5qs<~n($+4;2&CWue4|r@{oVBW-_BxUY zDvkRHcaq(m$izYl9Gl1-f#q(+%i7eh_vLkxoq$a90KaQu$_MbLq>HJ2{NbP%4TaZi zvCX|TBj%7hFIQ%kA?lT$>88ye@eUE)@Fi2_93!5?pvUZhI6Pw0w5&yG+*8i5QORV& zKF@(VQVR4J%HtFja=Vp$1WT^+Z!GNi^LHH7B8Idoz#TdO0)8-U L{d>ilk6->D%~g;a literal 0 HcmV?d00001 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)); + }); +}