diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md index c268c45e61..a2b70ba1bb 100644 --- a/ai-docs/ai-migration-v9-to-v10.md +++ b/ai-docs/ai-migration-v9-to-v10.md @@ -108,6 +108,9 @@ rg '` removed; the cap is paginator configuration + +```diff +- ++ +``` + +```diff ++ // any ONE of these ++ channel.messagePaginator.updateConfig({ maxLoadedItems: 200 }); ++ client.config.set({ messagePaginator: { maxLoadedItems: 200 } }); // every message list ++ client.config.set({ channel: { messagePaginator: { maxLoadedItems: 200 } } }); // channel lists only ++ client.config.set({ thread: { messagePaginator: { maxLoadedItems: 100 } } }); // thread replies only +``` + +Bounding the loaded window is state-layer work — it is the paginator that owns the loaded set, its +pagination cursors and its store membership — so in v10 it is paginator configuration rather than a +component prop. Same default (unset ⇒ unbounded) and the same purpose: a livestream channel that would +otherwise accumulate every message it has ever received. + +Three things the prop could not give you, which the config route does: + +- **Per-surface control.** The prop capped the channel list only. `thread.messagePaginator` can now be + capped separately, or both together through the shared `messagePaginator` key. +- **Runtime changes.** `updateConfig` applies to an open channel immediately. +- **It works outside React.** Nothing has to render for the cap to be in force. + +**Affects:** anyone passing `maximumMessageLimit`. The prop is **removed, not deprecated** — TypeScript +flags it, and there is no silent-fallback case to worry about. + +## O.2 Pruning actually prunes again (behavioural) + +In v9 the cap was enforced by `channel.state.pruneOldest()`, which went away with `channel.state.messages`. +For part of the v10 pre-release line `maximumMessageLimit` was therefore **inert** — it altered some +scroll behaviour but never bounded the window. If you set it during that period and saw no effect, that is +why. `maxLoadedItems` enforces it. + +What a prune does now, which is worth knowing if you build on the paginator: + +- Drops the oldest messages from the loaded window and releases them from `client.messageStore`. A message + another collection still holds — a pinned message, or a `show_in_channel` reply in an open thread — + keeps its content; only this list's reference goes. +- Re-opens `hasMoreTail` and re-points `cursor.tailward`, so scrolling back re-fetches. This happens even + if the list had already paginated to the very first message in the channel. +- Never drops an unsent or failed message, which sorts by the time it was composed and would otherwise be + destroyed by a cap. The window sits slightly above the cap while one is pending. +- Never runs while the user has jumped away from the newest window, and never while the SDK's viewability + tracking says the viewport is near the oldest loaded message. +- Does **not** touch the offline database. A pruned message is still in SQLite. + +A value below the list's `pageSize` is raised to it: a cap smaller than a page would prune away the page a +"load older" query had just fetched, and the list would immediately ask for it again. + +--- + # Part I — i18n ## 19. English-only bundle, dotted keys, shared runtime diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index d01c5aad04..52ee511f86 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -192,6 +192,15 @@ export const ChannelScreen: React.FC = ({ navigation, route const [selectedThread, setSelectedThread] = useState(); + /** + * Message-list pruning is state-layer configuration now, not a `` prop: the paginator + * bounds its own loaded window. Set on the instance so the secret-menu toggle applies to the open + * channel without a reload; `undefined` restores an unbounded list. + */ + useEffect(() => { + channel?.messagePaginator.updateConfig({ maxLoadedItems: messageListPruning }); + }, [channel, messageListPruning]); + useEffect(() => { const initChannel = async () => { if (!chatClient || !channelId || channelFromProp) { @@ -366,7 +375,6 @@ export const ChannelScreen: React.FC = ({ navigation, route messageId={messageId} onAlsoSentToChannelHeaderPress={onAlsoSentToChannelHeaderPress} thread={selectedThread} - maximumMessageLimit={messageListPruning} > diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 3dbe25c812..93a9e91cbd 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -168,7 +168,6 @@ export type ChannelPropsWithContext = Pick & | 'hideStickyDateHeader' | 'hideDateSeparators' | 'maxTimeBetweenGroupedMessages' - | 'maximumMessageLimit' > > & Pick & @@ -455,7 +454,6 @@ const ChannelWithContext = (props: PropsWithChildren) = thread: threadFromProps, threadList, topInset = 0, - maximumMessageLimit, initializeOnMount = true, urlPreviewType = 'full', } = props; @@ -914,7 +912,6 @@ const ChannelWithContext = (props: PropsWithChildren) = loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, loading: channelMessagesState.loading, - maximumMessageLimit, maxTimeBetweenGroupedMessages, reloadChannel, scrollToFirstUnreadThreshold, diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index 850829e102..c30d1ad749 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -15,7 +15,6 @@ export const useCreateChannelContext = ({ loadChannelAtFirstUnreadMessage, loading, maxTimeBetweenGroupedMessages, - maximumMessageLimit, reloadChannel, scrollToFirstUnreadThreshold, hasPendingInitialTargetLoad, @@ -36,7 +35,6 @@ export const useCreateChannelContext = ({ loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, loading, - maximumMessageLimit, maxTimeBetweenGroupedMessages, reloadChannel, scrollToFirstUnreadThreshold, @@ -44,15 +42,7 @@ export const useCreateChannelContext = ({ threadList, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [ - channelId, - disabled, - isChannelActive, - highlightedMessageId, - loading, - threadList, - maximumMessageLimit, - ], + [channelId, disabled, isChannelActive, highlightedMessageId, loading, threadList], ); return channelContext; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 547c2c637d..bb5301f6bf 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -119,7 +119,6 @@ type MessageFlashListPropsWithContext = Pick< | 'scrollToFirstUnreadThreshold' | 'hasPendingInitialTargetLoad' | 'threadList' - | 'maximumMessageLimit' > & Pick & Pick< @@ -324,7 +323,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => loadMore, loadMoreRecent, markRead, - maximumMessageLimit, messageInputFloating, messageInputHeightStore, myMessageTheme, @@ -394,12 +392,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => [myMessageThemeString, scheme, theme], ); - const { processedMessageList, rawMessageList, viewabilityChangedCallback } = useMessageList({ - isFlashList: true, - isLiveStreaming, - maximumMessageLimit, - threadList, - }); + const { maxLoadedItems, processedMessageList, rawMessageList, viewabilityChangedCallback } = + useMessageList({ + isFlashList: true, + isLiveStreaming, + threadList, + }); const renderItem = useCallback( ({ item: message, index }: { item: LocalMessage; index: number }) => { @@ -576,14 +574,21 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } }; - if (isMessageRemovedFromMessageList && !maximumMessageLimit) { - scrollToBottomIfNeeded(); + if (isMessageRemovedFromMessageList) { + if (maxLoadedItems) { + // The list shrank while a window cap is configured, so a prune is the likely cause. The + // trackers are keyed by list length, and a prune returns the length to a value already + // marked as consumed — leaving them would permanently wedge back-pagination. + resetPaginationTrackersRef.current(); + } else { + scrollToBottomIfNeeded(); + } } messageListLengthBeforeUpdate.current = messageListLengthAfterUpdate; topMessageBeforeUpdate.current = topMessageAfterUpdate; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [messageListLengthAfterUpdate, topMessageAfterUpdate?.id, maximumMessageLimit]); + }, [messageListLengthAfterUpdate, topMessageAfterUpdate?.id, maxLoadedItems]); useEffect(() => { if (!processedMessageList.length) { @@ -1322,7 +1327,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { isChannelActive, loadChannelAroundMessage, loading, - maximumMessageLimit, reloadChannel, scrollToFirstUnreadThreshold, hasPendingInitialTargetLoad, @@ -1364,7 +1368,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { loadingMore, loadingMoreRecent, markRead, - maximumMessageLimit, messageInputFloating, messageInputHeightStore, myMessageTheme, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index bb420b88cb..f657277526 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -203,7 +203,6 @@ type MessageListPropsWithContext = Pick< | 'reloadChannel' | 'scrollToFirstUnreadThreshold' | 'threadList' - | 'maximumMessageLimit' > & Pick & { loadMore: () => Promise; @@ -336,7 +335,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { loadMore, loadMoreRecent, markRead, - maximumMessageLimit, messageInputFloating, messageInputHeightStore, myMessageTheme, @@ -390,11 +388,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * NOTE: rawMessageList changes only when messages array state changes * processedMessageList changes on any state change */ - const { processedMessageList, rawMessageList, viewabilityChangedCallback } = useMessageList({ - isLiveStreaming, - maximumMessageLimit, - threadList, - }); + const { maxLoadedItems, processedMessageList, rawMessageList, viewabilityChangedCallback } = + useMessageList({ + isLiveStreaming, + threadList, + }); const previousDerivedItemsRef = useRef>(undefined); @@ -746,7 +744,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }; if (threadList || isMessageRemovedFromMessageList) { - if (maximumMessageLimit) { + if (maxLoadedItems) { // pruning has happened, reset the trackers resetPaginationTrackersRef.current(); } else { @@ -757,7 +755,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { messageListLengthBeforeUpdate.current = messageListLengthAfterUpdate; topMessageBeforeUpdate.current = topMessageAfterUpdate; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [threadList, messageListLengthAfterUpdate, topMessageAfterUpdate?.id, maximumMessageLimit]); + }, [threadList, messageListLengthAfterUpdate, topMessageAfterUpdate?.id, maxLoadedItems]); useEffect(() => { if (threadList) { @@ -794,7 +792,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { // we don't want this behaviour while pruning, as it may scroll unnecessarily in // certain scenarios - if ((maximumMessageLimit && shouldForceScrollToRecent) || !maximumMessageLimit) { + if ((maxLoadedItems && shouldForceScrollToRecent) || !maxLoadedItems) { setAutoscrollToRecent(shouldForceScrollToRecent); } @@ -816,7 +814,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { threadList, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef, - maximumMessageLimit, + maxLoadedItems, ]); // Scroll-to-target is driven by the paginator's messageFocusSignal (thread-aware): a jump @@ -1425,7 +1423,6 @@ export const MessageList = (props: MessageListProps) => { highlightedMessageId, loadChannelAroundMessage, loading, - maximumMessageLimit, reloadChannel, scrollToFirstUnreadThreshold, threadList, @@ -1463,7 +1460,6 @@ export const MessageList = (props: MessageListProps) => { loadMore, loadMoreRecent, markRead, - maximumMessageLimit, messageInputFloating, messageInputHeightStore, myMessageTheme, diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index 521567c977..8dd2a8df18 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -12,7 +12,6 @@ export type UseMessageListParams = { threadList?: boolean; isLiveStreaming?: boolean; isFlashList?: boolean; - maximumMessageLimit?: number; }; /** @@ -29,16 +28,13 @@ const EMPTY_MESSAGES: LocalMessage[] = []; const messageListSelector = (state: { items?: LocalMessage[] }) => ({ messages: state.items }); export const useMessageList = (params: UseMessageListParams) => { - const { threadList, isLiveStreaming, isFlashList = false, maximumMessageLimit } = params; + const { threadList, isLiveStreaming, isFlashList = false } = params; const { channel } = useChannelContext(); const { threadInstance } = useThreadContext(); - const messagePaginatorState = threadList - ? threadInstance?.messagePaginator?.state - : channel.messagePaginator.state; - const { messages } = useStateStore(messagePaginatorState, messageListSelector) ?? {}; - const { viewabilityChangedCallback } = usePrunableMessageList({ - maximumMessageLimit, - setMessages: () => {}, + const messagePaginator = threadList ? threadInstance?.messagePaginator : channel.messagePaginator; + const { messages } = useStateStore(messagePaginator?.state, messageListSelector) ?? {}; + const { maxLoadedItems, viewabilityChangedCallback } = usePrunableMessageList({ + paginator: messagePaginator, }); const messageList = messages ?? EMPTY_MESSAGES; @@ -51,12 +47,17 @@ export const useMessageList = (params: UseMessageListParams) => { return useMemo( () => ({ + /** + * The paginator's configured window cap, or `undefined` when the list is unbounded. Read from + * the paginator rather than a prop — it is state-layer configuration. + */ + maxLoadedItems, /** Messages enriched with dates/readby/groups and also reversed in order */ processedMessageList: data, /** Raw messages from the channel state */ rawMessageList: messageList, viewabilityChangedCallback, }), - [data, messageList, viewabilityChangedCallback], + [data, messageList, maxLoadedItems, viewabilityChangedCallback], ); }; diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index 244b53c211..b6bd58b1ab 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -83,12 +83,6 @@ export type ChannelContextValue = { * to still consider them grouped together */ maxTimeBetweenGroupedMessages?: number; - /** - * The maximum number of messages that can be loaded into the state when new messages arrive. - * Any excess messages will be pruned from the back of the list (oldest first), unless we are - * currently near them within the viewport. - */ - maximumMessageLimit?: number; threadList?: boolean; }; diff --git a/package/src/hooks/__tests__/usePrunableMessageList.test.tsx b/package/src/hooks/__tests__/usePrunableMessageList.test.tsx new file mode 100644 index 0000000000..e9a8917051 --- /dev/null +++ b/package/src/hooks/__tests__/usePrunableMessageList.test.tsx @@ -0,0 +1,175 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import type { Channel, LocalMessage } from 'stream-chat'; + +import { initiateClientWithChannels } from '../../mock-builders/api/initiateClientWithChannels'; +import { generateMessage } from '../../mock-builders/generator/message'; +import { convertDateToTimestamp } from '../../mock-builders/generator/time'; +import { usePrunableMessageList } from '../usePrunableMessageList'; + +const viewable = (indices: number[]) => + indices.map((index) => ({ index, isViewable: true, item: {}, key: String(index) })); + +describe('usePrunableMessageList', () => { + let channel: Channel; + + const seed = (count: number) => + channel.messagePaginator.ingestPage({ + page: Array.from({ length: count }, (_, i) => + channel.state.formatMessage( + generateMessage({ + cid: channel.cid, + created_at: convertDateToTimestamp( + `2020-01-01T00:${String(i).padStart(2, '0')}:00.000Z`, + ), + id: `m${i}`, + }), + ), + ) as LocalMessage[], + isHead: true, + isTail: false, + setActive: true, + }); + + beforeEach(async () => { + const { channels } = await initiateClientWithChannels(); + channel = channels[0]; + channel.messagePaginator.updateConfig({ maxLoadedItems: 10, pageSize: 5 }); + }); + + it('reports the cap the paginator is configured with', () => { + const { result } = renderHook(() => + usePrunableMessageList({ paginator: channel.messagePaginator }), + ); + expect(result.current.maxLoadedItems).toBe(10); + }); + + it('suspends pruning while the viewport is near the oldest loaded message', () => { + const spy = jest.spyOn(channel.messagePaginator, 'setPruningSuspended'); + const { result } = renderHook(() => + usePrunableMessageList({ paginator: channel.messagePaginator }), + ); + + // Inverted list: a high index is the OLD end. Within 20% of the cap ⇒ not safe to prune there. + act(() => { + result.current.viewabilityChangedCallback({ + inverted: true, + viewableItems: viewable([8, 9]), + }); + }); + expect(spy).toHaveBeenLastCalledWith(true); + + // Back at the newest end ⇒ the suspension lifts. + act(() => { + result.current.viewabilityChangedCallback({ + inverted: true, + viewableItems: viewable([0, 1]), + }); + }); + expect(spy).toHaveBeenLastCalledWith(false); + }); + + it('reads the non-inverted (FlashList) axis the other way round', () => { + const spy = jest.spyOn(channel.messagePaginator, 'setPruningSuspended'); + const { result } = renderHook(() => + usePrunableMessageList({ paginator: channel.messagePaginator }), + ); + + // Non-inverted: index 0 is the OLD end. + act(() => { + result.current.viewabilityChangedCallback({ + inverted: false, + viewableItems: viewable([0, 1]), + }); + }); + expect(spy).toHaveBeenLastCalledWith(true); + + act(() => { + result.current.viewabilityChangedCallback({ + inverted: false, + viewableItems: viewable([8, 9]), + }); + }); + expect(spy).toHaveBeenLastCalledWith(false); + }); + + it('does nothing at all when the paginator has no cap configured', () => { + channel.messagePaginator.updateConfig({ maxLoadedItems: undefined }); + const spy = jest.spyOn(channel.messagePaginator, 'setPruningSuspended'); + const { result } = renderHook(() => + usePrunableMessageList({ paginator: channel.messagePaginator }), + ); + + act(() => { + result.current.viewabilityChangedCallback({ + inverted: true, + viewableItems: viewable([8, 9]), + }); + }); + + expect(result.current.maxLoadedItems).toBeUndefined(); + expect(spy).not.toHaveBeenCalled(); + }); + + it('lifts a suspension on unmount, so an unmounted list cannot pin the window open', () => { + const spy = jest.spyOn(channel.messagePaginator, 'setPruningSuspended'); + const { result, unmount } = renderHook(() => + usePrunableMessageList({ paginator: channel.messagePaginator }), + ); + + act(() => { + result.current.viewabilityChangedCallback({ + inverted: true, + viewableItems: viewable([8, 9]), + }); + }); + expect(spy).toHaveBeenLastCalledWith(true); + + unmount(); + expect(spy).toHaveBeenLastCalledWith(false); + }); + + // The hook only decides WHEN; proving the two halves actually meet is what makes it worth having. + it('the gate really controls the paginator: suspended the window grows, allowed it is capped', () => { + seed(10); + const { result } = renderHook(() => + usePrunableMessageList({ paginator: channel.messagePaginator }), + ); + expect(channel.messagePaginator.items).toHaveLength(10); + + act(() => { + result.current.viewabilityChangedCallback({ + inverted: true, + viewableItems: viewable([9]), + }); + }); + + const ingest = (i: number) => + channel.messagePaginator.ingestItem( + channel.state.formatMessage( + generateMessage({ + cid: channel.cid, + created_at: convertDateToTimestamp( + `2020-01-02T00:${String(i).padStart(2, '0')}:00.000Z`, + ), + id: `n${i}`, + }), + ) as LocalMessage, + ); + + for (let i = 0; i < 5; i++) ingest(i); + // Reading near the oldest message, so nothing was pulled out from under the viewport. + expect(channel.messagePaginator.items).toHaveLength(15); + + act(() => { + result.current.viewabilityChangedCallback({ + inverted: true, + viewableItems: viewable([0]), + }); + }); + + for (let i = 5; i < 10; i++) ingest(i); + expect(channel.messagePaginator.items).toHaveLength(10); + expect(channel.messagePaginator.hasMoreTail).toBe(true); + }); +}); diff --git a/package/src/hooks/usePrunableMessageList.ts b/package/src/hooks/usePrunableMessageList.ts index ec781c9f32..399f5717b1 100644 --- a/package/src/hooks/usePrunableMessageList.ts +++ b/package/src/hooks/usePrunableMessageList.ts @@ -1,10 +1,10 @@ -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; -import { Channel } from 'stream-chat'; +import type { MessagePaginator } from 'stream-chat'; import { useStableCallback } from './useStableCallback'; +import { useStateStore } from './useStateStore'; -import { ChannelPropsWithContext } from '../components'; import type { ViewToken } from '../types/react-native-compat'; export type VisibleRangeConfig = { first: number; last: number; inverted: boolean }; @@ -32,19 +32,32 @@ const isNearEnd = ({ return last >= maximumMessageLimit - 1 - safeGap; }; -export function usePrunableMessageList({ - // setter to update the array used by the List - setMessages: rawSetMessages, - maximumMessageLimit, -}: { - setMessages: (channel: Channel) => void; -} & Pick) { - // Track visible index range (index in `channel.messages`) +const maxLoadedItemsSelector = (state: { maxLoadedItems?: number }) => ({ + maxLoadedItems: state.maxLoadedItems, +}); + +/** + * Drives the message list's window cap. + * + * The cap itself lives in `stream-chat` — configure it with + * `channel.messagePaginator.updateConfig({ maxLoadedItems })`, or declaratively via + * `client.config.set({ channel: { messagePaginator: { maxLoadedItems } } })`. The paginator drops the + * oldest messages as new ones arrive and re-opens its "load older" edge, so scrolling back re-fetches. + * + * What this hook owns is the one part the state layer cannot know: **whether pruning is safe right + * now**. Dropping the oldest messages while the user is reading near them would pull content out from + * under them, so viewability is tracked here and pushed down as a single boolean. The paginator reads + * it on ingest; nothing subscribes to it, so scrolling can never cost a render. + */ +export function usePrunableMessageList({ paginator }: { paginator?: MessagePaginator }) { + // Track visible index range (index in the rendered list) const visibleRangeConfigRef = useRef({ first: 0, inverted: true, last: -1 }); + const { maxLoadedItems } = useStateStore(paginator?.configState, maxLoadedItemsSelector) ?? {}; + const viewabilityChangedCallback = useStableCallback( ({ viewableItems, inverted = true }: ViewabilityChangedCallbackInput) => { - if (!viewableItems?.length || maximumMessageLimit == null) return; + if (!viewableItems?.length || !maxLoadedItems) return; let first = Infinity; let last = -1; for (const v of viewableItems) { @@ -52,28 +65,20 @@ export function usePrunableMessageList({ if (v.index < first) first = v.index; if (v.index > last) last = v.index; } - if (first !== Infinity) visibleRangeConfigRef.current = { first, inverted, last }; + if (first === Infinity) return; + const rangeConfig = { first, inverted, last }; + visibleRangeConfigRef.current = rangeConfig; + paginator?.setPruningSuspended( + isNearEnd({ maximumMessageLimit: maxLoadedItems, rangeConfig }), + ); }, ); - // Prune when length exceeds MAX, but only if the viewport is far from the back edge - const setMessages = useStableCallback((channel: Channel) => { - const rangeConfig = visibleRangeConfigRef.current; - - if ( - maximumMessageLimit == null || - (channel.messagePaginator.state.getLatestValue().items?.length ?? 0) <= maximumMessageLimit || - isNearEnd({ maximumMessageLimit, rangeConfig }) - ) { - rawSetMessages(channel); - return; - } - - // TODO(#6): reimplement pruning over channel.messagePaginator — it has no prune API yet, and - // channel.state.pruneOldest is being removed. This path is currently dead (setMessages is wired - // to a no-op in useMessageList), so no window-cap is enforced today. - rawSetMessages(channel); - }); + /** + * The paginator outlives this list, so a suspension must not outlive it either — leaving it set + * would keep the window growing unbounded long after the user navigated away. + */ + useEffect(() => () => paginator?.setPruningSuspended(false), [paginator]); - return { setMessages, viewabilityChangedCallback }; + return { maxLoadedItems, viewabilityChangedCallback }; }