Skip to content

Commit 76d0588

Browse files
CGQAQclaude
authored andcommitted
fix: include gap and border-box size in flex scrollable area calculation
_setMaxScrollableSize() was not accounting for main-axis gap between items or cross-axis gap between flex lines, and child.scrollableSize (padding-box) could be smaller than the border-box. This caused scrollWidth/scrollHeight to be underestimated, clipping the last item when scrolling to the end of an overflowing flex container with gap. Also fix test timing: use waitForFrame() before/after setting scrollLeft/scrollTop so the scroll controller has clients and the paint takes effect before snapshot capture. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 06fdbd3 commit 76d0588

File tree

9 files changed

+114
-19
lines changed

9 files changed

+114
-19
lines changed
-378 Bytes
Loading
-43 Bytes
Loading
-310 Bytes
Loading
-704 Bytes
Loading
-21 Bytes
Loading
-313 Bytes
Loading

integration_tests/specs/css/css-flexbox/flex-gap-sizing.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,15 @@ describe('css-flexbox gap sizing', () => {
7171
BODY.appendChild(p);
7272
BODY.appendChild(flexbox);
7373

74+
// Wait for layout to complete so the scroll controller has clients
75+
await waitForFrame();
76+
7477
// Scroll to the very end
7578
flexbox.scrollLeft = flexbox.scrollWidth;
7679

80+
// Wait for the scroll paint to take effect
81+
await waitForFrame();
82+
7783
await snapshot();
7884
});
7985

@@ -154,9 +160,15 @@ describe('css-flexbox gap sizing', () => {
154160
BODY.appendChild(p);
155161
BODY.appendChild(flexbox);
156162

163+
// Wait for layout to complete so the scroll controller has clients
164+
await waitForFrame();
165+
157166
// Scroll to the very end
158167
flexbox.scrollLeft = flexbox.scrollWidth;
159168

169+
// Wait for the scroll paint to take effect
170+
await waitForFrame();
171+
160172
await snapshot();
161173
});
162174

@@ -227,9 +239,15 @@ describe('css-flexbox gap sizing', () => {
227239
BODY.appendChild(p);
228240
BODY.appendChild(flexbox);
229241

242+
// Wait for layout to complete so the scroll controller has clients
243+
await waitForFrame();
244+
230245
// Scroll to the very end
231246
flexbox.scrollLeft = flexbox.scrollWidth;
232247

248+
// Wait for the scroll paint to take effect
249+
await waitForFrame();
250+
233251
await snapshot();
234252
});
235253

@@ -297,9 +315,15 @@ describe('css-flexbox gap sizing', () => {
297315
BODY.appendChild(p);
298316
BODY.appendChild(flexbox);
299317

318+
// Wait for layout to complete so the scroll controller has clients
319+
await waitForFrame();
320+
300321
// Scroll to the very end
301322
flexbox.scrollTop = flexbox.scrollHeight;
302323

324+
// Wait for the scroll paint to take effect
325+
await waitForFrame();
326+
303327
await snapshot();
304328
});
305329

@@ -376,9 +400,15 @@ describe('css-flexbox gap sizing', () => {
376400
BODY.appendChild(p);
377401
BODY.appendChild(flexbox);
378402

403+
// Wait for layout to complete so the scroll controller has clients
404+
await waitForFrame();
405+
379406
// Scroll to the very end
380407
flexbox.scrollTop = flexbox.scrollHeight;
381408

409+
// Wait for the scroll paint to take effect
410+
await waitForFrame();
411+
382412
await snapshot();
383413
});
384414

@@ -446,9 +476,15 @@ describe('css-flexbox gap sizing', () => {
446476
BODY.appendChild(p);
447477
BODY.appendChild(flexbox);
448478

479+
// Wait for layout to complete so the scroll controller has clients
480+
await waitForFrame();
481+
449482
// Scroll to the very end
450483
flexbox.scrollTop = flexbox.scrollHeight;
451484

485+
// Wait for the scroll paint to take effect
486+
await waitForFrame();
487+
452488
await snapshot();
453489
});
454490
});

webf/lib/src/rendering/flex.dart

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4027,14 +4027,26 @@ class RenderFlexLayout extends RenderLayoutBox {
40274027
}
40284028
}
40294029

