Cancel in-flight paginated fetches to prevent stale-write races

Rapidly switching filter chips, sort columns, or pagination on the devices
and notifications lists could overlap fetches, and an older response
landing after a newer one would silently overwrite the list with results
that no longer match the active query.

Thread an optional CancelToken into listDevices and listNotifications.
Each State now keeps one active token, cancels it on every new fetch and
on dispose, and bails from both success and catch branches when its token
has been superseded. DioExceptionType.cancel is silenced so internal
cancellations don't surface as user-visible errors. The notifications
list's prior `if (_isLoading) return;` guard is removed so the 1-min
periodic refresh is no longer skipped during user activity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-31 09:30:03 -04:00
co-authored by Claude Opus 4.7
parent d3b6c64e8b
commit 30df22df56
3 changed files with 28 additions and 6 deletions
+10 -2
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import '../model/device.dart';
@@ -35,6 +36,7 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
int _currentPage = 0;
bool _hasNextPage = false;
CancelToken? _fetchToken;
@override
void initState() {
@@ -61,6 +63,7 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
void dispose() {
routeObserver.unsubscribe(this);
_ownerDebounce?.cancel();
_fetchToken?.cancel();
_ownerController.dispose();
super.dispose();
}
@@ -74,6 +77,9 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
}
Future<void> _fetchPage(int page) async {
_fetchToken?.cancel();
final token = CancelToken();
_fetchToken = token;
setState(() {
_isLoading = _devices.isEmpty;
_error = null;
@@ -91,8 +97,9 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
sortAscending: _sortAscending,
offset: page * _pageSize,
limit: _pageSize + 1,
cancelToken: token,
);
if (!mounted) return;
if (!mounted || token != _fetchToken) return;
setState(() {
_currentPage = page;
_hasNextPage = results.length > _pageSize;
@@ -100,7 +107,8 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
if (!mounted || token != _fetchToken) return;
if (e is DioException && e.type == DioExceptionType.cancel) return;
setState(() {
_error = dioErrorToUserMessage(e);
_isLoading = false;
+10 -3
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import '../model/notification.dart' as oott_model;
@@ -45,6 +46,7 @@ class _NotificationsListState extends State<NotificationsList> with RouteAware {
bool _isLoading = false;
bool _hasNextPage = false;
String? _error;
CancelToken? _fetchToken;
@override
void initState() {
@@ -74,11 +76,14 @@ class _NotificationsListState extends State<NotificationsList> with RouteAware {
void dispose() {
routeObserver.unsubscribe(this);
_notificationTimer?.cancel();
_fetchToken?.cancel();
super.dispose();
}
Future<void> _fetchPage(int page) async {
if (_isLoading) return;
_fetchToken?.cancel();
final token = CancelToken();
_fetchToken = token;
setState(() {
_isLoading = true;
_error = null;
@@ -88,8 +93,9 @@ class _NotificationsListState extends State<NotificationsList> with RouteAware {
_filter.isNew,
page * _pageSize,
limit: _pageSize + 1,
cancelToken: token,
);
if (!mounted) return;
if (!mounted || token != _fetchToken) return;
setState(() {
_currentPage = page;
_hasNextPage = results.length > _pageSize;
@@ -97,7 +103,8 @@ class _NotificationsListState extends State<NotificationsList> with RouteAware {
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
if (!mounted || token != _fetchToken) return;
if (e is DioException && e.type == DioExceptionType.cancel) return;
setState(() {
_error = dioErrorToUserMessage(e);
_isLoading = false;
+8 -1
View File
@@ -142,6 +142,7 @@ class BackendAPI {
bool? sortAscending,
int? offset,
int? limit,
CancelToken? cancelToken,
}) async {
debugPrint('About to call /devices');
@@ -162,7 +163,11 @@ class BackendAPI {
params['page_limit'] = limit ?? _pageSize;
}
final response = await _dio.get('/devices', queryParameters: params);
final response = await _dio.get(
'/devices',
queryParameters: params,
cancelToken: cancelToken,
);
debugPrint('Received: ${response.data}');
@@ -257,6 +262,7 @@ class BackendAPI {
bool? isNew,
int offset, {
int? limit,
CancelToken? cancelToken,
}) async {
debugPrint('About to call /notifications');
@@ -268,6 +274,7 @@ class BackendAPI {
'page_offset': offset,
'page_limit': limit ?? _pageSize,
},
cancelToken: cancelToken,
);
debugPrint('Received: ${response.data}');