Skip to content

fix(paginator): correct windows filled by hydration rather than by a query - #1860

Merged
MartinCupela merged 2 commits into
release-v10from
fix/paginator-window-lost-on-empty-page
Sep 10, 2026
Merged

fix(paginator): correct windows filled by hydration rather than by a query#1860
MartinCupela merged 2 commits into
release-v10from
fix/paginator-window-lost-on-empty-page

Conversation

@MartinCupela

@MartinCupela MartinCupela commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Description

Two defects with one root cause: a paginator window can be filled either by a query or by hydration (setItems, mergeNewestPage), and the second path was mishandled in two different ways. Thread replies hit both, because Thread.reload() — the only public method that fetches replies — hydrates.

1. The first hydrate dropped its replies

A thread built from a message (rather than from queryThreads) has never held a reply window. Thread.reload() fetches replies via GET /threads/:id?reply_limit=50 and hands them to mergeNewestPage, which deliberately no-ops in that state — it merges a newest page into an anchored head and leaves seeding to the query path:

if (!logicalHead || hasAnchoredHead || !page?.length) return;

So the replies arrived and were discarded, leaving items undefined.

This went unnoticed because the message list separately issued GET /messages/:id/replies, which seeded the window through postQueryReconcile. The thread request looked redundant while the reply request was the one doing the work. It surfaced when the React SDK suppressed that second request.

Fix: hydrateState seeds when there is no window and merges when there is, reusing the setItems call the constructor already makes for a thread response's latest_replies. Completeness is judged against the parent message's reply_count (which includes soft-deleted replies) rather than the payload's top-level count (which does not).

2. A hydrated window lost itself on the next pagination

setItems records the query shape its window corresponds to; mergeNewestPage did not. With _lastQueryShape left undefined, shouldResetStateBeforeQuery reads the next pagination as a shape change rather than a continuation, so executeQuery takes its first-page branch and publishes getStateBeforeFirstQuery()items: undefined — before issuing the request.

A response carrying items rebuilds the window from the surviving intervals, so that case only churns. An empty response has nothing to rebuild from: scrolling to the top of a thread whose replies are all loaded emptied the list.

Fix: the shape recording in setItems is extracted to recordLoadedWindowQueryShape() and called from the hydrate path too. mergeNewestPage returns early in several places, so the public method is now a wrapper around the implementation and records on every exit.

Scope

Not thread-specific. Any window filled by hydration is affected — a channel's messages restored after reconnect, then scrolled to the first message, hits defect 2 by the same mechanism. Threads are simply where both were found.

Tests

  • threads.test.ts — hydrating a thread built from channel + parentMessage seeds the replies and reports nothing older to fetch. Fails without the fix.
  • MessagePaginator.test.ts — a hydrated window survives a continuation that returns no items. Fails without the fix.
  • MessagePaginator.test.ts — a hydrated window paginates correctly when the continuation does return items. Passes before and after; included so the half that already worked can't be broken while fixing the other.

Full unit suite passes.

Note for the React SDK

stream-chat-react has a companion change that stops the message list issuing GET /messages/:id/replies on thread open. That change depends on this one: without the seeding fix, suppressing the reply query leaves the thread panel empty. This should land and release first.

MartinCupela and others added 2 commits September 10, 2026 10:36
… items

Server data reaches a window without a query on two paths: a seeded page
(`setItems`) and a hydrate (`mergeNewestPage`, which `Thread.reload()` uses).
`setItems` records the query shape that window corresponds to; the hydrate did
not, so `_lastQueryShape` stayed undefined and `shouldResetStateBeforeQuery`
read the next pagination as a shape CHANGE rather than a continuation.

`executeQuery` then took its first-page branch and published
`getStateBeforeFirstQuery()` — items `undefined` — before issuing the request. A
response carrying items rebuilds the window from the surviving intervals, so that
case only churns. An empty one has nothing to rebuild from: scrolling to the top
of a thread whose replies are all loaded emptied the list.

Extracts the shape recording `setItems` already did into
`recordLoadedWindowQueryShape()` and calls it from the hydrate path too. The
hydrate body returns early in several places, so the public `mergeNewestPage` is
now a wrapper around the implementation and records on every exit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…f trying to merge them into a list that doesn't exist
@MartinCupela MartinCupela changed the title Fix/paginator window lost on empty page fix(paginator): correct pagination windows filled by hydration rather than by a query Sep 10, 2026
@MartinCupela MartinCupela changed the title fix(paginator): correct pagination windows filled by hydration rather than by a query fix(thread): load thread replies from the thread request itself Sep 10, 2026
@MartinCupela MartinCupela changed the title fix(thread): load thread replies from the thread request itself fix(paginator): correct windows filled by hydration rather than by a query Sep 10, 2026
@MartinCupela
MartinCupela merged commit 93b6fa0 into release-v10 Sep 10, 2026
9 of 12 checks passed
@MartinCupela
MartinCupela deleted the fix/paginator-window-lost-on-empty-page branch September 10, 2026 12:13
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