Skip to content

Commit 8cf2f7b

Browse files
authored
fix(ui): prevent cooldown resume crash if channel state is null (#2343)
1 parent 26bd2ee commit 8cf2f7b

File tree

5 files changed

+180
-6
lines changed

5 files changed

+180
-6
lines changed

packages/stream_chat/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## Upcoming
2+
3+
🐞 Fixed
4+
5+
- Fixed `Channel` methods to throw proper `StateError` exceptions instead of relying on assertions
6+
for state validation.
7+
18
## 9.15.0
29

310
✅ Added

packages/stream_chat/lib/src/client/channel.dart

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2049,15 +2049,18 @@ class Channel {
20492049
void dispose() {
20502050
client.state.removeChannel('$cid');
20512051
state?.dispose();
2052+
state = null;
20522053
_muteExpirationTimer?.cancel();
20532054
_keyStrokeHandler.cancel();
20542055
}
20552056

20562057
void _checkInitialized() {
2057-
assert(
2058-
_initializedCompleter.isCompleted,
2059-
"Channel $cid hasn't been initialized yet. Make sure to call .watch()"
2060-
' or to instantiate the client using [Channel.fromState]',
2058+
if (_initializedCompleter.isCompleted && state != null) return;
2059+
2060+
throw StateError(
2061+
"Channel $cid hasn't been initialized yet or has been disposed. "
2062+
'Make sure to call .watch() or instantiate the client using '
2063+
'[Channel.fromState]',
20612064
);
20622065
}
20632066
}

packages/stream_chat/test/src/client/channel_test.dart

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// ignore_for_file: lines_longer_than_80_chars
1+
// ignore_for_file: lines_longer_than_80_chars, cascade_invocations
22

33
import 'package:mocktail/mocktail.dart';
44
import 'package:stream_chat/stream_chat.dart';
@@ -5095,4 +5095,166 @@ void main() {
50955095
expect(channel.canUpdateChannel, false);
50965096
});
50975097
});
5098+
5099+
group('Channel State Validation and Cooldown', () {
5100+
late final client = MockStreamChatClient();
5101+
const channelId = 'test-channel-id';
5102+
const channelType = 'test-channel-type';
5103+
5104+
setUpAll(() {
5105+
// detached loggers
5106+
when(() => client.detachedLogger(any())).thenAnswer((invocation) {
5107+
final name = invocation.positionalArguments.first;
5108+
return _createLogger(name);
5109+
});
5110+
5111+
final retryPolicy = RetryPolicy(
5112+
shouldRetry: (_, __, ___) => false,
5113+
delayFactor: Duration.zero,
5114+
);
5115+
when(() => client.retryPolicy).thenReturn(retryPolicy);
5116+
5117+
// fake clientState
5118+
final clientState = FakeClientState();
5119+
when(() => client.state).thenReturn(clientState);
5120+
5121+
// client logger
5122+
when(() => client.logger).thenReturn(_createLogger('mock-client-logger'));
5123+
});
5124+
5125+
group('Non-initialized channel state validation', () {
5126+
test(
5127+
'should throw StateError when accessing cooldown on non-initialized channel',
5128+
() {
5129+
final channel = Channel(client, channelType, channelId);
5130+
expect(() => channel.cooldown, throwsA(isA<StateError>()));
5131+
},
5132+
);
5133+
5134+
test(
5135+
'should throw StateError when accessing getRemainingCooldown on non-initialized channel',
5136+
() {
5137+
final channel = Channel(client, channelType, channelId);
5138+
expect(channel.getRemainingCooldown, throwsA(isA<StateError>()));
5139+
},
5140+
);
5141+
5142+
test(
5143+
'should throw StateError when accessing cooldownStream on non-initialized channel',
5144+
() {
5145+
final channel = Channel(client, channelType, channelId);
5146+
expect(() => channel.cooldownStream, throwsA(isA<StateError>()));
5147+
},
5148+
);
5149+
});
5150+
5151+
group('Initialized channel cooldown functionality', () {
5152+
late Channel channel;
5153+
5154+
setUp(() {
5155+
final channelState = _generateChannelState(channelId, channelType);
5156+
channel = Channel.fromState(client, channelState);
5157+
});
5158+
5159+
tearDown(() => channel.dispose());
5160+
5161+
test(
5162+
'should return default cooldown value of 0 for initialized channel',
5163+
() => expect(channel.cooldown, equals(0)),
5164+
);
5165+
5166+
test('should return custom cooldown value when set in channel model', () {
5167+
final channelWithCooldown = ChannelModel(
5168+
id: channelId,
5169+
type: channelType,
5170+
cooldown: 30,
5171+
);
5172+
5173+
final stateWithCooldown = ChannelState(channel: channelWithCooldown);
5174+
final testChannel = Channel.fromState(client, stateWithCooldown);
5175+
addTearDown(testChannel.dispose);
5176+
5177+
expect(testChannel.cooldown, equals(30));
5178+
});
5179+
5180+
test('should return 0 remaining cooldown when no cooldown is set', () {
5181+
expect(channel.getRemainingCooldown(), equals(0));
5182+
});
5183+
5184+
test('should return cooldown stream with default value', () {
5185+
expectLater(channel.cooldownStream.take(1), emits(0));
5186+
});
5187+
});
5188+
5189+
group('Disposed channel state validation', () {
5190+
late Channel channel;
5191+
5192+
setUp(() {
5193+
final channelState = _generateChannelState(channelId, channelType);
5194+
channel = Channel.fromState(client, channelState);
5195+
});
5196+
5197+
test(
5198+
'should throw StateError when accessing cooldown after disposal',
5199+
() {
5200+
// First verify it works when initialized
5201+
expect(channel.cooldown, equals(0));
5202+
5203+
// Dispose the channel
5204+
channel.dispose();
5205+
5206+
// Now accessing cooldown should throw
5207+
expect(() => channel.cooldown, throwsA(isA<StateError>()));
5208+
},
5209+
);
5210+
5211+
test(
5212+
'should throw StateError when accessing getRemainingCooldown after disposal',
5213+
() {
5214+
// First verify it works when initialized
5215+
expect(channel.getRemainingCooldown(), equals(0));
5216+
5217+
// Dispose the channel
5218+
channel.dispose();
5219+
5220+
// Now accessing getRemainingCooldown should throw
5221+
expect(channel.getRemainingCooldown, throwsA(isA<StateError>()));
5222+
},
5223+
);
5224+
5225+
test(
5226+
'should throw StateError when accessing cooldownStream after disposal',
5227+
() {
5228+
// First verify it works when initialized
5229+
expectLater(channel.cooldownStream.take(1), emits(0));
5230+
5231+
// Dispose the channel
5232+
channel.dispose();
5233+
5234+
// Now accessing cooldownStream should throw
5235+
expect(() => channel.cooldownStream, throwsA(isA<StateError>()));
5236+
},
5237+
);
5238+
5239+
test(
5240+
'should handle race condition scenario - initialization then quick disposal',
5241+
() {
5242+
// This test simulates the race condition that was causing the production crash
5243+
final channelState = _generateChannelState(channelId, channelType);
5244+
final raceChannel = Channel.fromState(client, channelState);
5245+
5246+
// Verify it works initially
5247+
expect(raceChannel.cooldown, equals(0));
5248+
5249+
// Simulate quick disposal (like what happens with rapid navigation)
5250+
raceChannel.dispose();
5251+
5252+
// This should throw StateError instead of crashing with null check operator
5253+
expect(() => raceChannel.cooldown, throwsA(isA<StateError>()));
5254+
5255+
expect(raceChannel.getRemainingCooldown, throwsA(isA<StateError>()));
5256+
},
5257+
);
5258+
});
5259+
});
50985260
}

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
- Fixed context menu being truncated and scrollable on web when there was enough space to display it
66
fully. [[#2317]](https://github.com/GetStream/stream-chat-flutter/issues/2317)
7+
- Fixed `StreamMessageInput` cooldown resume error if channel state is not yet initialized.
8+
[[#2338]](https://github.com/GetStream/stream-chat-flutter/issues/2338)
79

810
✅ Added
911

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,7 @@ class StreamMessageInputState extends State<StreamMessageInput>
571571
final config = StreamChatConfiguration.of(context);
572572

573573
// Resumes the cooldown if the channel has currently an active cooldown.
574-
if (!_isEditing) {
574+
if (!_isEditing && channel.state != null) {
575575
_effectiveController.startCooldown(channel.getRemainingCooldown());
576576
}
577577

0 commit comments

Comments
 (0)