Skip to content

Keep the loader up while a grouped channel list fetches its first page - #6674

Merged
gpunto merged 6 commits into
v6from
gianmarcodavid/and-1460-grouped-channel-list-loading-state
Sep 4, 2026
Merged

Keep the loader up while a grouped channel list fetches its first page#6674
gpunto merged 6 commits into
v6from
gianmarcodavid/and-1460-grouped-channel-list-loading-state

Conversation

@gpunto

@gpunto gpunto commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Goal

A grouped channel list with nothing cached for a group showed the empty state ("no channels") for the whole first queryGroupedChannels round trip instead of the loader. Standard channel lists do not behave this way: they hold the loader until the network answers.

Closes AND-1460

Targets v6.

Implementation

  • loadOfflineGroupedChannels cleared the first-page loading flag unconditionally, so a cache miss moved channelsStateData from Loading to OfflineNoResults while the request was still in flight, and nothing raised it again: onQueryGroupedChannelsRequest only captured the query config.
  • Raise the flag in the request listener, the point that knows a fetch started, for each named first-page group. The guard is !hasCompletedAQuery && getChannels().isNullOrEmpty() and both halves are load-bearing. Without the marker a settled empty tab returns to a spinner on every reconnect, since SyncManager recovers grouped lists through a cursor-less request that reaches this listener. Without the emptiness check a request hides channels the offline read just loaded from cache, because ChannelsStateData reports Loading whenever the flag is set regardless of content.
  • hasCompletedAQuery is needed because state alone cannot express the difference: a group that has never loaded and one that loaded and is genuinely empty both hold an empty channel map.
  • A failure does not count as completion, so a retry raises the loader again, matching queryOffline for a standard list. An omitted group would count, since the server answered, but that branch is a guard rather than a live path: the server returns every requested group, with an empty channel list when the group has nothing. It stays because such a group would never reach applyGroupedResult and the loader it raised would never come down.
  • Clear the flag only once there is something to show, otherwise the offline read immediately undoes the raise. End the load on failure, and defensively for a group the response left out, neither of which reaches applyGroupedResult; ending it initialises the channel map as well as clearing the flag, because ChannelsStateData reports Loading while channels are still null.

Scope is limited to grouped lists: the new methods are called only from QueryGroupedChannelsListenerState, and loadOfflineGroupedChannels returns early for non-grouped identifiers.

Two behaviours worth calling out. Recovery of a group whose first page failed raises the loader on grouped, where standard recovery never does, because queryFirstPage bypasses the listener entirely. That is deliberate: a spinner while the retry runs is more accurate than a stale empty state. And a request with groups = null keeps today's behaviour, since the keys are unknown until the response, so the mechanism is inert rather than half-engaged and cannot leave a list loading.

Testing

  • Unit tests over a real QueryChannelsMutableState assert the emitted ChannelsStateData rather than setter calls: an in-flight first page on a cache miss stays Loading, a request arriving after the offline read still raises, and a failed load ends on OfflineNoResults. Reinstating the unconditional clear, or the earlier == null guard, fails them.

  • Mock tests cover the raise per first-page group, no raise while paginating, no raise over cached channels or a settled empty group, a failed page staying retryable, and the load being ended on failure and for an omitted group.

  • Verified on a grouped-channels sample built against this branch, cold start with an empty database on a throttled network:

    • before: OfflineNoResults -> Result(10) -> Result(20)
    • after: Loading -> Result(10) -> Result(20)

    Also ran the sample with the ordering flipped so the offline read runs before the query, which is the case the completion marker adds: Loading -> Result, so the new raise clears. A warm start still renders straight from cache.

@gpunto gpunto added the pr:bug Bug fix label Aug 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled (or ignored for dependabot PRs).

🎉 Great job! This PR is ready for review.

@gpunto

gpunto commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@gpunto
gpunto force-pushed the gianmarcodavid/and-1460-grouped-channel-list-loading-state branch from 7a313f5 to 849bf1b Compare August 31, 2026 11:32
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 447a300e-d6e0-4bda-ad9a-a55898c524d3

📥 Commits

Reviewing files that changed from the base of the PR and between 0b30827 and 7a313f5.

📒 Files selected for processing (4)
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt

Included review availability: Your plan provides up to 4 included reviews per hour; 2 remain after this review.


