Merge pull request #1468 from Cyrix126/desktop_fee_form_refetch
What changed, and why it matters
This commit fixes a desktop wallet bug where the send-fee form would repeatedly re-fetch fee estimates on every screen rebuild, wasting resources and possibly flickering. It now caches the fee estimate and only retries if the previous fetch failed. There is no indication this is a security fix.
No security action required; treat as normal quality/performance improvement.
Security signals we found
No security-relevant signals present in diff or commit metadata
Change is a UI/UX performance and reliability improvement
Evidence from the diff
The change refactors DesktopSendFeeForm to store the wallet fee Future in a state variable (_fees) instead of calling ref.watch(…) inside the FutureBuilder during every build. It also listens to sendAmountProvider to keep that provider alive, and resets _fees to null when the Future completes with an error so a later rebuild can retry. Tests verify caching and retry-on-failure behavior.
Changed components
lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.darttest/pages_desktop_specific/wallet/desktop_send_fee_form_test.dartInspect captured patch +176 / −5
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../../../models/paymint/fee_object_model.dart';
import '../../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart';
import '../../../../providers/providers.dart';
import '../../../../providers/ui/fee_rate_type_state_provider.dart';
@@ -56,6 +57,7 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
];
late final CryptoCurrency cryptoCurrency;
+ Future<FeeObject>? _fees;
bool get isEth => cryptoCurrency is Ethereum;
@@ -106,6 +108,8 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
@override
Widget build(BuildContext context) {
+ // Keep the send amount alive for the fee estimates.
+ ref.listen(sendAmountProvider, (_, __) {});
final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom;
final locale = ref.watch(
localeServiceChangeNotifierProvider.select((value) => value.locale),
@@ -174,12 +178,15 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
padding: const EdgeInsets.all(10),
child: (feeSelectionResult?.$2 == null)
? FutureBuilder(
- future: ref.watch(
- pWallets.select(
- (value) => value.getWallet(widget.walletId).fees,
- ),
- ),
+ future: _fees ??= ref
+ .read(pWallets)
+ .getWallet(widget.walletId)
+ .fees,
builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.done &&
+ snapshot.hasError) {
+ _fees = null;
+ }
if (snapshot.connectionState == ConnectionState.done &&
snapshot.hasData) {
return DesktopFeeItem(
### test/pages_desktop_specific/wallet/desktop_send_fee_form_test.dart
@@ -0,0 +1,164 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/models/isar/models/blockchain_data/address.dart';
+import 'package:stackwallet/models/isar/stack_theme.dart';
+import 'package:stackwallet/models/paymint/fee_object_model.dart';
+import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart';
+import 'package:stackwallet/providers/global/wallets_provider.dart';
+import 'package:stackwallet/providers/wallet/desktop_fee_providers.dart';
+import 'package:stackwallet/themes/stack_colors.dart';
+import 'package:stackwallet/utilities/amount/amount.dart';
+import 'package:stackwallet/utilities/amount/amount_formatter.dart';
+import 'package:stackwallet/utilities/amount/amount_unit.dart';
+import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart';
+import 'package:stackwallet/wallets/isar/models/wallet_info.dart';
+import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart';
+import 'package:stackwallet/wallets/wallet/wallet.dart';
+import 'package:stackwallet/widgets/desktop/desktop_fee_dialog.dart';
+
+import '../../sample_data/theme_json.dart';
+import '../../wallets/support/xelis_test_fakes.dart';
+
+const _walletId = "wallet id";
+final _coin = Bitcoin(CryptoCurrencyNetwork.main);
+final _info = WalletInfo(
+ walletId: _walletId,
+ name: "Bitcoin wallet",
+ mainAddressType: AddressType.p2wpkh,
+ coinName: _coin.identifier,
+);
+
+/// Bitcoin wallet counting its fee rate fetches, the first [failingFetches]
+/// of which fail.
+class _FeeWallet implements Wallet<Bitcoin> {
+ _FeeWallet({this.failingFetches = 0});
+
+ final int failingFetches;
+ var feeFetches = 0;
+
+ @override
+ WalletInfo get info => _info;
+
+ @override
+ Future<FeeObject> get fees async {
+ feeFetches++;
+ if (feeFetches <= failingFetches) throw Exception("node unreachable");
+ return FeeObject(
+ numberOfBlocksFast: 1,
+ numberOfBlocksAverage: 5,
+ numberOfBlocksSlow: 20,
+ fast: BigInt.from(20000),
+ medium: BigInt.from(10000),
+ slow: BigInt.from(5000),
+ );
+ }
+
+ @override
+ Future<Amount> estimateFeeFor(Amount amount, BigInt feeRate) async =>
+ Amount(rawValue: BigInt.from(1410), fractionDigits: 8);
+
+ @override
+ dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
+}
+
+/// Pump the fee form for [wallet] and return its parent's setState.
+Future<StateSetter> _pumpForm(WidgetTester tester, _FeeWallet wallet) async {
+ final theme = StackTheme.fromJson(json: lightThemeJsonMap);
+ late StateSetter rebuild;
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ pWallets.overrideWithValue(ConfirmationWallets(wallet)),
+ pWalletCoin(_walletId).overrideWithValue(_coin),
+ pAmountFormatter(_coin).overrideWithValue(
+ AmountFormatter(
+ unit: AmountUnit.normal,
+ locale: "en_US",
+ coin: _coin,
+ maxDecimals: 8,
+ ),
+ ),
+ ],
+ child: MaterialApp(
+ theme: ThemeData(extensions: [StackColors.fromStackColorTheme(theme)]),
+ home: Material(
+ child: StatefulBuilder(
+ builder: (context, setState) {
+ rebuild = setState;
+ return DesktopSendFeeForm(
+ walletId: _walletId,
+ isToken: false,
+ onCustomFeeSliderChanged: (_) {},
+ onCustomFeeOptionChanged: () {},
+ );
+ },
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+ return rebuild;
+}
+
+void main() {
+ testWidgets("a rebuild keeps the fee rates and the amount", (tester) async {
+ final wallet = _FeeWallet();
+ final rebuild = await _pumpForm(tester, wallet);
+ final container = ProviderScope.containerOf(
+ tester.element(find.byType(DesktopSendFeeForm)),
+ );
+ final amount = Amount(rawValue: BigInt.from(22000), fractionDigits: 8);
+ container.read(sendAmountProvider.notifier).state = amount;
+ await tester.pump();
+
+ rebuild(() {});
+ await tester.pump();
+ await tester.pump();
+
+ expect(wallet.feeFetches, 1);
+ expect(container.read(sendAmountProvider), amount);
+ });
+
+ testWidgets("a rebuild fetches the fee rates again after a failure", (
+ tester,
+ ) async {
+ final wallet = _FeeWallet(failingFetches: 1);
+ final rebuild = await _pumpForm(tester, wallet);
+ expect(find.byType(DesktopFeeItem), findsNothing);
+
+ rebuild(() {});
+ await tester.pump();
+ await tester.pump();
+
+ expect(wallet.feeFetches, 2);
+ expect(find.byType(DesktopFeeItem), findsOneWidget);
+
+ rebuild(() {});
+ await tester.pump();
+
+ expect(wallet.feeFetches, 2);
+ expect(find.byType(DesktopFeeItem), findsOneWidget);
+ });
+
+ testWidgets("the amount is kept while no fee estimate is shown", (
+ tester,
+ ) async {
+ final wallet = _FeeWallet(failingFetches: 1);
+ final rebuild = await _pumpForm(tester, wallet);
+ final container = ProviderScope.containerOf(
+ tester.element(find.byType(DesktopSendFeeForm)),
+ );
+ final amount = Amount(rawValue: BigInt.from(22000), fractionDigits: 8);
+ container.read(sendAmountProvider.notifier).state = amount;
+ await tester.pump();
+
+ rebuild(() {});
+ await tester.pump();
+ await tester.pump();
+
+ expect(find.byType(DesktopFeeItem), findsOneWidget);
+ expect(container.read(sendAmountProvider), amount);
+ });
+}Why this scored 17/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.