Skip to content

Commit 09bd4e4

Browse files
xsahil03xitsmeadi
andauthored
feat(ui, core): add maybeOf methods for safe context access (#2315)
Co-authored-by: itsmeadi <[email protected]>
1 parent 186d12e commit 09bd4e4

File tree

9 files changed

+569
-93
lines changed

9 files changed

+569
-93
lines changed

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
## Upcoming
2+
3+
✅ Added
4+
5+
- Added `StreamChat.maybeOf()` method for safe context access in async operations.
6+
7+
🐞 Fixed
8+
9+
- Fixed `StreamMessageInput` crashes with "Null check operator used on a null value" when async
10+
operations continue after widget unmounting.
11+
112
## 9.14.0
213

314
🐞 Fixed

packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart

Lines changed: 40 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -946,39 +946,36 @@ class StreamMessageInputState extends State<StreamMessageInput>
946946
defaultButton;
947947
}
948948

949-
Future<void> _sendPoll(Poll poll) {
950-
final streamChannel = StreamChannel.of(context);
951-
final channel = streamChannel.channel;
952-
949+
Future<void> _sendPoll(Poll poll, Channel channel) {
953950
return channel.sendPoll(poll);
954951
}
955952

956-
Future<void> _updatePoll(Poll poll) {
957-
final streamChannel = StreamChannel.of(context);
958-
final channel = streamChannel.channel;
959-
953+
Future<void> _updatePoll(Poll poll, Channel channel) {
960954
return channel.updatePoll(poll);
961955
}
962956

963-
Future<void> _deletePoll(Poll poll) {
964-
final streamChannel = StreamChannel.of(context);
965-
final channel = streamChannel.channel;
966-
957+
Future<void> _deletePoll(Poll poll, Channel channel) {
967958
return channel.deletePoll(poll);
968959
}
969960

970-
Future<void> _createOrUpdatePoll(Poll? old, Poll? current) async {
961+
Future<void> _createOrUpdatePoll(
962+
Poll? old,
963+
Poll? current,
964+
) async {
965+
final channel = StreamChannel.maybeOf(context)?.channel;
966+
if (channel == null) return;
967+
971968
// If both are null or the same, return
972969
if ((old == null && current == null) || old == current) return;
973970

974971
// If old is null, i.e., there was no poll before, create the poll.
975-
if (old == null) return _sendPoll(current!);
972+
if (old == null) return _sendPoll(current!, channel);
976973

977974
// If current is null, i.e., the poll is removed, delete the poll.
978-
if (current == null) return _deletePoll(old);
975+
if (current == null) return _deletePoll(old, channel);
979976

980977
// Otherwise, update the poll.
981-
return _updatePoll(current);
978+
return _updatePoll(current, channel);
982979
}
983980

984981
/// Handle the platform-specific logic for selecting files.
@@ -996,7 +993,10 @@ class StreamMessageInputState extends State<StreamMessageInput>
996993
..removeWhere((it) {
997994
if (it != AttachmentPickerType.poll) return false;
998995
if (_effectiveController.message.parentId != null) return true;
999-
final channel = StreamChannel.of(context).channel;
996+
997+
final channel = StreamChannel.maybeOf(context)?.channel;
998+
if (channel == null) return true;
999+
10001000
if (channel.config?.polls == true && channel.canSendPoll) return false;
10011001

10021002
return true;
@@ -1210,11 +1210,12 @@ class StreamMessageInputState extends State<StreamMessageInput>
12101210

12111211
late final _onChangedDebounced = debounce(
12121212
() {
1213-
var value = _effectiveController.text;
12141213
if (!mounted) return;
1215-
value = value.trim();
12161214

1217-
final channel = StreamChannel.of(context).channel;
1215+
final channel = StreamChannel.maybeOf(context)?.channel;
1216+
if (channel == null) return;
1217+
1218+
final value = _effectiveController.text.trim();
12181219
if (value.isNotEmpty && channel.canSendTypingEvents) {
12191220
// Notify the server that the user started typing.
12201221
channel.keyStroke(_effectiveController.message.parentId).onError(
@@ -1235,7 +1236,7 @@ class StreamMessageInputState extends State<StreamMessageInput>
12351236

12361237
setState(() => _actionsShrunk = value.isNotEmpty && actionsLength > 1);
12371238

1238-
_checkContainsUrl(value, context);
1239+
_checkContainsUrl(value, channel);
12391240
},
12401241
const Duration(milliseconds: 350),
12411242
leading: true,
@@ -1264,7 +1265,7 @@ class StreamMessageInputState extends State<StreamMessageInput>
12641265
caseSensitive: false,
12651266
);
12661267

1267-
void _checkContainsUrl(String value, BuildContext context) async {
1268+
void _checkContainsUrl(String value, Channel channel) async {
12681269
// Cancel the previous operation if it's still running
12691270
_enrichUrlOperation?.cancel();
12701271

@@ -1281,10 +1282,8 @@ class StreamMessageInputState extends State<StreamMessageInput>
12811282
}).toList();
12821283

12831284
// Reset the og attachment if the text doesn't contain any url
1284-
if (matchedUrls.isEmpty ||
1285-
!StreamChannel.of(context).channel.canSendLinks) {
1286-
_effectiveController.clearOGAttachment();
1287-
return;
1285+
if (matchedUrls.isEmpty || !channel.canSendLinks) {
1286+
return _effectiveController.clearOGAttachment();
12881287
}
12891288

12901289
final firstMatchedUrl = matchedUrls.first.group(0)!;
@@ -1294,7 +1293,8 @@ class StreamMessageInputState extends State<StreamMessageInput>
12941293
return;
12951294
}
12961295

1297-
final client = StreamChat.of(context).client;
1296+
final client = StreamChat.maybeOf(context)?.client;
1297+
if (client == null) return;
12981298

12991299
_enrichUrlOperation = CancelableOperation.fromFuture(
13001300
_enrichUrl(firstMatchedUrl, client),
@@ -1319,7 +1319,6 @@ class StreamMessageInputState extends State<StreamMessageInput>
13191319
) async {
13201320
var response = _ogAttachmentCache[url];
13211321
if (response == null) {
1322-
final client = StreamChat.of(context).client;
13231322
try {
13241323
response = await client.enrichUrl(url);
13251324
_ogAttachmentCache[url] = response;
@@ -1462,7 +1461,9 @@ class StreamMessageInputState extends State<StreamMessageInput>
14621461
if (_effectiveController.isSlowModeActive) return;
14631462
if (!widget.validator(_effectiveController.message)) return;
14641463

1465-
final streamChannel = StreamChannel.of(context);
1464+
final streamChannel = StreamChannel.maybeOf(context);
1465+
if (streamChannel == null) return;
1466+
14661467
final channel = streamChannel.channel;
14671468
var message = _effectiveController.value;
14681469

@@ -1483,7 +1484,7 @@ class StreamMessageInputState extends State<StreamMessageInput>
14831484
return;
14841485
}
14851486

1486-
_maybeDeleteDraftMessage(message);
1487+
_maybeDeleteDraftMessage(message, channel);
14871488
widget.onQuotedMessageCleared?.call();
14881489
_effectiveController.reset();
14891490

@@ -1501,7 +1502,7 @@ class StreamMessageInputState extends State<StreamMessageInput>
15011502
await WidgetsBinding.instance.endOfFrame;
15021503
}
15031504

1504-
await _sendOrUpdateMessage(message: message);
1505+
await _sendOrUpdateMessage(message: message, channel: channel);
15051506

15061507
if (mounted) {
15071508
if (widget.shouldKeepFocusAfterMessage ?? !_commandEnabled) {
@@ -1514,10 +1515,9 @@ class StreamMessageInputState extends State<StreamMessageInput>
15141515

15151516
Future<void> _sendOrUpdateMessage({
15161517
required Message message,
1518+
required Channel channel,
15171519
}) async {
15181520
try {
1519-
final channel = StreamChannel.of(context).channel;
1520-
15211521
// Note: edited messages which are bounced back with an error needs to be
15221522
// sent as new messages as the backend doesn't store them.
15231523
final resp = await switch (_isEditing && !message.isBouncedWithError) {
@@ -1558,19 +1558,21 @@ class StreamMessageInputState extends State<StreamMessageInput>
15581558
}
15591559

15601560
void _maybeUpdateOrDeleteDraftMessage() {
1561+
final channel = StreamChannel.maybeOf(context)?.channel;
1562+
if (channel == null) return;
1563+
15611564
final message = _effectiveController.message;
15621565
final isMessageValid = widget.validator.call(message);
15631566

15641567
// If the message is valid, we need to create or update it as a draft
15651568
// message for the channel or thread.
1566-
if (isMessageValid) return _maybeUpdateDraftMessage(message);
1569+
if (isMessageValid) return _maybeUpdateDraftMessage(message, channel);
15671570

15681571
// Otherwise, we need to delete the draft message.
1569-
return _maybeDeleteDraftMessage(message);
1572+
return _maybeDeleteDraftMessage(message, channel);
15701573
}
15711574

1572-
void _maybeUpdateDraftMessage(Message message) {
1573-
final channel = StreamChannel.of(context).channel;
1575+
void _maybeUpdateDraftMessage(Message message, Channel channel) {
15741576
final draft = switch (message.parentId) {
15751577
final parentId? => channel.state?.threadDraft(parentId),
15761578
null => channel.state?.draft,
@@ -1584,8 +1586,7 @@ class StreamMessageInputState extends State<StreamMessageInput>
15841586
return channel.createDraft(draftMessage).ignore();
15851587
}
15861588

1587-
void _maybeDeleteDraftMessage(Message message) {
1588-
final channel = StreamChannel.of(context).channel;
1589+
void _maybeDeleteDraftMessage(Message message, Channel channel) {
15891590
final draft = switch (message.parentId) {
15901591
final parentId? => channel.state?.threadDraft(parentId),
15911592
null => channel.state?.draft,

packages/stream_chat_flutter/lib/src/stream_chat.dart

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,19 +71,67 @@ class StreamChat extends StatefulWidget {
7171
@override
7272
StreamChatState createState() => StreamChatState();
7373

74-
/// Use this method to get the current [StreamChatState] instance
74+
/// Finds the [StreamChatState] from the closest [StreamChat] ancestor
75+
/// that encloses the given [context].
76+
///
77+
/// This will throw a [FlutterError] if no [StreamChat] is found in the
78+
/// widget tree above the given context.
79+
///
80+
/// Typical usage:
81+
///
82+
/// ```dart
83+
/// final chatState = StreamChat.of(context);
84+
/// ```
85+
///
86+
/// If you're calling this in the same `build()` method that creates the
87+
/// `StreamChat`, consider using a `Builder` or refactoring into a
88+
/// separate widget to obtain a context below the [StreamChat].
89+
///
90+
/// If you want to return null instead of throwing, use [maybeOf].
7591
static StreamChatState of(BuildContext context) {
76-
StreamChatState? streamChatState;
92+
final result = maybeOf(context);
93+
if (result != null) return result;
7794

78-
streamChatState = context.findAncestorStateOfType<StreamChatState>();
79-
80-
if (streamChatState == null) {
81-
throw Exception(
82-
'You must have a StreamChat widget at the top of your widget tree',
83-
);
84-
}
95+
throw FlutterError.fromParts(<DiagnosticsNode>[
96+
ErrorSummary(
97+
'StreamChat.of() called with a context that does not contain a '
98+
'StreamChat.',
99+
),
100+
ErrorDescription(
101+
'No StreamChat ancestor could be found starting from the context '
102+
'that was passed to StreamChat.of(). This usually happens when the '
103+
'context used comes from the widget that creates the StreamChat '
104+
'itself.',
105+
),
106+
ErrorHint(
107+
'To fix this, ensure that you are using a context that is a descendant '
108+
'of the StreamChat. You can use a Builder to get a new context that '
109+
'is under the StreamChat:\n\n'
110+
' Builder(\n'
111+
' builder: (context) {\n'
112+
' final chatState = StreamChat.of(context);\n'
113+
' ...\n'
114+
' },\n'
115+
' )',
116+
),
117+
ErrorHint(
118+
'Alternatively, split your build method into smaller widgets so that '
119+
'you get a new BuildContext that is below the StreamChat in the '
120+
'widget tree.',
121+
),
122+
context.describeElement('The context used was'),
123+
]);
124+
}
85125

86-
return streamChatState;
126+
/// Finds the [StreamChatState] from the closest [StreamChat] ancestor
127+
/// that encloses the given context.
128+
///
129+
/// Returns null if no such ancestor exists.
130+
///
131+
/// See also:
132+
/// * [of], which throws if no [StreamChat] is found.
133+
static StreamChatState? maybeOf(BuildContext context) {
134+
return context.findAncestorStateOfType<StreamChatState>();
87135
}
88136
}
89137

0 commit comments

Comments
 (0)