Walkthrough

Grouped channel queries now track first-page loading per group. Offline cache misses preserve the loading state. Failed and incomplete responses finish pending first-page loads. Tests cover routing, pagination, failures, omitted groups, and emitted channel state.

Changes

Grouped channel loading

Layer / File(s) Summary
Channel state loading lifecycle
stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt, stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt
First-page loading starts only for uninitialized groups. Cache misses preserve active loading. Completion initializes missing channel state and clears loading flags. Tests cover cache misses, concurrent results, and ChannelsStateData.Loading.
Grouped query orchestration
stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt, stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt
Grouped requests retain per-group configuration. First-page requests start loading before the network call. Failed and omitted groups finish loading. Returned groups replace on first pages and append during pagination. Tests verify per-group routing and lifecycle behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🔵 Low · up to 7a313

The change correctly keeps grouped lists in a loading state during initial network fetches, but overlapping requests for the same group could allow one response to hide the loader while another is still running. This is a bounded mergeable risk that warrants owner awareness and follow-up.

Suggested reviewers: velikovpetar

Poem

I’m a rabbit watching channels load,
Each group now follows its proper road.
First pages glow while requests run,
Empty caches wait for work to be done.
Failed paths close their loading gate,
And paged results join the state.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.13% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 31 functions across 4 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: keeping the loader visible during the first grouped channel list fetch.
Description check ✅ Passed The description is complete and directly aligned with the changes. It explains the bug, implementation, scope, linked issue, testing, and observed behavior. UI sections are not relevant to this state-…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gianmarcodavid/and-1460-grouped-channel-list-loading-state

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…rst page

Opening a grouped channel list with nothing cached for that group showed the
empty state ("no channels") for the whole duration of the first
queryGroupedChannels round trip, instead of the loading indicator. On a slow
connection that is a visible "No channels" flash before the list populates.
Standard channel lists do not behave this way; they deliberately hold the loader
until the network answers.

loadOfflineGroupedChannels() cleared the first-page loading flag unconditionally,
so a cache miss moved channelsStateData from Loading to OfflineNoResults while
the request was still in flight. The unconditional clear was deliberate: grouped
state is initialised separately from the fetch, so that call site cannot tell an
in-flight first page from one that is never coming, and leaving the flag raised
there would spin forever for an app that inits grouped state without querying.

Raise the flag at the point that does know a fetch is starting instead. The
request listener sets it per named first-page group, and only for a group that
has never loaded. The guard is a null check rather than an emptiness check, and
the difference matters: a group that loaded and came back empty holds a non-null
empty map, because addChannelsState writes the merged map even for an empty
result. Treating that as "empty, so show the loader" would put a settled empty tab
back into a spinner on every reconnect, since SyncManager recovers grouped lists
through a cursor-less request that reaches this listener. Standard lists never do
that: their recovery goes through queryFirstPage, which is a no-op for grouped
identifiers and otherwise calls the API directly, with no listener and no flag.
Raising it over a populated list would be wrong for a second reason too, since
ChannelsStateData reports Loading whenever the flag is set regardless of content.

The offline load now clears the flag once there is something on screen, whether
that is the cached channels it just added or channels a concurrent result already
applied. A miss with nothing in state leaves the flag alone, so it stays raised
while a request is in flight and stays false when none is coming, in which case
the empty state shows immediately as before.

Ending a load that produced no channels of its own needs both halves:
ChannelsStateData reports Loading while the flag is set *or* while channels are
still null, and a request that fails before the offline load ran leaves them null.
finishFirstPageLoad() initialises the channel map and clears the flag together,
so a failure cannot swap the flash for a permanent spinner. It runs for failed
requests and for requested groups the response leaves out, which never reach
applyGroupedResult and would otherwise keep their loader forever.

State reads in the new paths share groupedResultMutex with the offline load and
the result apply, so the emptiness check cannot observe a half-applied update.

Verified on the grouped-channels sample against a locally published build. Cold
start with an empty database now goes Loading -> Result rather than
Loading -> OfflineNoResults -> Result, and a warm start still renders from cache.
The failure path is covered by unit tests only, as the sample cannot reach the
list without a working connection.

