Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions packages/graphql/CANCELLATION_USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
# Cancellation Support

The graphql package now supports cancellation of `query` and `mutate` operations. This allows you to cancel in-flight operations when they're no longer needed (e.g., when a user navigates away or cancels an action).

## Basic Usage

### Option 1: Using CancellationToken directly

```dart
import 'package:graphql/client.dart';

// Create a cancellation token
final cancellationToken = CancellationToken();

// Execute a query with the cancellation token
final resultFuture = client.query(
QueryOptions(
document: gql(r'''
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
'''),
variables: {'id': '123'},
cancellationToken: cancellationToken,
),
);

// Later, if you need to cancel the operation:
cancellationToken.cancel();

// The resultFuture will complete with a QueryResult containing
// a CancelledException
try {
final result = await resultFuture;
print('Result: ${result.data}');
} catch (e) {
if (e is OperationException &&
e.linkException is CancelledException) {
print('Operation was cancelled');
}
}

// Don't forget to dispose the token when done
cancellationToken.dispose();
```

### Option 2: Using the convenience methods

The package provides `queryCancellable` and `mutateCancellable` convenience methods that automatically create a `CancellationToken` for you:

```dart
import 'package:graphql/client.dart';

// Execute a cancellable query
final operation = client.queryCancellable(
QueryOptions(
document: gql(r'''
query GetPosts {
posts {
id
title
content
}
}
'''),
),
);

// Access the result future
final resultFuture = operation.result;

// Cancel the operation if needed
operation.cancel();

// Handle the result
try {
final result = await resultFuture;
print('Posts: ${result.data}');
} catch (e) {
if (e is OperationException &&
e.linkException is CancelledException) {
print('Query was cancelled');
}
}
```

## Mutation Example

```dart
import 'package:graphql/client.dart';

// Execute a cancellable mutation
final operation = client.mutateCancellable(
MutationOptions(
document: gql(r'''
mutation CreatePost($title: String!, $content: String!) {
createPost(title: $title, content: $content) {
id
title
content
}
}
'''),
variables: {
'title': 'New Post',
'content': 'This is the content',
},
),
);

// Cancel if user navigates away
// operation.cancel();

try {
final result = await operation.result;
print('Created post: ${result.data}');
} catch (e) {
if (e is OperationException &&
e.linkException is CancelledException) {
print('Mutation was cancelled');
}
}
```

## Use Cases

### 1. User Navigation
Cancel pending requests when a user navigates away from a page:

```dart
class MyWidget extends StatefulWidget {
@override
_MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
CancellableOperation<QueryResult>? _operation;

@override
void initState() {
super.initState();
_operation = client.queryCancellable(
QueryOptions(document: gql('query { ... }')),
);
}

@override
void dispose() {
// Cancel the operation when the widget is disposed
_operation?.cancel();
super.dispose();
}

@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _operation?.result,
builder: (context, snapshot) {
// Build your UI
},
);
}
}
```

### 2. Search Debouncing
Cancel previous search requests when a new one is initiated:

```dart
CancellableOperation<QueryResult>? _searchOperation;

void search(String query) {
// Cancel the previous search
_searchOperation?.cancel();

// Start a new search
_searchOperation = client.queryCancellable(
QueryOptions(
document: gql(r'''
query Search($query: String!) {
search(query: $query) {
id
name
}
}
'''),
variables: {'query': query},
),
);

_searchOperation!.result.then((result) {
// Handle search results
}).catchError((e) {
if (e is OperationException &&
e.linkException is CancelledException) {
// Search was cancelled, ignore
return;
}
// Handle other errors
});
}
```

### 3. Timeout with Custom Message
Combine with timeouts for better control:

```dart
final cancellationToken = CancellationToken();

// Set a custom timeout
Timer(Duration(seconds: 10), () {
cancellationToken.cancel();
});

final result = await client.query(
QueryOptions(
document: gql('query { ... }'),
cancellationToken: cancellationToken,
),
);
```

## Important Notes

1. **Disposing CancellationTokens**: When using `CancellationToken` directly, remember to call `dispose()` when you're done with it to clean up resources.

2. **Convenience Methods**: The `queryCancellable` and `mutateCancellable` methods automatically create and manage `CancellationToken` instances for you.

3. **Error Handling**: Cancelled operations will complete with an `OperationException` containing a `CancelledException` as the `linkException`.

4. **Network Cleanup**: Cancelling an operation will attempt to cancel the underlying network request, but depending on the transport layer and server, the request may still complete on the server side.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't really see how it cancels the underlying request? can you explain?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are right i haven't noticed that the underlying http request wasn't actually cancelled, only the subscription was cancelled.

