diff --git a/AGENTS.md b/AGENTS.md index 8097495dec..33eeec42b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,7 +130,17 @@ yarn workspace sampleapp android └─ ``` -`` is the entry point. It sets SDK metadata on the `stream-chat` client (identifier, device info), disables the JS client's `recoverStateOnReconnect` (the SDK handles recovery itself), registers subscriptions for threads/polls/reminders (cleaned up on unmount), initializes `OfflineDB` when `enableOfflineSupport` is set, and wraps children in `ChatProvider` → `TranslationProvider` → `ThemeProvider` → `ChannelsStateProvider`. +`` is the entry point. It sets SDK metadata on the `stream-chat` client (identifier, device info), registers subscriptions for threads/polls/reminders (cleaned up on unmount), initializes `OfflineDB` when `enableOfflineSupport` is set, and wraps children in `ChatProvider` → `TranslationProvider` → `ThemeProvider`. + +**Reconnect recovery is owned by the JS client, not this SDK.** `client.connectionRecovery` re-runs +each channel list's own first-page query and reloads whichever channels are `active` (`` +marks its channel active on mount), then dispatches `connection.recovered`. So do not add a +`connection.changed` listener that re-queries a list or re-watches a channel — that duplicates it. +`recoverStateOnReconnect` is left at its default `true`; it used to be switched off here because the +client's old recovery was a single 30-channel query that ignored the lists' own filters. Two things +deliberately stay UI-side: mark-read after the reload (`` listens for +`connection.recovered`), and the open thread's reply refresh (`resyncThread` in `Channel.tsx`, since +nothing in the client recovers an open thread's replies yet). ### Context three-layer pattern diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md index e98c71d142..dabace2ee8 100644 --- a/ai-docs/ai-migration-v9-to-v10.md +++ b/ai-docs/ai-migration-v9-to-v10.md @@ -104,6 +104,10 @@ rg '\b(onAddedToChannel|onRemovedFromChannel|onChannelDeleted|onChannelHidden|on rg 'useChatContext\(\)' -A6 src/ | rg '\bchannelManager\b' rg '` / `useChatContext().channelManager` | `client.channelManager` | §18.4 | +| `client.recoverState()` | `client.connectionRecovery.recover()` | §L.6 | +| `` refreshing the channel / open thread on reconnect | `client.connectionRecovery`; `` only marks them active | §L.4 | +| a custom channel/thread view built on the contexts | call `channel.activate()` / `thread.activate()`, balanced with `deactivate()`, or recovery cannot see them (`` and `` do it for you) | §L.4 | +| `` setting `client.recoverStateOnReconnect = false` for you | it no longer does; the option is now the kill switch for `client.connectionRecovery` | §L.1 | +| a `connection.changed` listener re-querying a list / re-watching a channel | delete it — `client.connectionRecovery` owns reconnect | §L | +| `connection.recovered` as "the `_reconnect()` path fired" | now dispatched on **every** reconnect path, after the recovery reload | §L.2 | | `useStateStore(channel.state.readStore / typingStore / membersStore / watcherStore / ownCapabilitiesStore, sel)` | `useStateStore(channel.state, sel)` — drop the `.Store`, keep the selector | §K.1 | | `channel.state.mutedUsersStore` | `client.mutedUsersStore` (via `useMutedUsers()`) | §K.2 | | in-place `channel.data.member_count = n` / `.own_capabilities = […]` | reassign `channel.data = { …channel.data, member_count: n }` | §K.5 | @@ -1105,10 +1115,13 @@ Configure the shared manager through its own API (`client.channelManager.setEven now relocates by its sort key (e.g. `last_message_at`) instead of jumping to the very top. This also means a new message no longer overrides a pinned-first `sort` (pinned channels stay pinned). To keep the old jump-to-top, boost it yourself: `paginator.boost(channel.cid)`. -- **Watch-on-notification narrowed.** On `notification.*` events (e.g. added to a channel) v10 watches - only channels it does not already know; v9 re-watched unconditionally. Deliberate change — the SDK's - own reconnect/query flow re-establishes watches, and blanket auto-watch risks the watch limit. To - watch a specific channel, call `channel.watch()`. +- **Watch-on-notification narrowed — but a watch that was *lost* comes back.** On `notification.*` + events (e.g. added to a channel) v9 re-watched the routed channel unconditionally. v10 does two + narrower things: it watches a channel it does not already know, and it restores a watch this client + previously held and lost to a dropped socket (`watchStatus === 'wasWatching'` — see **§L.3**). A + channel that was never watched, or that you explicitly `stopWatching()`d, stays unwatched, so the + client's watch count can never exceed what it already had, and blanket auto-watch never risks the + watch limit. To watch a specific channel yourself, call `channel.watch()`. --- @@ -1264,6 +1277,246 @@ const { aiState } = useStateStore(channel.state, (s) => ({ aiState: s.aiState }) --- +# Part L — Connection recovery (`client.connectionRecovery`) + +> **Who this affects.** Every app inherits the new behaviour, but almost none needs a code change. +> Reconnect recovery moved out of the UI SDK into `stream-chat`, so `` / `` / +> `` get it for free. You have work to do only if you set `recoverStateOnReconnect: false` +> and hand-rolled recovery, called `client.recoverState()` yourself, or listen for +> `connection.recovered`. + +In v9 the RN SDK reimplemented reconnect recovery in three uncoordinated places, because the +client's own recovery was unusable and `` switched it off (`client.recoverStateOnReconnect = +false`). v10 gives the client a `ConnectionRecoveryManager` — reachable as +`client.connectionRecovery` — that owns the whole flow, and the SDK's own listeners were deleted in +favour of it. + +**What recovery does once the socket is back:** + +1. **Every loaded channel list re-runs its own first-page query** — `client.channelManager.recover()`, + i.e. `paginator.toTail({ keepPreviousItems: true, reset: 'yes' })` for each initialized paginator. + Each list re-asserts its *own* `filters` / `sort` / `pageSize`, and since `queryChannels` watches + by default, that page's watches come back as a side effect. Non-destructive — the list never + blanks, unlike `channelManager.reload()`. +2. **Every `active` channel reloads itself** — `channel.reload()`. A list page carries far fewer + messages per channel than an open channel's loaded window, so the open channel cannot be served by + the list query. `` marks its channel `active` on mount (§K.4). +3. **Every active thread reloads its replies** — `thread.reload()`, for threads in + `client.threads.threadsById` whose `state.active` is set. Nothing else covers them: a channel + reload refreshes the main message list, and `ThreadManager`'s own recovery refreshes the thread + *list*, reusing thread instances without rehydrating them unless something separately marked them + stale — which only `user.watching.stop` does, never a reconnect. Note the current limitation: a + thread opened from a message list only enters `threadsById` once `` adopts it (after its + replies load), so a reconnect before that — or after `ThreadManager.reload()` evicts it — skips it. +4. **Every other previously-watched channel self-heals on demand** — §L.3. + +It is deliberately **not** a sweep over `client.activeChannels`: watches are a bounded server +resource, and after any scrolling that cache holds far more channels than a query page. + +With offline support enabled, active-channel recovery is triggered off the offline DB's sync-status +edge, so the ordering `executePendingTasks()` → `sync()` → reload holds on every reconnect path — +including mobile backgrounding (`closeConnection()` → `openConnection()`), which v9's client-side +recovery never covered at all. + +**No app code is required for any of this.** Do not add a `connection.changed` listener that +re-queries a list or re-watches a channel — that now duplicates the client. + +## L.1 `recoverStateOnReconnect` — same name, new meaning (behavioral) + +The option and its `true` default are unchanged, but what it gates is not. + +| | v9 | v10 | +|---|---|---| +| Gates | one `queryChannels({ cid: { $in: Object.keys(activeChannels) } }, [{ last_message_at: -1 }], { limit: 30 })` | the whole `client.connectionRecovery` flow above | +| Query shape | invented, unrelated to any list's `filters` / `sort` | each list's own first-page query | +| Coverage | the 30 most recently active channels, silently truncated | every loaded list page + every `active` channel | +| Fires on | `StableWSConnection._reconnect()` only | every reconnect path, backgrounding included | + +If you set `recoverStateOnReconnect: false` and recover state yourself, nothing changes — it is still +the kill switch. **The RN SDK no longer sets it to `false`**, so if you were relying on `` +disabling client recovery for you, it no longer does. + +```ts +// still the escape hatch, still opt-out +const client = new StreamChat(apiKey, { recoverStateOnReconnect: false }); +``` + +## L.2 `connection.recovered` now fires on every reconnect path (behavioral) + +Same event, same (empty) payload, still exactly one dispatcher — but the dispatcher moved from +`client.recoverState()` to `ConnectionRecoveryManager`, and it is dispatched *after* the recovery +reload. In v9 `recoverState()` was called only by `StableWSConnection._reconnect()`, so a +`closeConnection()` → `openConnection()` cycle — mobile backgrounding, the dominant path on React +Native — produced no `connection.recovered` at all. + +Consequences: + +- A listener that was effectively dead on backgrounding now runs there. If it is expensive or not + idempotent, check it. +- It is now a valid "recovery finished" hook: the channel reloads have settled by the time it fires. + That is how the SDK sequences its own mark-read-on-catch-up (§L.4). + +## L.3 A lost watch is restored on demand (behavioral — amends §18.5) + +The server keys watches by connection ID, so a dropped socket ends every watch it held. Recovery +re-watches the first page of each list plus the active channel; everything else — pages 2+ of a +scrolled list, channels visited and navigated away from, channels matching no mounted list — is left +marked `ChannelWatchStatus.WasWatching` (§K.3). + +`ChannelManager`'s default event pipeline now re-watches such a channel the moment an event routes it +into a list. Without this, a channel that lost its watch still receives member-level events (e.g. +`notification.message_new`) — enough to relocate its row, but carrying no message body — so its +preview would sit frozen until the channel was opened. + +This is **narrower than v9, not a revert of §18.5's second bullet**: v9 re-watched any routed channel +unconditionally, whereas v10 only restores a watch this client previously *held and lost*. Skipped +for `channel.hidden` events, for a channel pending disposal, and for `NotWatching` — a channel never +watched, or one you explicitly `stopWatching()`d, stays unwatched. The client's watch count can +therefore never exceed what it already had. The re-watch runs after routing and is not awaited, so +the row still relocates immediately off the event; it is idempotent, and concurrent watches for the +same cid are deduped. + +> Applies to `stream-chat-react` as well — this is a default `ChannelManager` handler, not RN code. + +## L.4 `` no longer runs any reconnect resync (behavioral) + +`` used to reload the channel on reconnect, and — when a thread was open — reload that +thread's replies. **Both are gone**; the component's only remaining part is declaring what is on +screen: + +```tsx +// what does now — the rest is client.connectionRecovery's job +useEffect(() => { channel?.activate?.(); return () => channel?.deactivate?.(); }, [channel]); +``` + +The open thread is declared the same way, but by **``**, which already did this before v10 — +`threadInstance.activate()` on mount, `deactivate()` on unmount. `` does not need to (and +does not) activate it as well. + +Neither reconnect handler was ever exported, so there is no symbol to migrate — this is here because +the behaviour relocated, and because the *capability* is unchanged: an integrator who relied on +`` refreshing the channel (or the open thread) on reconnect still gets both, one layer down +and on more reconnect paths than before. + +**If you render `` and `` you get this for free.** If you built your own views on the +contexts, call `channel.activate()` / `thread.activate()` (each balanced with `deactivate()`) or those +surfaces will not be recovered — being active is the one thing recovery cannot infer. + +One thing stays deliberately UI-side: **mark-read after the reload**, on `connection.recovered`. It +has to be post-reload so the "is the window at the newest?" check reflects the refreshed window. Read +policy is a UI decision (`useMarkRead`, §5); refreshing state is not. + +**The error surface is not UI-side either.** `` holds no error state of its own — it reads +`channel.state.lastLoadError` and `thread.state.lastLoadError` straight into `useChannelContext().error` +(§L.5). Both failures a UI cares about are recorded by the client: `channel.watch()` records the +mount-time failure of a channel opened with no connection and — since `reload()` goes through +`watch()` — the reconnect refresh too. + +**Nothing clears these on a connection event.** A load error is invalidated by the next load, not by +coming back online: `watch()` and `Thread.reload()` each clear before they await anything, which is the +same clear-before-attempt v9's `resyncChannel` did on its first line. Because the clear sits above the +first await, a reconnect reload reaches it inside the synchronous `connection.changed` dispatch — the +same dispatch a UI flips its own online flag in, so the two land in one render and an error masked +behind `!isOnline` is never flashed over content that is about to refresh. Clearing on the way in +cannot hide a real failure: the attempt records its own on the way out. If you render your own error UI +from `useChannelContext().error` you get all of this for free; if you latch an error of your own, +delete it. + +``'s own reconnect listener is gone for the same reason — the list re-query is item 1. + +## L.5 Additive surface + +- **`client.connectionRecovery`** — the `ConnectionRecoveryManager` instance. Public method: + `recover()`, which runs a full recovery immediately without waiting for a connection event. +- **`client.channelManager.recover()`** — re-runs every initialized list's first-page query, + non-destructively. Sibling of the destructive `reload()`, which is unchanged. +- **`thread.activate()` / `deactivate()` are now refcounted**, matching `channel.activate()`, so a + thread held by more than one mount stays active until the last holder releases it. `active` is what + recovery filters on, so an unbalanced `deactivate()` now costs a missed reload as well as a missed + auto-read. +- **`thread.state.lastLoadError`** — the thread twin of the channel field below, same contract, + written by `Thread.reload()`. +- **`channel.state.lastLoadError`** (+ the `channel.lastLoadError` getter) — mirrors + `BasePaginator.lastQueryError` one level up. Owned by **`watch()`**: cleared before its first await, + set in its catch, and **rethrown**. Owning it there rather than in `reload()` is what makes it cover + both failures a UI cares about — the reconnect refresh (the manager runs reloads inside + `Promise.allSettled`, which would otherwise swallow them) *and* the mount-time `watch()` of a channel + opened offline, which throws long before anything could later prove it stale. The SDK ORs both fields + into `useChannelContext().error`, which is what keeps the "Error loading messages for this channel…" + indicator working. + +```tsx +// surface a failed load in your own UI +const { lastLoadError } = useStateStore(channel.state, (s) => ({ + lastLoadError: s.lastLoadError, +})); +``` + +## L.6 `client.recoverState()` removed (breaking — `stream-chat`) + +Removed outright rather than left as a no-op, so a caller fails loudly instead of silently getting no +recovery. To force a recovery by hand: + +```ts +// Before (v9) +await client.recoverState(); +// After (v10) +await client.connectionRecovery.recover(); +``` + +The two are not equivalent in shape — `recoverState()` ran the 30-channel bulk query described in +§L.1 — but `recover()` is the v10 way to say "bring everything I am reading back in line with the +server now". Most apps never called it: it was invoked automatically on reconnect, and it still is. + +## L.7 Unsent messages now survive a reconnect rebuild (bug fix) + +`channel.reload()` and `thread.reload()` both preserve failed (locally unsent) messages across the +refresh. They already intended to, but the check for "did this one fall out?" read the paginator's +item *index* rather than its visible window — and on a disjoint rebuild (the loaded window shares no +id with the server's newest page, e.g. after scrolling up into old history) a message can sit in the +index while being absent from the rendered list. So the re-ingest was skipped on exactly the path +that needed it, and the user's unsent message disappeared from the list on reconnect. + +No API change and nothing to do — noted because if you worked around it by re-adding failed messages +yourself after a reconnect, that workaround is now redundant. + +For threads there was a second hole: the preservation relied on `Thread`'s `failedRepliesMap`, which +is only written by `upsertReplyLocally` — whose callers are the thread's own subscriptions and the +offline-DB path keyed on `ThreadManager.threadsById`. Neither covers a thread constructed directly and +never registered, which is the common path. `thread.reload()` now reads the failed replies out of the +reply paginator instead, so it holds for managed and unmanaged threads alike. + +## L.8 The reconnect refresh now finds messages you never had (bug fix) + +Two defects meant a reconnect could refresh a channel or thread and still miss content that arrived +while you were away. Both are fixed; no API change. + +- **The request was sized to what was already loaded.** `channel.reload()` / `thread.reload()` asked + for `loadedCount` items, so a window holding one message asked the server for one message — new + content was undiscoverable, and the single item that came back was disjoint from the loaded window, + so the fold rebuilt and dropped what was there. Now at least a page is requested. +- **A window nothing had ever anchored discarded the page entirely.** When the first query returns + empty and the only content arrives live — a thread or channel created in the current session — the + fetched page was thrown away rather than merged. It is now anchored instead. + +Most visible as: create a thread, send a reply, go offline, someone else replies, come back — the new +replies never appeared, and re-entering the thread did not help. + +## L.9 A brand-new thread no longer reports an error (bug fix) + +A parent with no replies has no server-side thread, so `getThread` answers "not found". That was +being treated as a failed refresh: `thread.state.lastLoadError` was set, which the React Native SDK +ORs into `useChannelContext().error`, so simply opening a new thread raised the channel's error state. +It is now recognised as the expected answer — `reload()` resolves quietly instead of rejecting, and +publishes nothing. + +A genuine failure still publishes as before, including a thread that *did* have replies and comes back +not-found (its parent was deleted, possibly while you were offline). The two are told apart by +`replyCount`, which — unlike `deletedAt` — survives having missed the deletion event. + +Related: the SDK no longer issues a mark-read for a reply-less thread. There is nothing that could be +unread, and the call only ever 404'd. + --- ## 19. Verify @@ -1287,6 +1540,18 @@ const { aiState } = useStateStore(channel.state, (s) => ({ aiState: s.aiState }) pin/archive updates membership-driven UI; and — with `` mounted — focusing a channel with unreads marks it read and reconnect does not blank/re-seed the open message list. +- **Connection recovery** (§L), on a real device, with offline support both on and + off: toggle airplane mode with the list open (it re-queries, never blanks) and + with a channel open (the window comes back byte-identical, scrolled into old + history included); background and foreground the app (same, and this is the path + v9 never recovered); open a channel while offline, then reconnect (its messages + load); send while offline, then reconnect (the queued message goes out); leave a + channel that was open, push it off page 1 of the list, reconnect, then message it + from another user (its preview updates — that is §L.3); open a thread and + reconnect (its replies refresh — §L.4); and cold-boot with a populated offline DB + (exactly one recovery, no double load). Also scroll up into old history, send a + message that fails, then reconnect — the unsent message must still be in the list + (§L.7). --- diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 6cb8123fac..1377a5d6c6 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -2,12 +2,10 @@ import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useS import { StyleSheet, Text, View } from 'react-native'; import { - Channel as ChannelType, ChannelConfig, EventHandler, LocalMessage, MessageComposerConfig, - MessageResponse, SendMessageAPIResponse, SendMessageOptions, Event as StreamEvent, @@ -84,12 +82,7 @@ import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; import { compressedImageURI } from '../../utils/compressImage'; import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; -import { - getFileNameFromPath, - isLocalUrl, - MessageStatusTypes, - ReactionData, -} from '../../utils/utils'; +import { getFileNameFromPath, isLocalUrl, ReactionData } from '../../utils/utils'; import { NotificationAnnouncer } from '../Accessibility/NotificationAnnouncer'; import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker'; import type { KeyboardCompatibleViewProps } from '../KeyboardCompatibleView/KeyboardCompatibleView'; @@ -350,6 +343,10 @@ const availableCommandsSelector = (state: ChannelConfig) => ({ availableCommands: state.availableCommands, }); +const loadErrorSelector = (state: { lastLoadError?: Error }) => ({ + lastLoadError: state.lastLoadError, +}); + const messageFocusSignalSelector = (state: { signal: { messageId?: string } | null }) => ({ highlightedMessageId: state.signal?.messageId, }); @@ -470,7 +467,6 @@ const ChannelWithContext = (props: PropsWithChildren) = const styles = useStyles(); const [deleted, setDeleted] = useState(false); - const [error, setError] = useState(false); const lastReadRef = useRef(undefined); // The active thread is fully prop-driven: derive it synchronously during render so the reply // data is present on the first frame (no setState round-trip / one-frame gap). Opening a thread @@ -494,13 +490,16 @@ const ChannelWithContext = (props: PropsWithChildren) = const [messageInputHeightStore] = useState(() => new MessageInputHeightStore()); const { bottomSheetRef, closePicker, openPicker } = useAttachmentPickerBottomSheet(); - const syncingChannelRef = useRef(false); - const { highlightedMessageId } = useStateStore( (threadInstance ?? channel).messagePaginator.messageFocusSignal, messageFocusSignalSelector, ); + const { lastLoadError } = useStateStore(channel?.state, loadErrorSelector) ?? {}; + const { lastLoadError: threadLoadError } = + useStateStore(threadInstance?.state, loadErrorSelector) ?? {}; + const error = lastLoadError ?? threadLoadError; + /** * This ref keeps track of message IDs which have already been optimistically updated. * We need it to make sure we don't react on message.new/notification.message_new events @@ -591,7 +590,6 @@ const ChannelWithContext = (props: PropsWithChildren) = await channel?.watch(); } catch (err) { console.warn('Channel watch request failed with error:', err); - setError(true); errored = true; channel.offlineMode = true; } @@ -684,99 +682,28 @@ const ChannelWithContext = (props: PropsWithChildren) = // instance via useMarkRead(channel). Channel still needs it internally (mark-read-on-mount + resync). const markRead = useMarkRead(channel); - const resyncChannel = useStableCallback(async () => { - if (!channel || syncingChannelRef.current || (!channel.initialized && !channel.offlineMode)) { + // Mark-read after the LLC's reconnect reload. `connection.recovered` is dispatched by + // `client.connectionRecovery` once that reload has landed, so `hasMoreHead` read here reflects the + // refreshed window — which is why this cannot hang off `connection.changed`. Only the reload moved + // into the LLC; whether a caught-up channel is marked read stays a UI decision (see `useMarkRead`). + useEffect(() => { + if (!shouldSyncChannel) { return; } - syncingChannelRef.current = true; - setError(false); - - const parseMessage = (message: LocalMessage) => - ({ - ...message, - created_at: message.created_at.toString(), - pinned_at: message.pinned_at?.toString(), - updated_at: message.updated_at?.toString(), - }) as unknown as MessageResponse; - - const getRecoverableFailedMessages = (messages: LocalMessage[] = []) => - messages - .filter( - (message) => - message.status === MessageStatusTypes.FAILED && - !(message.parent_id - ? threadInstance?.messagePaginator.getItem(message.id) - : channel.messagePaginator.getItem(message.id)), - ) - .map(parseMessage); - - try { - if (!thread) { - // The LLC owns the reconnect refresh now: channel.reload() re-watches, folds the newest page, - // and reconciles messages hard-deleted while offline — capturing the pre-fetch snapshot + the - // requested limit itself, so this no longer passes them (see Channel.reload / - // MessagePaginator.mergeNewestPage). - await channel.reload(); - // Only mark read when the refreshed window is at the newest (hasMoreHead false); if the user - // has paginated up into older history, leave their read state untouched. - const atLatest = !channel.messagePaginator.hasMoreHead; - if (atLatest) { - await markRead(); - } - } else if (threadInstance) { - await threadInstance.reload(); - - const currentThreadMessages = - threadInstance.messagePaginator.state.getLatestValue().items ?? []; - const failedThreadMessages = getRecoverableFailedMessages(currentThreadMessages); - if (failedThreadMessages.length) { - failedThreadMessages.forEach((m) => - threadInstance.messagePaginator.ingestItem(channel.state.formatMessage(m)), - ); - } - } - } catch (err) { - if (err instanceof Error) { - setError(err); - } else { - setError(true); - } - } - - syncingChannelRef.current = false; - }); - - // resync channel is added to ref so that it can be used in useEffect without adding it as a dependency - const resyncChannelRef = useRef(resyncChannel); - resyncChannelRef.current = resyncChannel; - useEffect(() => { - const connectionChangedHandler = () => { - if (shouldSyncChannel) { - resyncChannelRef.current(); + // Mark read has to wait for `connection.recovered`, as it is dispatched once the reloads have + // landed, so `hasMoreHead` read here reflects the refreshed window. Channel view only, and only + // when that window is at the newest, only if the user has paginated up into older history so leave + // their read state alone. + const { unsubscribe } = client.on('connection.recovered', () => { + if (thread || channel.messagePaginator.hasMoreHead) { + return; } - }; - let connectionChangedSubscription: ReturnType; + markRead(); + }); - if (enableOfflineSupport && client.offlineDb) { - connectionChangedSubscription = client.offlineDb.syncManager.onSyncStatusChange( - (statusChanged) => { - if (statusChanged) { - connectionChangedHandler(); - } - }, - ); - } else { - connectionChangedSubscription = client.on('connection.changed', (event) => { - if (event.online) { - connectionChangedHandler(); - } - }); - } - return () => { - connectionChangedSubscription.unsubscribe(); - }; - }, [enableOfflineSupport, client, shouldSyncChannel]); + return unsubscribe; + }, [channel, client, markRead, shouldSyncChannel, thread]); /** * Channel configs for use in disabling local functionality. @@ -807,21 +734,10 @@ const ChannelWithContext = (props: PropsWithChildren) = } try { if (thread) { - try { - // jumpToMessage loads the message range into thread.messagePaginator (which backs the - // reply list) and emits the focus signal driving the thread-aware highlight + scroll. - // The reply-list loading spinner is driven off the paginator's own isLoading flag. - await threadInstance?.messagePaginator?.jumpToMessage(messageIdToLoadAround, { - focusReason: 'jump-to-message', - focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, - }); - } catch (err) { - if (err instanceof Error) { - setError(err); - } else { - setError(true); - } - } + await threadInstance?.messagePaginator?.jumpToMessage(messageIdToLoadAround, { + focusReason: 'jump-to-message', + focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, + }); } else { await loadChannelAroundMessageFn({ messageId: messageIdToLoadAround, @@ -839,10 +755,11 @@ const ChannelWithContext = (props: PropsWithChildren) = // (see the useChannelRequestHandlers call below) so it runs INSIDE the stream-chat send pipeline — // after the LLC's optimistic ingest (message already shows pending), before the POST — awaiting // `client.uploadManager` to finish the in-flight uploads and swapping local preview URLs for the - // returned CDN URLs. This deliberately stays RN-side: native image compression (`compressedImageURI`) + // returned CDN URLs. It lives here for now because native image compression (`compressedImageURI`) // and a custom uploader registered through `client.config` must remain reachable, and the - // sendMessageRequest seam lets it run in the right place without a pre-ingest or any LLC change. It is NOT slated to move - // into the LLC — this handler is its intended home. + // sendMessageRequest seam lets it run in the right place without a pre-ingest or any LLC change. + // It IS slated to move into the LLC, just not yet — and that move is what lets the + // `doSendMessageRequest` prop and its wrapper in `useChannelRequestHandlers` go. const uploadPendingAttachments = useStableCallback(async (message: LocalMessage) => { if (!message.attachments?.length || !channel?.cid) { return; diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index 4da6d8905a..87d769e4fa 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -3,7 +3,7 @@ import { View } from 'react-native'; import { act, cleanup, render, waitFor } from '@testing-library/react-native'; import type { Channel as ChannelType, StreamChat as StreamChatType } from 'stream-chat'; -import { StreamChat } from 'stream-chat'; +import { StreamChat, Thread } from 'stream-chat'; import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import { ChannelContext, ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; @@ -26,6 +26,7 @@ import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Attachment } from '../../Attachment/Attachment'; import { Chat } from '../../Chat/Chat'; +import { Thread as ThreadComponent } from '../../Thread/Thread'; import { Channel } from '../Channel'; import * as MessageListPaginationHooks from '../hooks/useMessageListPagination'; @@ -156,7 +157,9 @@ describe('Channel', () => { it('should set an error if channel watch fails and render a LoadingErrorIndicator', async () => { const watchError = new Error('channel watch fail'); - jest.spyOn(channel, 'watch').mockImplementationOnce(() => Promise.reject(watchError)); + // Rejected at the API seam so the real `watch()` runs: it is what records the failure on + // `channel.state.lastLoadError`, which is the only thing this component reads. + jest.spyOn(channel, 'getOrCreate').mockRejectedValueOnce(watchError); // The LoadingErrorIndicator renders only when watch errors AND the paginator has no messages // to fall back on; seed an empty (rather than the default undefined) item window so the guard // `messages?.length === 0` holds. @@ -562,7 +565,10 @@ describe('Channel initial load useEffect', () => { }); }); - it('should call resyncChannel when connection changed event is triggered', async () => { + it('reloads the channel on reconnect while preserving failed messages', async () => { + // The reload is issued by `client.connectionRecovery` now, not by this component — `` + // only marks the channel active. Asserted end to end on purpose: what matters is that a reconnect + // still refreshes the open channel and still does not lose locally-unsent messages. // Deterministic timestamps so the 10 loaded messages and the 10 offline-failed messages occupy // adjacent, ordered positions in the paginator's active window. const baseTime = 1600000000000; @@ -602,11 +608,402 @@ describe('Channel initial load useEffect', () => { act(() => dispatchConnectionChanged(chatClient)); }); - // resyncChannel re-watches via channel.reload() and reconciles, but preserves the failed - // (locally-unsent) messages — the 10 originals + 10 failed remain. + // The reload re-watches and reconciles, but preserves the failed (locally-unsent) messages — + // the 10 originals + 10 failed remain. await waitFor(() => { expect(reloadSpy).toHaveBeenCalled(); expect(channel.messagePaginator.headItems.length).toBe(20); }); }); + + // Regression guard for the reconnect refresh of an OPEN THREAD's replies, which now runs entirely in + // `client.connectionRecovery` — this component's only part is marking the thread active. + // + // Asserted end to end on purpose: the LLC can only reach the thread through `client.activeThreads`, + // and a thread resolved as `threadsById[id] ?? new Thread(...)` (the common path — see the + // `threadInstance` memo) is in no other registry. Drop the `threadInstance.activate()` effect and + // recovery silently skips the thread with nothing else failing, so it is pinned here. + it('reloads an open thread on reconnect', async () => { + const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const testChannel = chatClient.channel('messaging', mockedChannel.channel.id); + await testChannel.watch(); + + const parentMessage = generateMessage({ user }); + const threadInstance = new Thread({ + channel: testChannel, + client: chatClient, + parentMessage: testChannel.state.formatMessage(parentMessage), + }); + const reload = jest.spyOn(threadInstance, 'reload').mockResolvedValue(undefined); + // Recovery finds threads through `client.threads.threadsById`, and only adopts an + // unmanaged instance into the manager once its reply paginator has loaded (Thread.tsx:126, gated + // on `items !== undefined`). Seed loaded-but-empty replies so that adoption actually happens — + // without it this test exercises the documented gap (active but unadopted → skipped) rather than + // the path it means to cover. + act(() => threadInstance.messagePaginator.state.partialNext({ items: [], isLoading: false })); + + render( + + the one that owns the thread view + // (`shouldSyncChannel`); without it the channel view would claim it instead. + threadList + thread={{ thread: testChannel.state.formatMessage(parentMessage), threadInstance }} + > + {/* The real is what calls `threadInstance.activate()`, which is the ONLY thing + that puts the instance in `client.activeThreads` for recovery to find. Rendering it is + the point of the test — a bare would not activate anything. */} + + + , + ); + + // Wait for to activate AND adopt the instance — both are preconditions for recovery to + // see it at all. (With replies seeded above, Thread.tsx's mount metadata-reload is skipped, so + // the spy is clean; cleared anyway so this can only pass on a reconnect-driven call.) + await waitFor(() => { + expect(chatClient.threads.threadsById[threadInstance.id]).toBeDefined(); + expect(threadInstance.state.getLatestValue().active).toBe(true); + }); + reload.mockClear(); + + act(() => dispatchConnectionChanged(chatClient, false)); + act(() => dispatchConnectionChanged(chatClient)); + + await waitFor(() => expect(reload).toHaveBeenCalled()); + }); + + it('surfaces and drops a channel load error on a thread view too', async () => { + // Sibling of the channel-view case below, and the reason the error is a straight store read + // rather than anything gated on which view is open: a thread screen opened offline sees the same + // failed `channel.watch()`, and used to keep the "Error loading messages for this channel..." + // banner forever after a perfectly good recovery. + const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const testChannel = chatClient.channel('messaging', mockedChannel.channel.id); + + const parentMessage = generateMessage({ user }); + // Rejected at the API seam, not by mocking `watch()`: the real `watch()` is what records the + // failure on `channel.state.lastLoadError`, which is the whole surface under test. + const getOrCreateSpy = jest + .spyOn(testChannel, 'getOrCreate') + .mockRejectedValue(new Error('offline: watch failed')); + const threadInstance = new Thread({ + channel: testChannel, + client: chatClient, + parentMessage: testChannel.state.formatMessage(parentMessage), + }); + jest.spyOn(threadInstance, 'reload').mockResolvedValue(undefined); + + let contextError: unknown; + render( + + + {/* activates the instance; without it recovery never sees the thread. */} + + { + contextError = (ctx as { error: unknown }).error; + }} + context={ChannelContext as React.Context} + /> + + , + ); + + await waitFor(() => expect(contextError).toBeInstanceOf(Error)); + + getOrCreateSpy.mockRestore(); + act(() => dispatchConnectionChanged(chatClient, false)); + act(() => dispatchConnectionChanged(chatClient)); + + await waitFor(() => expect(contextError).toBeUndefined()); + }); + + it('drops the load error when the reload starts, not when it finishes', async () => { + // Timing, not just outcome. `NetworkDownIndicator` masks `error` behind `!isOnline`, and + // `isOnline` flips on `connection.changed`. The clear has to happen no later than that same + // dispatch, or the banner reads "Error loading messages for this channel..." over good + // (often offline-cached) content for the whole length of the recovery. It does, because + // `Channel.watch()` clears above its first await and the reload reaches it synchronously — the + // same clear-before-attempt v9's `resyncChannel` did on its first line. So the request is held + // open here and the error must ALREADY be gone. + const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const testChannel = chatClient.channel('messaging', mockedChannel.channel.id); + + const getOrCreateSpy = jest + .spyOn(testChannel, 'getOrCreate') + .mockRejectedValue(new Error('offline: watch failed')); + + let contextError: unknown; + render( + + + { + contextError = (ctx as { error: unknown }).error; + }} + context={ChannelContext as React.Context} + /> + + , + ); + + await waitFor(() => expect(contextError).toBeInstanceOf(Error)); + + // The re-attempt never comes back, so nothing about its OUTCOME can be what clears the error — + // only the clear that runs on the way in. Held at the API seam rather than by mocking `reload`, + // which would skip the very code under test. + getOrCreateSpy.mockReturnValue(new Promise(() => {})); + + act(() => dispatchConnectionChanged(chatClient, false)); + act(() => dispatchConnectionChanged(chatClient)); + + await waitFor(() => expect(contextError).toBeUndefined()); + }); + + it('does not mark a reply-less thread read on open, but does once it has replies', async () => { + // A parent with no replies has no server-side thread, so the mark-read 404s on every open. There + // is also nothing that could be unread, so the call is skipped rather than made and swallowed. + const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const testChannel = chatClient.channel('messaging', mockedChannel.channel.id); + await testChannel.watch(); + const markRead = jest + .spyOn(testChannel, 'markRead') + .mockResolvedValue({} as Awaited>); + + const parentMessage = generateMessage({ user }); + const makeThread = (replyCount: number) => { + const instance = new Thread({ + channel: testChannel, + client: chatClient, + parentMessage: testChannel.state.formatMessage({ + ...parentMessage, + reply_count: replyCount, + }), + }); + jest.spyOn(instance, 'reload').mockResolvedValue(undefined); + return instance; + }; + + const empty = makeThread(0); + const { unmount } = render( + + + + + , + ); + await waitFor(() => expect(empty.state.getLatestValue().active).toBe(true)); + expect(markRead).not.toHaveBeenCalled(); + unmount(); + + // Same component, a thread that does have replies: the call is made as before. + const withReplies = makeThread(3); + render( + + + + + , + ); + await waitFor(() => expect(markRead).toHaveBeenCalledWith({ thread_id: withReplies.id })); + }); + + it('does not raise the channel error when a brand-new thread has no server-side thread yet', async () => { + // Opening a parent with no replies makes 's metadata reload answer DoesNotExist (404). + // That is expected, so it must not reach `ChannelContext.error` — otherwise every freshly created + // thread shows the "could not load messages" state. + const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const testChannel = chatClient.channel('messaging', mockedChannel.channel.id); + await testChannel.watch(); + + const parentMessage = generateMessage({ user }); + const threadInstance = new Thread({ + channel: testChannel, + client: chatClient, + parentMessage: testChannel.state.formatMessage(parentMessage), + }); + const notFound = Object.assign(new Error('Request failed with status code 404'), { + code: 16, + StatusCode: 404, + }); + const getThread = jest.spyOn(chatClient, 'getThreadAndHydrate').mockRejectedValue(notFound); + + let contextError: unknown; + render( + + + {/* is what issues the metadata reload this test is about. */} + + { + contextError = (ctx as { error: unknown }).error; + }} + context={ChannelContext as React.Context} + /> + + , + ); + + // Anchor on the rejection having actually happened, then on the reload having settled — asserting + // `contextError` before either would pass without anything running (`isLoading` starts false). + await waitFor(() => expect(getThread).toHaveBeenCalled()); + await waitFor(() => expect(threadInstance.state.getLatestValue().isLoading).toBe(false)); + await waitFor(() => + expect(threadInstance.state.getLatestValue().lastLoadError).toBeUndefined(), + ); + + expect(contextError).toBeUndefined(); + }); + + it('surfaces a failed thread reload on the channel context', async () => { + // Recovery runs `thread.reload()` inside a `Promise.allSettled`, so a failure cannot reach this + // component as a throw — it arrives on `thread.state.lastLoadError` and has to be ORed into + // `ChannelContext.error`. That wiring is what this pins; the "recovery actually calls reload" + // half is covered by "reloads an open thread on reconnect" above, so the failure is published + // directly here rather than driven through a reconnect. + const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const testChannel = chatClient.channel('messaging', mockedChannel.channel.id); + await testChannel.watch(); + + const parentMessage = generateMessage({ user }); + const threadInstance = new Thread({ + channel: testChannel, + client: chatClient, + parentMessage: testChannel.state.formatMessage(parentMessage), + }); + + let contextError: unknown; + render( + + + { + contextError = (ctx as { error: unknown }).error; + }} + context={ChannelContext as React.Context} + /> + + , + ); + + await waitFor(() => expect(contextError).toBeUndefined()); + + const failure = new Error('thread reload failed'); + act(() => threadInstance.state.partialNext({ lastLoadError: failure })); + + await waitFor(() => expect(contextError).toBe(failure)); + }); + + it('surfaces the error from opening the channel offline, and drops it once recovery lands', async () => { + // Opening a channel with no connection makes the mount-time `watch()` throw. This component + // catches nothing and holds no error state: `watch()` records the failure on + // `channel.state.lastLoadError`, which is read straight into `ChannelContext.error` — and the LLC + // drops it on the connection edge. Both halves of that wiring are pinned here, because if either + // breaks the "Error loading messages for this channel..." indicator either never appears or + // outlives the recovery it describes. + const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const testChannel = chatClient.channel('messaging', mockedChannel.channel.id); + + // Not initialized and the API seam rejecting = the offline-open path, with the real `watch()` + // running so it is the LLC that records the failure. + const getOrCreateSpy = jest + .spyOn(testChannel, 'getOrCreate') + .mockRejectedValue(new Error('offline: watch failed')); + + let contextError: unknown; + render( + + + { + contextError = (ctx as { error: unknown }).error; + }} + context={ChannelContext as React.Context} + /> + + , + ); + + await waitFor(() => expect(contextError).toBeInstanceOf(Error)); + + // Connection is back: the reload the LLC issues now succeeds. + getOrCreateSpy.mockRestore(); + + act(() => dispatchConnectionChanged(chatClient, false)); + act(() => dispatchConnectionChanged(chatClient)); + + await waitFor(() => expect(contextError).toBeUndefined()); + }); + + it('surfaces a failed reconnect reload on the channel context', async () => { + // The reload is issued by `client.connectionRecovery` inside a `Promise.allSettled`, so a failure + // never reaches this component as a throw. It arrives on `channel.state.lastLoadError` instead, + // and has to reach `ChannelContext.error` — that is what drives the "Error loading messages for + // this channel…" indicator. + const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const testChannel = chatClient.channel('messaging', mockedChannel.channel.id); + await testChannel.watch(); + + // Rendered inline rather than through this describe's `renderComponent`, which does not take a + // context probe. + let contextError: unknown; + render( + + + { + contextError = (ctx as { error: unknown }).error; + }} + context={ChannelContext as React.Context} + /> + + , + ); + // Let the mount settle with a healthy channel first, so the error asserted below can only have + // come from the reconnect reload and not from the mount-time watch. + await waitFor(() => expect(contextError).toBeUndefined()); + + // Same seam: the reload goes through `watch()`, which records the failure on its way out. Also + // shows the connection-edge clear cannot stomp a fresh failure — it runs first, then this lands. + const failure = new Error('reload failed'); + jest.spyOn(testChannel, 'getOrCreate').mockRejectedValue(failure); + + act(() => dispatchConnectionChanged(chatClient, false)); + act(() => dispatchConnectionChanged(chatClient)); + + await waitFor(() => expect(contextError).toBe(failure)); + }); }); diff --git a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx index e4d4fde547..fc4f58b893 100644 --- a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx +++ b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx @@ -836,11 +836,16 @@ describe('ChannelList', () => { }); describe('connection.changed', () => { - it('should force reconnection refreshes past the pull-to-refresh debounce while keeping them out of the refreshing UI', async () => { - // Regression guard: a reconnect is the sole trigger that re-watches channels on the fresh - // socket, so it must bypass the 5s pull-to-refresh throttle (`force`). Without the bypass a - // second reconnect landing inside the debounce window is dropped and its channels stay - // un-watched (frozen last message / unread) until the next reconnect > 5s later. + it('refreshes on every reconnect, however close together, without surfacing in the refreshing UI', async () => { + // Regression guard for a shipped freeze bug: a reconnect is the trigger that re-watches + // channels on the fresh socket, so dropping one leaves its channels un-watched and their + // per-channel state (last message / unread) frozen until the next reconnect or an app reload. + // The list reorders anyway off member-level `notification.message_new`, which is what made it + // look like the connection was fine. + // + // Recovery is now owned by `client.connectionRecovery`, which has no throttle at all — the + // 5s window this used to have to be forced past belongs to pull-to-refresh only and is no + // longer on the reconnect path. The behaviour asserted here is unchanged. useMockedApis(chatClient, [queryChannelsApi([testChannel1])]); // Freeze the clock at t=0 for the whole mount so `lastRefresh` is seeded to 0 regardless of // how many `Date.now()` calls the render makes. @@ -881,8 +886,8 @@ describe('ChannelList', () => { ).toBe(false); }); - // Reconnect #2 at t=6000, i.e. 0ms after reconnect #1 → inside the debounce window. It fires a - // fresh query only because reconnection refreshes are forced past the throttle. + // Reconnect #2 at t=6000, i.e. 0ms after reconnect #1 — well inside what used to be the + // debounce window. It must still fire a fresh query. act(() => dispatchConnectionChangedEvent(chatClient, false)); act(() => dispatchConnectionChangedEvent(chatClient, true)); await waitFor(() => { diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts index a6381de068..27b41acbba 100644 --- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts +++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts @@ -169,19 +169,13 @@ export const usePaginatedChannels = ({ ); const refreshList = useStableCallback( - async ({ - force = false, - isBackground = false, - }: { force?: boolean; isBackground?: boolean } = {}) => { + async ({ isBackground = false }: { isBackground?: boolean } = {}) => { const now = Date.now(); - // Only allow pull-to-refresh 5 seconds after the last successful refresh. A reconnect (`force`) - // must bypass this throttle: it is the sole trigger that re-establishes channel watches after the - // socket reopens (the JS client's own recovery is disabled via `recoverStateOnReconnect = false`), - // so debouncing it leaves the channels un-watched — the list still reorders on member-level - // `notification.message_new`, but per-channel state (last message / unread) stays frozen until the - // next reconnect > 5s later or an app reload. This bites both a reconnect < 5s after launch - // (`lastRefresh` is seeded to mount time) and two reconnects < 5s apart. - if (!force && now - lastRefresh.current < RETRY_INTERVAL_IN_MS && error === undefined) { + // Only allow pull-to-refresh 5 seconds after the last successful refresh. This throttle is now + // purely about the user pulling repeatedly: reconnect refreshes no longer come through here at + // all — `client.connectionRecovery` owns them and re-runs each list's own query directly on the + // paginator, so they can never be swallowed by this window. + if (now - lastRefresh.current < RETRY_INTERVAL_IN_MS && error === undefined) { return; } @@ -214,20 +208,6 @@ export const usePaginatedChannels = ({ } reloadList(); - - const listener: ReturnType = client.on( - 'connection.changed', - async (event) => { - if (event.online) { - // Reconnection refreshes stay silent (`isBackground`) but must NOT be throttled by the - // pull-to-refresh debounce (`force`) — this is the query that re-watches the channels on the - // fresh socket. See the `force` note in `refreshList`. - await refreshList({ force: true, isBackground: true }); - } - }, - ); - - return () => listener?.unsubscribe?.(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [filterStr, optionsStr, sortStr, paginator]); diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index f7c39d819f..1711a33582 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -281,9 +281,6 @@ const ChatWithContext = (props: PropsWithChildren) => { version, }; client.deviceIdentifier = { os: `${Platform.OS} ${Platform.Version}` }; - // This is to disable recovery related logic in js client, since we handle it in this SDK - client.recoverStateOnReconnect = false; - client.preventThreadCleanup = true; client.persistUserOnConnectionFailure = enableOfflineSupport; } diff --git a/package/src/components/Indicators/LoadingErrorIndicator.tsx b/package/src/components/Indicators/LoadingErrorIndicator.tsx index e24e399906..cd00204c72 100644 --- a/package/src/components/Indicators/LoadingErrorIndicator.tsx +++ b/package/src/components/Indicators/LoadingErrorIndicator.tsx @@ -32,7 +32,7 @@ const LoadingErrorWrapper = (props: React.PropsWithChildren Promise; retry?: () => void; diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index 16266f28e8..fcc06f107a 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -141,13 +141,17 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { // Mark the thread read on open. Mirrors the pre-refactor openThread behavior: channel.markRead with // a thread_id marks reliably even when the thread instance's own unread count is 0 (so it can't - // rely on the LLC's active-thread auto-read); a reply-less parent has no server-side thread yet and - // rejects with "thread not found", which we swallow. + // rely on the LLC's active-thread auto-read); a reply-less parent has no server-side thread yet, so + // the call is skipped rather than left to 404 on every open. useEffect(() => { const channel = threadInstance?.channel; if (!threadInstance?.id || !channel?.initialized) { return; } + // No replies means nothing that could be unread — true whether or not the thread exists yet. + if (threadInstance.state.getLatestValue().replyCount === 0) { + return; + } channel .markRead({ thread_id: threadInstance.id }) .catch((err) => console.warn('Marking thread as read on open failed with error:', err)); diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index 80cdc07704..cfdec77fd8 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -37,7 +37,12 @@ export type ChannelContextValue = { * This is similar to reaction UX on [iMessage application](https://en.wikipedia.org/wiki/IMessage). */ enforceUniqueReaction: boolean; - error: boolean | Error; + /** + * The error from the most recent attempt to load this channel's messages, or the open thread's + * replies — read straight off `channel.state.lastLoadError` / `thread.state.lastLoadError`, each + * cleared by the next attempt. `undefined` when the last load succeeded. + */ + error?: Error; /** * Hide inline date separators on channel */