The flag is only raised when the request starts before the offline read. In the
opposite order the read misses, initializeChannelsIfNeeded() flips the channel map
from null to empty, and the group no longer counts as never-loaded, so the empty
state shows for the round trip as it does today. Nothing at that call site can
tell "a query is coming" from "none ever will", so this ordering is not fixable
there; an app that issues its grouped query at screen open, as the sample does,
gets the fix.

Known gaps, all pre-existing and unchanged here. A request with groups = null
relies on the server's default group set, so the keys are unknown until the
response and no loader can be raised for it. A grouped first page that fails does
not set recoveryNeeded, and recoverAll is false on the first connect, so such a
failure is not retried until a later connection event. And applyGroupedResult
replaces a first page by removing the existing channels before adding the new
ones, so a warm start emits a brief OfflineNoResults between the two writes -
reproduced on roughly two runs in five, with and without the persistence change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gpunto
gpunto force-pushed the gianmarcodavid/and-1460-grouped-channel-list-loading-state branch from 849bf1b to ec43e90 Compare August 31, 2026 11:37
@github-actions

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.26 MB 5.32 MB 0.05 MB 🟢
stream-chat-android-offline 5.49 MB 5.54 MB 0.04 MB 🟢
stream-chat-android-ui-components 10.64 MB 10.76 MB 0.11 MB 🟢
stream-chat-android-compose 12.87 MB 12.96 MB 0.10 MB 🟢

@gpunto
gpunto marked this pull request as ready for review August 31, 2026 16:00
@gpunto
gpunto requested a review from a team as a code owner August 31, 2026 16:00

@andremion andremion 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.

Looks good. One question on the ordering inline, plus a couple of nits.

gpunto and others added 2 commits September 1, 2026 13:55
Drop a comment duplicating the paragraph above it, call setLoadingFirstPage
directly now that the loadingPerPage branch is dead at that call site, and extend
the real-state test to cover the failure end of the chain rather than leaving it
to mocks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng it

The raise guard read `getChannels() == null`, which conflated "this group has
never loaded" with "nothing has initialised the channel map yet". The offline
read initialises the map on a cache miss, so a request arriving after it never
raised the loader and the list showed the empty state for the whole round trip.
Only the ordering where the request starts first was fixed.

Track completion explicitly instead. hasCompletedAQuery is set when a grouped
result is applied and when a load is ended, so it distinguishes a group that has
never loaded from one that loaded and is genuinely empty, which the channel map
alone cannot express: both are empty.

The guard keeps the emptiness check alongside it. Without the marker a settled
empty group returns to a spinner on every reconnect, since SyncManager recovers
grouped lists through this listener. Without the emptiness check a request hides
cached channels behind a spinner, because ChannelsStateData reports Loading
whenever the flag is set regardless of content.

A failed query counts as completion, so a retry shows the empty state rather than
returning to the loader. That matches what a standard list does while recovering.

Covered by a real-state test asserting the offline-read-first ordering now reaches
Loading, which fails against the previous guard, plus mock tests for the cached
and settled-empty cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@andremion andremion 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.

One stray file in d656e38, inline.

Comment thread gradle.properties Outdated
hasCompletedAQuery was set whenever a load ended, so a failed first page settled
the group and a retry showed the empty state. A standard list does the opposite:
after a failed empty first page the channel map is empty and loading is false, so
currentLoading resolves to loading, queryOffline does not early-return, and the
retry raises the loader.

finishFirstPageLoad now takes `completed`. A group the response omits has been
answered and settles; a failure has not, so the group stays never-loaded and a
later request raises the loader again. Recovery matches either way, since the
standard path does not raise there either.

Also revert an unrelated local gradle.properties change that was committed by
accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing dedupes concurrent grouped queries, so two first pages for one group can
be in flight together. The loading flag is shared, so the earlier completion
ends the load while the later request is still running, and the list shows the
empty state for that window.

That window is the behaviour this branch removes elsewhere, narrowed to the
overlap, and it recovers on its own once the later request answers. Gating the
clear on an in-flight count would close it, but the count is incremented in
doOnStart and decremented in doOnResult, and DoOnResultCall.cancel cancels the
scope the consumer runs in. A leaked start would never return to zero, so the
loader would stop clearing at all, including on a later success.

The test records that trade so a future count cannot land silently.
- Name the omitted-group branch as the guard it is. The server answers every
  requested group, with an empty channel list when the group has nothing, so it
  is expected to be a no-op; it stays because a group the response left out
  would never reach applyGroupedResult and its loader would never come down.