I now added true HTTP request cancellation to the graphql package.

  • Created custom HttpLink with platform-specific cancellation support

  • Web: Uses XMLHttpRequest.abort() → shows "(canceled)" in Chrome DevTools

  • IO (Linux/Desktop/Mobile): Uses HttpClient.close(force: true) + request.abort() → terminates TCP connection

  • Added CancellationContextEntry to pass the token through the Link chain

  • Added web package dependency (replaces deprecated dart:html)

  • Added additional tests for http cancellation

Result:

operation.cancel() now actually aborts the underlying HTTP request

Not just ignoring the response — the network connection is terminated

I verified it, in chrome and linux the http request gets actually cancelled


5. **Cache Updates**: Cancelled operations will not update the cache with any results they may have received before cancellation.
12 changes: 10 additions & 2 deletions packages/graphql/lib/src/core/_base_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:gql/ast.dart';

import 'package:graphql/client.dart';
import 'package:graphql/src/core/result_parser.dart';
import 'package:graphql/src/core/cancellation_token.dart';
import 'package:meta/meta.dart';

TParsed unprovidedParserFn<TParsed>(_d) => throw UnimplementedError(
Expand All @@ -24,6 +25,7 @@ abstract class BaseOptions<TParsed extends Object?> {
CacheRereadPolicy? cacheRereadPolicy,
this.optimisticResult,
this.queryRequestTimeout,
this.cancellationToken,
}) : policies = Policies(
fetch: fetchPolicy,
error: errorPolicy,
Expand Down Expand Up @@ -64,6 +66,12 @@ abstract class BaseOptions<TParsed extends Object?> {
/// Override default query timeout
final Duration? queryRequestTimeout;

/// Token that can be used to cancel the operation.
///
/// When the token is cancelled, the operation will be terminated and complete
/// with a [CancelledException].
final CancellationToken? cancellationToken;

// TODO consider inverting this relationship
/// Resolve these options into a request
Request get asRequest => Request(
Expand All @@ -85,11 +93,11 @@ abstract class BaseOptions<TParsed extends Object?> {
context,
parserFn,
queryRequestTimeout,
cancellationToken,
];

OperationType get type {
final definitions =
document.definitions.whereType<OperationDefinitionNode>().toList();
final definitions = document.definitions.whereType<OperationDefinitionNode>().toList();
if (operationName != null) {
definitions.removeWhere(
(node) => node.name!.value != operationName,
Expand Down
81 changes: 81 additions & 0 deletions packages/graphql/lib/src/core/cancellation_token.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import 'dart:async';

/// A token that can be used to cancel an in-flight GraphQL operation.
///
/// Create a [CancellationToken] and pass it to operations like [GraphQLClient.query]
/// or [GraphQLClient.mutate] to enable cancellation of those operations.
///
/// {@tool snippet}
/// Basic usage
///
/// ```dart
/// final cancellationToken = CancellationToken();
///
/// // Start a query
/// final resultFuture = client.query(
/// QueryOptions(
/// document: gql('query { ... }'),
/// cancellationToken: cancellationToken,
/// ),
/// );
///
/// // Later, cancel the operation
/// cancellationToken.cancel();
///
/// // The resultFuture will complete with a QueryResult containing
/// // a CancelledException
/// ```
/// {@end-tool}
class CancellationToken {
bool _isCancelled = false;
final _controller = StreamController<void>.broadcast();

/// Whether this token has been cancelled.
bool get isCancelled => _isCancelled;

/// A stream that emits when cancellation is requested.
Stream<void> get onCancel => _controller.stream;

/// Cancel the operation associated with this token.
///
/// This will cause any in-flight operation to be terminated and complete
/// with a [CancelledException].
///
/// Calling [cancel] multiple times has no additional effect.
void cancel() {
if (!_isCancelled) {
_isCancelled = true;
_controller.add(null);
}
}

/// Dispose of this token's resources.
///
/// After calling [dispose], this token should not be used again.
void dispose() {
_controller.close();
}
}

/// Result wrapper that includes both the result future and a cancellation token.
///
/// This allows you to cancel the operation if needed.
class CancellableOperation<T> {
/// The future that will complete with the operation result.
final Future<T> result;

/// The cancellation token that can be used to cancel this operation.
final CancellationToken cancellationToken;

CancellableOperation({
required this.result,
required this.cancellationToken,
});

/// Cancel this operation.
///
/// This is a convenience method that calls [CancellationToken.cancel].
void cancel() {
cancellationToken.cancel();
}
}
1 change: 1 addition & 0 deletions packages/graphql/lib/src/core/core.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export 'package:gql_exec/gql_exec.dart';
export 'package:gql_link/gql_link.dart';

export 'package:graphql/src/core/cancellation_token.dart';
export 'package:graphql/src/core/observable_query.dart';
export 'package:graphql/src/core/query_manager.dart';
export 'package:graphql/src/core/query_options.dart';
Expand Down
Loading