Skip to content

fix(mobile): give Riverpod state classes value equality - #7318

Open
vguerci wants to merge 4 commits into
block:mainfrom
vguerci:fix/riverpod-state-value-equality
Open

fix(mobile): give Riverpod state classes value equality#7318
vguerci wants to merge 4 commits into
block:mainfrom
vguerci:fix/riverpod-state-value-equality

Conversation

@vguerci

@vguerci vguerci commented Sep 4, 2026

Copy link
Copy Markdown

Summary

An unchanged relay config was tearing down and reconnecting the mobile relay
socket, roughly twice a second, once push notifications are on.

Riverpod's default updateShouldNotify is previous != next, so a state class
with no == is compared by identity and a rebuild producing an
identical-but-new object counts as a change. RelayConfigNotifier returns a
fresh RelayConfig every rebuild and RelaySessionNotifier disposes its socket
on rebuild, so a publish tore down the session it was publishing over.

This gives value equality to RelayConfig, Community and SessionState, and
stops two providers watching activeCommunityProvider's AsyncValue wrapper
when they only need the value inside it — a FutureProvider passes through
AsyncLoading on every recompute, which rebuilt them on its own.

It only reproduces with push enabled, which is off by default and landed in
PR #6269 on 2026-08-28 — hence no issue for it. Hand-written == with
Object.hash matches the existing convention in this package; no codegen
dependency is added.

Related issue

None found. Searched issues and PRs, and scanned every open PR's diff for added
operator ==, Object.hash or .select( under mobile/. All three classes
still lack == on b1f6b7ef7.

Testing

  • dart format and flutter analyze lib clean.
  • Full suite: 2083 passing against 2072 on the base commit b1f6b7ef7;
    the 11 added tests are the delta.
  • New tests cover each equality contract, plus a case pinning that an unchanged
    community does not replace the socket while a genuine relay-URL change still
    reconnects.

No UI change.

RelaySessionNotifier._connect re-emits SessionState(reconnecting, attempt: n)
with the attempt it already holds, so a reconnect publishes a fresh object
carrying values identical to the one _scheduleReconnect just set. SessionState
declared no ==, so Riverpod compared by identity and notified every watcher.

25 sites watch relaySessionProvider. Each redundant emission disposed and
rebuilt them all, and a rebuild that re-subscribes against the relay perturbs
the session that triggered it. Anything needing several round-trips could not
finish.

_scheduleReconnect genuinely increments the attempt, so real reconnects still
notify; only the no-op re-emission is filtered.

The equality test builds its operands through a non-const path on purpose:
identical const instances are canonicalized to a single object, so a const pair
compares equal on identity even with no operator== at all.

Signed-off-by: Vincent Guerci <vincent@guerci.com>
(cherry picked from commit 302692df299e1cd612c3142aaa40a4252bd71edb)
ReadStateNotifier.build() disposes and recreates its manager, then calls
ref.watch(activeCommunityProvider). Community declared no ==, so Riverpod
compared by identity and every community save tore the manager down and built a
new one.

With push notifications enabled that becomes self-sustaining:
reservePushLeaseGeneration saves the community on every publish attempt, the
save rebuilds the read-state manager, the rebuild re-subscribes against the
relay, and the resulting churn fails the publish that started it. Device logs
show the cycle repeating about twice a second, each pass replaying the full
read-state history, racing its own NIP-42 auth ("auth-required: not
authenticated") and ending in "Push lease bootstrap failed: Connection lost".

The comment directly above that watch documents this exact hazard for
SessionState. The same trap applied to the line below it.

Equality is field-by-field so a new field cannot silently join it, and delegates
the nested push state to buzzPushSubscriptionStateFingerprint, which the
codebase already treats as that type's canonical comparison.

Signed-off-by: Vincent Guerci <vincent@guerci.com>
(cherry picked from commit 98e2eadc55764aa5fe04bacefbdc5deaaec3d7c8)
Community equality alone does not stop the read-state rebuild loop.
activeCommunityProvider is a FutureProvider, so every recompute passes through
AsyncLoading before AsyncData. ReadStateNotifier watched the whole AsyncValue
and so rebuilt on that transition however equal the resulting Community was —
measured at four rebuilds per re-emission with == already in place.

Selecting the value puts the comparison on Community instead of the AsyncValue.
Both halves are load-bearing: removing either returns the count to four, which
the added test pins in both directions.

The test drives the production notifier against the existing inert fake relay
and forces a fresh Community carrying identical values, which is exactly what
reservePushLeaseGeneration does on every publish attempt.

Signed-off-by: Vincent Guerci <vincent@guerci.com>
(cherry picked from commit 29bb4ae0d1ac67f17ab770fde4b847242945aa47)
RelaySessionNotifier.build() watches relayConfigProvider, registers
ref.onDispose(_dispose) and schedules _connect(), so a rebuild destroys the
socket and opens a new one. RelayConfigNotifier.build() returns a fresh
RelayConfig on every rebuild, and RelayConfig declared no ==, so Riverpod
compared by identity and treated an unchanged config as new.

That closes a self-sustaining loop once push is on:
reservePushLeaseGeneration saves the community on every publish attempt, the
save re-emits the active community, the config rebuilds, the session is
disposed and reconnected, and the publish fails over the socket it just tore
down. Device logs show the cycle about twice a second, with
"Relay session is disposed", "auth-required: not authenticated" from requests
racing the new socket's NIP-42, and "Push lease bootstrap failed:
Connection lost".

The doc comment on baseUrl already recorded the hazard — Riverpod's default
updateShouldNotify falls back to identity, and "a fresh instance per rebuild
would resubscribe every listener". It held only for the const fallback; the
community-derived config is built fresh each time.

Equality compares the stored origin rather than the canonical one, because
storedOrigin keys identity-scoped preferences and two differently-stored
origins that canonicalize alike are not interchangeable.

The community watch is also selected, so the FutureProvider's AsyncLoading
transition alone no longer rebuilds the config.

Signed-off-by: Vincent Guerci <vincent@guerci.com>
(cherry picked from commit 4642c63be3e03a5f6d7774c0a08b3d042508ef1a)
@vguerci
vguerci requested a review from a team as a code owner September 4, 2026 08:11
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is b1f6b7ef770dddbb7f33c9f5861c379a47bca1d6...5ecd025ef792a5b518229f4445977bb7b0be7a38.
A new review must complete for this exact range. When manual authorization
is required, a Block organization member must comment exactly
@buzz-security-review 5ecd025ef792a5b518229f4445977bb7b0be7a38 to authorize a new review.
Any previous review applies only to its recorded range.

@Chessing234 Chessing234 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

adding ==/hashCode on the community + relay state objects matches the riverpod rebuild symptom. tests pin the publish-path identity churn.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants