Skip to content

Commit 03c8827

Browse files
authored
fix(ui): guard MessageCard._updateWidthLimit against unlaid RenderBox (#2702)
1 parent 7f0804d commit 03c8827

3 files changed

Lines changed: 187 additions & 5 deletions

File tree

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
until the server confirmed it.
77
- Fixed a `FlutterError` ("A RenderViewport exceeded its maximum number of layout cycles") that
88
could occur when fast-scrolling through the message list.
9+
- Fixed `RenderBox was not laid out` thrown by `MessageCard._updateWidthLimit` when the attachments
10+
subtree was detached between scheduling the post-frame callback and it firing (e.g. the list was
11+
rebuilt or the message removed). Added a `hasSize` guard before reading `RenderBox.size`.
912

1013
## 9.24.0
1114

packages/stream_chat_flutter/lib/src/message_widget/message_card.dart

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,15 +133,19 @@ class _MessageCardState extends State<MessageCard> {
133133
}
134134

135135
void _updateWidthLimit() {
136+
if (!mounted) return;
137+
136138
final attachmentContext = attachmentsKey.currentContext;
137139
final renderBox = attachmentContext?.findRenderObject() as RenderBox?;
138-
final attachmentsWidth = renderBox?.size.width;
140+
// The attachments subtree may have been detached between scheduling this
141+
// post-frame callback and it firing. Reading [RenderBox.size] without
142+
// checking [RenderBox.hasSize] throws `RenderBox was not laid out`.
143+
if (renderBox == null || !renderBox.hasSize) return;
144+
final attachmentsWidth = renderBox.size.width;
139145

140-
if (attachmentsWidth == null || attachmentsWidth == 0) return;
146+
if (attachmentsWidth == 0) return;
141147

142-
if (mounted) {
143-
setState(() => widthLimit = attachmentsWidth);
144-
}
148+
setState(() => widthLimit = attachmentsWidth);
145149
}
146150

147151
@override
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:mocktail/mocktail.dart';
4+
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
5+
6+
import '../mocks.dart';
7+
8+
/// Always-handles attachment builder used to inject a known-size widget into
9+
/// the attachments slot of [MessageCard]. Lets each test control the size that
10+
/// `_updateWidthLimit` will read off the rendered RenderBox.
11+
class _FixedSizeAttachmentBuilder extends StreamAttachmentWidgetBuilder {
12+
const _FixedSizeAttachmentBuilder({required this.size});
13+
14+
final Size size;
15+
16+
@override
17+
bool canHandle(Message message, Map<String, List<Attachment>> attachments) =>
18+
true;
19+
20+
@override
21+
Widget build(
22+
BuildContext context,
23+
Message message,
24+
Map<String, List<Attachment>> attachments,
25+
) {
26+
return SizedBox.fromSize(size: size);
27+
}
28+
}
29+
30+
MessageCard _buildCard({
31+
required bool hasNonUrlAttachments,
32+
StreamAttachmentWidgetBuilder? attachmentBuilder,
33+
}) {
34+
return MessageCard(
35+
message: Message(
36+
id: 'm1',
37+
text: 'hi',
38+
attachments: [Attachment(type: AttachmentType.file)],
39+
),
40+
isFailedState: false,
41+
showUserAvatar: DisplayWidget.show,
42+
messageTheme: const StreamMessageThemeData(),
43+
hasQuotedMessage: false,
44+
hasUrlAttachments: false,
45+
hasNonUrlAttachments: hasNonUrlAttachments,
46+
hasPoll: false,
47+
isOnlyEmoji: false,
48+
isGiphy: false,
49+
attachmentBuilders: attachmentBuilder == null ? null : [attachmentBuilder],
50+
attachmentPadding: const EdgeInsets.all(4),
51+
attachmentShape: null,
52+
onAttachmentTap: (_, __) {},
53+
onShowMessage: (_, __) {},
54+
onReplyTap: (_) {},
55+
attachmentActionsModalBuilder: null,
56+
textPadding: const EdgeInsets.all(8),
57+
reverse: false,
58+
);
59+
}
60+
61+
/// Wraps [child] in a minimal [StreamChat] + [MaterialApp] so descendant
62+
/// widgets like [StreamMessageText] can resolve `StreamChat.of(context)`.
63+
Widget _wrap(Widget child) {
64+
final client = MockClient();
65+
final clientState = MockClientState();
66+
final user = OwnUser(id: 'user-id');
67+
when(() => client.state).thenReturn(clientState);
68+
when(() => clientState.currentUser).thenReturn(user);
69+
when(() => clientState.currentUserStream)
70+
.thenAnswer((_) => Stream.value(user));
71+
72+
return MaterialApp(
73+
home: StreamChat(
74+
client: client,
75+
child: Scaffold(body: Center(child: child)),
76+
),
77+
);
78+
}
79+
80+
void main() {
81+
group('MessageCard._updateWidthLimit', () {
82+
testWidgets(
83+
'applies width limit from laid-out attachments render box',
84+
(tester) async {
85+
const attachmentSize = Size(220, 80);
86+
87+
await tester.pumpWidget(
88+
_wrap(
89+
_buildCard(
90+
hasNonUrlAttachments: true,
91+
attachmentBuilder:
92+
const _FixedSizeAttachmentBuilder(size: attachmentSize),
93+
),
94+
),
95+
);
96+
// Allow the post-frame callback scheduled in didChangeDependencies to
97+
// fire and apply the width limit via setState.
98+
await tester.pump();
99+
100+
final container = tester.widget<Container>(
101+
find.descendant(
102+
of: find.byType(MessageCard),
103+
matching: find.byType(Container),
104+
),
105+
);
106+
expect(container.constraints?.maxWidth, attachmentSize.width);
107+
},
108+
);
109+
110+
testWidgets(
111+
'does not throw when the message card is unmounted before '
112+
'the post-frame callback fires',
113+
(tester) async {
114+
const attachmentSize = Size(120, 60);
115+
116+
await tester.pumpWidget(
117+
_wrap(
118+
_buildCard(
119+
hasNonUrlAttachments: true,
120+
attachmentBuilder:
121+
const _FixedSizeAttachmentBuilder(size: attachmentSize),
122+
),
123+
),
124+
);
125+
// Replace the MessageCard with an empty tree BEFORE pumping the next
126+
// frame. The post-frame callback queued in didChangeDependencies will
127+
// fire against an unmounted state — the new `if (!mounted) return;`
128+
// guard must keep it from throwing.
129+
await tester.pumpWidget(_wrap(const SizedBox.shrink()));
130+
131+
expect(tester.takeException(), isNull);
132+
},
133+
);
134+
135+
testWidgets(
136+
'leaves width limit unset when attachment reports zero width',
137+
(tester) async {
138+
await tester.pumpWidget(
139+
_wrap(
140+
_buildCard(
141+
hasNonUrlAttachments: true,
142+
attachmentBuilder:
143+
const _FixedSizeAttachmentBuilder(size: Size.zero),
144+
),
145+
),
146+
);
147+
await tester.pump();
148+
149+
final container = tester.widget<Container>(
150+
find.descendant(
151+
of: find.byType(MessageCard),
152+
matching: find.byType(Container),
153+
),
154+
);
155+
// The early-return on `attachmentsWidth == 0` means widthLimit stays
156+
// null and the constraints stay unconstrained on the width axis.
157+
expect(container.constraints?.maxWidth, double.infinity);
158+
},
159+
);
160+
161+
testWidgets(
162+
'skips width measurement entirely when there are no attachments',
163+
(tester) async {
164+
await tester.pumpWidget(
165+
_wrap(_buildCard(hasNonUrlAttachments: false)),
166+
);
167+
await tester.pump();
168+
169+
// No exception — the post-frame callback is not scheduled at all when
170+
// hasAttachments is false.
171+
expect(tester.takeException(), isNull);
172+
},
173+
);
174+
});
175+
}

0 commit comments

Comments
 (0)