4030-
final double childScrollableMain = preSiblingsMainSize +
4031-
(_isHorizontalFlexDirection
4032-
? childScrollableSize.width + childOffsetX
4033-
: childScrollableSize.height + childOffsetY);
4034-
final double childScrollableCross = _isHorizontalFlexDirection
4030+
final double childBoxMainSize = _isHorizontalFlexDirection ? child.size.width : child.size.height;
4031+
final double childBoxCrossSize = _isHorizontalFlexDirection ? child.size.height : child.size.width;
4032+
final double childMainOffset = _isHorizontalFlexDirection ? childOffsetX : childOffsetY;
4033+
final double childCrossOffset = _isHorizontalFlexDirection ? childOffsetY : childOffsetX;
4034+
final double childScrollableMainExtent = _isHorizontalFlexDirection
4035+
? childScrollableSize.width + childOffsetX
4036+
: childScrollableSize.height + childOffsetY;
4037+
final double childScrollableCrossExtent = _isHorizontalFlexDirection
40354038
? childScrollableSize.height + childOffsetY
40364039
: childScrollableSize.width + childOffsetX;
40374040

4041+
// The child's extent must cover at least its offset border-box.
4042+
// child.scrollableSize may only cover padding-box, but offsets (margin/relative/transform)
4043+
// still need to be preserved so negative offsets don't create phantom trailing scroll range.
4044+
final double childScrollableMain = preSiblingsMainSize +
4045+
math.max(childBoxMainSize + childMainOffset, childScrollableMainExtent);
4046+
final double childScrollableCross = math.max(
4047+
childBoxCrossSize + childCrossOffset,
4048+
childScrollableCrossExtent);
4049+
40384050
maxScrollableMainSizeOfLine = math.max(maxScrollableMainSizeOfLine, childScrollableMain);
40394051
maxScrollableCrossSizeInLine = math.max(maxScrollableCrossSizeInLine, childScrollableCross);
40404052

@@ -4048,6 +4060,10 @@ class RenderFlexLayout extends RenderLayoutBox {
40484060
}
40494061
}
40504062
preSiblingsMainSize += childMainSize;
4063+
// Add main-axis gap between items (not after the last item).
4064+
if (runChild != runChildren.last) {
4065+
preSiblingsMainSize += _getMainAxisGap();
4066+
}
40514067
}
40524068

40534069
// Max scrollable cross size of all the children in the line.
@@ -4056,6 +4072,10 @@ class RenderFlexLayout extends RenderLayoutBox {
40564072
scrollableMainSizeOfLines.add(maxScrollableMainSizeOfLine);
40574073
scrollableCrossSizeOfLines.add(maxScrollableCrossSizeOfLine);
40584074
preLinesCrossSize += runMetric.crossAxisExtent;
4075+
// Add cross-axis gap between flex lines (not after the last line).
4076+
if (runMetric != runMetrics.last) {
4077+
preLinesCrossSize += _getCrossAxisGap();
4078+
}
40594079
}
40604080

40614081
// Max scrollable main size of all lines.

webf/test/src/rendering/flex_item_width_test.dart

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,13 @@ void main() {
3030
});
3131

