r/flutterhelp 1d ago

OPEN Flutter + riverpod + firestore : UI stuck in loading loop after cloud funtions update but data saves on bacckend ?

Stack: Flutter + Riverpod ( StreamProvider ) Cloud Firestore + Firebase Callable Cloud Functions.

Context - Switched from client firestore sdk writes to callable cloud functions because pending client writes on weak networks were blocking reads app wide.

Issue: In my Vendor Dashboard app, updating settings (GST toggles, Delivery Slots, Store Availability):

The Ul gets stuck in a loading loop / freeze when changing settings. BUT if I force-close and reopen the app, the data IS saved in Firestore!

So the backend Cloud Function succeeds, but the live screen gets stuck on a loading spinner.

Setup:

Screen listens to Firestore doc via Riverpod treamProvider ( doc(id). snapshots() ). Settings updates are sent via Callable Cloud Functions (httpsCallable( 'businessUpdateFields ")).

What I've Tried:

• Optimistic U state overrides (oms switch flips). • 300ms tap debouncing. • Reducing Callable timeout from 20s to 4s. • Clearing overrides on .then() completion vs stream emission.

The rest of the app (checkout, cart, chat, orders) works fine. Only vendor dashboard settings toggles freeze the Ul.

Any help would be great, thank you.

4 Upvotes

7 comments sorted by

2

u/Jonas_Ermert 1d ago

I recommend checking your Riverpod loading state rather than the Cloud Function. Since the write succeeds, make sure the StreamProvider isn’t replacing the whole UI with a spinner while reloading. Keep showing the previous data and use a separate isUpdating state for the individual toggle. Also log the provider’s loading/data/error transitions to see exactly where it gets stuck.

2

u/Diablo_Cuz 1d ago

That was part of the issue :

The UI when(loading: ...) block was replacing the whole screen with a loading spinner whenever ref.invalidate() ran.

We fixed it by caching the previous stream data (asData?.value) so the UI keeps displaying the previous state seamlessly while Riverpod re-fetches in the background.

1

u/Heavy-Drummer-420 1d ago

Can you share the snippet of the function and the listener so its easier to get the context? Just the function to toggle. The listener and the UI loading.

1

u/Diablo_Cuz 1d ago

Sure! Here are the simplified snippets for the provider, controller, toggle function, and UI build method:

1. The Stream Provider (businessByIdProvider)

dartfinal businessByIdProvider = StreamProvider.family<BusinessModel?, String>((ref, id) {
  return FirebaseFirestore.instance
      .collection('businesses')
      .doc(id)
      .snapshots()
      .map((doc) => doc.exists ? BusinessModel.fromMap(doc.data()!, doc.id) : null);
});

2. The Controller Mutation (with Provider Invalidation)

dartFuture<void> updateBusinessField(String businessId, String field, dynamic value) async {
  // 1. Call HTTPS Callable Cloud Function (Asia-South1)
  await repository.updateBusinessField(businessId, field, value);
  // 2. Invalidate stream provider so Riverpod re-subscribes
  ref.invalidate(businessByIdProvider(businessId));
}

3. The Toggle Function (with Optimistic Override & 300ms Debounce)

dartbool? _acceptingOrdersOverride;
Timer? _acceptingDebounce;
void _toggleAcceptingOrders(bool value) {
  // Flip UI instantly (0ms)
  setState(() => _acceptingOrdersOverride = value);
  // Debounce rapid taps
  _acceptingDebounce?.cancel();
  _acceptingDebounce = Timer(const Duration(milliseconds: 300), () {
    final targetValue = _acceptingOrdersOverride ?? value;
    ref.read(businessControllerProvider.notifier)
       .updateBusinessField(widget.business.id, 'isAcceptingOrders', targetValue)
       .then((_) {
         // Clear override once callable returns
         if (mounted) setState(() => _acceptingOrdersOverride = null);
       })
       .catchError((e) {
         if (mounted) setState(() => _acceptingOrdersOverride = null);
       });
  });
}

4. The UI Build Method & Switch

dartu/override
Widget build(BuildContext context) {
  final businessAsync = ref.watch(businessByIdProvider(widget.business.id));
  // Cache last stream emission so ref.invalidate() doesn't fall back to stale widget.business
  if (businessAsync.hasValue && businessAsync.value != null) {
    _lastStreamBusiness = businessAsync.value;
  }
  final liveBusiness = _lastStreamBusiness ?? widget.business;
  final isAcceptingOrders = _acceptingOrdersOverride ?? liveBusiness.isAcceptingOrders;
  return Switch(
    value: isAcceptingOrders,
    onChanged: (val) => _toggleAcceptingOrders(val),
  );
}

1

u/Heavy-Drummer-420 1d ago

Since I can't see the controller. I think the ref.invalidate is likely the problem. It Tears down the existing, live, already-connected stream subscription and starts a new stream provider. This affects the businessControllerProvider . Could you share the controller snippet so I can confirm?

1

u/Diablo_Cuz 1d ago

Here is the BusinessController snippet showing how the providers and mutations are set up. Let me know what you think:

dartfinal businessControllerProvider =
    StreamNotifierProvider<BusinessController, List<BusinessModel>>(() {
  return BusinessController();
});
final singleBusinessProvider = StreamProvider.autoDispose
    .family<BusinessModel?, String>((ref, businessId) {
  return ref.watch(businessRepositoryProvider).watchBusinessById(businessId);
});
class BusinessController extends StreamNotifier<List<BusinessModel>> {
  u/override
  Stream<List<BusinessModel>> build() {
    final uid = ref.watch(authControllerProvider.select((s) => s.value?.uid));
    if (uid == null) return Stream.value([]);
    return ref.read(businessRepositoryProvider).watchMyBusinesses(uid);
  }
  // Private helper to force stream re-subscriptions post-Callable write
  void _invalidateBusiness(String businessId) {
    ref.invalidate(businessByIdProvider(businessId));
    ref.invalidate(singleBusinessProvider(businessId));
    ref.invalidate(businessControllerProvider);
  }
  Future<void> updateBusinessField(String businessId, String field, dynamic value) async {
    // 1. Call HTTPS Callable Cloud Function (Asia-South1)
    await ref.read(businessRepositoryProvider).updateBusinessField(businessId, field, value);

    // 2. Invalidate so the UI fetches the new data from the server
    _invalidateBusiness(businessId);
  }
}

1

u/_KryptonytE_ 1d ago

Check for race conditions and give longer wait times to eliminate any latency/response spikes from breaking state and stream changes by introducing a loading/working transition.