diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index 874366f40f..db1c4b4629 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -51,7 +51,7 @@ "react-native-teleport": "^1.1.12", "react-native-web": "^0.21.2", "react-native-worklets": "0.11.1", - "stream-chat": "^9.50.3", + "stream-chat": "^9.51.0", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index b337bd3b94..674ff2413f 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -64,7 +64,7 @@ "react-native-teleport": "^1.1.12", "react-native-video": "^6.19.2", "react-native-worklets": "^0.11.1", - "stream-chat": "^9.50.3", + "stream-chat": "^9.51.0", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package/package.json b/package/package.json index 67642fa8e7..6d219434a6 100644 --- a/package/package.json +++ b/package/package.json @@ -78,7 +78,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.50.3", + "stream-chat": "^9.51.0", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 6e9b3079cc..7eb6228cf3 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -23,6 +23,7 @@ import { useStreami18n } from '../../hooks/useStreami18n'; import init from '../../init'; import { NativeHandlers } from '../../native'; +import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../store/constants'; import { OfflineDB } from '../../store/OfflineDB'; import type { Streami18n } from '../../utils/i18n/Streami18n'; @@ -44,6 +45,28 @@ export type ChatProps = Pick & * Enables offline storage and loading for chat data. */ enableOfflineSupport?: boolean; + /** + * Optional positive cap on the number of events a single `/sync` response may + * contain before the offline sync manager skips replaying those events into + * local storage. + * + * On reconnect the SDK downloads the events missed while offline and writes + * them to the offline DB. For a very large payload this replay is both costly + * on-device and unnecessary for what the user is looking at — the active + * channel list and any open channel are refreshed independently on reconnect + * (via `queryChannels` + `channel.watch()`). When the payload exceeds this + * limit the replay is skipped and that reconnect refresh covers the visible + * channels; inactive channels are hydrated on their next explicit query. The + * last-sync timestamp is still advanced so the same payload is not retried. + * + * Defaults to {@link DEFAULT_MAX_SYNC_EVENTS_LIMIT} (250). Pass `false` to + * disable the limit entirely (replay every event — the historical behavior). + * + * Only relevant when `enableOfflineSupport` is enabled. + * + * @default 250 + */ + maxSyncEventsLimit?: number | false; /** * When true, multipart uploads use the SDK's native upload adapter when available. * When false, uploads stay on the default axios adapter. @@ -151,6 +174,7 @@ const ChatWithContext = (props: PropsWithChildren) => { enableOfflineSupport = false, i18nInstance, isMessageAIGenerated, + maxSyncEventsLimit = DEFAULT_MAX_SYNC_EVENTS_LIMIT, style, useNativeMultipartUpload = false, } = props; @@ -224,7 +248,7 @@ const ChatWithContext = (props: PropsWithChildren) => { const initializeDatabase = async () => { if (!client.offlineDb) { - client.setOfflineDBApi(new OfflineDB({ client })); + client.setOfflineDBApi(new OfflineDB({ client, maxSyncEventsLimit })); } if (client.offlineDb) { @@ -233,7 +257,7 @@ const ChatWithContext = (props: PropsWithChildren) => { }; initializeDatabase(); - }, [userID, enableOfflineSupport, client]); + }, [userID, enableOfflineSupport, client, maxSyncEventsLimit]); useEffect(() => { if (!client) { diff --git a/package/src/components/Chat/__tests__/Chat.test.tsx b/package/src/components/Chat/__tests__/Chat.test.tsx index 945e04b376..4d6a43ad29 100644 --- a/package/src/components/Chat/__tests__/Chat.test.tsx +++ b/package/src/components/Chat/__tests__/Chat.test.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { View } from 'react-native'; import NetInfo from '@react-native-community/netinfo'; - import { act, cleanup, render, waitFor } from '@testing-library/react-native'; import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; @@ -13,6 +12,7 @@ import { useTranslationContext } from '../../../contexts/translationContext/Tran import dispatchConnectionChangedEvent from '../../../mock-builders/event/connectionChanged'; import dispatchConnectionRecoveredEvent from '../../../mock-builders/event/connectionRecovered'; import { getTestClient, getTestClientWithUser, setUser } from '../../../mock-builders/mock'; +import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../../store/constants'; import { Streami18n } from '../../../utils/i18n/Streami18n'; import { Chat } from '../Chat'; @@ -330,4 +330,41 @@ describe('TranslationContext', () => { ); }); }); + + it('forwards maxSyncEventsLimit to the offline DB sync manager', async () => { + const chatClientWithUser = await getTestClientWithUser({ id: 'testID' }); + + render(); + + await waitFor(() => { + expect(chatClientWithUser.offlineDb).toBeDefined(); + }); + expect(chatClientWithUser.offlineDb!.syncManager.syncMaxEventCount).toBe(42); + }); + + it('defaults maxSyncEventsLimit to 250 when not provided', async () => { + const chatClientWithUser = await getTestClientWithUser({ id: 'testID' }); + + render(); + + await waitFor(() => { + expect(chatClientWithUser.offlineDb).toBeDefined(); + }); + expect(chatClientWithUser.offlineDb!.syncManager.syncMaxEventCount).toBe( + DEFAULT_MAX_SYNC_EVENTS_LIMIT, + ); + expect(DEFAULT_MAX_SYNC_EVENTS_LIMIT).toBe(250); + }); + + it('disables the sync event limit when maxSyncEventsLimit is false', async () => { + const chatClientWithUser = await getTestClientWithUser({ id: 'testID' }); + + render(); + + await waitFor(() => { + expect(chatClientWithUser.offlineDb).toBeDefined(); + }); + // `false` opts out: the client stores no limit (undefined), so replay always runs. + expect(chatClientWithUser.offlineDb!.syncManager.syncMaxEventCount).toBeUndefined(); + }); }); diff --git a/package/src/store/OfflineDB.ts b/package/src/store/OfflineDB.ts index 78d744bfd8..d0d6a408fc 100644 --- a/package/src/store/OfflineDB.ts +++ b/package/src/store/OfflineDB.ts @@ -12,8 +12,17 @@ import * as api from './apis'; import { SqliteClient } from './SqliteClient'; export class OfflineDB extends AbstractOfflineDB { - constructor({ client }: { client: StreamChat }) { - super({ client }); + constructor({ + client, + maxSyncEventsLimit, + }: { + client: StreamChat; + maxSyncEventsLimit?: number | false; + }) { + super({ + client, + syncMaxEventCount: maxSyncEventsLimit === false ? undefined : maxSyncEventsLimit, + }); } upsertCidsForQuery = api.upsertCidsForQuery; diff --git a/package/src/store/constants.ts b/package/src/store/constants.ts index 603a7b497e..79ed28573d 100644 --- a/package/src/store/constants.ts +++ b/package/src/store/constants.ts @@ -1,3 +1,11 @@ export const DB_NAME = 'stream-chat-react-native'; export const DB_LOCATION = 'databases'; export const DB_STATUS_ERROR = 1; + +/** + * Default value for the `maxSyncEventsLimit` prop on `Chat`. Chosen conservatively + * and below the backend hard cap; tune with performance data. The underlying LLC + * (`stream-chat`) has no default of its own, this default is implied purely by the + * RN SDK. + */ +export const DEFAULT_MAX_SYNC_EVENTS_LIMIT = 250; diff --git a/yarn.lock b/yarn.lock index 3c3d11becd..22e9fc5f9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7029,7 +7029,7 @@ __metadata: react-native-teleport: "npm:^1.1.12" react-native-web: "npm:^0.21.2" react-native-worklets: "npm:0.11.1" - stream-chat: "npm:^9.50.3" + stream-chat: "npm:^9.51.0" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -7524,15 +7524,15 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.16.1": - version: 1.17.0 - resolution: "axios@npm:1.17.0" +"axios@npm:^1.19.0": + version: 1.19.0 + resolution: "axios@npm:1.19.0" dependencies: follow-redirects: "npm:^1.16.0" - form-data: "npm:^4.0.5" + form-data: "npm:^4.0.6" https-proxy-agent: "npm:^5.0.1" proxy-from-env: "npm:^2.1.0" - checksum: 10c0/c4fa19ff3a3a63bde48beec03ad816b133b9a6385cccffffe172577ab18c6a70e299280d57f12c80c867fe25df41f92cb91d3a8258708a6d2be3e9e085f92650 + checksum: 10c0/559fe7d51291787def61566a3db78b87510c8faf9c8a8c006d9d8b933808628ff0d8eca7756b40ae25e07da96d946c68c1ffba3da9075ed0b5d3661801d76869 languageName: node linkType: hard @@ -11080,16 +11080,16 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.5": - version: 4.0.5 - resolution: "form-data@npm:4.0.5" +"form-data@npm:^4.0.6": + version: 4.0.6 + resolution: "form-data@npm:4.0.6" dependencies: asynckit: "npm:^0.4.0" combined-stream: "npm:^1.0.8" es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.2" - mime-types: "npm:^2.1.12" - checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b + hasown: "npm:^2.0.4" + mime-types: "npm:^2.1.35" + checksum: 10c0/43947a77bf0ff45c6ceed789778982d47a3f3e720a74b71721174ebf3310a5f1a8be1d6b38a3ee3688e8a18a2c4273073ec0844cd37efda3eaf46d41c9c318ff languageName: node linkType: hard @@ -11618,6 +11618,15 @@ __metadata: languageName: node linkType: hard +"hasown@npm:^2.0.4": + version: 2.0.4 + resolution: "hasown@npm:2.0.4" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/2d8de939e270b70618f8cebb69746620db10617dbb495bc66ddad326955ea24d3ca4af133aff3eb7c1853e0218f867bc2b050ec26fe02e3aea58f880ffc5e506 + languageName: node + linkType: hard + "hermes-compiler@npm:250829098.0.14": version: 250829098.0.14 resolution: "hermes-compiler@npm:250829098.0.14" @@ -15006,7 +15015,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.27, mime-types@npm:^2.1.35, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": +"mime-types@npm:^2.1.27, mime-types@npm:^2.1.35, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -18101,7 +18110,7 @@ __metadata: react-native-teleport: "npm:^1.1.12" react-native-video: "npm:^6.19.2" react-native-worklets: "npm:^0.11.1" - stream-chat: "npm:^9.50.3" + stream-chat: "npm:^9.51.0" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -18840,7 +18849,7 @@ __metadata: react-native-worklets: "npm:^0.11.1" react-test-renderer: "npm:19.2.3" rimraf: "npm:^6.0.1" - stream-chat: "npm:^9.50.3" + stream-chat: "npm:^9.51.0" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.5.0" uuid: "npm:^11.1.0" @@ -18914,15 +18923,15 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.50.3": - version: 9.50.3 - resolution: "stream-chat@npm:9.50.3" +"stream-chat@npm:^9.51.0": + version: 9.51.0 + resolution: "stream-chat@npm:9.51.0" dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" - axios: "npm:^1.16.1" + axios: "npm:^1.19.0" base64-js: "npm:^1.5.1" - form-data: "npm:^4.0.5" + form-data: "npm:^4.0.6" isomorphic-ws: "npm:^5.0.0" jsonwebtoken: "npm:^9.0.3" linkifyjs: "npm:^4.3.3" @@ -18932,7 +18941,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/75be67c6533f6bf02313565eb164115a2b61b399ab268f931689bb709ee75f6a5c68c01baa835082252856b2c307c13c4aa8a6986de20ab0f19cda1f062c7d9b + checksum: 10c0/a2888b1dad9496f8ba35e5afb44f88a6ce6b64785780b0244eba349ee10c52594f43c08a16a23924b14640a50849ce835773f016435725378bf0bbcfe0d636ed languageName: node linkType: hard