From f6411dc23b72caeaa7450e2945d9f56fb5fb9f47 Mon Sep 17 00:00:00 2001 From: trvon Date: Tue, 22 Sep 2026 15:17:19 -0600 Subject: [PATCH] fix(riverpod): a listener that reads the value is not a render riverpod-async-gap treated any site that read an AsyncValue as a UI read, including ref.listen. A listener mirrors the value into a side effect and draws nothing, so it cannot leave a blank or stale screen. Dogfooding on word_alive flagged an analytics user-property listener this way. A gap now needs a ref.watch that reads the value. Listeners still count as places that handle error or loading. --- lib/src/state_flow.dart | 8 +++++++- .../stuck_loading/lib/riverpod_gaps.dart | 19 +++++++++++++++++++ test/riverpod_test.dart | 15 +++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/lib/src/state_flow.dart b/lib/src/state_flow.dart index 15d336e..ed83308 100644 --- a/lib/src/state_flow.dart +++ b/lib/src/state_flow.dart @@ -1334,7 +1334,13 @@ class RiverpodModel { if (!provider.isAsync) continue; final providerSites = sitesFor(provider.name); if (providerSites.isEmpty) continue; - if (!providerSites.any((site) => site.readsValue)) continue; + // Only a `ref.watch` renders the value. A `ref.listen` that reads it + // drives a side effect and draws nothing, so it cannot leave a blank or + // stale screen; listeners still count below as places that handle + // error or loading. + if (!providerSites.any((site) => site.watches && site.readsValue)) { + continue; + } final missing = []; if (!providerSites.any((site) => site.handlesError)) missing.add('error'); if (!providerSites.any((site) => site.handlesLoading)) { diff --git a/test/fixtures/stuck_loading/lib/riverpod_gaps.dart b/test/fixtures/stuck_loading/lib/riverpod_gaps.dart index b930378..a531fa3 100644 --- a/test/fixtures/stuck_loading/lib/riverpod_gaps.dart +++ b/test/fixtures/stuck_loading/lib/riverpod_gaps.dart @@ -33,6 +33,12 @@ class FutureProvider { class Ref { AsyncValue watch(FutureProvider provider) => throw UnimplementedError(); + + void listen( + FutureProvider provider, + void Function(AsyncValue? previous, AsyncValue next) listener, + ) => + throw UnimplementedError(); } class SubscriptionTier {} @@ -46,6 +52,9 @@ final unhandledProvider = /// Consumed with `when(data:, loading:, error:)`. final handledProvider = FutureProvider((ref) async => 1); +/// Consumed only by `ref.listen`, to mirror the value into a side effect. +final listenOnlyProvider = FutureProvider((ref) async => false); + class SubscriptionView { SubscriptionView(this.ref); @@ -60,4 +69,14 @@ class SubscriptionView { loading: () => -1, error: (error, stack) => -2, ); + + /// Not a render: a listener that mirrors the value into a side effect (for + /// example an analytics user property) draws nothing, so ignoring loading + /// and error here leaves no blank or stale screen. + void mirrorFlag(void Function(bool value) sink) { + ref.listen(listenOnlyProvider, (previous, next) { + final value = next.asData?.value; + if (value != null) sink(value); + }); + } } diff --git a/test/riverpod_test.dart b/test/riverpod_test.dart index 6e2b3a5..7122cbb 100644 --- a/test/riverpod_test.dart +++ b/test/riverpod_test.dart @@ -68,4 +68,19 @@ void main() { isNot(contains('handledProvider')), ); }); + + test('a provider consumed only by ref.listen is not a render gap', () { + // A listener mirrors the value into a side effect and draws nothing, so + // it cannot leave a blank or stale screen. Seen when dogfooding on + // word_alive: an analytics user-property listener was reported as a UI + // read with unhandled loading and error. + final sites = analysis.flow.riverpod.sitesFor('listenOnlyProvider'); + expect(sites, hasLength(1)); + expect(sites.single.watches, isFalse); + expect(sites.single.readsValue, isTrue); + expect( + analysis.flow.riverpod.gaps.map((g) => g.provider.name), + isNot(contains('listenOnlyProvider')), + ); + }); } -- 2.52.0