- Note in KDoc that an explicit Call.cancel between the raise and the result
  leaves the group on the loader until a later grouped query finishes.
  Cancelling the calling coroutine or its scope still runs the result listener,
  and nothing in the SDK cancels this call.
- Set hasCompletedAQuery inside groupedResultMutex, matching applyGroupedResult,
  so both writers hold the lock.
- Drop a test that duplicated the stub, call and assertion of the one below it.
@sonarqubecloud

sonarqubecloud Bot commented Sep 4, 2026

Copy link
Copy Markdown

@gpunto
gpunto merged commit 14a272f into v6 Sep 4, 2026
24 of 25 checks passed
@gpunto
gpunto deleted the gianmarcodavid/and-1460-grouped-channel-list-loading-state branch September 4, 2026 10:32
@stream-public-bot stream-public-bot added the released Included in a release label Sep 4, 2026
@stream-public-bot

Copy link
Copy Markdown
Contributor

🚀 Available in v6.43.0

pull Bot pushed a commit to Siriusmene/stream-chat-android that referenced this pull request Sep 4, 2026
GetStream#6679)

* state: Keep the loader up while a grouped channel list fetches its first page (develop)

Port of GetStream#6674 to develop.

Opening a grouped channel list with nothing cached for that group showed the
empty state ("no channels") for the whole duration of the first
queryGroupedChannels round trip, instead of the loading indicator. Standard
channel lists hold the loader until the network answers.

loadOfflineGroupedChannels() cleared the first-page loading flag unconditionally,
so a cache miss moved channelsStateData from Loading to OfflineNoResults while the
request was still in flight, and nothing raised it again. Raise it in the request
listener instead, for each named first-page group, guarded on
!hasCompletedAQuery && getChannels().isNullOrEmpty(). Both halves matter: without
the marker a settled empty group returns to a spinner on every reconnect, since
SyncManager recovers grouped lists through this listener; without the emptiness
check a request hides cached channels behind a spinner. A failure does not count
as completion, so a retry raises the loader again, matching queryOffline for a
standard list. Recovery of a group whose first page failed does differ from
standard, which never raises there because queryFirstPage bypasses the listener;
grouped showing a spinner while the retry runs is the more accurate of the two.
Clear the flag only once there is something to show, and end the load on failure
and for a group the response omits.

Only the query-channels path is affected. The two channel-logic implementations
on this branch, ChannelLogicImpl and ChannelLogicLegacyImpl, are the chat screen
and are untouched here; that half is tracked separately in AND-1462.

Differences from the v6 version are mechanical: the files live under
stream-chat-android-client rather than stream-chat-android-state, ChannelsStateData
is imported from client.api.state, and QueryChannelsStateLogic takes an extra
isLocalUnreadCountEnabled parameter that the test harness now passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* state: Pin grouped first-page behaviour when two requests overlap

Nothing dedupes concurrent grouped queries, so two first pages for one group can
be in flight together. The loading flag is shared, so the earlier completion
ends the load while the later request is still running, and the list shows the
empty state for that window.

That window is the behaviour this branch removes elsewhere, narrowed to the
overlap, and it recovers on its own once the later request answers. Gating the
clear on an in-flight count would close it, but the count is incremented in
doOnStart and decremented in doOnResult, and DoOnResultCall.cancel cancels the
scope the consumer runs in. A leaked start would never return to zero, so the
loader would stop clearing at all, including on a later success.

The test records that trade so a future count cannot land silently.

* state: Address review on the grouped first-page loader

- Name the omitted-group branch as the guard it is. The server answers every
  requested group, with an empty channel list when the group has nothing, so it
  is expected to be a no-op; it stays because a group the response left out
  would never reach applyGroupedResult and its loader would never come down.
- Note in KDoc that an explicit Call.cancel between the raise and the result
  leaves the group on the loader until a later grouped query finishes.
  Cancelling the calling coroutine or its scope still runs the result listener,
  and nothing in the SDK cancels this call.
- Set hasCompletedAQuery inside groupedResultMutex, matching applyGroupedResult,
  so both writers hold the lock.
- Drop a test that duplicated the stub, call and assertion of the one below it.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:bug Bug fix released Included in a release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants