Skip to content

Commit 2165511

Browse files
feat: expose connectionState stream on WebSocketLink and GraphQLClient
Expose the underlying SocketClient's connectionState stream at the WebSocketLink and GraphQLClient level, allowing users to detect when a subscription connection is lost or restored. This enables refetching stale data after reconnection. Fixes #1188 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 822a903 commit 2165511

3 files changed

Lines changed: 124 additions & 0 deletions

File tree

packages/graphql/lib/src/graphql_client.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:async';
33

44
import 'package:graphql/src/core/core.dart';
55
import 'package:graphql/src/cache/cache.dart';
6+
import 'package:graphql/src/links/websocket_link/websocket_link.dart';
67

78
import 'package:graphql/src/core/fetch_more.dart';
89

@@ -52,6 +53,30 @@ class GraphQLClient implements GraphQLDataProxy {
5253

5354
late final QueryManager queryManager;
5455

56+
/// Stream of [SocketConnectionState] changes for the underlying WebSocket connection.
57+
///
58+
/// Returns `null` if the [link] is not a [WebSocketLink] or if the socket client
59+
/// has not been initialized yet.
60+
///
61+
/// This is useful for detecting when a subscription connection is lost or restored,
62+
/// so that data can be refetched to ensure freshness.
63+
///
64+
/// Example usage:
65+
/// ```dart
66+
/// client.connectionState?.listen((state) {
67+
/// if (state == SocketConnectionState.notConnected) {
68+
/// // connection lost — refetch queries to get fresh data
69+
/// }
70+
/// });
71+
/// ```
72+
Stream<SocketConnectionState>? get connectionState {
73+
final l = link;
74+
if (l is WebSocketLink) {
75+
return l.connectionState;
76+
}
77+
return null;
78+
}
79+
5580
/// Create a copy of the client with the provided information.
5681
GraphQLClient copyWith({
5782
Link? link,

packages/graphql/lib/src/links/websocket_link/websocket_link.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ class WebSocketLink extends Link {
4747

4848
SocketClient? get getSocketClient => _socketClient;
4949

50+
/// Stream of [SocketConnectionState] changes for the underlying WebSocket connection.
51+
///
52+
/// Useful for detecting when a subscription connection is lost or restored.
53+
/// Returns `null` if the socket client has not been initialized yet.
54+
///
55+
/// Example usage:
56+
/// ```dart
57+
/// final wsLink = WebSocketLink('ws://example.com/graphql');
58+
/// wsLink.connectionState?.listen((state) {
59+
/// if (state == SocketConnectionState.notConnected) {
60+
/// // connection lost, refetch data
61+
/// }
62+
/// });
63+
/// ```
64+
Stream<SocketConnectionState>? get connectionState =>
65+
_socketClient?.connectionState;
66+
5067
/// Disposes the underlying socket client explicitly. Only use this, if you want to disconnect from
5168
/// the current server in favour of another one. If that's the case, create a new [WebSocketLink] instance.
5269
Future<void> dispose() async {

packages/graphql/test/websocket_test.dart

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,88 @@ Future<void> main() async {
917917
*/
918918
});
919919

920+
group('WebSocketLink connectionState', () {
921+
test('exposes connectionState from underlying SocketClient', () async {
922+
final wsLink = WebSocketLink(
923+
wsUrl,
924+
config: SocketClientConfig(
925+
delayBetweenReconnectionAttempts: const Duration(milliseconds: 1),
926+
initialPayload: {'protocol': GraphQLProtocol.graphqlWs},
927+
),
928+
);
929+
930+
// Before connecting, connectionState is null
931+
expect(wsLink.connectionState, isNull);
932+
933+
// Trigger connection by calling connectOrReconnect
934+
wsLink.connectOrReconnect();
935+
936+
// Now connectionState should be available
937+
expect(wsLink.connectionState, isNotNull);
938+
939+
// BehaviorSubject emits current state first (notConnected),
940+
// then transitions through connecting -> connected
941+
await expectLater(
942+
wsLink.connectionState!,
943+
emitsInOrder([
944+
SocketConnectionState.notConnected,
945+
SocketConnectionState.connecting,
946+
SocketConnectionState.connected,
947+
]),
948+
);
949+
950+
await wsLink.dispose();
951+
});
952+
953+
test('GraphQLClient exposes connectionState when using WebSocketLink',
954+
() async {
955+
final wsLink = WebSocketLink(
956+
wsUrl,
957+
config: SocketClientConfig(
958+
delayBetweenReconnectionAttempts: const Duration(milliseconds: 1),
959+
initialPayload: {'protocol': GraphQLProtocol.graphqlWs},
960+
),
961+
);
962+
963+
final client = GraphQLClient(
964+
link: wsLink,
965+
cache: GraphQLCache(),
966+
);
967+
968+
// Before connecting, connectionState is null
969+
expect(client.connectionState, isNull);
970+
971+
// Trigger connection
972+
wsLink.connectOrReconnect();
973+
974+
// Now connectionState should be available
975+
expect(client.connectionState, isNotNull);
976+
977+
// BehaviorSubject emits current state first (notConnected),
978+
// then transitions through connecting -> connected
979+
await expectLater(
980+
client.connectionState!,
981+
emitsInOrder([
982+
SocketConnectionState.notConnected,
983+
SocketConnectionState.connecting,
984+
SocketConnectionState.connected,
985+
]),
986+
);
987+
988+
await wsLink.dispose();
989+
});
990+
991+
test('GraphQLClient connectionState is null for non-WebSocket links', () {
992+
final mockLink = MockLink();
993+
final client = GraphQLClient(
994+
link: mockLink,
995+
cache: GraphQLCache(),
996+
);
997+
998+
expect(client.connectionState, isNull);
999+
});
1000+
});
1001+
9201002
group('SocketClient with dynamic payload', () {
9211003
late SocketClient socketClient;
9221004

0 commit comments

Comments
 (0)