From 15d93c4db3c51983ef6ab23b97e9e983cc213622 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 14 Aug 2026 15:23:54 +0200 Subject: [PATCH 1/6] perf: sync event count --- examples/SampleApp/metro.config.js | 17 +++++++---- package.json | 3 +- package/jest.config.js | 10 ++++++- package/src/components/Chat/Chat.tsx | 29 +++++++++++++++++-- .../components/Chat/__tests__/Chat.test.tsx | 27 ++++++++++++++++- package/src/store/OfflineDB.ts | 4 +-- package/src/store/constants.ts | 8 +++++ yarn.lock | 9 +++--- 8 files changed, 90 insertions(+), 17 deletions(-) diff --git a/examples/SampleApp/metro.config.js b/examples/SampleApp/metro.config.js index c96816d4be..1b9cbd825b 100644 --- a/examples/SampleApp/metro.config.js +++ b/examples/SampleApp/metro.config.js @@ -19,6 +19,9 @@ const metroExclusionList = require( const exclusionList = metroExclusionList.default || metroExclusionList; const packageDirPath = PATH.resolve(__dirname, '../../package'); const nativePackageDirPath = PATH.resolve(__dirname, '../../package/native-package'); +// Local portaled checkout of stream-chat-js. Metro doesn't honor Yarn's portal +// symlink for native bundling, so it must be pointed at the absolute path explicitly. +const streamChatLocalPath = '/Users/isekovanic/Projects/stream-chat-js-temp'; const symlinked = { 'stream-chat-react-native': nativePackageDirPath, @@ -65,10 +68,14 @@ const uniqueModules = dependencyPackageNames.map((packageName) => { const blockList = uniqueModules.map(({ blockPattern }) => blockPattern); // provide the path for the unique modules -const extraNodeModules = uniqueModules.reduce((acc, item) => { - acc[item.packageName] = item.modulePath; - return acc; -}, {}); +const extraNodeModules = uniqueModules.reduce( + (acc, item) => { + acc[item.packageName] = item.modulePath; + return acc; + }, + // Seed the local stream-chat portal so the uniqueModules reduce doesn't overwrite it. + { 'stream-chat': streamChatLocalPath }, +); config.resolver.blockList = exclusionList(blockList); config.resolver.extraNodeModules = extraNodeModules; @@ -76,6 +83,6 @@ config.resolver.extraNodeModules = extraNodeModules; config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; // add the package dir for metro to access the package folder -config.watchFolders = [packageDirPath]; +config.watchFolders = [packageDirPath, streamChatLocalPath]; module.exports = config; diff --git a/package.json b/package.json index 101c1df6cd..19645acaae 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "examples/ExpoMessaging" ], "resolutions": { - "@types/react": "^19.2.0" + "@types/react": "^19.2.0", + "stream-chat": "portal:/Users/isekovanic/Projects/stream-chat-js-temp" }, "engines": { "node": ">=20.19.4" diff --git a/package/jest.config.js b/package/jest.config.js index 2adb1e9ce8..e03bfa97a0 100644 --- a/package/jest.config.js +++ b/package/jest.config.js @@ -26,6 +26,14 @@ module.exports = { transform: { '^.+\\.[t|j]sx?$': 'babel-jest', }, - transformIgnorePatterns: ['node_modules/!(react-native-reanimated)'], + transformIgnorePatterns: [ + 'node_modules/!(react-native-reanimated)', + // LOCAL PORTAL ONLY (revert with the stream-chat portal): the portaled + // stream-chat-js checkout lives outside node_modules, so babel-jest would + // otherwise transform everything under it (its prebuilt CJS dist AND its own + // node_modules) and inject @babel/runtime helper requires that can't resolve + // from the checkout. It's all prebuilt CJS, so skip transforming any of it. + 'stream-chat-js-temp/', + ], verbose: true, }; diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 6e9b3079cc..00608eb4df 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,29 @@ 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 a larger number + * to raise the cap, or any non-positive value (e.g. `0`) for no limit โ€” i.e. + * replay every event (the historical behavior). + * + * Only relevant when `enableOfflineSupport` is enabled. + * + * @default 250 + */ + maxSyncEventsLimit?: number; /** * When true, multipart uploads use the SDK's native upload adapter when available. * When false, uploads stay on the default axios adapter. @@ -151,6 +175,7 @@ const ChatWithContext = (props: PropsWithChildren) => { enableOfflineSupport = false, i18nInstance, isMessageAIGenerated, + maxSyncEventsLimit = DEFAULT_MAX_SYNC_EVENTS_LIMIT, style, useNativeMultipartUpload = false, } = props; @@ -224,7 +249,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 +258,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..3890af3cef 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,29 @@ 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); + }); }); diff --git a/package/src/store/OfflineDB.ts b/package/src/store/OfflineDB.ts index 78d744bfd8..bbe18eb87f 100644 --- a/package/src/store/OfflineDB.ts +++ b/package/src/store/OfflineDB.ts @@ -12,8 +12,8 @@ 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 }) { + super({ client, syncMaxEventCount: 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..1376f64ac1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18914,9 +18914,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.50.3": - version: 9.50.3 - resolution: "stream-chat@npm:9.50.3" +"stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js-temp::locator=root%40workspace%3A.": + version: 0.0.0-use.local + resolution: "stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js-temp::locator=root%40workspace%3A." dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -18932,9 +18932,8 @@ __metadata: built: true husky: built: true - checksum: 10c0/75be67c6533f6bf02313565eb164115a2b61b399ab268f931689bb709ee75f6a5c68c01baa835082252856b2c307c13c4aa8a6986de20ab0f19cda1f062c7d9b languageName: node - linkType: hard + linkType: soft "stream-combiner2@npm:~1.1.1": version: 1.1.1 From 62f56ef50c18d0fb460a232aa4ce8ccea226aa33 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 14 Aug 2026 15:34:25 +0200 Subject: [PATCH 2/6] chore: bump stream-chat-js --- examples/ExpoMessaging/package.json | 2 +- examples/SampleApp/metro.config.js | 17 +++------- examples/SampleApp/package.json | 2 +- package.json | 3 +- package/jest.config.js | 10 +----- package/package.json | 2 +- yarn.lock | 52 +++++++++++++++++------------ 7 files changed, 41 insertions(+), 47 deletions(-) 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/metro.config.js b/examples/SampleApp/metro.config.js index 1b9cbd825b..c96816d4be 100644 --- a/examples/SampleApp/metro.config.js +++ b/examples/SampleApp/metro.config.js @@ -19,9 +19,6 @@ const metroExclusionList = require( const exclusionList = metroExclusionList.default || metroExclusionList; const packageDirPath = PATH.resolve(__dirname, '../../package'); const nativePackageDirPath = PATH.resolve(__dirname, '../../package/native-package'); -// Local portaled checkout of stream-chat-js. Metro doesn't honor Yarn's portal -// symlink for native bundling, so it must be pointed at the absolute path explicitly. -const streamChatLocalPath = '/Users/isekovanic/Projects/stream-chat-js-temp'; const symlinked = { 'stream-chat-react-native': nativePackageDirPath, @@ -68,14 +65,10 @@ const uniqueModules = dependencyPackageNames.map((packageName) => { const blockList = uniqueModules.map(({ blockPattern }) => blockPattern); // provide the path for the unique modules -const extraNodeModules = uniqueModules.reduce( - (acc, item) => { - acc[item.packageName] = item.modulePath; - return acc; - }, - // Seed the local stream-chat portal so the uniqueModules reduce doesn't overwrite it. - { 'stream-chat': streamChatLocalPath }, -); +const extraNodeModules = uniqueModules.reduce((acc, item) => { + acc[item.packageName] = item.modulePath; + return acc; +}, {}); config.resolver.blockList = exclusionList(blockList); config.resolver.extraNodeModules = extraNodeModules; @@ -83,6 +76,6 @@ config.resolver.extraNodeModules = extraNodeModules; config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; // add the package dir for metro to access the package folder -config.watchFolders = [packageDirPath, streamChatLocalPath]; +config.watchFolders = [packageDirPath]; module.exports = config; 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.json b/package.json index 19645acaae..101c1df6cd 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,7 @@ "examples/ExpoMessaging" ], "resolutions": { - "@types/react": "^19.2.0", - "stream-chat": "portal:/Users/isekovanic/Projects/stream-chat-js-temp" + "@types/react": "^19.2.0" }, "engines": { "node": ">=20.19.4" diff --git a/package/jest.config.js b/package/jest.config.js index e03bfa97a0..2adb1e9ce8 100644 --- a/package/jest.config.js +++ b/package/jest.config.js @@ -26,14 +26,6 @@ module.exports = { transform: { '^.+\\.[t|j]sx?$': 'babel-jest', }, - transformIgnorePatterns: [ - 'node_modules/!(react-native-reanimated)', - // LOCAL PORTAL ONLY (revert with the stream-chat portal): the portaled - // stream-chat-js checkout lives outside node_modules, so babel-jest would - // otherwise transform everything under it (its prebuilt CJS dist AND its own - // node_modules) and inject @babel/runtime helper requires that can't resolve - // from the checkout. It's all prebuilt CJS, so skip transforming any of it. - 'stream-chat-js-temp/', - ], + transformIgnorePatterns: ['node_modules/!(react-native-reanimated)'], verbose: true, }; 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/yarn.lock b/yarn.lock index 1376f64ac1..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@portal:/Users/isekovanic/Projects/stream-chat-js-temp::locator=root%40workspace%3A.": - version: 0.0.0-use.local - resolution: "stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js-temp::locator=root%40workspace%3A." +"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,8 +18941,9 @@ __metadata: built: true husky: built: true + checksum: 10c0/a2888b1dad9496f8ba35e5afb44f88a6ce6b64785780b0244eba349ee10c52594f43c08a16a23924b14640a50849ce835773f016435725378bf0bbcfe0d636ed languageName: node - linkType: soft + linkType: hard "stream-combiner2@npm:~1.1.1": version: 1.1.1 From 251eb79067968f10d9b2f7d46f1dbf0be7b06287 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 14 Aug 2026 15:45:52 +0200 Subject: [PATCH 3/6] Revert "chore: bump stream-chat-js" This reverts commit 62f56ef50c18d0fb460a232aa4ce8ccea226aa33. --- examples/ExpoMessaging/package.json | 2 +- examples/SampleApp/metro.config.js | 17 +++++++--- examples/SampleApp/package.json | 2 +- package.json | 3 +- package/jest.config.js | 10 +++++- package/package.json | 2 +- yarn.lock | 52 ++++++++++++----------------- 7 files changed, 47 insertions(+), 41 deletions(-) diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index db1c4b4629..874366f40f 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.51.0", + "stream-chat": "^9.50.3", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/metro.config.js b/examples/SampleApp/metro.config.js index c96816d4be..1b9cbd825b 100644 --- a/examples/SampleApp/metro.config.js +++ b/examples/SampleApp/metro.config.js @@ -19,6 +19,9 @@ const metroExclusionList = require( const exclusionList = metroExclusionList.default || metroExclusionList; const packageDirPath = PATH.resolve(__dirname, '../../package'); const nativePackageDirPath = PATH.resolve(__dirname, '../../package/native-package'); +// Local portaled checkout of stream-chat-js. Metro doesn't honor Yarn's portal +// symlink for native bundling, so it must be pointed at the absolute path explicitly. +const streamChatLocalPath = '/Users/isekovanic/Projects/stream-chat-js-temp'; const symlinked = { 'stream-chat-react-native': nativePackageDirPath, @@ -65,10 +68,14 @@ const uniqueModules = dependencyPackageNames.map((packageName) => { const blockList = uniqueModules.map(({ blockPattern }) => blockPattern); // provide the path for the unique modules -const extraNodeModules = uniqueModules.reduce((acc, item) => { - acc[item.packageName] = item.modulePath; - return acc; -}, {}); +const extraNodeModules = uniqueModules.reduce( + (acc, item) => { + acc[item.packageName] = item.modulePath; + return acc; + }, + // Seed the local stream-chat portal so the uniqueModules reduce doesn't overwrite it. + { 'stream-chat': streamChatLocalPath }, +); config.resolver.blockList = exclusionList(blockList); config.resolver.extraNodeModules = extraNodeModules; @@ -76,6 +83,6 @@ config.resolver.extraNodeModules = extraNodeModules; config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; // add the package dir for metro to access the package folder -config.watchFolders = [packageDirPath]; +config.watchFolders = [packageDirPath, streamChatLocalPath]; module.exports = config; diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 674ff2413f..b337bd3b94 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.51.0", + "stream-chat": "^9.50.3", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package.json b/package.json index 101c1df6cd..19645acaae 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "examples/ExpoMessaging" ], "resolutions": { - "@types/react": "^19.2.0" + "@types/react": "^19.2.0", + "stream-chat": "portal:/Users/isekovanic/Projects/stream-chat-js-temp" }, "engines": { "node": ">=20.19.4" diff --git a/package/jest.config.js b/package/jest.config.js index 2adb1e9ce8..e03bfa97a0 100644 --- a/package/jest.config.js +++ b/package/jest.config.js @@ -26,6 +26,14 @@ module.exports = { transform: { '^.+\\.[t|j]sx?$': 'babel-jest', }, - transformIgnorePatterns: ['node_modules/!(react-native-reanimated)'], + transformIgnorePatterns: [ + 'node_modules/!(react-native-reanimated)', + // LOCAL PORTAL ONLY (revert with the stream-chat portal): the portaled + // stream-chat-js checkout lives outside node_modules, so babel-jest would + // otherwise transform everything under it (its prebuilt CJS dist AND its own + // node_modules) and inject @babel/runtime helper requires that can't resolve + // from the checkout. It's all prebuilt CJS, so skip transforming any of it. + 'stream-chat-js-temp/', + ], verbose: true, }; diff --git a/package/package.json b/package/package.json index 6d219434a6..67642fa8e7 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.51.0", + "stream-chat": "^9.50.3", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 22e9fc5f9f..1376f64ac1 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.51.0" + stream-chat: "npm:^9.50.3" 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.19.0": - version: 1.19.0 - resolution: "axios@npm:1.19.0" +"axios@npm:^1.16.1": + version: 1.17.0 + resolution: "axios@npm:1.17.0" dependencies: follow-redirects: "npm:^1.16.0" - form-data: "npm:^4.0.6" + form-data: "npm:^4.0.5" https-proxy-agent: "npm:^5.0.1" proxy-from-env: "npm:^2.1.0" - checksum: 10c0/559fe7d51291787def61566a3db78b87510c8faf9c8a8c006d9d8b933808628ff0d8eca7756b40ae25e07da96d946c68c1ffba3da9075ed0b5d3661801d76869 + checksum: 10c0/c4fa19ff3a3a63bde48beec03ad816b133b9a6385cccffffe172577ab18c6a70e299280d57f12c80c867fe25df41f92cb91d3a8258708a6d2be3e9e085f92650 languageName: node linkType: hard @@ -11080,16 +11080,16 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.6": - version: 4.0.6 - resolution: "form-data@npm:4.0.6" +"form-data@npm:^4.0.5": + version: 4.0.5 + resolution: "form-data@npm:4.0.5" dependencies: asynckit: "npm:^0.4.0" combined-stream: "npm:^1.0.8" es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.4" - mime-types: "npm:^2.1.35" - checksum: 10c0/43947a77bf0ff45c6ceed789778982d47a3f3e720a74b71721174ebf3310a5f1a8be1d6b38a3ee3688e8a18a2c4273073ec0844cd37efda3eaf46d41c9c318ff + hasown: "npm:^2.0.2" + mime-types: "npm:^2.1.12" + checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b languageName: node linkType: hard @@ -11618,15 +11618,6 @@ __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" @@ -15015,7 +15006,7 @@ __metadata: languageName: node linkType: hard -"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.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": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -18110,7 +18101,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.51.0" + stream-chat: "npm:^9.50.3" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -18849,7 +18840,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.51.0" + stream-chat: "npm:^9.50.3" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.5.0" uuid: "npm:^11.1.0" @@ -18923,15 +18914,15 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.51.0": - version: 9.51.0 - resolution: "stream-chat@npm:9.51.0" +"stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js-temp::locator=root%40workspace%3A.": + version: 0.0.0-use.local + resolution: "stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js-temp::locator=root%40workspace%3A." dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" - axios: "npm:^1.19.0" + axios: "npm:^1.16.1" base64-js: "npm:^1.5.1" - form-data: "npm:^4.0.6" + form-data: "npm:^4.0.5" isomorphic-ws: "npm:^5.0.0" jsonwebtoken: "npm:^9.0.3" linkifyjs: "npm:^4.3.3" @@ -18941,9 +18932,8 @@ __metadata: built: true husky: built: true - checksum: 10c0/a2888b1dad9496f8ba35e5afb44f88a6ce6b64785780b0244eba349ee10c52594f43c08a16a23924b14640a50849ce835773f016435725378bf0bbcfe0d636ed languageName: node - linkType: hard + linkType: soft "stream-combiner2@npm:~1.1.1": version: 1.1.1 From eb1022e2550f53dd30ab0185daf53504deaa896f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 14 Aug 2026 15:45:52 +0200 Subject: [PATCH 4/6] Revert "perf: sync event count" This reverts commit 15d93c4db3c51983ef6ab23b97e9e983cc213622. --- examples/SampleApp/metro.config.js | 17 ++++------- package.json | 3 +- package/jest.config.js | 10 +------ package/src/components/Chat/Chat.tsx | 29 ++----------------- .../components/Chat/__tests__/Chat.test.tsx | 27 +---------------- package/src/store/OfflineDB.ts | 4 +-- package/src/store/constants.ts | 8 ----- yarn.lock | 9 +++--- 8 files changed, 17 insertions(+), 90 deletions(-) diff --git a/examples/SampleApp/metro.config.js b/examples/SampleApp/metro.config.js index 1b9cbd825b..c96816d4be 100644 --- a/examples/SampleApp/metro.config.js +++ b/examples/SampleApp/metro.config.js @@ -19,9 +19,6 @@ const metroExclusionList = require( const exclusionList = metroExclusionList.default || metroExclusionList; const packageDirPath = PATH.resolve(__dirname, '../../package'); const nativePackageDirPath = PATH.resolve(__dirname, '../../package/native-package'); -// Local portaled checkout of stream-chat-js. Metro doesn't honor Yarn's portal -// symlink for native bundling, so it must be pointed at the absolute path explicitly. -const streamChatLocalPath = '/Users/isekovanic/Projects/stream-chat-js-temp'; const symlinked = { 'stream-chat-react-native': nativePackageDirPath, @@ -68,14 +65,10 @@ const uniqueModules = dependencyPackageNames.map((packageName) => { const blockList = uniqueModules.map(({ blockPattern }) => blockPattern); // provide the path for the unique modules -const extraNodeModules = uniqueModules.reduce( - (acc, item) => { - acc[item.packageName] = item.modulePath; - return acc; - }, - // Seed the local stream-chat portal so the uniqueModules reduce doesn't overwrite it. - { 'stream-chat': streamChatLocalPath }, -); +const extraNodeModules = uniqueModules.reduce((acc, item) => { + acc[item.packageName] = item.modulePath; + return acc; +}, {}); config.resolver.blockList = exclusionList(blockList); config.resolver.extraNodeModules = extraNodeModules; @@ -83,6 +76,6 @@ config.resolver.extraNodeModules = extraNodeModules; config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; // add the package dir for metro to access the package folder -config.watchFolders = [packageDirPath, streamChatLocalPath]; +config.watchFolders = [packageDirPath]; module.exports = config; diff --git a/package.json b/package.json index 19645acaae..101c1df6cd 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,7 @@ "examples/ExpoMessaging" ], "resolutions": { - "@types/react": "^19.2.0", - "stream-chat": "portal:/Users/isekovanic/Projects/stream-chat-js-temp" + "@types/react": "^19.2.0" }, "engines": { "node": ">=20.19.4" diff --git a/package/jest.config.js b/package/jest.config.js index e03bfa97a0..2adb1e9ce8 100644 --- a/package/jest.config.js +++ b/package/jest.config.js @@ -26,14 +26,6 @@ module.exports = { transform: { '^.+\\.[t|j]sx?$': 'babel-jest', }, - transformIgnorePatterns: [ - 'node_modules/!(react-native-reanimated)', - // LOCAL PORTAL ONLY (revert with the stream-chat portal): the portaled - // stream-chat-js checkout lives outside node_modules, so babel-jest would - // otherwise transform everything under it (its prebuilt CJS dist AND its own - // node_modules) and inject @babel/runtime helper requires that can't resolve - // from the checkout. It's all prebuilt CJS, so skip transforming any of it. - 'stream-chat-js-temp/', - ], + transformIgnorePatterns: ['node_modules/!(react-native-reanimated)'], verbose: true, }; diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 00608eb4df..6e9b3079cc 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -23,7 +23,6 @@ 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'; @@ -45,29 +44,6 @@ 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 a larger number - * to raise the cap, or any non-positive value (e.g. `0`) for no limit โ€” i.e. - * replay every event (the historical behavior). - * - * Only relevant when `enableOfflineSupport` is enabled. - * - * @default 250 - */ - maxSyncEventsLimit?: number; /** * When true, multipart uploads use the SDK's native upload adapter when available. * When false, uploads stay on the default axios adapter. @@ -175,7 +151,6 @@ const ChatWithContext = (props: PropsWithChildren) => { enableOfflineSupport = false, i18nInstance, isMessageAIGenerated, - maxSyncEventsLimit = DEFAULT_MAX_SYNC_EVENTS_LIMIT, style, useNativeMultipartUpload = false, } = props; @@ -249,7 +224,7 @@ const ChatWithContext = (props: PropsWithChildren) => { const initializeDatabase = async () => { if (!client.offlineDb) { - client.setOfflineDBApi(new OfflineDB({ client, maxSyncEventsLimit })); + client.setOfflineDBApi(new OfflineDB({ client })); } if (client.offlineDb) { @@ -258,7 +233,7 @@ const ChatWithContext = (props: PropsWithChildren) => { }; initializeDatabase(); - }, [userID, enableOfflineSupport, client, maxSyncEventsLimit]); + }, [userID, enableOfflineSupport, client]); useEffect(() => { if (!client) { diff --git a/package/src/components/Chat/__tests__/Chat.test.tsx b/package/src/components/Chat/__tests__/Chat.test.tsx index 3890af3cef..945e04b376 100644 --- a/package/src/components/Chat/__tests__/Chat.test.tsx +++ b/package/src/components/Chat/__tests__/Chat.test.tsx @@ -2,6 +2,7 @@ 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'; @@ -12,7 +13,6 @@ 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,29 +330,4 @@ 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); - }); }); diff --git a/package/src/store/OfflineDB.ts b/package/src/store/OfflineDB.ts index bbe18eb87f..78d744bfd8 100644 --- a/package/src/store/OfflineDB.ts +++ b/package/src/store/OfflineDB.ts @@ -12,8 +12,8 @@ import * as api from './apis'; import { SqliteClient } from './SqliteClient'; export class OfflineDB extends AbstractOfflineDB { - constructor({ client, maxSyncEventsLimit }: { client: StreamChat; maxSyncEventsLimit?: number }) { - super({ client, syncMaxEventCount: maxSyncEventsLimit }); + constructor({ client }: { client: StreamChat }) { + super({ client }); } upsertCidsForQuery = api.upsertCidsForQuery; diff --git a/package/src/store/constants.ts b/package/src/store/constants.ts index 79ed28573d..603a7b497e 100644 --- a/package/src/store/constants.ts +++ b/package/src/store/constants.ts @@ -1,11 +1,3 @@ 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 1376f64ac1..3c3d11becd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18914,9 +18914,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js-temp::locator=root%40workspace%3A.": - version: 0.0.0-use.local - resolution: "stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js-temp::locator=root%40workspace%3A." +"stream-chat@npm:^9.50.3": + version: 9.50.3 + resolution: "stream-chat@npm:9.50.3" dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -18932,8 +18932,9 @@ __metadata: built: true husky: built: true + checksum: 10c0/75be67c6533f6bf02313565eb164115a2b61b399ab268f931689bb709ee75f6a5c68c01baa835082252856b2c307c13c4aa8a6986de20ab0f19cda1f062c7d9b languageName: node - linkType: soft + linkType: hard "stream-combiner2@npm:~1.1.1": version: 1.1.1 From 3e56a09bd7acef8b4acbd0c1b69dd4f001697da5 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:18:01 +0200 Subject: [PATCH 5/6] perf: introduce offline sync event limit (#3773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal Implements [this spec](https://app.notion.com/p/stream-wiki/Sync-events-limit-3666a5d7f9f6801f97ecc1461fc62049). Since most other stuff is already supported by the SDK, here we introduce: - A way to limit the number of sync events we want to go through (regardless of what number the server returns) - We anyway still do `queryChannels` so this just prevents additional DB pressure if we get many, many events - The default stays at 250 ## ๐Ÿ›  Implementation details ## ๐ŸŽจ UI Changes
iOS
Before After
Android
Before After
## ๐Ÿงช Testing ## โ˜‘๏ธ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- examples/ExpoMessaging/package.json | 2 +- examples/SampleApp/package.json | 2 +- package/package.json | 2 +- package/src/components/Chat/Chat.tsx | 29 ++++++++++- .../components/Chat/__tests__/Chat.test.tsx | 27 +++++++++- package/src/store/OfflineDB.ts | 4 +- package/src/store/constants.ts | 8 +++ yarn.lock | 51 +++++++++++-------- 8 files changed, 96 insertions(+), 29 deletions(-) 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..00608eb4df 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,29 @@ 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 a larger number + * to raise the cap, or any non-positive value (e.g. `0`) for no limit โ€” i.e. + * replay every event (the historical behavior). + * + * Only relevant when `enableOfflineSupport` is enabled. + * + * @default 250 + */ + maxSyncEventsLimit?: number; /** * When true, multipart uploads use the SDK's native upload adapter when available. * When false, uploads stay on the default axios adapter. @@ -151,6 +175,7 @@ const ChatWithContext = (props: PropsWithChildren) => { enableOfflineSupport = false, i18nInstance, isMessageAIGenerated, + maxSyncEventsLimit = DEFAULT_MAX_SYNC_EVENTS_LIMIT, style, useNativeMultipartUpload = false, } = props; @@ -224,7 +249,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 +258,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..3890af3cef 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,29 @@ 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); + }); }); diff --git a/package/src/store/OfflineDB.ts b/package/src/store/OfflineDB.ts index 78d744bfd8..bbe18eb87f 100644 --- a/package/src/store/OfflineDB.ts +++ b/package/src/store/OfflineDB.ts @@ -12,8 +12,8 @@ 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 }) { + super({ client, syncMaxEventCount: 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 From 50590e126ff6fc7d1024beccd0a1d815a0bc4dd9 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:46:18 +0200 Subject: [PATCH 6/6] fix: provide a sensible way to disable sync limit (#3775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR is a quick follow-up to the offline sync limit one, in which we provide a sensible way to disable the behaviour rather than rely on "magic numbers". ## ๐Ÿ›  Implementation details ## ๐ŸŽจ UI Changes
iOS
Before After
Android
Before After
## ๐Ÿงช Testing ## โ˜‘๏ธ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- package/src/components/Chat/Chat.tsx | 7 +++---- package/src/components/Chat/__tests__/Chat.test.tsx | 12 ++++++++++++ package/src/store/OfflineDB.ts | 13 +++++++++++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 00608eb4df..7eb6228cf3 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -59,15 +59,14 @@ export type ChatProps = Pick & * 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 a larger number - * to raise the cap, or any non-positive value (e.g. `0`) for no limit โ€” i.e. - * replay every event (the historical behavior). + * 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; + 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. diff --git a/package/src/components/Chat/__tests__/Chat.test.tsx b/package/src/components/Chat/__tests__/Chat.test.tsx index 3890af3cef..4d6a43ad29 100644 --- a/package/src/components/Chat/__tests__/Chat.test.tsx +++ b/package/src/components/Chat/__tests__/Chat.test.tsx @@ -355,4 +355,16 @@ describe('TranslationContext', () => { ); 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 bbe18eb87f..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, maxSyncEventsLimit }: { client: StreamChat; maxSyncEventsLimit?: number }) { - super({ client, syncMaxEventCount: maxSyncEventsLimit }); + constructor({ + client, + maxSyncEventsLimit, + }: { + client: StreamChat; + maxSyncEventsLimit?: number | false; + }) { + super({ + client, + syncMaxEventCount: maxSyncEventsLimit === false ? undefined : maxSyncEventsLimit, + }); } upsertCidsForQuery = api.upsertCidsForQuery;