3232
group('Flex Item Width', () {
33-
testWidgets('block elements in flex container should not stretch to parent width', (WidgetTester tester) async {
33+
testWidgets(
34+
'block elements in flex container should not stretch to parent width',
35+
(WidgetTester tester) async {
3436
final prepared = await WebFWidgetTestUtils.prepareWidgetTest(
3537
tester: tester,
36-
controllerName: 'flex-item-width-${DateTime.now().millisecondsSinceEpoch}',
38+
controllerName:
39+
'flex-item-width-${DateTime.now().millisecondsSinceEpoch}',
3740
html: '''
3841
<html>
3942
<body style="margin: 0; padding: 0;">
@@ -53,30 +56,32 @@ void main() {
5356
final item1 = prepared.getElementById('item1');
5457
final item2 = prepared.getElementById('item2');
5558
final item3 = prepared.getElementById('item3');
56-
59+
5760
// Container should be 600px wide
5861
expect(container.offsetWidth, equals(600));
59-
62+
6063
// Items should size to their content, not stretch to 600px
6164
expect(item1.offsetWidth, lessThan(600));
6265
expect(item2.offsetWidth, lessThan(600));
6366
expect(item3.offsetWidth, lessThan(600));
64-
67+
6568
// Items should have different widths based on their content
6669
expect(item1.offsetWidth, isNot(equals(item2.offsetWidth)));
6770
expect(item2.offsetWidth, isNot(equals(item3.offsetWidth)));
68-
71+
6972
// Debug output
7073
print('Container width: ${container.offsetWidth}');
7174
print('Item 1 width: ${item1.offsetWidth}');
7275
print('Item 2 width: ${item2.offsetWidth}');
7376
print('Item 3 width: ${item3.offsetWidth}');
7477
});
7578

76-
testWidgets('block elements with explicit width should respect it', (WidgetTester tester) async {
79+
testWidgets('block elements with explicit width should respect it',
80+
(WidgetTester tester) async {
7781
final prepared = await WebFWidgetTestUtils.prepareWidgetTest(
7882
tester: tester,
79-
controllerName: 'flex-item-explicit-width-${DateTime.now().millisecondsSinceEpoch}',
83+
controllerName:
84+
'flex-item-explicit-width-${DateTime.now().millisecondsSinceEpoch}',
8085
html: '''
8186
<html>
8287
<body style="margin: 0; padding: 0;">
@@ -93,10 +98,10 @@ void main() {
9398

9499
final item1 = prepared.getElementById('item1');
95100
final item2 = prepared.getElementById('item2');
96-
101+
97102
// Item with explicit width should be 100px
98103
expect(item1.offsetWidth, equals(100));
99-
104+
100105
// Item without width should size to content
101106
expect(item2.offsetWidth, lessThan(600));
102107
expect(item2.offsetWidth, isNot(equals(100)));
@@ -105,7 +110,8 @@ void main() {
105110
testWidgets('flex-grow should expand items', (WidgetTester tester) async {
106111
final prepared = await WebFWidgetTestUtils.prepareWidgetTest(
107112
tester: tester,
108-
controllerName: 'flex-grow-test-${DateTime.now().millisecondsSinceEpoch}',
113+
controllerName:
114+
'flex-grow-test-${DateTime.now().millisecondsSinceEpoch}',
109115
html: '''
110116
<html>
111117
<body style="margin: 0; padding: 0;">
@@ -122,15 +128,48 @@ void main() {
122128

123129
final item1 = prepared.getElementById('item1');
124130
final item2 = prepared.getElementById('item2');
125-
131+
126132
// Item 2 should size to content
127133
final item2ContentWidth = item2.offsetWidth;
128-
134+
129135
// Item 1 with flex-grow should take remaining space
130136
expect(item1.offsetWidth, equals(600 - item2ContentWidth));
131-
137+
132138
// Total should equal container width
133139
expect(item1.offsetWidth + item2.offsetWidth, equals(600));
134140
});
141+
142+
testWidgets(
143+
'negative start-side offset should not create phantom trailing scroll range',
144+
(WidgetTester tester) async {
145+
final prepared = await WebFWidgetTestUtils.prepareWidgetTest(
146+
tester: tester,
147+
controllerName:
148+
'flex-negative-offset-scroll-${DateTime.now().millisecondsSinceEpoch}',
149+
html: '''
150+
<html>
151+
<body style="margin: 0; padding: 0;">
152+
<div id="relative" style="display: flex; width: 200px; overflow: auto;">
153+
<div style="flex: 0 0 150px; height: 20px; background: #f66;"></div>
154+
<div style="flex: 0 0 150px; height: 20px; background: #66f; position: relative; left: -100px;"></div>
155+
</div>
156+
<div id="transform" style="display: flex; width: 200px; overflow: auto; margin-top: 8px;">
157+
<div style="flex: 0 0 150px; height: 20px; background: #6c6;"></div>
158+
<div style="flex: 0 0 150px; height: 20px; background: #fc6; transform: translateX(-100px);"></div>
159+
</div>
160+
</body>
161+
</html>
162+
''',
163+
);
164+
165+
await tester.pump();
166+
167+
final relative = prepared.getElementById('relative');
168+
final transform = prepared.getElementById('transform');
169+
170+
// Both containers should end at 200px; shifted children should not add trailing blank scroll area.
171+
expect(relative.scrollWidth, moreOrLessEquals(200, epsilon: 1));
172+
expect(transform.scrollWidth, moreOrLessEquals(200, epsilon: 1));
173+
});
135174
});
136175
}

0 commit comments

Comments
 (0)