Skip to content

Commit 836ed6f

Browse files
Merge pull request #1 from icnahom/pr/brunovsiqueira/1340
refactor(supabase_flutter): expose pendingLifecycleOperation for testing
2 parents 60f9372 + 09840e8 commit 836ed6f

2 files changed

Lines changed: 97 additions & 84 deletions

File tree

packages/supabase_flutter/lib/src/supabase.dart

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ final _log = Logger('supabase.supabase_flutter');
3434
/// See also:
3535
///
3636
/// * [SupabaseAuth]
37-
class Supabase with WidgetsBindingObserver {
37+
class Supabase {
3838
/// Gets the current supabase instance.
3939
///
4040
/// An [AssertionError] is thrown if supabase isn't initialized yet.
@@ -149,8 +149,6 @@ class Supabase with WidgetsBindingObserver {
149149
Supabase._();
150150
static final Supabase _instance = Supabase._();
151151

152-
static WidgetsBinding? get _widgetsBindingInstance => WidgetsBinding.instance;
153-
154152
bool _isInitialized = false;
155153

156154
/// Whether the Supabase instance has been initialized. Useful for debugging.
@@ -168,6 +166,9 @@ class Supabase with WidgetsBindingObserver {
168166
/// Wraps the `recoverSession()` call so that it can be terminated when `dispose()` is called
169167
late CancelableOperation _restoreSessionCancellableOperation;
170168

169+
// Listener for app lifecycle events to handle Realtime reconnection.
170+
AppLifecycleListener? _lifecycleListener;
171+
171172
/// Serial queue for lifecycle operations (connect/disconnect). Each event
172173
/// appends via `.then()` so operations never overlap.
173174
Future<void> _pendingLifecycleOperation = Future.value();
@@ -186,7 +187,7 @@ class Supabase with WidgetsBindingObserver {
186187
_logSubscription?.cancel();
187188
client.dispose();
188189
_instance._supabaseAuth?.dispose();
189-
_widgetsBindingInstance?.removeObserver(this);
190+
_lifecycleListener?.dispose();
190191
_isInitialized = false;
191192
}
192193

@@ -223,23 +224,28 @@ class Supabase with WidgetsBindingObserver {
223224
disposePreviousClient();
224225
markClientToDispose(client);
225226
}
226-
_widgetsBindingInstance?.addObserver(this);
227+
228+
_setupLifecycleListener();
229+
227230
_isInitialized = true;
228231
}
229232

230-
@override
231-
void didChangeAppLifecycleState(AppLifecycleState state) {
232-
switch (state) {
233-
case AppLifecycleState.resumed:
234-
case AppLifecycleState.paused:
235-
case AppLifecycleState.detached:
236-
_targetLifecycleState = state;
237-
_pendingLifecycleOperation = _pendingLifecycleOperation
238-
.then((_) => _processLifecycle(state))
239-
.catchError((_) {});
240-
default:
241-
break;
242-
}
233+
void _setupLifecycleListener() {
234+
_lifecycleListener = AppLifecycleListener(
235+
onStateChange: (state) {
236+
switch (state) {
237+
case AppLifecycleState.resumed:
238+
case AppLifecycleState.paused:
239+
case AppLifecycleState.detached:
240+
_targetLifecycleState = state;
241+
_pendingLifecycleOperation = _pendingLifecycleOperation
242+
.then((_) => _processLifecycle(state))
243+
.catchError((_) {});
244+
default:
245+
break;
246+
}
247+
},
248+
);
243249
}
244250

245251
/// Processes a lifecycle state change. Operations are serialized via

packages/supabase_flutter/test/lifecycle_test.dart

Lines changed: 73 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import 'widget_test_stubs.dart';
1111
/// implementing all [StreamChannelMixin] methods.
1212
class FakeWebSocketChannel extends Fake implements WebSocketChannel {
1313
final Completer<void> readyCompleter;
14-
final FakeWebSocketSink fakeSink = FakeWebSocketSink();
14+
late final FakeWebSocketSink fakeSink = FakeWebSocketSink(_streamController);
1515
final StreamController<dynamic> _streamController =
1616
StreamController<dynamic>.broadcast();
1717

@@ -35,17 +35,23 @@ class FakeWebSocketChannel extends Fake implements WebSocketChannel {
3535
}
3636

3737
class FakeWebSocketSink extends Fake implements WebSocketSink {
38+
final StreamController<dynamic> _streamController;
3839
final Completer<void> _doneCompleter = Completer<void>();
3940
int? closeCode;
4041
String? closeReason;
4142

43+
FakeWebSocketSink(this._streamController);
44+
4245
@override
4346
Future<void> close([int? code, String? reason]) async {
4447
closeCode = code;
4548
closeReason = reason;
4649
if (!_doneCompleter.isCompleted) {
4750
_doneCompleter.complete();
4851
}
52+
if (!_streamController.isClosed) {
53+
await _streamController.close();
54+
}
4955
}
5056

5157
@override
@@ -68,22 +74,10 @@ void main() {
6874
const supabaseKey = '';
6975

7076
group('Lifecycle realtime reconnection', () {
71-
late List<FakeWebSocketChannel> createdChannels;
7277
late List<Completer<void>> readyCompleters;
7378

74-
setUp(() {
75-
mockAppLink();
76-
createdChannels = [];
79+
setUp(() async {
7780
readyCompleters = [];
78-
});
79-
80-
tearDown(() async {
81-
try {
82-
await Supabase.instance.dispose();
83-
} catch (_) {}
84-
});
85-
86-
Future<void> initWithMockTransport() async {
8781
await Supabase.initialize(
8882
url: supabaseUrl,
8983
anonKey: supabaseKey,
@@ -96,13 +90,17 @@ void main() {
9690
transport: (url, headers) {
9791
final completer = Completer<void>();
9892
readyCompleters.add(completer);
99-
final channel = FakeWebSocketChannel(readyCompleter: completer);
100-
createdChannels.add(channel);
101-
return channel;
93+
return FakeWebSocketChannel(readyCompleter: completer);
10294
},
10395
),
10496
);
105-
}
97+
});
98+
99+
tearDown(() async {
100+
try {
101+
await Supabase.instance.dispose();
102+
} catch (_) {}
103+
});
106104

107105
/// Helper: call connect() and immediately complete the
108106
/// ready future created by the transport factory.
@@ -114,11 +112,19 @@ void main() {
114112
await future;
115113
}
116114

115+
// Helper: complete all pending ready futures to unblock connect()
116+
void completeReadyCompleters() async {
117+
for (final completer in readyCompleters) {
118+
if (!completer.isCompleted) completer.complete();
119+
}
120+
readyCompleters.clear();
121+
}
122+
117123
test(
118124
'paused then resumed waits for disconnect '
119125
'before reconnecting', () async {
120-
await initWithMockTransport();
121126
final realtime = Supabase.instance.client.realtime;
127+
final binding = TestWidgetsFlutterBinding.instance;
122128

123129
// Add a channel so onResumed() processes reconnection
124130
realtime.channel('test');
@@ -128,123 +134,124 @@ void main() {
128134
expect(realtime.connState, SocketStates.open);
129135

130136
// paused → triggers disconnect
131-
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.paused);
132-
await Future<void>.delayed(Duration.zero);
137+
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
138+
binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
139+
binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
133140

134141
// resumed → waits for disconnect, then reconnects
135-
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.resumed);
136-
await Future<void>.delayed(Duration.zero);
142+
binding.handleAppLifecycleStateChanged(AppLifecycleState.detached);
143+
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
137144

138145
// Complete any pending ready futures (reconnect)
139-
for (final c in readyCompleters) {
140-
if (!c.isCompleted) c.complete();
141-
}
142-
await Future<void>.delayed(const Duration(milliseconds: 100));
146+
completeReadyCompleters();
147+
await pumpEventQueue();
143148

144149
expect(realtime.connState, SocketStates.open);
150+
expect(realtime.conn, isNotNull);
145151
});
146152

147153
test(
148154
'paused → resumed → inactive → resumed '
149155
'still reconnects', () async {
150-
await initWithMockTransport();
151156
final realtime = Supabase.instance.client.realtime;
157+
final binding = TestWidgetsFlutterBinding.instance;
152158

153159
realtime.channel('test');
154160

155161
await connectAndReady(realtime);
156162
expect(realtime.connState, SocketStates.open);
157163

158164
// paused → starts disconnect
159-
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.paused);
160-
await Future<void>.delayed(Duration.zero);
165+
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
166+
binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
167+
binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
161168

162169
// first resumed → queues reconnect after disconnect
163-
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.resumed);
164-
await Future<void>.delayed(Duration.zero);
170+
binding.handleAppLifecycleStateChanged(AppLifecycleState.detached);
171+
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
165172

166173
// inactive → does nothing (not a tracked lifecycle state)
167-
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.inactive);
168-
await Future<void>.delayed(Duration.zero);
174+
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
169175

170176
// second resumed → queues another reconnect (idempotent)
171-
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.resumed);
172-
await Future<void>.delayed(Duration.zero);
177+
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
173178

174179
// Complete all pending ready futures
175-
for (final c in readyCompleters) {
176-
if (!c.isCompleted) c.complete();
177-
}
178-
await Future<void>.delayed(const Duration(milliseconds: 100));
180+
completeReadyCompleters();
181+
await pumpEventQueue();
179182

180183
// Should have reconnected, not stuck disconnecting
181-
expect(realtime.connState, isNot(SocketStates.disconnecting));
184+
expect(realtime.connState, SocketStates.open);
182185
expect(realtime.conn, isNotNull);
183186
});
184187

185188
test(
186189
'rapid paused → resumed → paused → resumed '
187190
'ends up connected', () async {
188-
await initWithMockTransport();
189191
final realtime = Supabase.instance.client.realtime;
192+
final binding = TestWidgetsFlutterBinding.instance;
190193

191194
realtime.channel('test');
192195

193196
await connectAndReady(realtime);
194197
expect(realtime.connState, SocketStates.open);
195198

196199
// Rapid lifecycle flapping
197-
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.paused);
198-
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.resumed);
199-
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.paused);
200-
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.resumed);
200+
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
201+
binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
202+
binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
201203

202-
// Complete all pending ready futures as they appear
203-
for (final c in readyCompleters) {
204-
if (!c.isCompleted) c.complete();
205-
}
206-
await Future<void>.delayed(const Duration(milliseconds: 200));
204+
binding.handleAppLifecycleStateChanged(AppLifecycleState.detached);
205+
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
207206

208-
// Also complete any new ready futures created during processing
209-
for (final c in readyCompleters) {
210-
if (!c.isCompleted) c.complete();
211-
}
212-
await Future<void>.delayed(const Duration(milliseconds: 200));
207+
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
208+
binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
209+
binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
210+
211+
binding.handleAppLifecycleStateChanged(AppLifecycleState.detached);
212+
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
213+
214+
// Complete all pending ready futures as they appear
215+
completeReadyCompleters();
216+
await pumpEventQueue();
213217

214218
expect(realtime.connState, SocketStates.open);
219+
expect(realtime.conn, isNotNull);
215220
});
216221

