Skip to content

Commit 1475f86

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 1475f86

3 files changed

Lines changed: 150 additions & 0 deletions

File tree

packages/graphql/lib/src/graphql_client.dart

Lines changed: 44 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

@@ -23,6 +24,7 @@ class GraphQLClient implements GraphQLDataProxy {
2324
/// Constructs a [GraphQLClient] given a [Link] and a [Cache].
2425
GraphQLClient({
2526
required this.link,
27+
this.websocketLink,
2628
required this.cache,
2729
DefaultPolicies? defaultPolicies,
2830
bool alwaysRebroadcast = false,
@@ -47,14 +49,55 @@ class GraphQLClient implements GraphQLDataProxy {
4749
/// The [Link] over which GraphQL documents will be resolved into a [Response].
4850
final Link link;
4951

52+
/// Optional [WebSocketLink] for subscription-aware clients that compose [link]
53+
/// with helpers like `Link.split(...)`.
54+
final WebSocketLink? websocketLink;
55+
5056
/// The initial [Cache] to use in the data store.
5157
final GraphQLCache cache;
5258

5359
late final QueryManager queryManager;
5460

61+
/// Stream of [SocketConnectionState] changes for the underlying WebSocket connection.
62+
///
63+
/// Returns `null` if no [WebSocketLink] is configured or if the socket client
64+
/// has not been initialized yet.
65+
///
66+
/// This is useful for detecting when a subscription connection is lost or restored,
67+
/// so that data can be refetched to ensure freshness.
68+
///
69+
/// Example usage:
70+
/// ```dart
71+
/// final wsLink = WebSocketLink('ws://example.com/graphql');
72+
/// final client = GraphQLClient(
73+
/// link: Link.split((request) => request.isSubscription, wsLink, httpLink),
74+
/// cache: GraphQLCache(),
75+
/// websocketLink: wsLink,
76+
/// );
77+
///
78+
/// client.connectionState?.listen((state) {
79+
/// if (state == SocketConnectionState.notConnected) {
80+
/// // connection lost — refetch queries to get fresh data
81+
/// }
82+
/// });
83+
/// ```
84+
Stream<SocketConnectionState>? get connectionState {
85+
final configuredLink = websocketLink;
86+
if (configuredLink != null) {
87+
return configuredLink.connectionState;
88+
}
89+
90+
final l = link;
91+
if (l is WebSocketLink) {
92+
return l.connectionState;
93+
}
94+
return null;
95+
}
96+
5597
/// Create a copy of the client with the provided information.
5698
GraphQLClient copyWith({
5799
Link? link,
100+
WebSocketLink? websocketLink,
58101
GraphQLCache? cache,
59102
DefaultPolicies? defaultPolicies,
60103
bool? alwaysRebroadcast,
@@ -65,6 +108,7 @@ class GraphQLClient implements GraphQLDataProxy {
65108
}) {
66109
return GraphQLClient(
67110
link: link ?? this.link,
111+
websocketLink: websocketLink ?? this.websocketLink,
68112
cache: cache ?? this.cache,
69113
defaultPolicies: defaultPolicies ?? this.defaultPolicies,
70114
alwaysRebroadcast: alwaysRebroadcast ?? queryManager.alwaysRebroadcast,

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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,95 @@ 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(
954+
'GraphQLClient exposes connectionState when using Link.split with a WebSocketLink',
955+
() async {
956+
final wsLink = WebSocketLink(
957+
wsUrl,
958+
config: SocketClientConfig(
959+
delayBetweenReconnectionAttempts: const Duration(milliseconds: 1),
960+
initialPayload: {'protocol': GraphQLProtocol.graphqlWs},
961+
),
962+
);
963+
final mockLink = MockLink();
964+
965+
final client = GraphQLClient(
966+
link: Link.split(
967+
(request) => request.isSubscription,
968+
wsLink,
969+
mockLink,
970+
),
971+
websocketLink: wsLink,
972+
cache: GraphQLCache(),
973+
);
974+
975+
// Before connecting, connectionState is null
976+
expect(client.connectionState, isNull);
977+
978+
// Trigger connection
979+
wsLink.connectOrReconnect();
980+
981+
// Now connectionState should be available
982+
expect(client.connectionState, isNotNull);
983+
984+
// BehaviorSubject emits current state first (notConnected),
985+
// then transitions through connecting -> connected
986+
await expectLater(
987+
client.connectionState!,
988+
emitsInOrder([
989+
SocketConnectionState.notConnected,
990+
SocketConnectionState.connecting,
991+
SocketConnectionState.connected,
992+
]),
993+
);
994+
995+
await wsLink.dispose();
996+
});
997+
998+
test('GraphQLClient connectionState is null for non-WebSocket links', () {
999+
final mockLink = MockLink();
1000+
final client = GraphQLClient(
1001+
link: mockLink,
1002+
cache: GraphQLCache(),
1003+
);
1004+
1005+
expect(client.connectionState, isNull);
1006+
});
1007+
});
1008+
9201009
group('SocketClient with dynamic payload', () {
9211010
late SocketClient socketClient;
9221011

0 commit comments

Comments
 (0)