217222
test(
218223
'resumed then paused before connect completes '
219224
'cancels reconnect', () async {
220-
await initWithMockTransport();
221225
final realtime = Supabase.instance.client.realtime;
226+
final binding = TestWidgetsFlutterBinding.instance;
222227

223228
realtime.channel('test');
224229

225230
await connectAndReady(realtime);
226231
expect(realtime.connState, SocketStates.open);
227232

228233
// paused → triggers disconnect
229-
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.paused);
230-
await Future<void>.delayed(Duration.zero);
234+
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
235+
binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
236+
binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
231237

232238
// resumed → queues reconnect
233-
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.resumed);
234-
await Future<void>.delayed(Duration.zero);
239+
binding.handleAppLifecycleStateChanged(AppLifecycleState.detached);
240+
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
235241

236242
// paused again before connect completes → should cancel the
237243
// reconnect (target state is now paused)
238-
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.paused);
244+
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
245+
binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
246+
binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
239247

240248
// Complete all pending ready futures
241-
for (final c in readyCompleters) {
242-
if (!c.isCompleted) c.complete();
243-
}
244-
await Future<void>.delayed(const Duration(milliseconds: 200));
249+
completeReadyCompleters();
250+
await pumpEventQueue();
245251

246252
// Should be disconnected since the last event was paused
247253
expect(realtime.connState, SocketStates.disconnected);
254+
expect(realtime.conn, isNull);
248255
});
249256
});
250257
}

0 commit comments

Comments
 (0)