From 4e11c125eb9037d4bf405374e41012eaa03c0d42 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sun, 12 Jul 2026 17:02:00 -0500 Subject: [PATCH 01/33] move SSS to general, add toggle on login and load, remove config option --- config.json | 4 - .../settings/experimental/Experimental.tsx | 2 - src/app/features/settings/general/General.tsx | 114 ++++++------------ src/app/hooks/useClientConfig.ts | 1 - .../pages/auth/login/PasswordLoginForm.tsx | 20 ++- src/app/pages/auth/login/loginUtil.ts | 11 +- src/app/pages/client/ClientRoot.tsx | 40 ++++++ src/client/initMatrix.ts | 16 +-- 8 files changed, 103 insertions(+), 105 deletions(-) diff --git a/config.json b/config.json index 77be1ad3ca..0002880c9b 100644 --- a/config.json +++ b/config.json @@ -16,10 +16,6 @@ "themeCatalogBaseUrl": "https://raw.githubusercontent.com/SableClient/themes/main/", "themeCatalogApprovedHostPrefixes": ["https://raw.githubusercontent.com/SableClient/themes/"], - "slidingSync": { - "enabled": true - }, - "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index 085355f40f..7cd673bc4f 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -7,7 +7,6 @@ import { useSetting } from '$state/hooks/settings'; import { SequenceCardStyle } from '$features/common-settings/styles.css'; import { SettingTile } from '$components/setting-tile'; import { SequenceCard } from '$components/sequence-card'; -import { Sync } from '../general'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { BandwidthSavingEmojis } from './BandwithSavingEmojis'; import { MSC4268HistoryShare } from './MSC4268HistoryShare'; @@ -60,7 +59,6 @@ export function Experimental({ requestBack, requestClose }: Readonly
- diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index a3fef67ac7..dc6d1f4a65 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -49,8 +49,6 @@ import { useMessageSpacingItems } from '$hooks/useMessageSpacing'; import { useDateFormatItems } from '$hooks/useDateFormat'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { sessionsAtom, activeSessionIdAtom } from '$state/sessions'; -import { useClientConfig } from '$hooks/useClientConfig'; -import { resolveSlidingEnabled } from '$client/initMatrix'; import { isKeyHotkey } from 'is-hotkey'; import { settingsSyncLastSyncedAtom, settingsSyncStatusAtom } from '$hooks/useSettingsSync'; import { exportSettingsAsJson, importSettingsFromJson } from '$utils/settingsSync'; @@ -1380,14 +1378,17 @@ function Embeds() { ); } -export function Sync() { - const clientConfig = useClientConfig(); +type GeneralProps = { + requestBack?: () => void; + requestClose: () => void; +}; + +function Sync() { const sessions = useAtomValue(sessionsAtom); const activeSessionId = useAtomValue(activeSessionIdAtom); const setSessions = useSetAtom(sessionsAtom); const activeSession = sessions.find((s) => s.userId === activeSessionId) ?? sessions[0]; - const serverSlidingEnabled = resolveSlidingEnabled(clientConfig.slidingSync?.enabled); const useSlidingSync = activeSession?.slidingSyncOptIn === true; const handleSetSlidingSync = (value: boolean) => { @@ -1402,76 +1403,6 @@ export function Sync() { window.location.reload(); }; - return ( - - - Sync - - - - Enable Sliding Sync for this current login/session. Requires server support and - admin configuration.{' '} - - More info/Documentation - - .{' '} - - Known issues (Sable GitHub) - - . - - ) : ( - <> - Unavailable: the server has disabled Sliding Sync in its config.{' '} - - More info - - . - - ) - } - after={ - - } - /> - - - ); -} - -type GeneralProps = { - requestBack?: () => void; - requestClose: () => void; -}; - -function SettingsSyncSection() { const [syncEnabled, setSyncEnabled] = useSetting(settingsAtom, 'settingsSyncEnabled'); const lastSynced = useAtomValue(settingsSyncLastSyncedAtom); const syncStatus = useAtomValue(settingsSyncStatusAtom); @@ -1500,7 +1431,34 @@ function SettingsSyncSection() { return ( - Settings Sync & Backup + Data Syncing + + + Enable Sliding Sync for this current login/session. Requires server support.{' '} + + Known issues (Sable GitHub) + + . + + } + after={ + + } + /> + } @@ -1669,7 +1627,7 @@ export function General({ requestBack, requestClose }: Readonly) { - + diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 6850a7e774..6fb34e8978 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -28,7 +28,6 @@ export type ClientConfig = { }; slidingSync?: { - enabled?: boolean; proxyBaseUrl?: string; bootstrapClassicOnColdCache?: boolean; listPageSize?: number; diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index f681e31f75..5c18a386d2 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -13,6 +13,7 @@ import { OverlayCenter, PopOut, Spinner, + Switch, Text, config, } from 'folds'; @@ -120,7 +121,12 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog Parameters >(useCallback(login, [])); - useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined); + const [useSlidingSync, setUseSlidingSync] = useState(false); + + useLoginComplete( + loginState.status === AsyncStatus.Success ? loginState.data : undefined, + useSlidingSync + ); const handleUsernameLogin = (username: string, password: string) => { startLogin(baseUrl, { @@ -265,6 +271,18 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog + + + + Use sliding sync (faster, but buggier) + + + - - - - - - ); -} diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 856e94f180..fcda2b5d10 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -48,7 +48,7 @@ import { useSyncNicknames } from '$hooks/useNickname'; import { useAppVisibility } from '$hooks/useAppVisibility'; import { composerIcon, DotsThreeOutlineVerticalIcon } from '$components/icons/phosphor'; import { getHomePath } from '$pages/pathUtils'; -import { useClientConfig } from '$hooks/useClientConfig'; +import { useClientConfig, useClientConfigLoaded } from '$hooks/useClientConfig'; import { pushSessionToSW } from '../../../sw-session'; import { SyncStatus } from './SyncStatus'; import { SpecVersions } from './SpecVersions'; @@ -219,6 +219,7 @@ type ClientRootProps = { export function ClientRoot({ children }: ClientRootProps) { const navigate = useNavigate(); const clientConfig = useClientConfig(); + const configLoaded = useClientConfigLoaded(); const sessions = useAtomValue(sessionsAtom); const [activeSessionId, setActiveSessionId] = useAtom(activeSessionIdAtom); const setSessions = useSetAtom(sessionsAtom); @@ -316,10 +317,10 @@ export function ClientRoot({ children }: ClientRootProps) { }, [loadState, loadMatrix]); useEffect(() => { - if (mx && !mx.clientRunning) { + if (mx && !mx.clientRunning && configLoaded) { startMatrix(mx); } - }, [mx, startMatrix]); + }, [mx, startMatrix, configLoaded]); useSyncState( mx, diff --git a/src/app/pages/client/SpecVersions.tsx b/src/app/pages/client/SpecVersions.tsx index 794a9d41c2..dbda208c32 100644 --- a/src/app/pages/client/SpecVersions.tsx +++ b/src/app/pages/client/SpecVersions.tsx @@ -1,28 +1,33 @@ import type { ReactNode } from 'react'; import { useCallback } from 'react'; -import { Box, Dialog, config, Text, Button } from 'folds'; +import { Box, Button, Dialog, config, Text } from 'folds'; import { SpecVersionsLoader } from '$components/SpecVersionsLoader'; import { SpecVersionsProvider } from '$hooks/useSpecVersions'; -import type { SpecVersions } from '../../cs-api'; import { SplashScreen } from '$components/splash-screen'; +import type { SpecVersions } from '../../cs-api'; -function specVersionsError(_err: unknown, retry: () => void, ignore: () => void) { +const EMPTY_VERSIONS: SpecVersions = { versions: [] }; + +type HomeserverOfflineErrorProps = { + baseUrl: string; + onRetry: () => void; +}; +function HomeserverOfflineError({ baseUrl, onRetry }: HomeserverOfflineErrorProps) { return ( - - Failed to connect to homeserver. Either homeserver is down or your internet. - - - @@ -40,8 +45,20 @@ export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: [children] ); + const renderFallback = useCallback( + () => {children}, + [children] + ); + + const renderError = useCallback( + (_err: unknown, retry: () => void) => ( + + ), + [baseUrl] + ); + return ( - + {renderChildren} ); From 82b1feee3c96a60b9266fc0ce3ca7cb19c6c8229 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 13 Jul 2026 11:03:42 -0500 Subject: [PATCH 05/33] concurrent crypto and sync store start up --- src/app/pages/client/ClientRoot.tsx | 17 +++- src/client/initMatrix.ts | 150 ++++++++++++++++++++-------- 2 files changed, 126 insertions(+), 41 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index fcda2b5d10..ee825a7576 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -243,7 +243,22 @@ export function ClientRoot({ children }: ClientRootProps) { log.log('persisting activeSessionId →', activeSession.userId); setActiveSessionId(activeSession.userId); } - await clearMismatchedStores(); + const storeCleanupStart = performance.now(); + let storeCleanupOutcome = 'success'; + try { + await clearMismatchedStores(); + } catch (error) { + storeCleanupOutcome = 'error'; + throw error; + } finally { + Sentry.metrics.distribution( + 'sable.startup.phase_ms', + performance.now() - storeCleanupStart, + { + attributes: { phase: 'store_cleanup', outcome: storeCleanupOutcome }, + } + ); + } log.log('initClient for', activeSession.userId); const newMx = await initClient(activeSession); loadedUserIdRef.current = activeSession.userId; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index d052f23d98..86f39e9c37 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -25,6 +25,28 @@ const slidingSyncByClient = new WeakMap(); const presenceSyncByClient = new WeakMap(); const SLIDING_SYNC_POLL_TIMEOUT_MS = 20000; +type StartupPhase = 'sync_store' | 'rust_crypto' | 'client_init' | 'client_start'; + +const measureStartupPhase = async ( + phase: StartupPhase, + task: () => Promise, + attributes?: Record +): Promise => { + const startTime = performance.now(); + try { + const result = await task(); + Sentry.metrics.distribution('sable.startup.phase_ms', performance.now() - startTime, { + attributes: { phase, outcome: 'success', ...attributes }, + }); + return result; + } catch (error) { + Sentry.metrics.distribution('sable.startup.phase_ms', performance.now() - startTime, { + attributes: { phase, outcome: 'error', ...attributes }, + }); + throw error; + } +}; + type FetchRoomEventResult = Awaited>; type MatrixClientWithWritableFetchRoomEvent = MatrixClient & { fetchRoomEvent: (roomId: string, eventId: string) => Promise; @@ -253,7 +275,12 @@ export const clearMismatchedStores = async (): Promise => { ); }; -const buildClient = async (session: Session): Promise => { +type BuiltClient = { + mx: MatrixClient; + indexedDBStore: IndexedDBStore; +}; + +const buildClient = (session: Session): BuiltClient => { const storeName = getSessionStoreName(session); const indexedDBStore = new IndexedDBStore({ @@ -276,8 +303,44 @@ const buildClient = async (session: Session): Promise => { verificationMethods: ['m.sas.v1'], }); - await indexedDBStore.startup(); - return mx; + return { mx, indexedDBStore }; +}; + +type ClientInitializationResult = + | { ok: true; mx: MatrixClient } + | { ok: false; error: unknown; phase: 'sync_store' | 'rust_crypto' }; + +const initializeClient = async ( + session: Session, + cryptoDatabasePrefix: string +): Promise => { + let builtClient: BuiltClient; + try { + builtClient = buildClient(session); + } catch (error) { + return { ok: false, error, phase: 'sync_store' }; + } + const { mx, indexedDBStore } = builtClient; + + const syncStorePromise = measureStartupPhase('sync_store', () => indexedDBStore.startup()); + const cryptoPromise = measureStartupPhase('rust_crypto', () => + mx.initRustCrypto({ cryptoDatabasePrefix }) + ); + const [syncStoreResult, cryptoResult] = await Promise.allSettled([ + syncStorePromise, + cryptoPromise, + ]); + + if (syncStoreResult.status === 'rejected') { + mx.stopClient(); + return { ok: false, error: syncStoreResult.reason, phase: 'sync_store' }; + } + if (cryptoResult.status === 'rejected') { + mx.stopClient(); + return { ok: false, error: cryptoResult.reason, phase: 'rust_crypto' }; + } + + return { ok: true, mx }; }; export const initClient = async (session: Session): Promise => { @@ -314,43 +377,45 @@ export const initClient = async (session: Session): Promise => { } }; - let mx: MatrixClient; + const initStartTime = performance.now(); + let initOutcome = 'success'; try { - mx = await buildClient(session); - } catch (err) { - if (!isMismatch(err)) { - debugLog.error('sync', 'Failed to build client', { error: err }); - throw err; - } - log.warn('initClient: mismatch on buildClient — wiping and retrying:', err); - debugLog.warn('sync', 'Client build mismatch - wiping stores and retrying', { error: err }); - await wipeAllStores(); - mx = await buildClient(session); - } + let result = await initializeClient(session, storeName.rustCryptoPrefix); + if (!result.ok) { + if (!isMismatch(result.error)) { + debugLog.error('sync', 'Failed to initialize client', { + phase: result.phase, + error: result.error, + }); + throw result.error; + } - try { - await mx.initRustCrypto({ - cryptoDatabasePrefix: storeName.rustCryptoPrefix, - }); - } catch (err) { - if (!isMismatch(err)) { - debugLog.error('sync', 'Failed to initialize crypto', { error: err }); - throw err; + log.warn(`initClient: mismatch during ${result.phase} — wiping and retrying:`, result.error); + debugLog.warn('sync', 'Client initialization mismatch - wiping stores and retrying', { + phase: result.phase, + error: result.error, + }); + await wipeAllStores(); + result = await initializeClient(session, storeName.rustCryptoPrefix); + if (!result.ok) { + debugLog.error('sync', 'Failed to initialize client after store reset', { + phase: result.phase, + error: result.error, + }); + throw result.error; + } } - log.warn('initClient: mismatch on initRustCrypto — wiping and retrying:', err); - debugLog.warn('sync', 'Crypto init mismatch - wiping stores and retrying', { - error: err, - }); - mx.stopClient(); - await wipeAllStores(); - mx = await buildClient(session); - await mx.initRustCrypto({ - cryptoDatabasePrefix: storeName.rustCryptoPrefix, + + result.mx.setMaxListeners(50); + return result.mx; + } catch (error) { + initOutcome = 'error'; + throw error; + } finally { + Sentry.metrics.distribution('sable.startup.phase_ms', performance.now() - initStartTime, { + attributes: { phase: 'client_init', outcome: initOutcome }, }); } - - mx.setMaxListeners(50); - return mx; }; export type StartClientConfig = { @@ -418,11 +483,16 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): } try { - await mx.startClient({ - lazyLoadMembers: true, - slidingSync: manager?.slidingSync, - threadSupport: true, - }); + await measureStartupPhase( + 'client_start', + () => + mx.startClient({ + lazyLoadMembers: true, + slidingSync: manager?.slidingSync, + threadSupport: true, + }), + { transport: useSliding ? 'sliding' : 'classic' } + ); } catch (err) { debugLog.error('network', 'Failed to start client with sliding sync', { error: err instanceof Error ? err.message : String(err), From 58559692955c181984fa3e844af8c999c702f2b2 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 13 Jul 2026 11:38:43 -0500 Subject: [PATCH 06/33] remove maxRooms and reduce initial sync --- src/app/hooks/useClientConfig.ts | 1 - src/client/slidingSync.test.ts | 48 +++++++++++++++++-- src/client/slidingSync.ts | 79 +++++++++++++------------------- 3 files changed, 76 insertions(+), 52 deletions(-) diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index fa4de2efa1..3c92951de4 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -33,7 +33,6 @@ export type ClientConfig = { listPageSize?: number; timelineLimit?: number; pollTimeoutMs?: number; - maxRooms?: number; includeInviteList?: boolean; probeTimeoutMs?: number; }; diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 4f8f5c80fe..c1def55584 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -3,15 +3,17 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { MatrixClient } from '$types/matrix-sdk'; +import type { MatrixClient, MSC3575List } from '$types/matrix-sdk'; +import { SlidingSyncEvent, SlidingSyncState } from '$types/matrix-sdk'; import { SlidingSyncManager, type SlidingSyncConfig } from './slidingSync'; // ── vi.hoisted mocks ───────────────────────────────────────────────────────── // Must be defined via vi.hoisted const mocks = vi.hoisted(() => ({ + slidingSyncConstructorArgs: undefined as unknown[] | undefined, slidingSyncInstance: { - on: vi.fn<() => void>(), + on: vi.fn<(event: unknown, handler: unknown) => void>(), off: vi.fn<() => void>(), removeListener: vi.fn<() => void>(), stop: vi.fn<() => void>(), @@ -20,8 +22,8 @@ const mocks = vi.hoisted(() => ({ addCustomSubscription: vi.fn<() => void>(), useCustomSubscription: vi.fn<() => void>(), registerExtension: vi.fn<() => void>(), - getListData: vi.fn<() => null>(), - getListParams: vi.fn<() => null>(), + getListData: vi.fn<(key: string) => null>(), + getListParams: vi.fn<(key: string) => null>(), setList: vi.fn<() => void>(), setListRanges: vi.fn<() => void>(), }, @@ -44,7 +46,8 @@ vi.mock('@sentry/react', () => ({ // A plain function constructor is the correct pattern vi.mock('$types/matrix-sdk', async (importOriginal) => { const actual = await importOriginal>(); - function MockSlidingSync() { + function MockSlidingSync(...args: unknown[]) { + mocks.slidingSyncConstructorArgs = args; return mocks.slidingSyncInstance; } return { ...actual, SlidingSync: MockSlidingSync }; @@ -72,6 +75,41 @@ function makeManager(mx: ReturnType): SlidingSyncManager { beforeEach(() => { vi.clearAllMocks(); + mocks.slidingSyncConstructorArgs = undefined; +}); + +describe('SlidingSyncManager initial request', () => { + it('starts with a small room-list range and only row-level state', () => { + makeManager(makeMockMx()); + + const lists = mocks.slidingSyncConstructorArgs?.[1] as Map; + const joined = lists.get('joined'); + + expect(joined?.ranges).toEqual([[0, 29]]); + expect(joined?.required_state).toHaveLength(8); + expect(joined?.required_state).not.toContainEqual(['m.image_pack', '*']); + }); + + it('expands by one page after the first response instead of jumping to all rooms', () => { + const manager = makeManager(makeMockMx()); + mocks.slidingSyncInstance.getListData.mockImplementation((key: string) => + key === 'joined' ? ({ joinedCount: 1000 } as never) : null + ); + mocks.slidingSyncInstance.getListParams.mockReturnValue({ + ranges: [[0, 29]], + } as never); + manager.attach(); + + const lifecycleCall = mocks.slidingSyncInstance.on.mock.calls.find( + ([event]) => event === SlidingSyncEvent.Lifecycle + ); + const lifecycle = lifecycleCall?.[1] as + | ((state: SlidingSyncState, response: unknown, error?: Error) => void) + | undefined; + lifecycle?.(SlidingSyncState.Complete, {}); + + expect(mocks.slidingSyncInstance.setListRanges).toHaveBeenCalledWith('joined', [[0, 279]]); + }); }); // ── dispose() ──────────────────────────────────────────────────────────────── diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 862b122d9f..1840b88a51 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -31,8 +31,8 @@ export const LIST_ROOM_SEARCH = 'room_search'; export const LIST_SPACE = 'space'; const LIST_TIMELINE_LIMIT = 1; const DEFAULT_LIST_PAGE_SIZE = 250; +const INITIAL_LIST_SIZE = 30; const DEFAULT_POLL_TIMEOUT_MS = 20000; -const DEFAULT_MAX_ROOMS = 5000; const LIST_SORT_ORDER = ['by_recency', 'by_name']; @@ -52,7 +52,6 @@ export type SlidingSyncConfig = { listPageSize?: number; timelineLimit?: number; pollTimeoutMs?: number; - maxRooms?: number; includeInviteList?: boolean; probeTimeoutMs?: number; }; @@ -76,23 +75,19 @@ const clampPositive = (value: number | undefined, fallback: number): number => { }; const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [ - [EventType.RoomJoinRules, ''], + // first sync limited solely to what's needed to render rooms [EventType.RoomAvatar, ''], [EventType.RoomTombstone, ''], [EventType.RoomEncryption, ''], [EventType.RoomCreate, ''], - [EventType.RoomTopic, ''], + [EventType.RoomName, ''], [EventType.RoomCanonicalAlias, ''], [EventType.RoomMember, MSC3575_STATE_KEY_ME], ['m.space.child', MSC3575_WILDCARD], - [EventType.GroupCallMemberPrefix, MSC3575_WILDCARD], - [CustomStateEvent.ImagePack, MSC3575_WILDCARD], - [CustomStateEvent.PoniesRoomEmotes, MSC3575_WILDCARD], - [CustomStateEvent.RoomAbbreviations, ''], - [CustomStateEvent.RoomBanner, ''], ]; const ACTIVE_ROOM_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ + // active room subscription includes all room data [EventType.RoomPowerLevels, ''], [EventType.RoomName, ''], [EventType.RoomTopic, ''], @@ -130,7 +125,7 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map(); const listRequiredState = buildListRequiredState(); - const initialRange = Math.min(pageSize, 100); + const initialRange = Math.min(pageSize, INITIAL_LIST_SIZE); lists.set(LIST_JOINED, { ranges: [[0, Math.max(0, initialRange - 1)]], @@ -161,8 +156,6 @@ const getListEndIndex = (list: MSC3575List | null): number => { export class SlidingSyncManager { private disposed = false; - private readonly maxRooms: number; - private readonly listKeys: string[]; private readonly activeRoomSubscriptions = new Set(); @@ -186,6 +179,8 @@ export class SlidingSyncManager { private initialSyncCompleted = false; + private activeRoomDataReceived = false; + private syncCount = 0; private previousListCounts: Map = new Map(); @@ -218,7 +213,6 @@ export class SlidingSyncManager { const listPageSize = clampPositive(config.listPageSize, DEFAULT_LIST_PAGE_SIZE); const pollTimeoutMs = clampPositive(config.pollTimeoutMs, DEFAULT_POLL_TIMEOUT_MS); this.probeTimeoutMs = clampPositive(config.probeTimeoutMs, 5000); - this.maxRooms = clampPositive(config.maxRooms, DEFAULT_MAX_ROOMS); this.listPageSize = listPageSize; const includeInviteList = config.includeInviteList !== false; @@ -324,7 +318,7 @@ export class SlidingSyncManager { this.initialSyncSpan = null; } - this.expandListsToKnownCount(); + this.expandListsByPage(); Sentry.metrics.distribution('sable.sync.processing_ms', syncDuration, { attributes: { transport: 'sliding' }, @@ -352,7 +346,6 @@ export class SlidingSyncManager { proxyBaseUrl: this.proxyBaseUrl, listPageSize: this.listPageSize, roomTimelineLimit: this.roomTimelineLimit, - maxRooms: this.maxRooms, lists: this.listKeys, }); @@ -406,8 +399,9 @@ export class SlidingSyncManager { }; } - private expandListsToKnownCount(): void { - if (this.listsFullyLoaded) return; + private expandListsByPage(): void { + if (!this.initialSyncCompleted) return; + if (this.activeRoomSubscriptions.size > 0 && !this.activeRoomDataReceived) return; let allListsComplete = true; let expandedAny = false; @@ -437,7 +431,7 @@ export class SlidingSyncManager { const existing = this.slidingSync.getListParams(key); const currentEnd = getListEndIndex(existing); - const maxEnd = Math.min(knownCount, this.maxRooms) - 1; + const maxEnd = knownCount - 1; if (currentEnd >= maxEnd) { expansionDetails[key] = { status: 'complete', knownCount, currentEnd }; @@ -446,7 +440,7 @@ export class SlidingSyncManager { allListsComplete = false; - const desiredEnd = maxEnd; + const desiredEnd = Math.min(maxEnd, currentEnd + this.listPageSize); if (desiredEnd === currentEnd) { expansionDetails[key] = { @@ -469,46 +463,37 @@ export class SlidingSyncManager { roomsToLoad: desiredEnd - currentEnd, }; - debugLog.info('sync', `Expanding list "${key}" to full range`, { + debugLog.info('sync', `Expanding list "${key}" by page`, { list: key, knownCount, previousEnd: currentEnd, newEnd: desiredEnd, roomsToLoad: desiredEnd - currentEnd, }); - - if (knownCount > this.maxRooms) { - log.warn( - `Sliding Sync list "${key}" capped at ${this.maxRooms}/${knownCount} rooms for ${this.mx.getUserId()}` - ); - debugLog.warn('sync', `List "${key}" exceeds maxRooms limit`, { - list: key, - knownCount, - maxRooms: this.maxRooms, - cappedCount: this.maxRooms, - }); - } }); const expansionDuration = performance.now() - expansionStartTime; const hasExpansions = Object.values(expansionDetails).some((d) => d.status === 'expanding'); if (allListsComplete) { - this.listsFullyLoaded = true; - log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`); - const totalRooms = this.listKeys.reduce( - (sum, key) => sum + (this.slidingSync.getListData(key)?.joinedCount ?? 0), - 0 - ); - const listsLoadedMs = - this.attachTime != null ? Math.round(performance.now() - this.attachTime) : 0; - Sentry.metrics.distribution('sable.sync.lists_loaded_ms', listsLoadedMs, { - attributes: { transport: 'sliding' }, - }); - Sentry.metrics.gauge('sable.sync.total_rooms', totalRooms, { - attributes: { transport: 'sliding' }, - }); + if (!this.listsFullyLoaded) { + this.listsFullyLoaded = true; + log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`); + const totalRooms = this.listKeys.reduce( + (sum, key) => sum + (this.slidingSync.getListData(key)?.joinedCount ?? 0), + 0 + ); + const listsLoadedMs = + this.attachTime != null ? Math.round(performance.now() - this.attachTime) : 0; + Sentry.metrics.distribution('sable.sync.lists_loaded_ms', listsLoadedMs, { + attributes: { transport: 'sliding' }, + }); + Sentry.metrics.gauge('sable.sync.total_rooms', totalRooms, { + attributes: { transport: 'sliding' }, + }); + } } else if (expandedAny) { + this.listsFullyLoaded = false; log.log(`Sliding Sync lists expanding... for ${this.mx.getUserId()}`); } @@ -646,6 +631,8 @@ export class SlidingSyncManager { }); this.slidingSync.removeListener(SlidingSyncEvent.RoomData, onFirstRoomData); this.pendingRoomDataListeners.delete(roomId); + this.activeRoomDataReceived = true; + this.expandListsByPage(); }; this.pendingRoomDataListeners.set(roomId, onFirstRoomData); this.slidingSync.on(SlidingSyncEvent.RoomData, onFirstRoomData); From 67cb91546ff6f4baeda2b1b4a485b198aa157c3f Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 13 Jul 2026 12:25:46 -0500 Subject: [PATCH 07/33] remove the rest of sliding sync config --- .../developer-tools/DevelopTools.tsx | 2 +- .../developer-tools/SyncDiagnostics.tsx | 2 +- src/app/hooks/useClientConfig.ts | 10 ---- src/app/hooks/useSlidingSyncActiveRoom.ts | 11 ++-- .../pages/client/BackgroundNotifications.tsx | 11 +--- src/app/pages/client/ClientRoot.tsx | 10 ++-- src/client/initMatrix.ts | 27 +++++----- src/client/slidingSync.test.ts | 5 +- src/client/slidingSync.ts | 51 ++++++++----------- 9 files changed, 49 insertions(+), 80 deletions(-) diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index 422e538540..f710eacebc 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -422,7 +422,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { {syncDiagnostics.sliding ? ( <> - Proxy: {syncDiagnostics.sliding.proxyBaseUrl} + Base URL: {syncDiagnostics.sliding.baseUrl} Room timeline: {syncDiagnostics.sliding.timelineLimit} | page diff --git a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx index bce39fe571..4739dc2691 100644 --- a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx +++ b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx @@ -179,7 +179,7 @@ export function SyncDiagnostics() { {expandSliding && ( - Sliding proxy: {diagnostics.sliding.proxyBaseUrl} + Sliding sync base URL: {diagnostics.sliding.baseUrl} Room timeline limit: {diagnostics.sliding.timelineLimit} | page size:{' '} {diagnostics.sliding.listPageSize} diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 3c92951de4..9efc4bac00 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -27,16 +27,6 @@ export type ClientConfig = { webPushAppID?: string; }; - slidingSync?: { - proxyBaseUrl?: string; - bootstrapClassicOnColdCache?: boolean; - listPageSize?: number; - timelineLimit?: number; - pollTimeoutMs?: number; - includeInviteList?: boolean; - probeTimeoutMs?: number; - }; - featuredCommunities?: { openAsDefault?: boolean; spaces?: string[]; diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts index 42481970f9..850f539298 100644 --- a/src/app/hooks/useSlidingSyncActiveRoom.ts +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -2,19 +2,22 @@ import { useEffect } from 'react'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { getSlidingSyncManager } from '$client/initMatrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; +import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; export const useSlidingSyncActiveRoom = (): void => { const mx = useMatrixClient(); const roomId = useSelectedRoom(); + const spaceId = useSelectedSpace(); useEffect(() => { - if (!roomId) return undefined; const manager = getSlidingSyncManager(mx); if (!manager) return undefined; - manager.subscribeToRoom(roomId); + const activeRoomIds = [...new Set([spaceId, roomId].filter(Boolean))] as string[]; + activeRoomIds.forEach((activeRoomId) => manager.subscribeToRoom(activeRoomId)); + return () => { - manager.unsubscribeFromRoom(roomId); + activeRoomIds.forEach((activeRoomId) => manager.unsubscribeFromRoom(activeRoomId)); }; - }, [mx, roomId]); + }, [mx, roomId, spaceId]); }; diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index e7fdff6d76..b839307809 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -43,7 +43,6 @@ import { } from '$utils/notificationStyle'; import * as Sentry from '@sentry/react'; import { startClient, stopClient } from '$client/initMatrix'; -import { useClientConfig } from '$hooks/useClientConfig'; import { mobileOrTablet } from '$utils/user-agent'; const log = createLogger('BackgroundNotifications'); @@ -55,10 +54,7 @@ const BACKGROUND_STAGGER_DELAY_MS = 5_000; const isClientReadyForNotifications = (state: SyncState | string | null): boolean => state === SyncState.Prepared || state === SyncState.Syncing || state === SyncState.Catchup; -const startBackgroundClient = async ( - session: Session, - slidingSyncConfig: ReturnType['slidingSync'] -): Promise => { +const startBackgroundClient = async (session: Session): Promise => { const storeName = { sync: `bg-sync${session.userId}`, crypto: `bg-crypto${session.userId}`, @@ -82,7 +78,6 @@ const startBackgroundClient = async ( const startOpts = { baseUrl: session.baseUrl, - slidingSync: session.slidingSyncOptIn ? slidingSyncConfig : undefined, sessionSlidingSyncOptIn: session.slidingSyncOptIn, pollTimeoutMs: BACKGROUND_SYNC_POLL_TIMEOUT_MS, timelineLimit: 1, @@ -121,7 +116,6 @@ const waitForSync = (mx: MatrixClient): Promise => }); export function BackgroundNotifications() { - const clientConfig = useClientConfig(); const sessions = useAtomValue(sessionsAtom); const [activeSessionId, setActiveSessionId] = useAtom(activeSessionIdAtom); const [showNotifications] = useSetting(settingsAtom, 'useInAppNotifications'); @@ -256,7 +250,7 @@ export function BackgroundNotifications() { // fresh retry referencing the latest session from inactiveSessionsRef. const startSession = (session: Session, attempt = 0): void => { let sessionMx: MatrixClient | undefined; - startBackgroundClient(session, clientConfig.slidingSync) + startBackgroundClient(session) .then(async (mx) => { sessionMx = mx; current.set(session.userId, mx); @@ -591,7 +585,6 @@ export function BackgroundNotifications() { }); }; }, [ - clientConfig.slidingSync, inactiveSessions, shouldRunBackgroundNotifications, setActiveSessionId, diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index ee825a7576..0928ae9c92 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -48,7 +48,7 @@ import { useSyncNicknames } from '$hooks/useNickname'; import { useAppVisibility } from '$hooks/useAppVisibility'; import { composerIcon, DotsThreeOutlineVerticalIcon } from '$components/icons/phosphor'; import { getHomePath } from '$pages/pathUtils'; -import { useClientConfig, useClientConfigLoaded } from '$hooks/useClientConfig'; +import { useClientConfigLoaded } from '$hooks/useClientConfig'; import { pushSessionToSW } from '../../../sw-session'; import { SyncStatus } from './SyncStatus'; import { SpecVersions } from './SpecVersions'; @@ -218,7 +218,6 @@ type ClientRootProps = { }; export function ClientRoot({ children }: ClientRootProps) { const navigate = useNavigate(); - const clientConfig = useClientConfig(); const configLoaded = useClientConfigLoaded(); const sessions = useAtomValue(sessionsAtom); const [activeSessionId, setActiveSessionId] = useAtom(activeSessionIdAtom); @@ -274,10 +273,9 @@ export function ClientRoot({ children }: ClientRootProps) { (m) => startClient(m, { baseUrl: activeSession?.baseUrl, - slidingSync: clientConfig.slidingSync, sessionSlidingSyncOptIn: activeSession?.slidingSyncOptIn, }), - [activeSession?.baseUrl, activeSession?.slidingSyncOptIn, clientConfig.slidingSync] + [activeSession?.baseUrl, activeSession?.slidingSyncOptIn] ) ); @@ -359,12 +357,12 @@ export function ClientRoot({ children }: ClientRootProps) { if (!activeSession?.baseUrl) return undefined; Sentry.setContext('client', { homeserver: activeSession.baseUrl, - sliding_sync: clientConfig.slidingSync, + sliding_sync: activeSession.slidingSyncOptIn === true, }); return () => { Sentry.setContext('client', null); }; - }, [activeSession?.baseUrl, clientConfig.slidingSync]); + }, [activeSession?.baseUrl, activeSession?.slidingSyncOptIn]); // Set a pseudonymous hashed user ID for error grouping — never sends raw Matrix ID useEffect(() => { diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 86f39e9c37..e8d3c4ad33 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -15,7 +15,7 @@ import { createDebugLogger } from '$utils/debugLogger'; import * as Sentry from '@sentry/react'; import { pushSessionToSW } from '../sw-session'; import { cryptoCallbacks } from './secretStorageKeys'; -import type { SlidingSyncConfig, SlidingSyncDiagnostics } from './slidingSync'; +import type { SlidingSyncDiagnostics } from './slidingSync'; import { SlidingSyncManager } from './slidingSync'; import { PresenceSyncManager } from './presenceSync'; @@ -58,7 +58,7 @@ const slidingSyncConnIdCleanupByClient = new WeakMap void>() type SlidingSyncMethod = ( reqBody: MSC3575SlidingSyncRequest, - proxyBaseUrl?: string, + baseUrl?: string, abortSignal?: AbortSignal ) => Promise; @@ -75,13 +75,12 @@ function installSlidingSyncConnId(mx: MatrixClient): void { const mxWritable = mx as MatrixClientWithWritableSlidingSync; const original = mx.slidingSync.bind(mx) as SlidingSyncMethod; - - mxWritable.slidingSync = (reqBody, proxyBaseUrl, abortSignal) => { + mxWritable.slidingSync = (reqBody, baseUrl, abortSignal) => { const req = reqBody as SlidingSyncRequestWithConnId; if (req.conn_id === undefined) { req.conn_id = SLIDING_SYNC_CONN_ID; } - return original(reqBody, proxyBaseUrl, abortSignal); + return original(reqBody, baseUrl, abortSignal); }; slidingSyncConnIdCleanupByClient.set(mx, () => { @@ -420,7 +419,6 @@ export const initClient = async (session: Session): Promise => { export type StartClientConfig = { baseUrl?: string; - slidingSync?: SlidingSyncConfig; sessionSlidingSyncOptIn?: boolean; pollTimeoutMs?: number; timelineLimit?: number; @@ -453,13 +451,13 @@ export const getPresenceSyncManager = (mx: MatrixClient): PresenceSyncManager | presenceSyncByClient.get(mx); export const startClient = async (mx: MatrixClient, config?: StartClientConfig): Promise => { - debugLog.info('sync', 'Starting Matrix client', { userId: mx.getUserId() }); disposeSlidingSync(mx); disposePresenceSync(mx); - const slidingConfig = config?.slidingSync; - const proxyBaseUrl = slidingConfig?.proxyBaseUrl ?? config?.baseUrl ?? mx.baseUrl; - const useSliding = config?.sessionSlidingSyncOptIn === true && !!slidingConfig; + const baseUrl = config?.baseUrl ?? mx.baseUrl; + const useSliding = config?.sessionSlidingSyncOptIn === true; + + debugLog.info('sync', 'Starting Matrix client', { userId: mx.getUserId() }); const presenceManager = new PresenceSyncManager(mx); presenceSyncByClient.set(mx, presenceManager); @@ -469,10 +467,9 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): let manager: SlidingSyncManager | undefined; if (useSliding) { - manager = new SlidingSyncManager(mx, proxyBaseUrl, { - ...slidingConfig, - includeInviteList: true, - pollTimeoutMs: slidingConfig?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS, + manager = new SlidingSyncManager(mx, baseUrl, { + pollTimeoutMs: config?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS, + timelineLimit: config?.timelineLimit, }); installStartupFetchRoomEventPatch(mx, manager); @@ -497,7 +494,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): debugLog.error('network', 'Failed to start client with sliding sync', { error: err instanceof Error ? err.message : String(err), userId: mx.getUserId(), - proxyBaseUrl: useSliding ? proxyBaseUrl : undefined, + baseUrl: useSliding ? baseUrl : undefined, stack: err instanceof Error ? err.stack : undefined, }); disposeSlidingSync(mx); diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index c1def55584..1181249c71 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { MatrixClient, MSC3575List } from '$types/matrix-sdk'; import { SlidingSyncEvent, SlidingSyncState } from '$types/matrix-sdk'; -import { SlidingSyncManager, type SlidingSyncConfig } from './slidingSync'; +import { SlidingSyncManager } from './slidingSync'; // ── vi.hoisted mocks ───────────────────────────────────────────────────────── // Must be defined via vi.hoisted @@ -69,8 +69,7 @@ function makeMockMx(overrides: Record = {}) { } function makeManager(mx: ReturnType): SlidingSyncManager { - const config: SlidingSyncConfig = {}; - return new SlidingSyncManager(mx, 'https://sliding.example.com', config); + return new SlidingSyncManager(mx, 'https://sliding.example.com'); } beforeEach(() => { diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 1840b88a51..62f2ec22ab 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -45,15 +45,10 @@ export type PartialSlidingSyncRequest = { ranges?: [number, number][]; }; -export type SlidingSyncConfig = { - enabled?: boolean; - proxyBaseUrl?: string; - bootstrapClassicOnColdCache?: boolean; +type SlidingSyncOptions = { listPageSize?: number; timelineLimit?: number; pollTimeoutMs?: number; - includeInviteList?: boolean; - probeTimeoutMs?: number; }; export type SlidingSyncListDiagnostics = { @@ -63,7 +58,7 @@ export type SlidingSyncListDiagnostics = { }; export type SlidingSyncDiagnostics = { - proxyBaseUrl: string; + baseUrl: string; timelineLimit: number; listPageSize: number; lists: SlidingSyncListDiagnostics[]; @@ -121,7 +116,7 @@ const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscri required_state: ACTIVE_ROOM_REQUIRED_STATE, }); -const buildLists = (pageSize: number, includeInviteList: boolean): Map => { +const buildLists = (pageSize: number): Map => { const lists = new Map(); const listRequiredState = buildListRequiredState(); @@ -135,15 +130,13 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map { From 9b2fa13ac10729a85e875988f61005ea115a7c8a Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 13 Jul 2026 13:00:07 -0500 Subject: [PATCH 08/33] add join rules to request data --- src/client/slidingSync.test.ts | 5 +++-- src/client/slidingSync.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 1181249c71..104bd97ebc 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { MatrixClient, MSC3575List } from '$types/matrix-sdk'; -import { SlidingSyncEvent, SlidingSyncState } from '$types/matrix-sdk'; +import { EventType, SlidingSyncEvent, SlidingSyncState } from '$types/matrix-sdk'; import { SlidingSyncManager } from './slidingSync'; @@ -85,7 +85,8 @@ describe('SlidingSyncManager initial request', () => { const joined = lists.get('joined'); expect(joined?.ranges).toEqual([[0, 29]]); - expect(joined?.required_state).toHaveLength(8); + expect(joined?.required_state).toHaveLength(9); + expect(joined?.required_state).toContainEqual([EventType.RoomJoinRules, '']); expect(joined?.required_state).not.toContainEqual(['m.image_pack', '*']); }); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 62f2ec22ab..e7271d7b0f 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -77,6 +77,7 @@ const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [EventType.RoomCreate, ''], [EventType.RoomName, ''], [EventType.RoomCanonicalAlias, ''], + [EventType.RoomJoinRules, ''], [EventType.RoomMember, MSC3575_STATE_KEY_ME], ['m.space.child', MSC3575_WILDCARD], ]; From 3713694575e3bb72263ef7f493440dd85c2042de Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 13 Jul 2026 13:25:34 -0500 Subject: [PATCH 09/33] fetch image packs when needed --- src/app/hooks/useImagePacks.ts | 27 +++++++++++++++++ src/app/plugins/custom-emoji/utils.ts | 12 ++++++++ src/client/slidingSync.ts | 42 +++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/app/hooks/useImagePacks.ts b/src/app/hooks/useImagePacks.ts index 63cfb0d73a..5cf32b6786 100644 --- a/src/app/hooks/useImagePacks.ts +++ b/src/app/hooks/useImagePacks.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ImagePack, ImageUsage } from '$plugins/custom-emoji'; import { getGlobalImagePacks, + getGlobalImagePackRoomIds, getRoomImagePack, getRoomImagePacks, getUserImagePack, @@ -15,6 +16,7 @@ import { writeCachedPack, writeCachedPacks, } from '$plugins/custom-emoji'; +import { getSlidingSyncManager } from '$client/initMatrix'; import { useMatrixClient } from './useMatrixClient'; import { useAccountDataCallback } from './useAccountDataCallback'; import { useStateEventCallback } from './useStateEventCallback'; @@ -101,6 +103,25 @@ export const useGlobalImagePacks = (): ImagePack[] => { return cachedPacks.length > 0 ? cachedPacks : livePacks; }); + useEffect(() => { + const manager = getSlidingSyncManager(mx); + if (!manager) return undefined; + + const refresh = () => { + setGlobalPacks((prev) => { + const next = getGlobalImagePacks(mx); + return imagePackListEqual(prev, next) ? prev : next; + }); + }; + + const listeners = getGlobalImagePackRoomIds(mx).map((roomId) => { + manager.subscribeToImagePackRoom(roomId); + return manager.onRoomData(roomId, refresh); + }); + + return () => listeners.forEach((unsubscribe) => unsubscribe()); + }, [mx]); + useEffect(() => { const userId = mx.getUserId(); if (userId) writeCachedPacks(userId, globalPacksScope(), globalPacks); @@ -114,6 +135,12 @@ export const useGlobalImagePacks = (): ImagePack[] => { mEvent.getType() === (CustomAccountDataEvent.ImagePackRooms as string) || mEvent.getType() === (CustomAccountDataEvent.PoniesEmoteRooms as string) ) { + const manager = getSlidingSyncManager(mx); + if (manager) { + getGlobalImagePackRoomIds(mx).forEach((roomId) => + manager.subscribeToImagePackRoom(roomId) + ); + } setGlobalPacks((prev) => { const next = getGlobalImagePacks(mx); return imagePackListEqual(prev, next) ? prev : next; diff --git a/src/app/plugins/custom-emoji/utils.ts b/src/app/plugins/custom-emoji/utils.ts index 4597b64baf..318a2a4e6d 100644 --- a/src/app/plugins/custom-emoji/utils.ts +++ b/src/app/plugins/custom-emoji/utils.ts @@ -105,6 +105,18 @@ export function getGlobalImagePacks(mx: MatrixClient): ImagePack[] { return packs; } +export function getGlobalImagePackRoomIds(mx: MatrixClient): string[] { + const emoteRoomsContent = + getAccountData(mx, CustomAccountDataEvent.ImagePackRooms)?.getContent() || + getAccountData(mx, CustomAccountDataEvent.PoniesEmoteRooms)?.getContent(); + + if (typeof emoteRoomsContent !== 'object' || !emoteRoomsContent) return []; + const { rooms: roomIdToPackInfo } = emoteRoomsContent as { rooms?: unknown }; + if (typeof roomIdToPackInfo !== 'object' || !roomIdToPackInfo) return []; + + return Object.keys(roomIdToPackInfo); +} + export function getUserImagePack(mx: MatrixClient): ImagePack | undefined { const packEvent = getAccountData(mx, CustomAccountDataEvent.PoniesUserEmotes); const userId = mx.getUserId(); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index e7271d7b0f..8b4dfcc7d7 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -37,6 +37,7 @@ const DEFAULT_POLL_TIMEOUT_MS = 20000; const LIST_SORT_ORDER = ['by_recency', 'by_name']; const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted'; +const IMAGE_PACK_SUBSCRIPTION_KEY = 'image_packs'; const ACTIVE_ROOM_TIMELINE_LIMIT = 50; export type PartialSlidingSyncRequest = { @@ -117,6 +118,14 @@ const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscri required_state: ACTIVE_ROOM_REQUIRED_STATE, }); +const buildImagePackSubscription = (): MSC3575RoomSubscription => ({ + timeline_limit: 0, + required_state: [ + [CustomStateEvent.ImagePack, MSC3575_WILDCARD], + [CustomStateEvent.PoniesRoomEmotes, MSC3575_WILDCARD], + ], +}); + const buildLists = (pageSize: number): Map => { const lists = new Map(); const listRequiredState = buildListRequiredState(); @@ -154,6 +163,8 @@ export class SlidingSyncManager { private readonly activeRoomSubscriptions = new Set(); + private readonly imagePackRoomSubscriptions = new Set(); + private readonly listPageSize: number; private readonly roomTimelineLimit: number; @@ -218,6 +229,10 @@ export class SlidingSyncManager { UNENCRYPTED_SUBSCRIPTION_KEY, buildUnencryptedSubscription(roomTimelineLimit) ); + this.slidingSync.addCustomSubscription( + IMAGE_PACK_SUBSCRIPTION_KEY, + buildImagePackSubscription() + ); this.onLifecycle = (state, resp, err) => { const syncStartTime = performance.now(); @@ -564,6 +579,25 @@ export class SlidingSyncManager { return this.activeRoomSubscriptions.has(roomId); } + public subscribeToImagePackRoom(roomId: string): void { + if (this.disposed || this.imagePackRoomSubscriptions.has(roomId)) return; + this.slidingSync.useCustomSubscription(roomId, IMAGE_PACK_SUBSCRIPTION_KEY); + this.imagePackRoomSubscriptions.add(roomId); + this.slidingSync.modifyRoomSubscriptions( + new Set([...this.activeRoomSubscriptions, ...this.imagePackRoomSubscriptions]) + ); + } + + /* Listen for raw sliding-sync data for one room */ + public onRoomData(roomId: string, listener: () => void): () => void { + const handler = (dataRoomId: string) => { + // Let matrix-js-sdk finish applying the room data before reading state events + if (dataRoomId === roomId) globalThis.setTimeout(listener, 0); + }; + this.slidingSync.on(SlidingSyncEvent.RoomData, handler); + return () => this.slidingSync.removeListener(SlidingSyncEvent.RoomData, handler); + } + public subscribeToRoom(roomId: string): void { if (this.disposed) return; const room = this.mx.getRoom(roomId); @@ -572,7 +606,9 @@ export class SlidingSyncManager { this.slidingSync.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_KEY); } this.activeRoomSubscriptions.add(roomId); - this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions)); + this.slidingSync.modifyRoomSubscriptions( + new Set([...this.activeRoomSubscriptions, ...this.imagePackRoomSubscriptions]) + ); Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { attributes: { transport: 'sliding' }, }); @@ -636,7 +672,9 @@ export class SlidingSyncManager { this.pendingRoomDataListeners.delete(roomId); } this.activeRoomSubscriptions.delete(roomId); - this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions)); + this.slidingSync.modifyRoomSubscriptions( + new Set([...this.activeRoomSubscriptions, ...this.imagePackRoomSubscriptions]) + ); Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { attributes: { transport: 'sliding' }, }); From 6be3d0a986e3a827ecea07cd9a983c22f1316680 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 13 Jul 2026 13:49:50 -0500 Subject: [PATCH 10/33] gate ui behind first usable sync response --- src/app/pages/client/ClientRoot.tsx | 44 +++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 0928ae9c92..fc4b4ba0b2 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -231,6 +231,7 @@ export function ClientRoot({ children }: ClientRootProps) { const loadedUserIdRef = useRef(undefined); const syncStartTimeRef = useRef(performance.now()); const firstSyncReadyRef = useRef(false); + const [syncReadyClient, setSyncReadyClient] = useState(); const [loadState, loadMatrix, setLoadState] = useAsyncCallback( useCallback(async () => { @@ -335,19 +336,38 @@ export function ClientRoot({ children }: ClientRootProps) { } }, [mx, startMatrix, configLoaded]); + useEffect(() => { + firstSyncReadyRef.current = false; + syncStartTimeRef.current = performance.now(); + + if (!mx) { + setSyncReadyClient(undefined); + return; + } + + if (isClientReady(mx.getSyncState())) { + setSyncReadyClient(mx); + firstSyncReadyRef.current = true; + } + }, [mx]); + useSyncState( mx, - useCallback((state: string) => { - if (isClientReady(state)) { - if (!firstSyncReadyRef.current) { - firstSyncReadyRef.current = true; - Sentry.metrics.distribution( - 'sable.sync.time_to_ready_ms', - performance.now() - syncStartTimeRef.current - ); + useCallback( + (state: string) => { + if (isClientReady(state)) { + setSyncReadyClient((current) => (current === mx ? current : mx)); + if (!firstSyncReadyRef.current) { + firstSyncReadyRef.current = true; + Sentry.metrics.distribution( + 'sable.sync.time_to_ready_ms', + performance.now() - syncStartTimeRef.current + ); + } } - } - }, []) + }, + [mx] + ) ); const isError = loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error; @@ -434,9 +454,9 @@ export function ClientRoot({ children }: ClientRootProps) { )} - {!mx ? ( + {!mx || (!syncReadyClient && !isError) ? ( - ) : ( + ) : isError ? null : ( {(serverConfigs) => ( From 5f5e0a8a125345bd4ce6679dc2df59bd72632068 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 13 Jul 2026 14:09:11 -0500 Subject: [PATCH 11/33] delay presence until first sync --- src/app/pages/client/ClientRoot.tsx | 64 +++++++++++++---------------- src/client/initMatrix.ts | 57 +++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 38 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index fc4b4ba0b2..e7fe4f8af2 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -424,39 +424,33 @@ export function ClientRoot({ children }: ClientRootProps) { return ( - - {mx && } - {(!mx || isError) && } - {isError && ( - - - - - {loadState.status === AsyncStatus.Error && ( - {`Failed to load. ${loadState.error.message}`} - )} - {startState.status === AsyncStatus.Error && ( - {`Failed to start. ${startState.error.message}`} - )} - - - - - - )} - {!mx || (!syncReadyClient && !isError) ? ( - - ) : isError ? null : ( + {mx && } + {(!mx || isError) && } + {isError && ( + + + + + {loadState.status === AsyncStatus.Error && ( + {`Failed to load. ${loadState.error.message}`} + )} + {startState.status === AsyncStatus.Error && ( + {`Failed to start. ${startState.error.message}`} + )} + + + + + + )} + {!mx || (!syncReadyClient && !isError) ? ( + + ) : isError ? null : ( + {(serverConfigs) => ( @@ -470,8 +464,8 @@ export function ClientRoot({ children }: ClientRootProps) { )} - )} - + + )} ); } diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index e8d3c4ad33..330891d4bb 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -4,7 +4,13 @@ import type { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, } from '$types/matrix-sdk'; -import { createClient, IndexedDBStore, IndexedDBCryptoStore } from '$types/matrix-sdk'; +import { + ClientEvent, + createClient, + IndexedDBStore, + IndexedDBCryptoStore, + SyncState, +} from '$types/matrix-sdk'; import { clearNavToActivePathStore } from '$state/navToActivePath'; import type { Session, Sessions, SessionStoreName } from '$state/sessions'; @@ -23,8 +29,51 @@ const log = createLogger('initMatrix'); const debugLog = createDebugLogger('initMatrix'); const slidingSyncByClient = new WeakMap(); const presenceSyncByClient = new WeakMap(); +const presenceStartCleanupByClient = new WeakMap void>(); const SLIDING_SYNC_POLL_TIMEOUT_MS = 20000; +const isInitialSyncReady = (state: string | null): boolean => + state === SyncState.Prepared || state === SyncState.Syncing || state === SyncState.Catchup; + +const startPresenceAfterInitialSync = ( + mx: MatrixClient, + manager: PresenceSyncManager +): (() => void) => { + let started = false; + let startTimer: ReturnType | undefined; + + const start = () => { + if (started) return; + started = true; + presenceStartCleanupByClient.delete(mx); + mx.removeListener(ClientEvent.Sync, onSync); + manager.start(); + }; + + const scheduleStart = () => { + if (startTimer !== undefined) return; + startTimer = globalThis.setTimeout(() => { + startTimer = undefined; + start(); + }, 0); + }; + + const onSync = (state: SyncState) => { + if (isInitialSyncReady(state)) scheduleStart(); + }; + + if (isInitialSyncReady(mx.getSyncState())) scheduleStart(); + else mx.on(ClientEvent.Sync, onSync); + + const cleanup = () => { + if (startTimer !== undefined) globalThis.clearTimeout(startTimer); + mx.removeListener(ClientEvent.Sync, onSync); + presenceStartCleanupByClient.delete(mx); + }; + presenceStartCleanupByClient.set(mx, cleanup); + return cleanup; +}; + type StartupPhase = 'sync_store' | 'rust_crypto' | 'client_init' | 'client_start'; const measureStartupPhase = async ( @@ -321,6 +370,8 @@ const initializeClient = async ( } const { mx, indexedDBStore } = builtClient; + void mx.getVersions().catch(() => undefined); + const syncStorePromise = measureStartupPhase('sync_store', () => indexedDBStore.startup()); const cryptoPromise = measureStartupPhase('rust_crypto', () => mx.initRustCrypto({ cryptoDatabasePrefix }) @@ -438,6 +489,7 @@ const disposeSlidingSync = (mx: MatrixClient): void => { }; const disposePresenceSync = (mx: MatrixClient): void => { + presenceStartCleanupByClient.get(mx)?.(); const manager = presenceSyncByClient.get(mx); if (!manager) return; manager.dispose(); @@ -461,8 +513,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): const presenceManager = new PresenceSyncManager(mx); presenceSyncByClient.set(mx, presenceManager); - - presenceManager.start(); + startPresenceAfterInitialSync(mx, presenceManager); let manager: SlidingSyncManager | undefined; From 04a0649cdb045d667fc32c8b57b12c214a969646 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 13 Jul 2026 14:42:40 -0500 Subject: [PATCH 12/33] remove full cleanup from every start --- src/app/pages/client/ClientRoot.tsx | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index e7fe4f8af2..104e96b268 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -22,7 +22,6 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { clearCacheAndReload, clearLoginData, - clearMismatchedStores, initClient, logoutClient, startClient, @@ -243,22 +242,6 @@ export function ClientRoot({ children }: ClientRootProps) { log.log('persisting activeSessionId →', activeSession.userId); setActiveSessionId(activeSession.userId); } - const storeCleanupStart = performance.now(); - let storeCleanupOutcome = 'success'; - try { - await clearMismatchedStores(); - } catch (error) { - storeCleanupOutcome = 'error'; - throw error; - } finally { - Sentry.metrics.distribution( - 'sable.startup.phase_ms', - performance.now() - storeCleanupStart, - { - attributes: { phase: 'store_cleanup', outcome: storeCleanupOutcome }, - } - ); - } log.log('initClient for', activeSession.userId); const newMx = await initClient(activeSession); loadedUserIdRef.current = activeSession.userId; From b64cb8b333d3a5bad2e4e4ee2ddf9696ab8e11eb Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 13 Jul 2026 23:30:05 -0500 Subject: [PATCH 13/33] fix sync when open room, and clean up sync sizes and data, other stuff i forgot --- src/app/hooks/router/useResolvedRoomId.ts | 64 ++++++++ src/app/hooks/useSlidingSyncActiveRoom.ts | 63 ++++++-- src/app/hooks/useSyncState.ts | 8 +- src/app/pages/client/ClientRoot.tsx | 36 +++-- src/app/pages/client/direct/RoomProvider.tsx | 7 +- src/app/pages/client/home/RoomProvider.tsx | 7 +- src/app/pages/client/space/RoomProvider.tsx | 7 +- src/app/pages/client/space/SpaceProvider.tsx | 7 +- src/client/initMatrix.ts | 1 + src/client/slidingSync.test.ts | 78 +++++++++- src/client/slidingSync.ts | 147 +++++++++++++++++-- 11 files changed, 369 insertions(+), 56 deletions(-) create mode 100644 src/app/hooks/router/useResolvedRoomId.ts diff --git a/src/app/hooks/router/useResolvedRoomId.ts b/src/app/hooks/router/useResolvedRoomId.ts new file mode 100644 index 0000000000..43bffe3823 --- /dev/null +++ b/src/app/hooks/router/useResolvedRoomId.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { getCanonicalAliasRoomId, isRoomAlias } from '$utils/matrix'; +import { useMatrixClient } from '$hooks/useMatrixClient'; + +type AliasResolutionState = { + alias: string; + roomId?: string; +}; + +export type ResolvedRoomId = { + roomId?: string; + resolving: boolean; +}; + +/** Resolve aliases even when the room is not present in the local sync store yet. */ +export const useResolvedRoomIdOrAlias = (roomIdOrAlias?: string): ResolvedRoomId => { + const mx = useMatrixClient(); + const localRoomId = + roomIdOrAlias && isRoomAlias(roomIdOrAlias) + ? getCanonicalAliasRoomId(mx, roomIdOrAlias) + : roomIdOrAlias; + const [aliasResolution, setAliasResolution] = useState(); + + useEffect(() => { + if (!roomIdOrAlias || !isRoomAlias(roomIdOrAlias) || localRoomId) return undefined; + if (aliasResolution?.alias === roomIdOrAlias) return undefined; + + let cancelled = false; + void mx + .getRoomIdForAlias(roomIdOrAlias) + .then(({ room_id: resolvedRoomId }) => { + if (!cancelled) setAliasResolution({ alias: roomIdOrAlias, roomId: resolvedRoomId }); + }) + .catch(() => { + if (!cancelled) setAliasResolution({ alias: roomIdOrAlias }); + }); + + return () => { + cancelled = true; + }; + }, [aliasResolution?.alias, localRoomId, mx, roomIdOrAlias]); + + if (localRoomId) return { roomId: localRoomId, resolving: false }; + if (!roomIdOrAlias || !isRoomAlias(roomIdOrAlias)) { + return { roomId: roomIdOrAlias, resolving: false }; + } + if (aliasResolution?.alias === roomIdOrAlias) { + return { roomId: aliasResolution.roomId, resolving: false }; + } + return { resolving: true }; +}; + +export const useResolvedSelectedRoom = (): ResolvedRoomId => { + const { roomIdOrAlias: encodedRoomIdOrAlias } = useParams(); + const roomIdOrAlias = encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias); + return useResolvedRoomIdOrAlias(roomIdOrAlias); +}; + +export const useResolvedSelectedSpace = (): ResolvedRoomId => { + const { spaceIdOrAlias: encodedSpaceIdOrAlias } = useParams(); + const spaceIdOrAlias = encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias); + return useResolvedRoomIdOrAlias(spaceIdOrAlias); +}; diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts index 850f539298..321a3c2af9 100644 --- a/src/app/hooks/useSlidingSyncActiveRoom.ts +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -1,23 +1,62 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { getSlidingSyncManager } from '$client/initMatrix'; -import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; -import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; +import { useResolvedSelectedRoom, useResolvedSelectedSpace } from '$hooks/router/useResolvedRoomId'; +import { useSpaces } from '$state/hooks/roomList'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { ClientEvent } from '$types/matrix-sdk'; -export const useSlidingSyncActiveRoom = (): void => { +const useAvailableSlidingSyncManager = () => { const mx = useMatrixClient(); - const roomId = useSelectedRoom(); - const spaceId = useSelectedSpace(); + const [manager, setManager] = useState(() => getSlidingSyncManager(mx)); useEffect(() => { - const manager = getSlidingSyncManager(mx); - if (!manager) return undefined; + if (manager) return undefined; - const activeRoomIds = [...new Set([spaceId, roomId].filter(Boolean))] as string[]; - activeRoomIds.forEach((activeRoomId) => manager.subscribeToRoom(activeRoomId)); + const checkForManager = () => { + const nextManager = getSlidingSyncManager(mx); + if (nextManager) setManager(nextManager); + }; + const retryId = globalThis.setTimeout(checkForManager, 0); + mx.on(ClientEvent.Sync, checkForManager); return () => { - activeRoomIds.forEach((activeRoomId) => manager.unsubscribeFromRoom(activeRoomId)); + globalThis.clearTimeout(retryId); + mx.removeListener(ClientEvent.Sync, checkForManager); }; - }, [mx, roomId, spaceId]); + }, [manager, mx]); + + return manager; +}; + +export const useSlidingSyncRouteRooms = (): void => { + const manager = useAvailableSlidingSyncManager(); + const { roomId, resolving: resolvingRoom } = useResolvedSelectedRoom(); + const { roomId: spaceId, resolving: resolvingSpace } = useResolvedSelectedSpace(); + + useEffect(() => { + if (!manager || resolvingRoom || resolvingSpace) return undefined; + + const activeRoomIds = [...new Set([spaceId, roomId].filter(Boolean))] as string[]; + activeRoomIds.forEach((activeRoomId) => manager.subscribeToRoom(activeRoomId)); + + return () => activeRoomIds.forEach((activeRoomId) => manager.unsubscribeFromRoom(activeRoomId)); + }, [manager, resolvingRoom, resolvingSpace, roomId, spaceId]); +}; + +export const useSlidingSyncSpaceSubscriptions = (): void => { + const manager = useAvailableSlidingSyncManager(); + const mx = useMatrixClient(); + const spaces = useSpaces(mx, allRoomsAtom); + + useEffect(() => { + if (!manager) return undefined; + manager.setSpaceSubscriptions(spaces); + return undefined; + }, [manager, spaces]); +}; + +export const useSlidingSyncActiveRoom = (): void => { + useSlidingSyncRouteRooms(); + useSlidingSyncSpaceSubscriptions(); }; diff --git a/src/app/hooks/useSyncState.ts b/src/app/hooks/useSyncState.ts index 61d48ed40c..b7cc987830 100644 --- a/src/app/hooks/useSyncState.ts +++ b/src/app/hooks/useSyncState.ts @@ -7,7 +7,13 @@ export const useSyncState = ( onChange: ClientEventHandlerMap[ClientEvent.Sync] ): void => { useEffect(() => { - mx?.on(ClientEvent.Sync, onChange); + if (!mx) return undefined; + + mx.on(ClientEvent.Sync, onChange); + + const currentState = mx.getSyncState(); + if (currentState) onChange(currentState, null); + return () => { mx?.removeListener(ClientEvent.Sync, onChange); }; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 104e96b268..b6c27646f4 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -430,24 +430,28 @@ export function ClientRoot({ children }: ClientRootProps) { )} - {!mx || (!syncReadyClient && !isError) ? ( + {!mx ? ( ) : isError ? null : ( - - - - {(serverConfigs) => ( - - - - {children} - - - - )} - - - + + {!syncReadyClient ? ( + + ) : ( + + + {(serverConfigs) => ( + + + + {children} + + + + )} + + + )} + )} ); diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx index 666477dd0b..14c1622203 100644 --- a/src/app/pages/client/direct/RoomProvider.tsx +++ b/src/app/pages/client/direct/RoomProvider.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; +import { Spinner } from 'folds'; import { useParams } from 'react-router-dom'; -import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; +import { useResolvedSelectedRoom } from '$hooks/router/useResolvedRoomId'; import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; @@ -13,9 +14,11 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { const { roomIdOrAlias: encodedRoomIdOrAlias, eventId: encodedEventId } = useParams(); const roomIdOrAlias = encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias); const eventId = encodedEventId && decodeURIComponent(encodedEventId); - const roomId = useSelectedRoom(); + const { roomId, resolving } = useResolvedSelectedRoom(); const room = mx.getRoom(roomId); + if (resolving) return ; + if (!room || !rooms.includes(room.roomId)) { return ; } diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index 2cd2c0acb7..baba83a7f0 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; +import { Spinner } from 'folds'; import { useParams } from 'react-router-dom'; -import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; +import { useResolvedSelectedRoom } from '$hooks/router/useResolvedRoomId'; import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; @@ -18,9 +19,11 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { const roomIdOrAlias = encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias); const eventId = encodedEventId && decodeURIComponent(encodedEventId); const viaServers = useSearchParamsViaServers(); - const roomId = useSelectedRoom(); + const { roomId, resolving } = useResolvedSelectedRoom(); const room = mx.getRoom(roomId); + if (resolving) return ; + if (!room || (!isShowingAllRoomsInHome && !rooms.includes(room.roomId))) { return ( ; + if (!room || !allRooms.includes(room.roomId)) { // room is not joined return ( diff --git a/src/app/pages/client/space/SpaceProvider.tsx b/src/app/pages/client/space/SpaceProvider.tsx index 09c9e468d2..ddef162cdc 100644 --- a/src/app/pages/client/space/SpaceProvider.tsx +++ b/src/app/pages/client/space/SpaceProvider.tsx @@ -1,9 +1,10 @@ import type { ReactNode } from 'react'; +import { Spinner } from 'folds'; import { useParams } from 'react-router-dom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useSpaces } from '$state/hooks/roomList'; import { allRoomsAtom } from '$state/room-list/roomList'; -import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; +import { useResolvedSelectedSpace } from '$hooks/router/useResolvedRoomId'; import { SpaceProvider } from '$hooks/useSpace'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; import { useSearchParamsViaServers } from '$hooks/router/useSearchParamsViaServers'; @@ -19,9 +20,11 @@ export function RouteSpaceProvider({ children }: RouteSpaceProviderProps) { const spaceIdOrAlias = encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias); const viaServers = useSearchParamsViaServers(); - const selectedSpaceId = useSelectedSpace(); + const { roomId: selectedSpaceId, resolving } = useResolvedSelectedSpace(); const space = mx.getRoom(selectedSpaceId); + if (resolving) return ; + if (!space || !joinedSpaces.includes(space.roomId)) { return ; } diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 330891d4bb..57b1608c30 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -129,6 +129,7 @@ function installSlidingSyncConnId(mx: MatrixClient): void { if (req.conn_id === undefined) { req.conn_id = SLIDING_SYNC_CONN_ID; } + return original(reqBody, baseUrl, abortSignal); }; diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 104bd97ebc..6cdd6974f6 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -25,7 +25,7 @@ const mocks = vi.hoisted(() => ({ getListData: vi.fn<(key: string) => null>(), getListParams: vi.fn<(key: string) => null>(), setList: vi.fn<() => void>(), - setListRanges: vi.fn<() => void>(), + setListRanges: vi.fn<(key: string, ranges: number[][]) => void>(), }, })); @@ -74,9 +74,22 @@ function makeManager(mx: ReturnType): SlidingSyncManager { beforeEach(() => { vi.clearAllMocks(); + mocks.slidingSyncInstance.getListData.mockReset().mockReturnValue(null); + mocks.slidingSyncInstance.getListParams.mockReset().mockReturnValue(null); + mocks.slidingSyncInstance.setListRanges.mockReset(); mocks.slidingSyncConstructorArgs = undefined; }); +function fireLifecycle(state: SlidingSyncState, response: unknown = {}) { + const lifecycleCall = mocks.slidingSyncInstance.on.mock.calls.find( + ([event]) => event === SlidingSyncEvent.Lifecycle + ); + const lifecycle = lifecycleCall?.[1] as + | ((nextState: SlidingSyncState, nextResponse: unknown, error?: Error) => void) + | undefined; + lifecycle?.(state, response); +} + describe('SlidingSyncManager initial request', () => { it('starts with a small room-list range and only row-level state', () => { makeManager(makeMockMx()); @@ -85,9 +98,9 @@ describe('SlidingSyncManager initial request', () => { const joined = lists.get('joined'); expect(joined?.ranges).toEqual([[0, 29]]); - expect(joined?.required_state).toHaveLength(9); + expect(joined?.required_state).toHaveLength(8); expect(joined?.required_state).toContainEqual([EventType.RoomJoinRules, '']); - expect(joined?.required_state).not.toContainEqual(['m.image_pack', '*']); + expect(joined?.required_state).not.toContainEqual(['m.space.child', '*']); }); it('expands by one page after the first response instead of jumping to all rooms', () => { @@ -108,7 +121,60 @@ describe('SlidingSyncManager initial request', () => { | undefined; lifecycle?.(SlidingSyncState.Complete, {}); - expect(mocks.slidingSyncInstance.setListRanges).toHaveBeenCalledWith('joined', [[0, 279]]); + expect(mocks.slidingSyncInstance.setListRanges).toHaveBeenCalledWith('joined', [ + [0, 29], + [30, 59], + ]); + }); + + it('continues expanding lists when an active-room subscription has not returned data yet', () => { + const manager = makeManager(makeMockMx()); + mocks.slidingSyncInstance.getListData.mockImplementation((key: string) => + key === 'joined' ? ({ joinedCount: 1000 } as never) : null + ); + mocks.slidingSyncInstance.getListParams.mockReturnValue({ + ranges: [[0, 29]], + } as never); + manager.attach(); + manager.subscribeToRoom('!active:example.com'); + + const lifecycleCall = mocks.slidingSyncInstance.on.mock.calls.find( + ([event]) => event === SlidingSyncEvent.Lifecycle + ); + const lifecycle = lifecycleCall?.[1] as + | ((state: SlidingSyncState, response: unknown, error?: Error) => void) + | undefined; + lifecycle?.(SlidingSyncState.Complete, {}); + + expect(mocks.slidingSyncInstance.setListRanges).toHaveBeenCalledWith('joined', [ + [0, 29], + [30, 59], + ]); + }); + + it('defers active subscriptions while an expanded range is awaiting its response', () => { + let joinedRange: [number, number][] = [[0, 29]]; + const manager = makeManager(makeMockMx()); + mocks.slidingSyncInstance.getListData.mockImplementation((key: string) => + key === 'joined' ? ({ joinedCount: 193 } as never) : null + ); + mocks.slidingSyncInstance.getListParams.mockImplementation((key: string) => + key === 'joined' ? ({ ranges: joinedRange } as never) : ({ ranges: [[0, 29]] } as never) + ); + mocks.slidingSyncInstance.setListRanges.mockImplementation((key, ranges) => { + if (key === 'joined') joinedRange = ranges as [number, number][]; + }); + manager.attach(); + + fireLifecycle(SlidingSyncState.Complete); + manager.subscribeToRoom('!active:example.com'); + + const diagnostics = manager.getDiagnostics(); + expect(manager.isRoomActive('!active:example.com')).toBe(true); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).not.toHaveBeenCalled(); + expect(diagnostics.lists.find((list) => list.key === 'joined')).toMatchObject({ + rangeEnd: 59, + }); }); }); @@ -147,6 +213,7 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => { const mx = makeMockMx(); const manager = makeManager(mx); manager.attach(); + fireLifecycle(SlidingSyncState.Complete); manager.subscribeToRoom('!room:example.com'); fireMembershipEvent(mx, 'leave'); @@ -159,6 +226,7 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => { const mx = makeMockMx(); const manager = makeManager(mx); manager.attach(); + fireLifecycle(SlidingSyncState.Complete); manager.subscribeToRoom('!room:example.com'); fireMembershipEvent(mx, 'ban'); @@ -170,6 +238,7 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => { const mx = makeMockMx(); const manager = makeManager(mx); manager.attach(); + fireLifecycle(SlidingSyncState.Complete); manager.subscribeToRoom('!room:example.com'); fireMembershipEvent(mx, 'leave', '!room:example.com', '@other:example.com'); @@ -182,6 +251,7 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => { const mx = makeMockMx(); const manager = makeManager(mx); manager.attach(); + fireLifecycle(SlidingSyncState.Complete); manager.subscribeToRoom('!room:example.com'); fireMembershipEvent(mx, 'join'); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 8b4dfcc7d7..0733229b7d 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -29,14 +29,16 @@ export const LIST_INVITES = 'invites'; export const LIST_SEARCH = 'search'; export const LIST_ROOM_SEARCH = 'room_search'; export const LIST_SPACE = 'space'; -const LIST_TIMELINE_LIMIT = 1; +const LIST_TIMELINE_LIMIT = 0; const DEFAULT_LIST_PAGE_SIZE = 250; const INITIAL_LIST_SIZE = 30; +const LIST_EXPANSION_PAGE_SIZE = INITIAL_LIST_SIZE; const DEFAULT_POLL_TIMEOUT_MS = 20000; const LIST_SORT_ORDER = ['by_recency', 'by_name']; const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted'; +const SPACE_SUBSCRIPTION_KEY = 'space'; const IMAGE_PACK_SUBSCRIPTION_KEY = 'image_packs'; const ACTIVE_ROOM_TIMELINE_LIMIT = 50; @@ -80,7 +82,18 @@ const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [EventType.RoomCanonicalAlias, ''], [EventType.RoomJoinRules, ''], [EventType.RoomMember, MSC3575_STATE_KEY_ME], +]; + +const SPACE_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ + [EventType.RoomCreate, ''], + [EventType.RoomName, ''], + [EventType.RoomAvatar, ''], + [EventType.RoomCanonicalAlias, ''], + [EventType.RoomJoinRules, ''], + [EventType.RoomEncryption, ''], + [EventType.RoomTombstone, ''], ['m.space.child', MSC3575_WILDCARD], + ['m.space.parent', MSC3575_WILDCARD], ]; const ACTIVE_ROOM_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ @@ -110,7 +123,7 @@ const ACTIVE_ROOM_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ const buildEncryptedSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({ timeline_limit: timelineLimit, - required_state: [[MSC3575_WILDCARD, MSC3575_WILDCARD]], + required_state: ACTIVE_ROOM_REQUIRED_STATE, }); const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({ @@ -126,6 +139,11 @@ const buildImagePackSubscription = (): MSC3575RoomSubscription => ({ ], }); +const buildSpaceSubscription = (): MSC3575RoomSubscription => ({ + timeline_limit: 0, + required_state: SPACE_REQUIRED_STATE, +}); + const buildLists = (pageSize: number): Map => { const lists = new Map(); const listRequiredState = buildListRequiredState(); @@ -163,6 +181,12 @@ export class SlidingSyncManager { private readonly activeRoomSubscriptions = new Set(); + private readonly deferredActiveRoomSubscriptions = new Set(); + + private readonly spaceSubscriptions = new Set(); + + private deferredSpaceSubscriptions = new Set(); + private readonly imagePackRoomSubscriptions = new Set(); private readonly listPageSize: number; @@ -184,12 +208,14 @@ export class SlidingSyncManager { private initialSyncCompleted = false; - private activeRoomDataReceived = false; - private syncCount = 0; private previousListCounts: Map = new Map(); + private readonly requestedListRangeEnds = new Map(); + + private readonly confirmedListRangeEnds = new Map(); + /** * One-shot RoomData listeners keyed by roomId, used to measure the latency * between subscribeToRoom() and the first data arriving for that room. @@ -223,6 +249,9 @@ export class SlidingSyncManager { const defaultSubscription = buildEncryptedSubscription(roomTimelineLimit); const lists = buildLists(listPageSize); this.listKeys = Array.from(lists.keys()); + lists.forEach((list, key) => { + this.requestedListRangeEnds.set(key, getListEndIndex(list)); + }); this.slidingSync = new SlidingSync(baseUrl, lists, defaultSubscription, mx, pollTimeoutMs); this.slidingSync.addCustomSubscription( @@ -233,6 +262,7 @@ export class SlidingSyncManager { IMAGE_PACK_SUBSCRIPTION_KEY, buildImagePackSubscription() ); + this.slidingSync.addCustomSubscription(SPACE_SUBSCRIPTION_KEY, buildSpaceSubscription()); this.onLifecycle = (state, resp, err) => { const syncStartTime = performance.now(); @@ -267,6 +297,10 @@ export class SlidingSyncManager { if (err || !resp || state !== SlidingSyncState.Complete) return; + this.requestedListRangeEnds.forEach((rangeEnd, key) => { + this.confirmedListRangeEnds.set(key, rangeEnd); + }); + const changes: Record = {}; let totalRoomCount = 0; let hasChanges = false; @@ -406,7 +440,6 @@ export class SlidingSyncManager { private expandListsByPage(): void { if (!this.initialSyncCompleted) return; - if (this.activeRoomSubscriptions.size > 0 && !this.activeRoomDataReceived) return; let allListsComplete = true; let expandedAny = false; @@ -434,10 +467,22 @@ export class SlidingSyncManager { } const existing = this.slidingSync.getListParams(key); - const currentEnd = getListEndIndex(existing); + const requestedEnd = getListEndIndex(existing); + const currentEnd = this.confirmedListRangeEnds.get(key) ?? -1; const maxEnd = knownCount - 1; + if (requestedEnd > currentEnd) { + allListsComplete = false; + expansionDetails[key] = { + status: 'awaiting-response', + knownCount, + currentEnd, + desiredEnd: requestedEnd, + }; + return; + } + if (currentEnd >= maxEnd) { expansionDetails[key] = { status: 'complete', knownCount, currentEnd }; return; @@ -445,7 +490,7 @@ export class SlidingSyncManager { allListsComplete = false; - const desiredEnd = Math.min(maxEnd, currentEnd + this.listPageSize); + const desiredEnd = Math.min(maxEnd, currentEnd + LIST_EXPANSION_PAGE_SIZE); if (desiredEnd === currentEnd) { expansionDetails[key] = { @@ -457,7 +502,10 @@ export class SlidingSyncManager { return; } - this.slidingSync.setListRanges(key, [[0, desiredEnd]]); + const existingRanges = existing?.ranges ?? []; + const nextRangeStart = currentEnd + 1; + this.slidingSync.setListRanges(key, [...existingRanges, [nextRangeStart, desiredEnd]]); + this.requestedListRangeEnds.set(key, desiredEnd); expandedAny = true; expansionDetails[key] = { @@ -479,10 +527,10 @@ export class SlidingSyncManager { const expansionDuration = performance.now() - expansionStartTime; const hasExpansions = Object.values(expansionDetails).some((d) => d.status === 'expanding'); - if (allListsComplete) { if (!this.listsFullyLoaded) { this.listsFullyLoaded = true; + globalThis.setTimeout(() => this.flushDeferredSubscriptions(), 0); log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`); const totalRooms = this.listKeys.reduce( (sum, key) => sum + (this.slidingSync.getListData(key)?.joinedCount ?? 0), @@ -576,7 +624,21 @@ export class SlidingSyncManager { } public isRoomActive(roomId: string): boolean { - return this.activeRoomSubscriptions.has(roomId); + return ( + this.activeRoomSubscriptions.has(roomId) || this.deferredActiveRoomSubscriptions.has(roomId) + ); + } + + private flushDeferredSubscriptions(): void { + if (this.disposed || !this.listsFullyLoaded) return; + + const spaces = this.deferredSpaceSubscriptions; + this.deferredSpaceSubscriptions = new Set(); + if (spaces.size > 0) this.setSpaceSubscriptions(spaces); + + const activeRooms = [...this.deferredActiveRoomSubscriptions]; + this.deferredActiveRoomSubscriptions.clear(); + activeRooms.forEach((roomId) => this.subscribeToRoom(roomId)); } public subscribeToImagePackRoom(roomId: string): void { @@ -584,7 +646,11 @@ export class SlidingSyncManager { this.slidingSync.useCustomSubscription(roomId, IMAGE_PACK_SUBSCRIPTION_KEY); this.imagePackRoomSubscriptions.add(roomId); this.slidingSync.modifyRoomSubscriptions( - new Set([...this.activeRoomSubscriptions, ...this.imagePackRoomSubscriptions]) + new Set([ + ...this.activeRoomSubscriptions, + ...this.spaceSubscriptions, + ...this.imagePackRoomSubscriptions, + ]) ); } @@ -600,6 +666,15 @@ export class SlidingSyncManager { public subscribeToRoom(roomId: string): void { if (this.disposed) return; + if (!this.listsFullyLoaded) { + this.deferredActiveRoomSubscriptions.add(roomId); + debugLog.info('sync', 'Room subscription deferred until lists are hydrated', { + deferredSubscriptions: this.deferredActiveRoomSubscriptions.size, + syncCycle: this.syncCount, + }); + return; + } + if (this.activeRoomSubscriptions.has(roomId)) return; const room = this.mx.getRoom(roomId); const isEncrypted = this.mx.isRoomEncrypted(roomId); if (room && !isEncrypted) { @@ -607,8 +682,14 @@ export class SlidingSyncManager { } this.activeRoomSubscriptions.add(roomId); this.slidingSync.modifyRoomSubscriptions( - new Set([...this.activeRoomSubscriptions, ...this.imagePackRoomSubscriptions]) + new Set([ + ...this.activeRoomSubscriptions, + ...this.spaceSubscriptions, + ...this.imagePackRoomSubscriptions, + ]) ); + + this.expandListsByPage(); Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { attributes: { transport: 'sliding' }, }); @@ -657,8 +738,6 @@ export class SlidingSyncManager { }); this.slidingSync.removeListener(SlidingSyncEvent.RoomData, onFirstRoomData); this.pendingRoomDataListeners.delete(roomId); - this.activeRoomDataReceived = true; - this.expandListsByPage(); }; this.pendingRoomDataListeners.set(roomId, onFirstRoomData); this.slidingSync.on(SlidingSyncEvent.RoomData, onFirstRoomData); @@ -666,14 +745,23 @@ export class SlidingSyncManager { public unsubscribeFromRoom(roomId: string): void { if (this.disposed) return; + this.deferredActiveRoomSubscriptions.delete(roomId); + if (!this.activeRoomSubscriptions.has(roomId)) return; const pendingListener = this.pendingRoomDataListeners.get(roomId); if (pendingListener) { this.slidingSync.removeListener(SlidingSyncEvent.RoomData, pendingListener); this.pendingRoomDataListeners.delete(roomId); } this.activeRoomSubscriptions.delete(roomId); + if (this.spaceSubscriptions.has(roomId)) { + this.slidingSync.useCustomSubscription(roomId, SPACE_SUBSCRIPTION_KEY); + } this.slidingSync.modifyRoomSubscriptions( - new Set([...this.activeRoomSubscriptions, ...this.imagePackRoomSubscriptions]) + new Set([ + ...this.activeRoomSubscriptions, + ...this.spaceSubscriptions, + ...this.imagePackRoomSubscriptions, + ]) ); Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { attributes: { transport: 'sliding' }, @@ -684,4 +772,33 @@ export class SlidingSyncManager { syncCycle: this.syncCount, }); } + + public setSpaceSubscriptions(roomIds: Iterable): void { + if (this.disposed) return; + const next = new Set(roomIds); + if (!this.listsFullyLoaded) { + this.deferredSpaceSubscriptions = next; + return; + } + const unchanged = + next.size === this.spaceSubscriptions.size && + [...next].every((roomId) => this.spaceSubscriptions.has(roomId)); + if (unchanged) return; + + this.spaceSubscriptions.clear(); + next.forEach((roomId) => { + this.spaceSubscriptions.add(roomId); + if (!this.activeRoomSubscriptions.has(roomId)) { + this.slidingSync.useCustomSubscription(roomId, SPACE_SUBSCRIPTION_KEY); + } + }); + this.slidingSync.modifyRoomSubscriptions( + new Set([ + ...this.activeRoomSubscriptions, + ...this.spaceSubscriptions, + ...this.imagePackRoomSubscriptions, + ]) + ); + this.expandListsByPage(); + } } From 9fbd2e5c6c7bcd1b41f7044b4f3b2518607c6e79 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Tue, 14 Jul 2026 15:52:30 -0500 Subject: [PATCH 14/33] some clean up --- src/app/components/SpecVersionsLoader.tsx | 4 +- .../developer-tools/DevelopTools.tsx | 3 +- .../developer-tools/SyncDiagnostics.tsx | 5 +- src/app/hooks/useClientConfig.ts | 8 - src/app/hooks/useImagePacks.ts | 22 +-- src/app/pages/App.tsx | 16 +- src/app/pages/client/ClientRoot.tsx | 6 +- src/app/pages/client/SpecVersions.tsx | 10 +- src/app/state/room-list/inviteList.ts | 15 +- src/app/state/room-list/roomList.ts | 15 +- src/app/state/room-list/utils.test.ts | 27 +++ src/app/state/room-list/utils.ts | 72 +++++++- src/client/initMatrix.ts | 12 ++ src/client/slidingSync.test.ts | 63 +++++-- src/client/slidingSync.ts | 161 +++++++----------- 15 files changed, 265 insertions(+), 174 deletions(-) create mode 100644 src/app/state/room-list/utils.test.ts diff --git a/src/app/components/SpecVersionsLoader.tsx b/src/app/components/SpecVersionsLoader.tsx index fe01d4b0b7..8dbfe1305c 100644 --- a/src/app/components/SpecVersionsLoader.tsx +++ b/src/app/components/SpecVersionsLoader.tsx @@ -6,18 +6,20 @@ import { specVersions } from '../cs-api'; type SpecVersionsLoaderProps = { baseUrl: string; + loadVersions?: () => Promise; fallback?: () => ReactNode; error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode; children: (versions: SpecVersions) => ReactNode; }; export function SpecVersionsLoader({ baseUrl, + loadVersions, fallback, error, children, }: SpecVersionsLoaderProps) { const [state, load] = useAsyncCallback( - useCallback(() => specVersions(fetch, baseUrl), [baseUrl]) + useCallback(() => loadVersions?.() ?? specVersions(fetch, baseUrl), [baseUrl, loadVersions]) ); const [ignoreError, setIgnoreError] = useState(false); diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index f710eacebc..e920a17f61 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -425,8 +425,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { Base URL: {syncDiagnostics.sliding.baseUrl} - Room timeline: {syncDiagnostics.sliding.timelineLimit} | page - size: {syncDiagnostics.sliding.listPageSize} + Room timeline: {syncDiagnostics.sliding.timelineLimit} ) : ( diff --git a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx index 4739dc2691..8fd1d0e293 100644 --- a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx +++ b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx @@ -180,10 +180,7 @@ export function SyncDiagnostics() { {expandSliding && ( Sliding sync base URL: {diagnostics.sliding.baseUrl} - - Room timeline limit: {diagnostics.sliding.timelineLimit} | page size:{' '} - {diagnostics.sliding.listPageSize} - + Room timeline limit: {diagnostics.sliding.timelineLimit} {diagnostics.sliding.lists.map((list) => ( List `{list.key}` coverage:{' '} diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 9efc4bac00..76ca7eae6b 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -61,14 +61,6 @@ export function useOptionalClientConfig(): ClientConfig { return useContext(ClientConfigContext); } -const ClientConfigLoadedContext = createContext(false); - -export const ClientConfigLoadedProvider = ClientConfigLoadedContext.Provider; - -export function useClientConfigLoaded(): boolean { - return useContext(ClientConfigLoadedContext); -} - export const clientDefaultServer = (clientConfig: ClientConfig): string => clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org'; diff --git a/src/app/hooks/useImagePacks.ts b/src/app/hooks/useImagePacks.ts index 5cf32b6786..516aba467b 100644 --- a/src/app/hooks/useImagePacks.ts +++ b/src/app/hooks/useImagePacks.ts @@ -57,6 +57,9 @@ const imagePackListEqual = (a: ImagePack[], b: ImagePack[]): boolean => { return a.every((pack, index) => imagePackEqual(pack, b[index])); }; +const roomIdListEqual = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((roomId, index) => roomId === b[index]); + export const useUserImagePack = (): ImagePack | undefined => { const mx = useMatrixClient(); // Seed from cache during initial state when live data is not available yet. @@ -102,6 +105,7 @@ export const useGlobalImagePacks = (): ImagePack[] => { const cachedPacks = readCachedPacks(userId, globalPacksScope()); return cachedPacks.length > 0 ? cachedPacks : livePacks; }); + const [packRoomIds, setPackRoomIds] = useState(() => getGlobalImagePackRoomIds(mx)); useEffect(() => { const manager = getSlidingSyncManager(mx); @@ -114,13 +118,11 @@ export const useGlobalImagePacks = (): ImagePack[] => { }); }; - const listeners = getGlobalImagePackRoomIds(mx).map((roomId) => { - manager.subscribeToImagePackRoom(roomId); - return manager.onRoomData(roomId, refresh); - }); + manager.setImagePackSubscriptions(packRoomIds); + const listeners = packRoomIds.map((roomId) => manager.onRoomData(roomId, refresh)); return () => listeners.forEach((unsubscribe) => unsubscribe()); - }, [mx]); + }, [mx, packRoomIds]); useEffect(() => { const userId = mx.getUserId(); @@ -136,11 +138,11 @@ export const useGlobalImagePacks = (): ImagePack[] => { mEvent.getType() === (CustomAccountDataEvent.PoniesEmoteRooms as string) ) { const manager = getSlidingSyncManager(mx); - if (manager) { - getGlobalImagePackRoomIds(mx).forEach((roomId) => - manager.subscribeToImagePackRoom(roomId) - ); - } + const nextPackRoomIds = getGlobalImagePackRoomIds(mx); + manager?.setImagePackSubscriptions(nextPackRoomIds); + setPackRoomIds((current) => + roomIdListEqual(current, nextPackRoomIds) ? current : nextPackRoomIds + ); setGlobalPacks((prev) => { const next = getGlobalImagePacks(mx); return imagePackListEqual(prev, next) ? prev : next; diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index e9275fd8b7..7fdab7fcc1 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -7,7 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; import type { ClientConfig } from '$hooks/useClientConfig'; -import { ClientConfigLoadedProvider, ClientConfigProvider } from '$hooks/useClientConfig'; +import { ClientConfigProvider } from '$hooks/useClientConfig'; import { setMatrixToBase } from '$plugins/matrix-to'; import type { ScreenSize } from '$hooks/useScreenSize'; import { ScreenSizeProvider, useScreenSize } from '$hooks/useScreenSize'; @@ -65,9 +65,8 @@ function renderSentryErrorFallback({ error, eventId }: { error: unknown; eventId ); } -function useClientConfigLoader(): { config: ClientConfig; loaded: boolean } { +function useClientConfigLoader(): ClientConfig { const [config, setConfig] = useState({}); - const [loaded, setLoaded] = useState(false); useEffect(() => { let cancelled = false; @@ -80,11 +79,8 @@ function useClientConfigLoader(): { config: ClientConfig; loaded: boolean } { if (cancelled) return; setConfig(data); setMatrixToBase(data.matrixToBaseUrl); - setLoaded(true); } catch (err) { log.error('Failed to load config.json, continuing with empty config:', err); - if (cancelled) return; - setLoaded(true); } }; @@ -94,7 +90,7 @@ function useClientConfigLoader(): { config: ClientConfig; loaded: boolean } { }; }, []); - return { config, loaded }; + return config; } function App() { @@ -102,7 +98,7 @@ function App() { useCompositionEndTracking(); const portalContainer = document.getElementById('portalContainer') ?? undefined; - const { config: clientConfig, loaded: configLoaded } = useClientConfigLoader(); + const clientConfig = useClientConfigLoader(); return ( @@ -112,9 +108,7 @@ function App() { - - - + diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 1efecf9647..9b1c1d4d30 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -47,7 +47,6 @@ import { useSyncNicknames } from '$hooks/useNickname'; import { useAppVisibility } from '$hooks/useAppVisibility'; import { composerIcon, DotsThreeOutlineVerticalIcon } from '$components/icons/phosphor'; import { getHomePath } from '$pages/pathUtils'; -import { useClientConfigLoaded } from '$hooks/useClientConfig'; import { pushSessionToSW } from '../../../sw-session'; import { SyncStatus } from './SyncStatus'; import { SpecVersions } from './SpecVersions'; @@ -228,7 +227,6 @@ type ClientRootProps = { }; export function ClientRoot({ children }: ClientRootProps) { const navigate = useNavigate(); - const configLoaded = useClientConfigLoaded(); const sessions = useAtomValue(sessionsAtom); const [activeSessionId, setActiveSessionId] = useAtom(activeSessionIdAtom); const setSessions = useSetAtom(sessionsAtom); @@ -325,10 +323,10 @@ export function ClientRoot({ children }: ClientRootProps) { }, [loadState, loadMatrix]); useEffect(() => { - if (mx && !mx.clientRunning && configLoaded) { + if (mx && !mx.clientRunning) { startMatrix(mx); } - }, [mx, startMatrix, configLoaded]); + }, [mx, startMatrix]); useEffect(() => { firstSyncReadyRef.current = false; diff --git a/src/app/pages/client/SpecVersions.tsx b/src/app/pages/client/SpecVersions.tsx index dbda208c32..39b44a6dd8 100644 --- a/src/app/pages/client/SpecVersions.tsx +++ b/src/app/pages/client/SpecVersions.tsx @@ -4,6 +4,7 @@ import { Box, Button, Dialog, config, Text } from 'folds'; import { SpecVersionsLoader } from '$components/SpecVersionsLoader'; import { SpecVersionsProvider } from '$hooks/useSpecVersions'; import { SplashScreen } from '$components/splash-screen'; +import { useMatrixClient } from '$hooks/useMatrixClient'; import type { SpecVersions } from '../../cs-api'; const EMPTY_VERSIONS: SpecVersions = { versions: [] }; @@ -38,6 +39,8 @@ function HomeserverOfflineError({ baseUrl, onRetry }: HomeserverOfflineErrorProp } export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: ReactNode }) { + const mx = useMatrixClient(); + const loadVersions = useCallback(() => mx.getVersions(), [mx]); const renderChildren = useCallback( (versions: SpecVersions) => ( {children} @@ -58,7 +61,12 @@ export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: ); return ( - + {renderChildren} ); diff --git a/src/app/state/room-list/inviteList.ts b/src/app/state/room-list/inviteList.ts index efd4d6f808..5222f19ac2 100644 --- a/src/app/state/room-list/inviteList.ts +++ b/src/app/state/room-list/inviteList.ts @@ -4,22 +4,15 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { useMemo } from 'react'; import type { RoomsAction } from './utils'; -import { useBindRoomsWithMembershipsAtom } from './utils'; +import { applyRoomsAction, useBindRoomsWithMembershipsAtom } from './utils'; import { KnownMembership } from '$types/matrix-sdk'; const baseRoomsAtom = atom([]); export const allInvitesAtom = atom( (get) => get(baseRoomsAtom), - (get, set, action) => { - if (action.type === 'INITIALIZE') { - set(baseRoomsAtom, action.rooms); - return; - } - set(baseRoomsAtom, (ids) => { - const newIds = ids.filter((id) => id !== action.roomId); - if (action.type === 'PUT') newIds.push(action.roomId); - return newIds; - }); + (_get, set, action) => { + set(baseRoomsAtom, (ids) => applyRoomsAction(ids, action)); + return undefined; } ); diff --git a/src/app/state/room-list/roomList.ts b/src/app/state/room-list/roomList.ts index 240efe828c..96efbf40bf 100644 --- a/src/app/state/room-list/roomList.ts +++ b/src/app/state/room-list/roomList.ts @@ -3,22 +3,15 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { useMemo } from 'react'; import type { RoomsAction } from './utils'; -import { useBindRoomsWithMembershipsAtom } from './utils'; +import { applyRoomsAction, useBindRoomsWithMembershipsAtom } from './utils'; import { KnownMembership } from '$types/matrix-sdk'; const baseRoomsAtom = atom([]); export const allRoomsAtom = atom( (get) => get(baseRoomsAtom), - (get, set, action) => { - if (action.type === 'INITIALIZE') { - set(baseRoomsAtom, action.rooms); - return; - } - set(baseRoomsAtom, (ids) => { - const newIds = ids.filter((id) => id !== action.roomId); - if (action.type === 'PUT') newIds.push(action.roomId); - return newIds; - }); + (_get, set, action) => { + set(baseRoomsAtom, (ids) => applyRoomsAction(ids, action)); + return undefined; } ); export const useBindAllRoomsAtom = (mx: MatrixClient, allRooms: typeof allRoomsAtom) => { diff --git a/src/app/state/room-list/utils.test.ts b/src/app/state/room-list/utils.test.ts new file mode 100644 index 0000000000..a6fdb5932e --- /dev/null +++ b/src/app/state/room-list/utils.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { applyRoomsAction } from './utils'; + +describe('applyRoomsAction', () => { + it('applies a page of room changes in one stable update', () => { + const current = ['!one:example.com', '!remove:example.com']; + + expect( + applyRoomsAction(current, { + type: 'BATCH', + put: ['!one:example.com', '!two:example.com', '!three:example.com'], + delete: ['!remove:example.com'], + }) + ).toEqual(['!one:example.com', '!two:example.com', '!three:example.com']); + }); + + it('preserves the existing array when a batch makes no changes', () => { + const current = ['!one:example.com']; + const next = applyRoomsAction(current, { + type: 'BATCH', + put: ['!one:example.com'], + delete: [], + }); + + expect(next).toBe(current); + }); +}); diff --git a/src/app/state/room-list/utils.ts b/src/app/state/room-list/utils.ts index b1184a2641..f0f5074279 100644 --- a/src/app/state/room-list/utils.ts +++ b/src/app/state/room-list/utils.ts @@ -10,10 +10,46 @@ export type RoomsAction = rooms: string[]; } | { - type: 'PUT' | 'DELETE'; + type: 'PUT'; roomId: string; + } + | { + type: 'DELETE'; + roomId: string; + } + | { + type: 'BATCH'; + put: string[]; + delete: string[]; }; +export const applyRoomsAction = (ids: string[], action: RoomsAction): string[] => { + if (action.type === 'INITIALIZE') return [...new Set(action.rooms)]; + + if (action.type === 'PUT') { + if (ids.includes(action.roomId)) return ids; + return [...ids, action.roomId]; + } + + if (action.type === 'DELETE') { + if (!ids.includes(action.roomId)) return ids; + return ids.filter((id) => id !== action.roomId); + } + + const deleted = new Set(action.delete); + const next = deleted.size === 0 ? [...ids] : ids.filter((id) => !deleted.has(id)); + const known = new Set(next); + action.put.forEach((roomId) => { + if (known.has(roomId)) return; + known.add(roomId); + next.push(roomId); + }); + + const unchanged = + next.length === ids.length && next.every((roomId, index) => roomId === ids[index]); + return unchanged ? ids : next; +}; + export const useBindRoomsWithMembershipsAtom = ( mx: MatrixClient, roomsAtom: WritableAtom, @@ -22,6 +58,30 @@ export const useBindRoomsWithMembershipsAtom = ( const setRoomsAtom = useSetAtom(roomsAtom); useEffect(() => { + let flushTimer: ReturnType | undefined; + const pendingRoomActions = new Map(); + + const flushPendingRoomActions = () => { + flushTimer = undefined; + if (pendingRoomActions.size === 0) return; + + const put: string[] = []; + const deleted: string[] = []; + pendingRoomActions.forEach((action, roomId) => { + if (action === 'PUT') put.push(roomId); + else deleted.push(roomId); + }); + pendingRoomActions.clear(); + setRoomsAtom({ type: 'BATCH', put, delete: deleted }); + }; + + const queueRoomAction = (action: 'PUT' | 'DELETE', roomId: string) => { + pendingRoomActions.set(roomId, action); + if (flushTimer === undefined) { + flushTimer = globalThis.setTimeout(flushPendingRoomActions, 0); + } + }; + const satisfyMembership = (room: Room): boolean => !!memberships.find((membership) => membership === room.getMyMembership()); setRoomsAtom({ @@ -34,26 +94,28 @@ export const useBindRoomsWithMembershipsAtom = ( const handleAddRoom = (room: Room) => { if (satisfyMembership(room)) { - setRoomsAtom({ type: 'PUT', roomId: room.roomId }); + queueRoomAction('PUT', room.roomId); } }; const handleMembershipChange = (room: Room) => { if (satisfyMembership(room)) { - setRoomsAtom({ type: 'PUT', roomId: room.roomId }); + queueRoomAction('PUT', room.roomId); } else { - setRoomsAtom({ type: 'DELETE', roomId: room.roomId }); + queueRoomAction('DELETE', room.roomId); } }; const handleDeleteRoom = (roomId: string) => { - setRoomsAtom({ type: 'DELETE', roomId }); + queueRoomAction('DELETE', roomId); }; mx.on(ClientEvent.Room, handleAddRoom); mx.on(RoomEvent.MyMembership, handleMembershipChange); mx.on(ClientEvent.DeleteRoom, handleDeleteRoom); return () => { + if (flushTimer !== undefined) globalThis.clearTimeout(flushTimer); + pendingRoomActions.clear(); mx.removeListener(ClientEvent.Room, handleAddRoom); mx.removeListener(RoomEvent.MyMembership, handleMembershipChange); mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 57b1608c30..26750a0869 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -148,11 +148,19 @@ function installStartupFetchRoomEventPatch( const mxWritable = mx as MatrixClientWithWritableFetchRoomEvent; const origFetchRoomEvent = mx.fetchRoomEvent.bind(mx); + let restored = false; const restore = () => { + if (restored) return; + restored = true; fetchRoomEventStartupCleanupByClient.delete(mx); + mx.removeListener(ClientEvent.Sync, onSync); mxWritable.fetchRoomEvent = origFetchRoomEvent; }; + const onSync = (state: SyncState) => { + if (isInitialSyncReady(state)) restore(); + }; + mxWritable.fetchRoomEvent = (roomId: string, eventId: string) => { if (slidingSyncManager.isRoomActive(roomId)) { return origFetchRoomEvent(roomId, eventId); @@ -166,6 +174,7 @@ function installStartupFetchRoomEventPatch( }; fetchRoomEventStartupCleanupByClient.set(mx, restore); + mx.on(ClientEvent.Sync, onSync); } const deleteDatabase = (name: string): Promise => @@ -549,6 +558,8 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): baseUrl: useSliding ? baseUrl : undefined, stack: err instanceof Error ? err.stack : undefined, }); + fetchRoomEventStartupCleanupByClient.get(mx)?.(); + slidingSyncConnIdCleanupByClient.get(mx)?.(); disposeSlidingSync(mx); disposePresenceSync(mx); throw err; @@ -558,6 +569,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): export const stopClient = (mx: MatrixClient): void => { log.log('stopClient', mx.getUserId()); debugLog.info('sync', 'Stopping client', { userId: mx.getUserId() }); + fetchRoomEventStartupCleanupByClient.get(mx)?.(); slidingSyncConnIdCleanupByClient.get(mx)?.(); disposeSlidingSync(mx); disposePresenceSync(mx); diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 6cdd6974f6..dd13304222 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -121,10 +121,7 @@ describe('SlidingSyncManager initial request', () => { | undefined; lifecycle?.(SlidingSyncState.Complete, {}); - expect(mocks.slidingSyncInstance.setListRanges).toHaveBeenCalledWith('joined', [ - [0, 29], - [30, 59], - ]); + expect(mocks.slidingSyncInstance.setListRanges).toHaveBeenCalledWith('joined', [[0, 59]]); }); it('continues expanding lists when an active-room subscription has not returned data yet', () => { @@ -146,13 +143,10 @@ describe('SlidingSyncManager initial request', () => { | undefined; lifecycle?.(SlidingSyncState.Complete, {}); - expect(mocks.slidingSyncInstance.setListRanges).toHaveBeenCalledWith('joined', [ - [0, 29], - [30, 59], - ]); + expect(mocks.slidingSyncInstance.setListRanges).toHaveBeenCalledWith('joined', [[0, 59]]); }); - it('defers active subscriptions while an expanded range is awaiting its response', () => { + it('prioritizes active subscriptions while an expanded range is awaiting its response', () => { let joinedRange: [number, number][] = [[0, 29]]; const manager = makeManager(makeMockMx()); mocks.slidingSyncInstance.getListData.mockImplementation((key: string) => @@ -171,13 +165,62 @@ describe('SlidingSyncManager initial request', () => { const diagnostics = manager.getDiagnostics(); expect(manager.isRoomActive('!active:example.com')).toBe(true); - expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).not.toHaveBeenCalled(); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledWith( + new Set(['!active:example.com']) + ); + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenCalledWith( + '!active:example.com', + 'active_room' + ); expect(diagnostics.lists.find((list) => list.key === 'joined')).toMatchObject({ rangeEnd: 59, }); }); }); +describe('SlidingSyncManager room subscription coordination', () => { + it('uses the active subscription while a room is also an image-pack room', () => { + const manager = makeManager(makeMockMx()); + const roomId = '!pack:example.com'; + + manager.setImagePackSubscriptions([roomId]); + manager.subscribeToRoom(roomId); + + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'active_room' + ); + }); + + it('restores the image-pack subscription when the room is no longer active', () => { + const manager = makeManager(makeMockMx()); + const roomId = '!pack:example.com'; + + manager.setImagePackSubscriptions([roomId]); + manager.subscribeToRoom(roomId); + manager.unsubscribeFromRoom(roomId); + + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'image_packs' + ); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith( + new Set([roomId]) + ); + }); + + it('removes image-pack subscriptions which are no longer configured', () => { + const manager = makeManager(makeMockMx()); + + manager.setImagePackSubscriptions(['!old:example.com', '!current:example.com']); + manager.setImagePackSubscriptions(['!current:example.com']); + + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith( + new Set(['!current:example.com']) + ); + }); +}); + // ── dispose() ──────────────────────────────────────────────────────────────── describe('SlidingSyncManager.dispose()', () => { diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 0733229b7d..2beba4d4b2 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -30,14 +30,12 @@ export const LIST_SEARCH = 'search'; export const LIST_ROOM_SEARCH = 'room_search'; export const LIST_SPACE = 'space'; const LIST_TIMELINE_LIMIT = 0; -const DEFAULT_LIST_PAGE_SIZE = 250; -const INITIAL_LIST_SIZE = 30; -const LIST_EXPANSION_PAGE_SIZE = INITIAL_LIST_SIZE; +const LIST_PAGE_SIZE = 30; const DEFAULT_POLL_TIMEOUT_MS = 20000; const LIST_SORT_ORDER = ['by_recency', 'by_name']; -const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted'; +const ACTIVE_ROOM_SUBSCRIPTION_KEY = 'active_room'; const SPACE_SUBSCRIPTION_KEY = 'space'; const IMAGE_PACK_SUBSCRIPTION_KEY = 'image_packs'; const ACTIVE_ROOM_TIMELINE_LIMIT = 50; @@ -49,7 +47,6 @@ export type PartialSlidingSyncRequest = { }; type SlidingSyncOptions = { - listPageSize?: number; timelineLimit?: number; pollTimeoutMs?: number; }; @@ -63,7 +60,6 @@ export type SlidingSyncListDiagnostics = { export type SlidingSyncDiagnostics = { baseUrl: string; timelineLimit: number; - listPageSize: number; lists: SlidingSyncListDiagnostics[]; }; @@ -144,14 +140,12 @@ const buildSpaceSubscription = (): MSC3575RoomSubscription => ({ required_state: SPACE_REQUIRED_STATE, }); -const buildLists = (pageSize: number): Map => { +const buildLists = (): Map => { const lists = new Map(); const listRequiredState = buildListRequiredState(); - const initialRange = Math.min(pageSize, INITIAL_LIST_SIZE); - lists.set(LIST_JOINED, { - ranges: [[0, Math.max(0, initialRange - 1)]], + ranges: [[0, LIST_PAGE_SIZE - 1]], sort: LIST_SORT_ORDER, timeline_limit: LIST_TIMELINE_LIMIT, required_state: listRequiredState, @@ -159,7 +153,7 @@ const buildLists = (pageSize: number): Map => { }); lists.set(LIST_INVITES, { - ranges: [[0, Math.max(0, initialRange - 1)]], + ranges: [[0, LIST_PAGE_SIZE - 1]], sort: LIST_SORT_ORDER, timeline_limit: LIST_TIMELINE_LIMIT, required_state: listRequiredState, @@ -181,16 +175,12 @@ export class SlidingSyncManager { private readonly activeRoomSubscriptions = new Set(); - private readonly deferredActiveRoomSubscriptions = new Set(); - private readonly spaceSubscriptions = new Set(); private deferredSpaceSubscriptions = new Set(); private readonly imagePackRoomSubscriptions = new Set(); - private readonly listPageSize: number; - private readonly roomTimelineLimit: number; private readonly onLifecycle: ( @@ -210,6 +200,8 @@ export class SlidingSyncManager { private syncCount = 0; + private responseProcessingStartedAt: number | undefined; + private previousListCounts: Map = new Map(); private readonly requestedListRangeEnds = new Map(); @@ -239,15 +231,13 @@ export class SlidingSyncManager { private readonly baseUrl: string, options: SlidingSyncOptions = {} ) { - const listPageSize = clampPositive(options.listPageSize, DEFAULT_LIST_PAGE_SIZE); const pollTimeoutMs = clampPositive(options.pollTimeoutMs, DEFAULT_POLL_TIMEOUT_MS); - this.listPageSize = listPageSize; const roomTimelineLimit = clampPositive(options.timelineLimit, ACTIVE_ROOM_TIMELINE_LIMIT); this.roomTimelineLimit = roomTimelineLimit; const defaultSubscription = buildEncryptedSubscription(roomTimelineLimit); - const lists = buildLists(listPageSize); + const lists = buildLists(); this.listKeys = Array.from(lists.keys()); lists.forEach((list, key) => { this.requestedListRangeEnds.set(key, getListEndIndex(list)); @@ -255,7 +245,7 @@ export class SlidingSyncManager { this.slidingSync = new SlidingSync(baseUrl, lists, defaultSubscription, mx, pollTimeoutMs); this.slidingSync.addCustomSubscription( - UNENCRYPTED_SUBSCRIPTION_KEY, + ACTIVE_ROOM_SUBSCRIPTION_KEY, buildUnencryptedSubscription(roomTimelineLimit) ); this.slidingSync.addCustomSubscription( @@ -265,16 +255,9 @@ export class SlidingSyncManager { this.slidingSync.addCustomSubscription(SPACE_SUBSCRIPTION_KEY, buildSpaceSubscription()); this.onLifecycle = (state, resp, err) => { - const syncStartTime = performance.now(); - this.syncCount += 1; - Sentry.metrics.count('sable.sync.cycle', 1, { - attributes: { transport: 'sliding', state }, - }); - - debugLog.info('sync', `Sliding sync lifecycle: ${state} (cycle #${this.syncCount})`, { + debugLog.info('sync', `Sliding sync lifecycle: ${state}`, { state, hasError: !!err, - syncNumber: this.syncCount, isInitialSync: !this.initialSyncCompleted, }); @@ -295,8 +278,18 @@ export class SlidingSyncManager { return; } + if (state === SlidingSyncState.RequestFinished) { + if (!err && resp) this.responseProcessingStartedAt = performance.now(); + return; + } + if (err || !resp || state !== SlidingSyncState.Complete) return; + this.syncCount += 1; + Sentry.metrics.count('sable.sync.cycle', 1, { + attributes: { transport: 'sliding' }, + }); + this.requestedListRangeEnds.forEach((rangeEnd, key) => { this.confirmedListRangeEnds.set(key, rangeEnd); }); @@ -332,7 +325,11 @@ export class SlidingSyncManager { }); } - const syncDuration = performance.now() - syncStartTime; + const syncDuration = + this.responseProcessingStartedAt === undefined + ? 0 + : performance.now() - this.responseProcessingStartedAt; + this.responseProcessingStartedAt = undefined; if (!this.initialSyncCompleted) { this.initialSyncCompleted = true; @@ -383,7 +380,6 @@ export class SlidingSyncManager { public attach(): void { debugLog.info('sync', 'Attaching sliding sync listeners', { baseUrl: this.baseUrl, - listPageSize: this.listPageSize, roomTimelineLimit: this.roomTimelineLimit, lists: this.listKeys, }); @@ -425,7 +421,6 @@ export class SlidingSyncManager { return { baseUrl: this.baseUrl, timelineLimit: this.roomTimelineLimit, - listPageSize: this.listPageSize, lists: this.listKeys.map((key) => { const listData = this.slidingSync.getListData(key); const params = this.slidingSync.getListParams(key); @@ -490,7 +485,7 @@ export class SlidingSyncManager { allListsComplete = false; - const desiredEnd = Math.min(maxEnd, currentEnd + LIST_EXPANSION_PAGE_SIZE); + const desiredEnd = Math.min(maxEnd, currentEnd + LIST_PAGE_SIZE); if (desiredEnd === currentEnd) { expansionDetails[key] = { @@ -502,9 +497,7 @@ export class SlidingSyncManager { return; } - const existingRanges = existing?.ranges ?? []; - const nextRangeStart = currentEnd + 1; - this.slidingSync.setListRanges(key, [...existingRanges, [nextRangeStart, desiredEnd]]); + this.slidingSync.setListRanges(key, [[0, desiredEnd]]); this.requestedListRangeEnds.set(key, desiredEnd); expandedAny = true; @@ -572,7 +565,7 @@ export class SlidingSyncManager { let list = this.slidingSync.getListParams(listKey); if (!list) { list = { - ranges: [[0, 20]], + ranges: [[0, LIST_PAGE_SIZE - 1]], sort: LIST_SORT_ORDER, timeline_limit: LIST_TIMELINE_LIMIT, required_state: buildListRequiredState(), @@ -618,15 +611,13 @@ export class SlidingSyncManager { : { is_invite: false }; this.ensureListRegistered(LIST_SPACE, { filters, - ranges: spaceId ? [[0, Math.min(this.listPageSize - 1, 499)]] : [[0, 0]], + ranges: spaceId ? [[0, LIST_PAGE_SIZE - 1]] : [[0, 0]], sort: LIST_SORT_ORDER, }); } public isRoomActive(roomId: string): boolean { - return ( - this.activeRoomSubscriptions.has(roomId) || this.deferredActiveRoomSubscriptions.has(roomId) - ); + return this.activeRoomSubscriptions.has(roomId); } private flushDeferredSubscriptions(): void { @@ -634,24 +625,40 @@ export class SlidingSyncManager { const spaces = this.deferredSpaceSubscriptions; this.deferredSpaceSubscriptions = new Set(); - if (spaces.size > 0) this.setSpaceSubscriptions(spaces); + this.setSpaceSubscriptions(spaces); + } - const activeRooms = [...this.deferredActiveRoomSubscriptions]; - this.deferredActiveRoomSubscriptions.clear(); - activeRooms.forEach((roomId) => this.subscribeToRoom(roomId)); + private syncRoomSubscriptions(): void { + const desiredSubscriptions = new Set([ + ...this.activeRoomSubscriptions, + ...this.spaceSubscriptions, + ...this.imagePackRoomSubscriptions, + ]); + + desiredSubscriptions.forEach((roomId) => { + if (this.activeRoomSubscriptions.has(roomId)) { + this.slidingSync.useCustomSubscription(roomId, ACTIVE_ROOM_SUBSCRIPTION_KEY); + } else if (this.spaceSubscriptions.has(roomId)) { + this.slidingSync.useCustomSubscription(roomId, SPACE_SUBSCRIPTION_KEY); + } else { + this.slidingSync.useCustomSubscription(roomId, IMAGE_PACK_SUBSCRIPTION_KEY); + } + }); + + this.slidingSync.modifyRoomSubscriptions(desiredSubscriptions); } - public subscribeToImagePackRoom(roomId: string): void { - if (this.disposed || this.imagePackRoomSubscriptions.has(roomId)) return; - this.slidingSync.useCustomSubscription(roomId, IMAGE_PACK_SUBSCRIPTION_KEY); - this.imagePackRoomSubscriptions.add(roomId); - this.slidingSync.modifyRoomSubscriptions( - new Set([ - ...this.activeRoomSubscriptions, - ...this.spaceSubscriptions, - ...this.imagePackRoomSubscriptions, - ]) - ); + public setImagePackSubscriptions(roomIds: Iterable): void { + if (this.disposed) return; + const next = new Set(roomIds); + const unchanged = + next.size === this.imagePackRoomSubscriptions.size && + [...next].every((roomId) => this.imagePackRoomSubscriptions.has(roomId)); + if (unchanged) return; + + this.imagePackRoomSubscriptions.clear(); + next.forEach((roomId) => this.imagePackRoomSubscriptions.add(roomId)); + this.syncRoomSubscriptions(); } /* Listen for raw sliding-sync data for one room */ @@ -666,28 +673,11 @@ export class SlidingSyncManager { public subscribeToRoom(roomId: string): void { if (this.disposed) return; - if (!this.listsFullyLoaded) { - this.deferredActiveRoomSubscriptions.add(roomId); - debugLog.info('sync', 'Room subscription deferred until lists are hydrated', { - deferredSubscriptions: this.deferredActiveRoomSubscriptions.size, - syncCycle: this.syncCount, - }); - return; - } if (this.activeRoomSubscriptions.has(roomId)) return; const room = this.mx.getRoom(roomId); const isEncrypted = this.mx.isRoomEncrypted(roomId); - if (room && !isEncrypted) { - this.slidingSync.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_KEY); - } this.activeRoomSubscriptions.add(roomId); - this.slidingSync.modifyRoomSubscriptions( - new Set([ - ...this.activeRoomSubscriptions, - ...this.spaceSubscriptions, - ...this.imagePackRoomSubscriptions, - ]) - ); + this.syncRoomSubscriptions(); this.expandListsByPage(); Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { @@ -745,7 +735,6 @@ export class SlidingSyncManager { public unsubscribeFromRoom(roomId: string): void { if (this.disposed) return; - this.deferredActiveRoomSubscriptions.delete(roomId); if (!this.activeRoomSubscriptions.has(roomId)) return; const pendingListener = this.pendingRoomDataListeners.get(roomId); if (pendingListener) { @@ -753,16 +742,7 @@ export class SlidingSyncManager { this.pendingRoomDataListeners.delete(roomId); } this.activeRoomSubscriptions.delete(roomId); - if (this.spaceSubscriptions.has(roomId)) { - this.slidingSync.useCustomSubscription(roomId, SPACE_SUBSCRIPTION_KEY); - } - this.slidingSync.modifyRoomSubscriptions( - new Set([ - ...this.activeRoomSubscriptions, - ...this.spaceSubscriptions, - ...this.imagePackRoomSubscriptions, - ]) - ); + this.syncRoomSubscriptions(); Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { attributes: { transport: 'sliding' }, }); @@ -786,19 +766,8 @@ export class SlidingSyncManager { if (unchanged) return; this.spaceSubscriptions.clear(); - next.forEach((roomId) => { - this.spaceSubscriptions.add(roomId); - if (!this.activeRoomSubscriptions.has(roomId)) { - this.slidingSync.useCustomSubscription(roomId, SPACE_SUBSCRIPTION_KEY); - } - }); - this.slidingSync.modifyRoomSubscriptions( - new Set([ - ...this.activeRoomSubscriptions, - ...this.spaceSubscriptions, - ...this.imagePackRoomSubscriptions, - ]) - ); + next.forEach((roomId) => this.spaceSubscriptions.add(roomId)); + this.syncRoomSubscriptions(); this.expandListsByPage(); } } From 0f8ce00b6b8c2054eae19f14e44e5da4b50821a6 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Tue, 14 Jul 2026 16:05:06 -0500 Subject: [PATCH 15/33] loader for rooms --- src/app/features/room/RoomTimeline.tsx | 11 +++++++ src/app/hooks/useSlidingSyncActiveRoom.ts | 16 +++++++++ src/client/slidingSync.test.ts | 22 +++++++++++++ src/client/slidingSync.ts | 40 +++++++++++++++++++++++ 4 files changed, 89 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 6a48ff9898..d92de8911a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -41,6 +41,7 @@ import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { useGetMemberPowerTag } from '$hooks/useMemberPowerTag'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { useSlidingSyncRoomLoading } from '$hooks/useSlidingSyncActiveRoom'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; @@ -282,6 +283,7 @@ export function RoomTimeline({ }: Readonly) { const mx = useMatrixClient(); const alive = useAlive(); + const roomSyncLoading = useSlidingSyncRoomLoading(room.roomId); const { editId, handleEdit } = useMessageEdit(editor, { onReset: onEditorReset, alive }); const { navigateRoom } = useRoomNavigate(); @@ -1041,6 +1043,15 @@ export function RoomTimeline({ return ( + {roomSyncLoading && timelineSync.eventsLength === 0 && ( + + + + )} {unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && isReady && ( { }, [manager, spaces]); }; +export const useSlidingSyncRoomLoading = (roomId: string): boolean => { + const manager = useAvailableSlidingSyncManager(); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!manager) { + setLoading(false); + return undefined; + } + + return manager.onRoomSubscriptionStatus(roomId, setLoading); + }, [manager, roomId]); + + return loading; +}; + export const useSlidingSyncActiveRoom = (): void => { useSlidingSyncRouteRooms(); useSlidingSyncSpaceSubscriptions(); diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index dd13304222..eeaca39cd9 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -179,6 +179,28 @@ describe('SlidingSyncManager initial request', () => { }); describe('SlidingSyncManager room subscription coordination', () => { + it('reports loading until the subscribed room returns data', () => { + const manager = makeManager(makeMockMx()); + const roomId = '!slow:example.com'; + const loadingStates: boolean[] = []; + + manager.onRoomSubscriptionStatus(roomId, (loading) => loadingStates.push(loading)); + manager.subscribeToRoom(roomId); + + const roomDataHandler = mocks.slidingSyncInstance.on.mock.calls + .toReversed() + .find(([event]) => event === SlidingSyncEvent.RoomData)?.[1] as + | ((dataRoomId: string, data: unknown) => void) + | undefined; + roomDataHandler?.(roomId, {}); + expect(loadingStates).toEqual([false, true]); + + manager.attach(); + fireLifecycle(SlidingSyncState.Complete); + + expect(loadingStates).toEqual([false, true, false]); + }); + it('uses the active subscription while a room is also an image-pack room', () => { const manager = makeManager(makeMockMx()); const roomId = '!pack:example.com'; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 2beba4d4b2..e5538ee61c 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -218,6 +218,13 @@ export class SlidingSyncManager { (roomId: string, data: MSC3575RoomData) => void >(); + private readonly roomSubscriptionStatusListeners = new Map< + string, + Set<(loading: boolean) => void> + >(); + + private readonly roomDataAwaitingSyncCompletion = new Set(); + /** Wall-clock time recorded in attach() — used to compute true initial-sync latency. */ private attachTime: number | null = null; @@ -285,6 +292,11 @@ export class SlidingSyncManager { if (err || !resp || state !== SlidingSyncState.Complete) return; + this.roomDataAwaitingSyncCompletion.forEach((roomId) => + this.notifyRoomSubscriptionStatus(roomId, false) + ); + this.roomDataAwaitingSyncCompletion.clear(); + this.syncCount += 1; Sentry.metrics.count('sable.sync.cycle', 1, { attributes: { transport: 'sliding' }, @@ -406,6 +418,11 @@ export class SlidingSyncManager { }); this.pendingRoomDataListeners.clear(); + this.roomDataAwaitingSyncCompletion.clear(); + this.roomSubscriptionStatusListeners.forEach((listeners) => + listeners.forEach((listener) => listener(false)) + ); + this.roomSubscriptionStatusListeners.clear(); this.disposed = true; this.slidingSync.stop(); @@ -671,6 +688,25 @@ export class SlidingSyncManager { return () => this.slidingSync.removeListener(SlidingSyncEvent.RoomData, handler); } + public onRoomSubscriptionStatus( + roomId: string, + listener: (loading: boolean) => void + ): () => void { + const listeners = this.roomSubscriptionStatusListeners.get(roomId) ?? new Set(); + listeners.add(listener); + this.roomSubscriptionStatusListeners.set(roomId, listeners); + listener(this.pendingRoomDataListeners.has(roomId)); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) this.roomSubscriptionStatusListeners.delete(roomId); + }; + } + + private notifyRoomSubscriptionStatus(roomId: string, loading: boolean): void { + this.roomSubscriptionStatusListeners.get(roomId)?.forEach((listener) => listener(loading)); + } + public subscribeToRoom(roomId: string): void { if (this.disposed) return; if (this.activeRoomSubscriptions.has(roomId)) return; @@ -728,9 +764,11 @@ export class SlidingSyncManager { }); this.slidingSync.removeListener(SlidingSyncEvent.RoomData, onFirstRoomData); this.pendingRoomDataListeners.delete(roomId); + this.roomDataAwaitingSyncCompletion.add(roomId); }; this.pendingRoomDataListeners.set(roomId, onFirstRoomData); this.slidingSync.on(SlidingSyncEvent.RoomData, onFirstRoomData); + this.notifyRoomSubscriptionStatus(roomId, true); } public unsubscribeFromRoom(roomId: string): void { @@ -741,6 +779,8 @@ export class SlidingSyncManager { this.slidingSync.removeListener(SlidingSyncEvent.RoomData, pendingListener); this.pendingRoomDataListeners.delete(roomId); } + this.roomDataAwaitingSyncCompletion.delete(roomId); + this.notifyRoomSubscriptionStatus(roomId, false); this.activeRoomSubscriptions.delete(roomId); this.syncRoomSubscriptions(); Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { From c3975cdbb2e9fd2758c3b5805fb54b96793cf678 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Tue, 14 Jul 2026 16:32:57 -0500 Subject: [PATCH 16/33] load initial room with first sync and bump poll time --- src/app/pages/client/ClientRoot.tsx | 34 +++++++++++++++++++---- src/client/initMatrix.ts | 4 ++- src/client/slidingSync.test.ts | 42 +++++++++++++++++++++++------ src/client/slidingSync.ts | 25 ++++++++++++++--- 4 files changed, 87 insertions(+), 18 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 9b1c1d4d30..e3109e39d4 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -17,7 +17,7 @@ import FocusTrap from 'focus-trap-react'; import type { MouseEventHandler, ReactNode } from 'react'; import { useRef, useCallback, useEffect, useState } from 'react'; import * as Sentry from '@sentry/react'; -import { useNavigate } from 'react-router-dom'; +import { matchPath, useLocation, useNavigate } from 'react-router-dom'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { clearCacheAndReload, @@ -47,6 +47,8 @@ import { useSyncNicknames } from '$hooks/useNickname'; import { useAppVisibility } from '$hooks/useAppVisibility'; import { composerIcon, DotsThreeOutlineVerticalIcon } from '$components/icons/phosphor'; import { getHomePath } from '$pages/pathUtils'; +import { DIRECT_ROOM_PATH, HOME_ROOM_PATH, SPACE_ROOM_PATH } from '$pages/paths'; +import { getCanonicalAliasRoomId, isRoomAlias, isRoomId } from '$utils/matrix'; import { pushSessionToSW } from '../../../sw-session'; import { SyncStatus } from './SyncStatus'; import { SpecVersions } from './SpecVersions'; @@ -227,6 +229,7 @@ type ClientRootProps = { }; export function ClientRoot({ children }: ClientRootProps) { const navigate = useNavigate(); + const location = useLocation(); const sessions = useAtomValue(sessionsAtom); const [activeSessionId, setActiveSessionId] = useAtom(activeSessionIdAtom); const setSessions = useSetAtom(sessionsAtom); @@ -261,14 +264,35 @@ export function ClientRoot({ children }: ClientRootProps) { const mx = loadState.status === AsyncStatus.Success ? loadState.data : undefined; + const roomMatch = + matchPath(HOME_ROOM_PATH, location.pathname) ?? + matchPath(DIRECT_ROOM_PATH, location.pathname) ?? + matchPath(SPACE_ROOM_PATH, location.pathname); + const encodedInitialRoomIdOrAlias = roomMatch?.params.roomIdOrAlias; + const [startState, startMatrix] = useAsyncCallback( useCallback( - (m) => - startClient(m, { + (m) => { + let initialRoomId: string | undefined; + if (encodedInitialRoomIdOrAlias) { + try { + const roomIdOrAlias = decodeURIComponent(encodedInitialRoomIdOrAlias); + if (isRoomId(roomIdOrAlias)) initialRoomId = roomIdOrAlias; + else if (isRoomAlias(roomIdOrAlias)) { + initialRoomId = getCanonicalAliasRoomId(m, roomIdOrAlias); + } + } catch { + // Ignore malformed route values and let the normal router handle them. + } + } + + return startClient(m, { baseUrl: activeSession?.baseUrl, sessionSlidingSyncOptIn: activeSession?.slidingSyncOptIn, - }), - [activeSession?.baseUrl, activeSession?.slidingSyncOptIn] + initialRoomIds: initialRoomId ? [initialRoomId] : undefined, + }); + }, + [activeSession?.baseUrl, activeSession?.slidingSyncOptIn, encodedInitialRoomIdOrAlias] ) ); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 26750a0869..815c554713 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -30,7 +30,7 @@ const debugLog = createDebugLogger('initMatrix'); const slidingSyncByClient = new WeakMap(); const presenceSyncByClient = new WeakMap(); const presenceStartCleanupByClient = new WeakMap void>(); -const SLIDING_SYNC_POLL_TIMEOUT_MS = 20000; +const SLIDING_SYNC_POLL_TIMEOUT_MS = 45000; const isInitialSyncReady = (state: string | null): boolean => state === SyncState.Prepared || state === SyncState.Syncing || state === SyncState.Catchup; @@ -483,6 +483,7 @@ export type StartClientConfig = { sessionSlidingSyncOptIn?: boolean; pollTimeoutMs?: number; timelineLimit?: number; + initialRoomIds?: Iterable; }; export type ClientSyncDiagnostics = { @@ -531,6 +532,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): manager = new SlidingSyncManager(mx, baseUrl, { pollTimeoutMs: config?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS, timelineLimit: config?.timelineLimit, + initialRoomIds: config?.initialRoomIds, }); installStartupFetchRoomEventPatch(mx, manager); diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index eeaca39cd9..63d99cad10 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -90,17 +90,46 @@ function fireLifecycle(state: SlidingSyncState, response: unknown = {}) { lifecycle?.(state, response); } +function fireRoomData(roomId: string) { + const roomDataHandler = mocks.slidingSyncInstance.on.mock.calls + .toReversed() + .find(([event]) => event === SlidingSyncEvent.RoomData)?.[1] as + | ((dataRoomId: string, data: unknown) => void) + | undefined; + roomDataHandler?.(roomId, {}); +} + describe('SlidingSyncManager initial request', () => { it('starts with a small room-list range and only row-level state', () => { makeManager(makeMockMx()); const lists = mocks.slidingSyncConstructorArgs?.[1] as Map; const joined = lists.get('joined'); + const defaultSubscription = mocks.slidingSyncConstructorArgs?.[2] as { + timeline_limit: number; + }; expect(joined?.ranges).toEqual([[0, 29]]); expect(joined?.required_state).toHaveLength(8); expect(joined?.required_state).toContainEqual([EventType.RoomJoinRules, '']); expect(joined?.required_state).not.toContainEqual(['m.space.child', '*']); + expect(defaultSubscription.timeline_limit).toBe(30); + expect(mocks.slidingSyncConstructorArgs?.[4]).toBe(45000); + }); + + it('includes the selected room subscription before the first request', () => { + const manager = new SlidingSyncManager(makeMockMx(), 'https://sliding.example.com', { + initialRoomIds: ['!selected:example.com'], + }); + + expect(manager.isRoomActive('!selected:example.com')).toBe(true); + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenCalledWith( + '!selected:example.com', + 'active_room' + ); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledWith( + new Set(['!selected:example.com']) + ); }); it('expands by one page after the first response instead of jumping to all rooms', () => { @@ -124,7 +153,7 @@ describe('SlidingSyncManager initial request', () => { expect(mocks.slidingSyncInstance.setListRanges).toHaveBeenCalledWith('joined', [[0, 59]]); }); - it('continues expanding lists when an active-room subscription has not returned data yet', () => { + it('does not expand lists synchronously while adding an active-room subscription', () => { const manager = makeManager(makeMockMx()); mocks.slidingSyncInstance.getListData.mockImplementation((key: string) => key === 'joined' ? ({ joinedCount: 1000 } as never) : null @@ -135,6 +164,8 @@ describe('SlidingSyncManager initial request', () => { manager.attach(); manager.subscribeToRoom('!active:example.com'); + expect(mocks.slidingSyncInstance.setListRanges).not.toHaveBeenCalledWith('joined', [[0, 59]]); + const lifecycleCall = mocks.slidingSyncInstance.on.mock.calls.find( ([event]) => event === SlidingSyncEvent.Lifecycle ); @@ -173,7 +204,7 @@ describe('SlidingSyncManager initial request', () => { 'active_room' ); expect(diagnostics.lists.find((list) => list.key === 'joined')).toMatchObject({ - rangeEnd: 59, + rangeEnd: 29, }); }); }); @@ -187,12 +218,7 @@ describe('SlidingSyncManager room subscription coordination', () => { manager.onRoomSubscriptionStatus(roomId, (loading) => loadingStates.push(loading)); manager.subscribeToRoom(roomId); - const roomDataHandler = mocks.slidingSyncInstance.on.mock.calls - .toReversed() - .find(([event]) => event === SlidingSyncEvent.RoomData)?.[1] as - | ((dataRoomId: string, data: unknown) => void) - | undefined; - roomDataHandler?.(roomId, {}); + fireRoomData(roomId); expect(loadingStates).toEqual([false, true]); manager.attach(); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index e5538ee61c..442a1c08fc 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -31,14 +31,14 @@ export const LIST_ROOM_SEARCH = 'room_search'; export const LIST_SPACE = 'space'; const LIST_TIMELINE_LIMIT = 0; const LIST_PAGE_SIZE = 30; -const DEFAULT_POLL_TIMEOUT_MS = 20000; +const DEFAULT_POLL_TIMEOUT_MS = 45000; const LIST_SORT_ORDER = ['by_recency', 'by_name']; const ACTIVE_ROOM_SUBSCRIPTION_KEY = 'active_room'; const SPACE_SUBSCRIPTION_KEY = 'space'; const IMAGE_PACK_SUBSCRIPTION_KEY = 'image_packs'; -const ACTIVE_ROOM_TIMELINE_LIMIT = 50; +const ACTIVE_ROOM_TIMELINE_LIMIT = 30; export type PartialSlidingSyncRequest = { filters?: MSC3575List['filters']; @@ -49,6 +49,7 @@ export type PartialSlidingSyncRequest = { type SlidingSyncOptions = { timelineLimit?: number; pollTimeoutMs?: number; + initialRoomIds?: Iterable; }; export type SlidingSyncListDiagnostics = { @@ -387,6 +388,10 @@ export class SlidingSyncManager { if (!this.activeRoomSubscriptions.has(member.roomId)) return; this.unsubscribeFromRoom(member.roomId); }; + + for (const roomId of options.initialRoomIds ?? []) { + this.subscribeToRoom(roomId); + } } public attach(): void { @@ -665,6 +670,19 @@ export class SlidingSyncManager { this.slidingSync.modifyRoomSubscriptions(desiredSubscriptions); } + private pauseUnconfirmedListExpansion(): void { + this.listKeys.forEach((key) => { + const confirmedEnd = this.confirmedListRangeEnds.get(key) ?? -1; + if (confirmedEnd < 0) return; + + const requestedEnd = getListEndIndex(this.slidingSync.getListParams(key)); + if (requestedEnd <= confirmedEnd) return; + + this.slidingSync.setListRanges(key, [[0, confirmedEnd]]); + this.requestedListRangeEnds.set(key, confirmedEnd); + }); + } + public setImagePackSubscriptions(roomIds: Iterable): void { if (this.disposed) return; const next = new Set(roomIds); @@ -713,9 +731,8 @@ export class SlidingSyncManager { const room = this.mx.getRoom(roomId); const isEncrypted = this.mx.isRoomEncrypted(roomId); this.activeRoomSubscriptions.add(roomId); + this.pauseUnconfirmedListExpansion(); this.syncRoomSubscriptions(); - - this.expandListsByPage(); Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { attributes: { transport: 'sliding' }, }); From a463709928870f90723731ca686df45aef076d5b Mon Sep 17 00:00:00 2001 From: 7w1 Date: Tue, 14 Jul 2026 17:38:36 -0500 Subject: [PATCH 17/33] cache space and room layout --- src/app/pages/client/ClientRoot.tsx | 29 +-- src/app/utils/sort.test.ts | 21 +- src/app/utils/sort.ts | 17 +- src/client/initMatrix.ts | 8 + src/client/slidingSync.test.ts | 1 + src/client/slidingSync.ts | 75 ++++++++ src/client/slidingSyncSidebarCache.test.ts | 132 +++++++++++++ src/client/slidingSyncSidebarCache.ts | 212 +++++++++++++++++++++ src/types/matrix-sdk.ts | 1 + 9 files changed, 473 insertions(+), 23 deletions(-) create mode 100644 src/client/slidingSyncSidebarCache.test.ts create mode 100644 src/client/slidingSyncSidebarCache.ts diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index e3109e39d4..78a7ea43aa 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -61,6 +61,21 @@ const log = createLogger('ClientRoot'); const isClientReady = (syncState: string | null): boolean => syncState === 'PREPARED' || syncState === 'SYNCING' || syncState === 'CATCHUP'; +const resolveLocalRoomId = ( + mx: MatrixClient, + encodedRoomIdOrAlias?: string +): string | undefined => { + if (!encodedRoomIdOrAlias) return undefined; + try { + const roomIdOrAlias = decodeURIComponent(encodedRoomIdOrAlias); + if (isRoomId(roomIdOrAlias)) return roomIdOrAlias; + if (isRoomAlias(roomIdOrAlias)) return getCanonicalAliasRoomId(mx, roomIdOrAlias); + } catch { + // Ignore malformed route values and let the normal router handle them. + } + return undefined; +}; + function ClientRootLoading() { const sessions = useAtomValue(sessionsAtom); const activeSessionId = useAtomValue(activeSessionIdAtom); @@ -273,23 +288,13 @@ export function ClientRoot({ children }: ClientRootProps) { const [startState, startMatrix] = useAsyncCallback( useCallback( (m) => { - let initialRoomId: string | undefined; - if (encodedInitialRoomIdOrAlias) { - try { - const roomIdOrAlias = decodeURIComponent(encodedInitialRoomIdOrAlias); - if (isRoomId(roomIdOrAlias)) initialRoomId = roomIdOrAlias; - else if (isRoomAlias(roomIdOrAlias)) { - initialRoomId = getCanonicalAliasRoomId(m, roomIdOrAlias); - } - } catch { - // Ignore malformed route values and let the normal router handle them. - } - } + const initialRoomId = resolveLocalRoomId(m, encodedInitialRoomIdOrAlias); return startClient(m, { baseUrl: activeSession?.baseUrl, sessionSlidingSyncOptIn: activeSession?.slidingSyncOptIn, initialRoomIds: initialRoomId ? [initialRoomId] : undefined, + onCachedRoomsLoaded: () => setSyncReadyClient(m), }); }, [activeSession?.baseUrl, activeSession?.slidingSyncOptIn, encodedInitialRoomIdOrAlias] diff --git a/src/app/utils/sort.test.ts b/src/app/utils/sort.test.ts index 8f9a413884..a232ea8e58 100644 --- a/src/app/utils/sort.test.ts +++ b/src/app/utils/sort.test.ts @@ -14,14 +14,18 @@ import { } from './sort'; // Minimal stub that satisfies the MatrixClient shape needed by these sort functions. -function makeClient(rooms: Record): MatrixClient { +function makeClient( + rooms: Record +): MatrixClient { return { getRoom: (id: string) => { const r = rooms[id]; if (!r) return null; - return { name: r.name, getLastActiveTimestamp: () => r.ts } as unknown as ReturnType< - MatrixClient['getRoom'] - >; + return { + name: r.name, + getLastActiveTimestamp: () => r.ts, + getBumpStamp: () => r.bumpStamp, + } as unknown as ReturnType; }, } as unknown as MatrixClient; } @@ -63,6 +67,15 @@ describe('factoryRoomIdByActivity', () => { const sort = factoryRoomIdByActivity(mx); expect(['!unknown:h', '!known:h'].toSorted(sort)).toEqual(['!known:h', '!unknown:h']); }); + + it('uses sliding-sync bump stamps when no timeline event is loaded', () => { + const mx = makeClient({ + '!old:h': { name: 'Old', ts: Number.MIN_SAFE_INTEGER, bumpStamp: 1000 }, + '!new:h': { name: 'New', ts: Number.MIN_SAFE_INTEGER, bumpStamp: 9000 }, + }); + const sort = factoryRoomIdByActivity(mx); + expect(['!old:h', '!new:h'].toSorted(sort)).toEqual(['!new:h', '!old:h']); + }); }); describe('factoryRoomIdByAtoZ', () => { diff --git a/src/app/utils/sort.ts b/src/app/utils/sort.ts index 454521df60..0de221b28b 100644 --- a/src/app/utils/sort.ts +++ b/src/app/utils/sort.ts @@ -2,16 +2,19 @@ import type { MatrixClient } from '$types/matrix-sdk'; export type SortFunc = (a: T, b: T) => number; +const getRoomActivity = (mx: MatrixClient, roomId: string): number => { + const room = mx.getRoom(roomId); + if (!room) return Number.MIN_SAFE_INTEGER; + const timelineTimestamp = room.getLastActiveTimestamp(); + return timelineTimestamp === Number.MIN_SAFE_INTEGER + ? (room.getBumpStamp() ?? Number.MIN_SAFE_INTEGER) + : timelineTimestamp; +}; + export const factoryRoomIdByActivity = (mx: MatrixClient): SortFunc => (a, b) => { - const room1 = mx.getRoom(a); - const room2 = mx.getRoom(b); - - return ( - (room2?.getLastActiveTimestamp() ?? Number.MIN_SAFE_INTEGER) - - (room1?.getLastActiveTimestamp() ?? Number.MIN_SAFE_INTEGER) - ); + return getRoomActivity(mx, b) - getRoomActivity(mx, a); }; export const factoryRoomIdByAtoZ = diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 815c554713..c73a58a8d8 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -24,6 +24,7 @@ import { cryptoCallbacks } from './secretStorageKeys'; import type { SlidingSyncDiagnostics } from './slidingSync'; import { SlidingSyncManager } from './slidingSync'; import { PresenceSyncManager } from './presenceSync'; +import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; const log = createLogger('initMatrix'); const debugLog = createDebugLogger('initMatrix'); @@ -484,6 +485,7 @@ export type StartClientConfig = { pollTimeoutMs?: number; timelineLimit?: number; initialRoomIds?: Iterable; + onCachedRoomsLoaded?: () => void; }; export type ClientSyncDiagnostics = { @@ -539,6 +541,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): installSlidingSyncConnId(mx); manager.attach(); + manager.prepareSidebarCacheHydration(); slidingSyncByClient.set(mx, manager); } @@ -553,6 +556,9 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): }), { transport: useSliding ? 'sliding' : 'classic' } ); + if (manager && (await manager.waitForSidebarCacheHydration())) { + config?.onCachedRoomsLoaded?.(); + } } catch (err) { debugLog.error('network', 'Failed to start client with sliding sync', { error: err instanceof Error ? err.message : String(err), @@ -582,6 +588,7 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { log.log('clearCacheAndReload', mx.getUserId()); stopClient(mx); clearNavToActivePathStore(mx.getSafeUserId()); + SlidingSyncSidebarCache.clear(mx.getSafeUserId()); await mx.store.deleteAllData(); window.location.reload(); }; @@ -616,6 +623,7 @@ export const logoutClient = async (mx: MatrixClient, session?: Session) => { } if (session) { + SlidingSyncSidebarCache.clear(session.userId); const storeName: SessionStoreName = getSessionStoreName(session); await mx.clearStores({ cryptoDatabasePrefix: storeName.rustCryptoPrefix }); await deleteDatabase(storeName.sync); diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 63d99cad10..839aa45377 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -74,6 +74,7 @@ function makeManager(mx: ReturnType): SlidingSyncManager { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); mocks.slidingSyncInstance.getListData.mockReset().mockReturnValue(null); mocks.slidingSyncInstance.getListParams.mockReset().mockReturnValue(null); mocks.slidingSyncInstance.setListRanges.mockReset(); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 442a1c08fc..9f60698410 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -1,5 +1,6 @@ import type { MatrixClient, + MatrixEvent, MSC3575List, MSC3575RoomData, MSC3575RoomSubscription, @@ -15,11 +16,14 @@ import { MSC3575_STATE_KEY_LAZY, MSC3575_STATE_KEY_ME, EventType, + EventEmitterEvents, + ClientEvent, } from '$types/matrix-sdk'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import { CustomStateEvent } from '$types/matrix/room'; import * as Sentry from '@sentry/react'; +import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; const log = createLogger('slidingSync'); const debugLog = createDebugLogger('slidingSync'); @@ -184,6 +188,22 @@ export class SlidingSyncManager { private readonly roomTimelineLimit: number; + private readonly sidebarCache: SlidingSyncSidebarCache; + + private cacheHydrationPromise: Promise = Promise.resolve(false); + + private cacheHydrationNewListener: + | ((eventName: string | symbol, listener: (...args: unknown[]) => void) => void) + | undefined; + + private cacheHydrationResolve: ((hydrated: boolean) => void) | undefined; + + private hydratingSidebarCache = false; + + private readonly onCacheRoomData: (roomId: string, data: MSC3575RoomData) => void; + + private readonly onCacheAccountData: (event: MatrixEvent) => void; + private readonly onLifecycle: ( state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, @@ -243,6 +263,7 @@ export class SlidingSyncManager { const roomTimelineLimit = clampPositive(options.timelineLimit, ACTIVE_ROOM_TIMELINE_LIMIT); this.roomTimelineLimit = roomTimelineLimit; + this.sidebarCache = new SlidingSyncSidebarCache(mx.getSafeUserId()); const defaultSubscription = buildEncryptedSubscription(roomTimelineLimit); const lists = buildLists(); @@ -385,10 +406,14 @@ export class SlidingSyncManager { if (member.userId !== this.mx.getUserId()) return; if (member.membership !== KnownMembership.Leave && member.membership !== KnownMembership.Ban) return; + this.sidebarCache.removeRoom(member.roomId); if (!this.activeRoomSubscriptions.has(member.roomId)) return; this.unsubscribeFromRoom(member.roomId); }; + this.onCacheRoomData = (roomId, data) => this.sidebarCache.cacheRoom(roomId, data); + this.onCacheAccountData = (event) => this.sidebarCache.cacheAccountData(event); + for (const roomId of options.initialRoomIds ?? []) { this.subscribeToRoom(roomId); } @@ -409,7 +434,9 @@ export class SlidingSyncManager { }); this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle); + this.slidingSync.on(SlidingSyncEvent.RoomData, this.onCacheRoomData); this.mx.on(RoomMemberEvent.Membership, this.onMembershipLeave); + this.mx.on(ClientEvent.AccountData, this.onCacheAccountData); debugLog.info('sync', 'Sliding sync listeners attached successfully'); } @@ -432,7 +459,19 @@ export class SlidingSyncManager { this.disposed = true; this.slidingSync.stop(); this.slidingSync.removeListener(SlidingSyncEvent.Lifecycle, this.onLifecycle); + this.slidingSync.removeListener(SlidingSyncEvent.RoomData, this.onCacheRoomData); + if (this.cacheHydrationNewListener) { + this.slidingSync.removeListener( + EventEmitterEvents.NewListener, + this.cacheHydrationNewListener as never + ); + this.cacheHydrationNewListener = undefined; + } + this.cacheHydrationResolve?.(false); + this.cacheHydrationResolve = undefined; this.mx.removeListener(RoomMemberEvent.Membership, this.onMembershipLeave); + this.mx.removeListener(ClientEvent.AccountData, this.onCacheAccountData); + this.sidebarCache.dispose(); debugLog.info('sync', 'Sliding sync disposed successfully', { totalSyncCycles: this.syncCount, @@ -455,6 +494,41 @@ export class SlidingSyncManager { }; } + public prepareSidebarCacheHydration(): void { + if (this.disposed || this.cacheHydrationNewListener) return; + + this.cacheHydrationPromise = new Promise((resolve) => { + this.cacheHydrationResolve = resolve; + }); + + this.cacheHydrationNewListener = (eventName) => { + if (eventName !== SlidingSyncEvent.RoomData) return; + const listener = this.cacheHydrationNewListener; + if (listener) { + this.slidingSync.removeListener(EventEmitterEvents.NewListener, listener as never); + } + this.cacheHydrationNewListener = undefined; + + globalThis.queueMicrotask(() => { + this.hydratingSidebarCache = true; + void this.sidebarCache + .hydrate(this.mx, this.slidingSync) + .then((hydrated) => this.cacheHydrationResolve?.(hydrated)) + .catch(() => this.cacheHydrationResolve?.(false)) + .finally(() => { + this.hydratingSidebarCache = false; + this.cacheHydrationResolve = undefined; + }); + }); + }; + + this.slidingSync.on(EventEmitterEvents.NewListener, this.cacheHydrationNewListener as never); + } + + public waitForSidebarCacheHydration(): Promise { + return this.cacheHydrationPromise; + } + private expandListsByPage(): void { if (!this.initialSyncCompleted) return; @@ -759,6 +833,7 @@ export class SlidingSyncManager { const subscribeMs = performance.now(); const onFirstRoomData = (dataRoomId: string) => { if (dataRoomId !== roomId) return; + if (this.hydratingSidebarCache) return; const latencyMs = Math.round(performance.now() - subscribeMs); const subscribedRoom = this.mx.getRoom(roomId); const eventCount = subscribedRoom?.getLiveTimeline().getEvents().length ?? 0; diff --git a/src/client/slidingSyncSidebarCache.test.ts b/src/client/slidingSyncSidebarCache.test.ts new file mode 100644 index 0000000000..f288a87f04 --- /dev/null +++ b/src/client/slidingSyncSidebarCache.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MatrixClient, MSC3575RoomData, SlidingSync } from '$types/matrix-sdk'; +import { EventType, MatrixEvent, SlidingSyncEvent } from '$types/matrix-sdk'; +import { CustomAccountDataEvent } from '$types/matrix/accountData'; +import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; + +const userId = '@user:example.com'; + +const stateEvent = (type: string, stateKey: string, content: Record = {}) => ({ + type, + state_key: stateKey, + content, + event_id: `$${type}-${stateKey}`, + sender: userId, + origin_server_ts: 1, +}); + +beforeEach(() => { + localStorage.clear(); +}); + +describe('SlidingSyncSidebarCache', () => { + it('restores sidebar state without caching timelines or bulk members', async () => { + const cache = new SlidingSyncSidebarCache(userId); + cache.cacheRoom('!room:example.com', { + name: 'Cached Room', + initial: true, + bump_stamp: 42, + required_state: [ + stateEvent(EventType.RoomName, '', { name: 'Cached Room' }), + stateEvent(EventType.SpaceChild, '!child:example.com', { via: ['example.com'] }), + stateEvent(EventType.RoomMember, userId, { membership: 'join' }), + stateEvent(EventType.RoomMember, '@other:example.com', { membership: 'join' }), + ], + timeline: [ + { + type: EventType.RoomMessage, + content: { body: 'do not cache me' }, + event_id: '$message', + sender: '@other:example.com', + origin_server_ts: 2, + }, + ], + } as MSC3575RoomData); + cache.cacheAccountData( + new MatrixEvent({ type: EventType.Direct, content: { '@other:example.com': ['!room'] } }) + ); + cache.cacheAccountData( + new MatrixEvent({ + type: CustomAccountDataEvent.CinnySpaces, + content: { categories: ['!room:example.com'] }, + }) + ); + cache.dispose(); + + const emitPromised = vi + .fn<(event: SlidingSyncEvent, roomId: string, data: MSC3575RoomData) => Promise>() + .mockResolvedValue(true); + const storeAccountDataEvents = vi.fn<(events: MatrixEvent[]) => void>(); + const restored = new SlidingSyncSidebarCache(userId); + const hydrated = await restored.hydrate( + { store: { storeAccountDataEvents } } as unknown as MatrixClient, + { emitPromised } as unknown as SlidingSync + ); + + expect(hydrated).toBe(true); + expect(emitPromised).toHaveBeenCalledWith( + SlidingSyncEvent.RoomData, + '!room:example.com', + expect.objectContaining({ + name: 'Cached Room', + timeline: [], + initial: true, + bump_stamp: 42, + }) + ); + const hydratedRoom = emitPromised.mock.calls[0]?.[2] as MSC3575RoomData; + expect(hydratedRoom.required_state).toHaveLength(3); + expect(hydratedRoom.required_state).toContainEqual( + expect.objectContaining({ + type: EventType.SpaceChild, + state_key: '!child:example.com', + }) + ); + expect(hydratedRoom.required_state).not.toContainEqual( + expect.objectContaining({ state_key: '@other:example.com' }) + ); + expect(storeAccountDataEvents).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ event: expect.objectContaining({ type: EventType.Direct }) }), + expect.objectContaining({ + event: expect.objectContaining({ type: CustomAccountDataEvent.CinnySpaces }), + }), + ]) + ); + }); + + it('merges later state into the existing cached room snapshot', async () => { + const cache = new SlidingSyncSidebarCache(userId); + cache.cacheRoom('!room:example.com', { + name: 'Before', + required_state: [stateEvent(EventType.RoomName, '', { name: 'Before' })], + timeline: [], + }); + cache.cacheRoom('!room:example.com', { + name: 'After', + required_state: [stateEvent(EventType.RoomAvatar, '', { url: 'mxc://example/avatar' })], + timeline: [], + }); + cache.dispose(); + + const emitPromised = vi + .fn<(event: SlidingSyncEvent, roomId: string, data: MSC3575RoomData) => Promise>() + .mockResolvedValue(true); + const restored = new SlidingSyncSidebarCache(userId); + await restored.hydrate( + { + store: { storeAccountDataEvents: vi.fn<(events: MatrixEvent[]) => void>() }, + } as unknown as MatrixClient, + { emitPromised } as unknown as SlidingSync + ); + + const hydratedRoom = emitPromised.mock.calls[0]?.[2] as MSC3575RoomData; + expect(hydratedRoom.name).toBe('After'); + expect(hydratedRoom.required_state).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: EventType.RoomName }), + expect.objectContaining({ type: EventType.RoomAvatar }), + ]) + ); + }); +}); diff --git a/src/client/slidingSyncSidebarCache.ts b/src/client/slidingSyncSidebarCache.ts new file mode 100644 index 0000000000..d45afd842f --- /dev/null +++ b/src/client/slidingSyncSidebarCache.ts @@ -0,0 +1,212 @@ +import type { MatrixClient, MSC3575RoomData, SlidingSync } from '$types/matrix-sdk'; +import { EventType, MatrixEvent, SlidingSyncEvent } from '$types/matrix-sdk'; +import { CustomAccountDataEvent } from '$types/matrix/accountData'; + +const CACHE_VERSION = 1; +const CACHE_KEY_PREFIX = 'sable.slidingSyncSidebarCache.'; +const CACHE_WRITE_DELAY_MS = 500; +const MAX_CACHED_ROOMS = 2000; +const HYDRATION_BATCH_SIZE = 50; + +type StateEvent = MSC3575RoomData['required_state'][number]; + +type SidebarCacheData = { + version: number; + rooms: Record; + accountData: Record>; +}; + +const CACHED_ACCOUNT_DATA_TYPES = new Set([ + EventType.Direct, + CustomAccountDataEvent.CinnySpaces, +]); + +const CACHED_STATE_TYPES = new Set([ + EventType.RoomCreate, + EventType.RoomName, + EventType.RoomTopic, + EventType.RoomAvatar, + EventType.RoomCanonicalAlias, + EventType.RoomJoinRules, + EventType.RoomHistoryVisibility, + EventType.RoomGuestAccess, + EventType.RoomEncryption, + EventType.RoomTombstone, + EventType.RoomPowerLevels, + EventType.RoomPinnedEvents, + EventType.RoomServerAcl, + EventType.SpaceChild, + EventType.SpaceParent, +]); + +const emptyCache = (): SidebarCacheData => ({ + version: CACHE_VERSION, + rooms: {}, + accountData: {}, +}); + +const stateEventKey = (event: StateEvent): string => `${event.type}\u0000${event.state_key}`; + +const mergeStateEvents = ( + previous: StateEvent[] | undefined, + incoming: StateEvent[] | undefined, + userId: string +): StateEvent[] => { + const merged = new Map(); + previous?.forEach((event) => merged.set(stateEventKey(event), event)); + incoming?.forEach((event) => { + const cacheMember = event.type === 'm.room.member' && event.state_key === userId; + if (cacheMember || CACHED_STATE_TYPES.has(event.type)) { + merged.set(stateEventKey(event), event); + } + }); + return [...merged.values()]; +}; + +const mergeRoomData = ( + previous: MSC3575RoomData | undefined, + incoming: MSC3575RoomData, + userId: string +): MSC3575RoomData => ({ + ...previous, + ...incoming, + name: incoming.name ?? previous?.name ?? '', + heroes: incoming.heroes ?? previous?.heroes, + notification_count: incoming.notification_count ?? previous?.notification_count, + highlight_count: incoming.highlight_count ?? previous?.highlight_count, + joined_count: incoming.joined_count ?? previous?.joined_count, + invited_count: incoming.invited_count ?? previous?.invited_count, + is_dm: incoming.is_dm ?? previous?.is_dm, + bump_stamp: incoming.bump_stamp ?? previous?.bump_stamp, + required_state: mergeStateEvents(previous?.required_state, incoming.required_state, userId), + invite_state: incoming.invite_state + ? mergeStateEvents(previous?.invite_state, incoming.invite_state, userId) + : previous?.invite_state, + timeline: [], + initial: true, + limited: false, + num_live: 0, + prev_batch: undefined, +}); + +const parseCache = (value: string | null): SidebarCacheData => { + if (!value) return emptyCache(); + try { + const parsed = JSON.parse(value) as Partial; + if (parsed.version !== CACHE_VERSION || !parsed.rooms || !parsed.accountData) { + return emptyCache(); + } + return parsed as SidebarCacheData; + } catch { + return emptyCache(); + } +}; + +const hydrateRoomBatch = async ( + slidingSync: SlidingSync, + rooms: [string, MSC3575RoomData][], + startIndex = 0 +): Promise => { + const batch = rooms.slice(startIndex, startIndex + HYDRATION_BATCH_SIZE); + await Promise.all( + batch.map(([roomId, roomData]) => + slidingSync.emitPromised(SlidingSyncEvent.RoomData, roomId, roomData) + ) + ); + if (startIndex + HYDRATION_BATCH_SIZE < rooms.length) { + await hydrateRoomBatch(slidingSync, rooms, startIndex + HYDRATION_BATCH_SIZE); + } +}; + +export class SlidingSyncSidebarCache { + public static clear(userId: string): void { + try { + globalThis.localStorage?.removeItem(`${CACHE_KEY_PREFIX}${encodeURIComponent(userId)}`); + } catch { + // Storage can be disabled for this origin. + } + } + + private readonly storageKey: string; + + private data: SidebarCacheData; + + private writeTimer: ReturnType | undefined; + + public constructor(private readonly userId: string) { + this.storageKey = `${CACHE_KEY_PREFIX}${encodeURIComponent(userId)}`; + let stored: string | null = null; + try { + stored = globalThis.localStorage?.getItem(this.storageKey) ?? null; + } catch { + // Storage can be disabled for this origin. + } + this.data = parseCache(stored); + } + + public cacheRoom(roomId: string, roomData: MSC3575RoomData): void { + this.data.rooms[roomId] = mergeRoomData(this.data.rooms[roomId], roomData, this.userId); + this.scheduleWrite(); + } + + public removeRoom(roomId: string): void { + if (!this.data.rooms[roomId]) return; + delete this.data.rooms[roomId]; + this.scheduleWrite(); + } + + public cacheAccountData(event: MatrixEvent): void { + if (!CACHED_ACCOUNT_DATA_TYPES.has(event.getType())) return; + this.data.accountData[event.getType()] = event.getContent>(); + this.scheduleWrite(); + } + + public async hydrate(mx: MatrixClient, slidingSync: SlidingSync): Promise { + const rooms = Object.entries(this.data.rooms); + const accountData = Object.entries(this.data.accountData); + if (rooms.length === 0 && accountData.length === 0) return false; + + if (accountData.length > 0) { + mx.store.storeAccountDataEvents( + accountData.map(([type, content]) => new MatrixEvent({ type, content })) + ); + } + + await hydrateRoomBatch(slidingSync, rooms); + return rooms.length > 0; + } + + public dispose(): void { + if (this.writeTimer !== undefined) { + globalThis.clearTimeout(this.writeTimer); + this.writeTimer = undefined; + this.write(); + } + } + + private scheduleWrite(): void { + if (this.writeTimer !== undefined) return; + this.writeTimer = globalThis.setTimeout(() => { + this.writeTimer = undefined; + this.write(); + }, CACHE_WRITE_DELAY_MS); + } + + private write(): void { + let rooms = Object.entries(this.data.rooms) + .toSorted(([, a], [, b]) => (b.bump_stamp ?? 0) - (a.bump_stamp ?? 0)) + .slice(0, MAX_CACHED_ROOMS); + + while (true) { + const nextData = { ...this.data, rooms: Object.fromEntries(rooms) }; + try { + globalThis.localStorage?.setItem(this.storageKey, JSON.stringify(nextData)); + this.data = nextData; + return; + } catch { + if (rooms.length <= 1) return; + rooms = rooms.slice(0, Math.floor(rooms.length / 2)); + } + } + } +} diff --git a/src/types/matrix-sdk.ts b/src/types/matrix-sdk.ts index d05073f2eb..2fb2709b5c 100644 --- a/src/types/matrix-sdk.ts +++ b/src/types/matrix-sdk.ts @@ -22,6 +22,7 @@ export * from 'matrix-js-sdk/lib/models/user'; export * from 'matrix-js-sdk/lib/models/search-result'; export * from 'matrix-js-sdk/lib/models/event-timeline'; export * from 'matrix-js-sdk/lib/models/event-timeline-set'; +export { EventEmitterEvents } from 'matrix-js-sdk/lib/models/typed-event-emitter'; export { Relations, RelationsEvent } from 'matrix-js-sdk/lib/models/relations'; export * from 'matrix-js-sdk/lib/store/indexeddb'; From 1300e3ac1c0a64936c58b791a015848465ac7a85 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 15 Jul 2026 16:11:27 -0500 Subject: [PATCH 18/33] stabilize sliding sync room state and membership updates --- .../leave-room-prompt/LeaveRoomPrompt.tsx | 2 +- src/app/features/room/RoomTimeline.tsx | 8 ++-- src/app/hooks/useSlidingSyncActiveRoom.ts | 2 +- src/app/state/room/roomToUnread.ts | 47 ++----------------- src/client/initMatrix.ts | 41 ++++++++++++++++ src/client/slidingSync.test.ts | 38 ++++++++++++++- src/client/slidingSync.ts | 29 ++++++++++-- 7 files changed, 114 insertions(+), 53 deletions(-) diff --git a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx index ac43b4c563..48f11d0e8a 100644 --- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx +++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx @@ -34,7 +34,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro const [leaveState, leaveRoom] = useAsyncCallback( useCallback(async () => { debugLog.info('ui', 'Leave room button clicked', { roomId }); - mx.leave(roomId); + await mx.leave(roomId); }, [mx, roomId]) ); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d92de8911a..33103b9990 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -12,7 +12,7 @@ import { import type { Editor } from 'slate'; import { useAtomValue, useSetAtom } from 'jotai'; import type { Room } from '$types/matrix-sdk'; -import { PushProcessor, Direction } from '$types/matrix-sdk'; +import { PushProcessor, Direction, EventType } from '$types/matrix-sdk'; import classNames from 'classnames'; import type { VListHandle } from 'virtua'; import { VList } from 'virtua'; @@ -933,6 +933,8 @@ export function RoomTimeline({ timelineSync.backwardStatus === 'loading' && timelineSync.eventsLength > 0; const showFrontPaginationSpinner = timelineSync.forwardStatus === 'loading' && timelineSync.eventsLength > 0; + const hasPowerLevelState = !!room.currentState.getStateEvents(EventType.RoomPowerLevels, ''); + const hideTimelineForRoomState = roomSyncLoading && hideMemberInReadOnly && !hasPowerLevelState; const timelineBottomFloatLift = !atBottomState && isReady ? { bottom: `calc(${config.space.S400} + ${toRem(52)})` } : undefined; const timelineTopFloatLift = @@ -1043,7 +1045,7 @@ export function RoomTimeline({ return ( - {roomSyncLoading && timelineSync.eventsLength === 0 && ( + {(hideTimelineForRoomState || (roomSyncLoading && timelineSync.eventsLength === 0)) && ( diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts index b0c8752e02..f0b9dbce08 100644 --- a/src/app/hooks/useSlidingSyncActiveRoom.ts +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -58,7 +58,7 @@ export const useSlidingSyncSpaceSubscriptions = (): void => { export const useSlidingSyncRoomLoading = (roomId: string): boolean => { const manager = useAvailableSlidingSyncManager(); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(() => manager?.isRoomSubscriptionLoading(roomId) ?? false); useEffect(() => { if (!manager) { diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index 24ded9f800..bff76ebfde 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -9,7 +9,7 @@ import type { ReceiptType, } from '$types/matrix-sdk'; import { RoomEvent, SyncState, EventType, ClientEvent, KnownMembership } from '$types/matrix-sdk'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect } from 'react'; import type { RoomToUnread, UnreadInfo, Unread } from '$types/matrix/room'; import { NotificationType } from '$types/matrix/room'; import { @@ -19,7 +19,6 @@ import { getUnreadInfos, isNotificationEvent, } from '$utils/room'; -import { useStateEventCallback } from '$hooks/useStateEventCallback'; import { useSyncState } from '$hooks/useSyncState'; import { useRoomsNotificationPreferencesContext } from '$hooks/useRoomsNotificationPreferences'; import { getClientSyncDiagnostics } from '$client/initMatrix'; @@ -180,7 +179,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo const setUnreadAtom = useSetAtom(unreadAtom); const roomsNotificationPreferences = useRoomsNotificationPreferencesContext(); const mDirects = useAtomValue(mDirectAtom); - const spaceChildResetTimerRef = useRef(null); + const roomToParents = useAtomValue(roomToParentsAtom); const shouldApplyUnreadFixup = useCallback( () => getClientSyncDiagnostics(mx).transport === 'sliding', [mx] @@ -194,7 +193,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo mDirects, }), }); - }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); + }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects, roomToParents]); useSyncState( mx, @@ -405,44 +404,4 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo mx.removeListener(ClientEvent.Room, handleRoomAdded as (room: Room) => void); }; }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); - - useEffect( - () => () => { - if (spaceChildResetTimerRef.current !== null) { - window.clearTimeout(spaceChildResetTimerRef.current); - spaceChildResetTimerRef.current = null; - } - }, - [] - ); - - useStateEventCallback( - mx, - useCallback( - (mEvent) => { - if (mEvent.getType() === (EventType.SpaceChild as string)) { - const roomId = mEvent.getRoomId(); - if (!roomId) return; - const parentRoom = mx.getRoom(roomId); - if (!parentRoom || parentRoom.getMyMembership() !== (KnownMembership.Join as string)) - return; - - if (spaceChildResetTimerRef.current !== null) { - window.clearTimeout(spaceChildResetTimerRef.current); - } - spaceChildResetTimerRef.current = window.setTimeout(() => { - setUnreadAtom({ - type: 'RESET', - unreadInfos: getUnreadInfos(mx, { - applyFixup: shouldApplyUnreadFixup(), - mDirects, - }), - }); - spaceChildResetTimerRef.current = null; - }, 150); - } - }, - [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects] - ) - ); }; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 148236ab75..ecd0165502 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -9,6 +9,7 @@ import { createClient, IndexedDBStore, IndexedDBCryptoStore, + KnownMembership, SyncState, } from '$types/matrix-sdk'; @@ -30,6 +31,7 @@ import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; const log = createLogger('initMatrix'); const debugLog = createDebugLogger('initMatrix'); const slidingSyncByClient = new WeakMap(); +const membershipActionCleanupByClient = new WeakMap void>(); const presenceSyncByClient = new WeakMap(); const presenceStartCleanupByClient = new WeakMap void>(); const SLIDING_SYNC_POLL_TIMEOUT_MS = 45000; @@ -505,12 +507,49 @@ export type ClientSyncDiagnostics = { }; const disposeSlidingSync = (mx: MatrixClient): void => { + membershipActionCleanupByClient.get(mx)?.(); const manager = slidingSyncByClient.get(mx); if (!manager) return; manager.dispose(); slidingSyncByClient.delete(mx); }; +type MatrixClientWithWritableMembershipActions = MatrixClient & { + joinRoom: MatrixClient['joinRoom']; + leave: MatrixClient['leave']; +}; + +const installMembershipActionReconciliation = ( + mx: MatrixClient, + manager: SlidingSyncManager +): void => { + membershipActionCleanupByClient.get(mx)?.(); + + const writableMx = mx as MatrixClientWithWritableMembershipActions; + // Keep the exact methods so cleanup restores the client without leaving bound replacements behind. + // oxlint-disable-next-line typescript/unbound-method + const originalJoinRoom = mx.joinRoom; + // oxlint-disable-next-line typescript/unbound-method + const originalLeave = mx.leave; + + writableMx.joinRoom = async (...args) => { + const room = await originalJoinRoom.apply(mx, args); + manager.reconcileRoomMembership(room.roomId, KnownMembership.Join); + return room; + }; + writableMx.leave = async (...args) => { + const result = await originalLeave.apply(mx, args); + manager.reconcileRoomMembership(args[0], KnownMembership.Leave); + return result; + }; + + membershipActionCleanupByClient.set(mx, () => { + membershipActionCleanupByClient.delete(mx); + writableMx.joinRoom = originalJoinRoom; + writableMx.leave = originalLeave; + }); +}; + const disposePresenceSync = (mx: MatrixClient): void => { presenceStartCleanupByClient.get(mx)?.(); const manager = presenceSyncByClient.get(mx); @@ -552,6 +591,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): manager.attach(); manager.prepareSidebarCacheHydration(); + installMembershipActionReconciliation(mx, manager); slidingSyncByClient.set(mx, manager); } @@ -578,6 +618,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): }); fetchRoomEventStartupCleanupByClient.get(mx)?.(); slidingSyncConnIdCleanupByClient.get(mx)?.(); + membershipActionCleanupByClient.get(mx)?.(); disposeSlidingSync(mx); disposePresenceSync(mx); throw err; diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 839aa45377..b24a8bb409 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { MatrixClient, MSC3575List } from '$types/matrix-sdk'; -import { EventType, SlidingSyncEvent, SlidingSyncState } from '$types/matrix-sdk'; +import { EventType, KnownMembership, SlidingSyncEvent, SlidingSyncState } from '$types/matrix-sdk'; import { SlidingSyncManager } from './slidingSync'; @@ -221,6 +221,7 @@ describe('SlidingSyncManager room subscription coordination', () => { fireRoomData(roomId); expect(loadingStates).toEqual([false, true]); + expect(manager.isRoomSubscriptionLoading(roomId)).toBe(true); manager.attach(); fireLifecycle(SlidingSyncState.Complete); @@ -270,6 +271,41 @@ describe('SlidingSyncManager room subscription coordination', () => { }); }); +describe('SlidingSyncManager local membership reconciliation', () => { + it('updates an existing invite immediately after a successful join', () => { + const updateMyMembership = vi.fn<() => void>(); + const manager = makeManager( + makeMockMx({ + getRoom: vi.fn<() => { updateMyMembership: typeof updateMyMembership }>().mockReturnValue({ + updateMyMembership, + }), + }) + ); + + manager.reconcileRoomMembership('!invite:example.com', KnownMembership.Join); + + expect(updateMyMembership).toHaveBeenCalledWith(KnownMembership.Join); + }); + + it('updates and unsubscribes a room immediately after a successful leave', () => { + const updateMyMembership = vi.fn<() => void>(); + const manager = makeManager( + makeMockMx({ + getRoom: vi.fn<() => { updateMyMembership: typeof updateMyMembership }>().mockReturnValue({ + updateMyMembership, + }), + }) + ); + manager.subscribeToRoom('!room:example.com'); + + manager.reconcileRoomMembership('!room:example.com', KnownMembership.Leave); + + expect(updateMyMembership).toHaveBeenCalledWith(KnownMembership.Leave); + expect(manager.isRoomActive('!room:example.com')).toBe(false); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith(new Set()); + }); +}); + // ── dispose() ──────────────────────────────────────────────────────────────── describe('SlidingSyncManager.dispose()', () => { diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 9f60698410..b38e0cb5f6 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -303,7 +303,9 @@ export class SlidingSyncManager { } if (this.disposed) { - debugLog.warn('sync', 'Sync lifecycle called after disposal', { state }); + debugLog.warn('sync', 'Sync lifecycle called after disposal', { + state, + }); return; } @@ -430,7 +432,10 @@ export class SlidingSyncManager { this.initialSyncSpan = Sentry.startInactiveSpan({ name: 'sync.initial', op: 'matrix.sync', - attributes: { 'sync.transport': 'sliding', 'sync.base_url': this.baseUrl }, + attributes: { + 'sync.transport': 'sliding', + 'sync.base_url': this.baseUrl, + }, }); this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle); @@ -716,6 +721,18 @@ export class SlidingSyncManager { return this.activeRoomSubscriptions.has(roomId); } + public reconcileRoomMembership( + roomId: string, + membership: KnownMembership.Join | KnownMembership.Leave + ): void { + this.mx.getRoom(roomId)?.updateMyMembership(membership); + + if (membership === KnownMembership.Leave) { + this.sidebarCache.removeRoom(roomId); + this.unsubscribeFromRoom(roomId); + } + } + private flushDeferredSubscriptions(): void { if (this.disposed || !this.listsFullyLoaded) return; @@ -787,7 +804,7 @@ export class SlidingSyncManager { const listeners = this.roomSubscriptionStatusListeners.get(roomId) ?? new Set(); listeners.add(listener); this.roomSubscriptionStatusListeners.set(roomId, listeners); - listener(this.pendingRoomDataListeners.has(roomId)); + listener(this.isRoomSubscriptionLoading(roomId)); return () => { listeners.delete(listener); @@ -795,6 +812,12 @@ export class SlidingSyncManager { }; } + public isRoomSubscriptionLoading(roomId: string): boolean { + return ( + this.pendingRoomDataListeners.has(roomId) || this.roomDataAwaitingSyncCompletion.has(roomId) + ); + } + private notifyRoomSubscriptionStatus(roomId: string, loading: boolean): void { this.roomSubscriptionStatusListeners.get(roomId)?.forEach((listener) => listener(loading)); } From 539125cb14596de12ebcc849fc6a36f7e4c5520d Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 15 Jul 2026 16:48:44 -0500 Subject: [PATCH 19/33] fix presence sync and member list subscription --- src/app/features/room/Room.tsx | 2 +- src/app/hooks/useRoomMembers.ts | 9 +++- src/app/hooks/useSlidingSyncActiveRoom.ts | 52 ++++++++++++++++++-- src/app/pages/client/ClientNonUIFeatures.tsx | 7 ++- src/app/utils/presence.ts | 7 ++- src/client/presenceSync.ts | 43 ++++++++++++++-- 6 files changed, 105 insertions(+), 15 deletions(-) diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index e8ba663823..fa7d171cb6 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -62,7 +62,7 @@ export function Room() { }); }, [isWidgetDrawerOpen, room.roomId]); const powerLevels = usePowerLevels(room); - const members = useRoomMembers(mx, room.roomId); + const members = useRoomMembers(mx, room.roomId, screenSize === ScreenSize.Desktop && isDrawer); const chat = useAtomValue(callChatAtom); const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId)); const [threadBrowserOpen, setThreadBrowserOpen] = useAtom( diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts index 46640040ec..31e04867f5 100644 --- a/src/app/hooks/useRoomMembers.ts +++ b/src/app/hooks/useRoomMembers.ts @@ -2,10 +2,15 @@ import type { MatrixClient, MatrixEvent, RoomMember } from '$types/matrix-sdk'; import { EventType, RoomMemberEvent, RoomStateEvent } from '$types/matrix-sdk'; import { useEffect, useState } from 'react'; -export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] => { +export const useRoomMembers = (mx: MatrixClient, roomId: string, enabled = true): RoomMember[] => { const [members, setMembers] = useState([]); useEffect(() => { + if (!enabled) { + setMembers([]); + return undefined; + } + const room = mx.getRoom(roomId); let loadingMembers = true; let disposed = false; @@ -40,7 +45,7 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] = mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList); mx.removeListener(RoomStateEvent.Events, handleStateEvent); }; - }, [mx, roomId]); + }, [enabled, mx, roomId]); return members; }; diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts index f0b9dbce08..66f9e9a1c5 100644 --- a/src/app/hooks/useSlidingSyncActiveRoom.ts +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -1,10 +1,54 @@ import { useEffect, useState } from 'react'; +import { matchPath, useLocation } from 'react-router-dom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { getSlidingSyncManager } from '$client/initMatrix'; -import { useResolvedSelectedRoom, useResolvedSelectedSpace } from '$hooks/router/useResolvedRoomId'; +import { useResolvedRoomIdOrAlias } from '$hooks/router/useResolvedRoomId'; import { useSpaces } from '$state/hooks/roomList'; import { allRoomsAtom } from '$state/room-list/roomList'; import { ClientEvent } from '$types/matrix-sdk'; +import { + DIRECT_ROOM_PATH, + HOME_ROOM_PATH, + SPACE_LOBBY_PATH, + SPACE_PATH, + SPACE_ROOM_PATH, + SPACE_SEARCH_PATH, +} from '$pages/paths'; +import { isRoomAlias, isRoomId } from '$utils/matrix'; + +type RouteRoomRefs = { + roomIdOrAlias?: string; + spaceIdOrAlias?: string; +}; + +const decodeRoomRef = (value: string | undefined): string | undefined => { + if (!value) return undefined; + const decoded = decodeURIComponent(value); + return isRoomId(decoded) || isRoomAlias(decoded) ? decoded : undefined; +}; + +export const getRouteRoomRefs = (pathname: string): RouteRoomRefs => { + const homeRoomMatch = matchPath(HOME_ROOM_PATH, pathname); + const directRoomMatch = matchPath(DIRECT_ROOM_PATH, pathname); + const nonSpaceRoomMatch = homeRoomMatch ?? directRoomMatch; + if (nonSpaceRoomMatch) { + return { roomIdOrAlias: decodeRoomRef(nonSpaceRoomMatch.params.roomIdOrAlias) }; + } + + const spaceRoomMatch = matchPath(SPACE_ROOM_PATH, pathname); + if (spaceRoomMatch) { + return { + roomIdOrAlias: decodeRoomRef(spaceRoomMatch.params.roomIdOrAlias), + spaceIdOrAlias: decodeRoomRef(spaceRoomMatch.params.spaceIdOrAlias), + }; + } + + const spaceMatch = + matchPath(SPACE_LOBBY_PATH, pathname) ?? + matchPath(SPACE_SEARCH_PATH, pathname) ?? + matchPath(SPACE_PATH, pathname); + return { spaceIdOrAlias: decodeRoomRef(spaceMatch?.params.spaceIdOrAlias) }; +}; const useAvailableSlidingSyncManager = () => { const mx = useMatrixClient(); @@ -31,8 +75,10 @@ const useAvailableSlidingSyncManager = () => { export const useSlidingSyncRouteRooms = (): void => { const manager = useAvailableSlidingSyncManager(); - const { roomId, resolving: resolvingRoom } = useResolvedSelectedRoom(); - const { roomId: spaceId, resolving: resolvingSpace } = useResolvedSelectedSpace(); + const { pathname } = useLocation(); + const { roomIdOrAlias, spaceIdOrAlias } = getRouteRoomRefs(pathname); + const { roomId, resolving: resolvingRoom } = useResolvedRoomIdOrAlias(roomIdOrAlias); + const { roomId: spaceId, resolving: resolvingSpace } = useResolvedRoomIdOrAlias(spaceIdOrAlias); useEffect(() => { if (!manager || resolvingRoom || resolvingSpace) return undefined; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 52ac07ef2f..dc62597c03 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -883,8 +883,11 @@ function PresenceFeature() { useEffect(() => { // Classic sync / MSC4186 presence: set_presence query param on every /sync poll. // Passing undefined restores the default (online); Offline suppresses broadcasting. - mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline); - getPresenceSyncManager(mx)?.setPresenceEnabled(sendPresence); + const syncPresence = sendPresence ? undefined : SetPresence.Offline; + mx.setSyncPresence(syncPresence); + const presenceManager = getPresenceSyncManager(mx); + presenceManager?.setPresence(syncPresence); + presenceManager?.setPresenceEnabled(sendPresence); }, [mx, sendPresence]); return null; diff --git a/src/app/utils/presence.ts b/src/app/utils/presence.ts index cb50bab9d0..cbe835f90f 100644 --- a/src/app/utils/presence.ts +++ b/src/app/utils/presence.ts @@ -1,5 +1,6 @@ import type { MatrixClient } from 'matrix-js-sdk'; import { SetPresence } from 'matrix-js-sdk'; +import { getPresenceSyncManager } from '$client/initMatrix'; import { Presence } from '../hooks/useUserPresence'; const PRESENCE_TO_SET_PRESENCE: Record = { @@ -16,8 +17,10 @@ export const setUserPresence = async ( presence: Presence, statusMsg?: string ): Promise => { - Promise.all([ - mx.setSyncPresence(presenceToSetPresence(presence)), + const syncPresence = presenceToSetPresence(presence); + getPresenceSyncManager(mx)?.setPresence(syncPresence); + await Promise.all([ + mx.setSyncPresence(syncPresence), mx.setPresence({ presence, status_msg: statusMsg, diff --git a/src/client/presenceSync.ts b/src/client/presenceSync.ts index 3148fd1651..556a314db2 100644 --- a/src/client/presenceSync.ts +++ b/src/client/presenceSync.ts @@ -1,5 +1,12 @@ import type { CryptoBackend, IDeviceLists, IToDeviceEvent, MatrixClient } from '$types/matrix-sdk'; -import { ClientEvent, Filter, Method, processToDeviceMessages, User } from '$types/matrix-sdk'; +import { + ClientEvent, + Filter, + Method, + processToDeviceMessages, + SetPresence, + User, +} from '$types/matrix-sdk'; import { createDebugLogger } from '$utils/debugLogger'; const debugLog = createDebugLogger('presenceSync'); @@ -19,6 +26,12 @@ export class PresenceSyncManager { private enabled = true; + private started = false; + + private presence = SetPresence.Online; + + private restartAfterRequest = false; + private activeRequest: Promise | null = null; private abortController: AbortController | undefined; @@ -40,23 +53,40 @@ export class PresenceSyncManager { this.enabled = enabled; if (!enabled) { + this.restartAfterRequest = false; if (this.nextPollTimer !== undefined) clearTimeout(this.nextPollTimer); this.nextPollTimer = undefined; this.abortController?.abort(); return; } - if (!this.activeRequest && !this.disposed) this.poll(); + if (this.started && !this.activeRequest && !this.disposed) this.poll(); + } + + public setPresence(presence: SetPresence = SetPresence.Online): void { + if (this.presence === presence) return; + this.presence = presence; + + if (!this.started || !this.enabled || this.disposed) return; + if (this.activeRequest) { + this.restartAfterRequest = true; + this.abortController?.abort(); + } else { + this.poll(); + } } public start(): void { - if (this.disposed || this.activeRequest || !this.enabled) return; + if (this.disposed || this.started) return; + this.started = true; + if (!this.enabled) return; this.poll(); } public dispose(): void { this.disposed = true; this.enabled = false; + this.started = false; if (this.nextPollTimer !== undefined) clearTimeout(this.nextPollTimer); this.nextPollTimer = undefined; this.abortController?.abort(); @@ -135,7 +165,7 @@ export class PresenceSyncManager { filter: filterId, since: this.syncToken, timeout: this.pollTimeoutMs, - set_presence: 'online', + set_presence: this.presence, }, undefined, { abortSignal: signal } @@ -161,7 +191,10 @@ export class PresenceSyncManager { this.activeRequest = null; this.abortController = undefined; - if (this.enabled && !this.disposed) { + if (this.restartAfterRequest && this.started && this.enabled && !this.disposed) { + this.restartAfterRequest = false; + this.poll(); + } else if (this.started && this.enabled && !this.disposed) { this.nextPollTimer = setTimeout(() => { this.nextPollTimer = undefined; this.poll(); From 071b29bff7d93afcaeb461ba42d93f262784bef7 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 15 Jul 2026 17:10:28 -0500 Subject: [PATCH 20/33] adjust sliding sync toggle style and login with sso compatibility --- src/app/pages/auth/login/Login.tsx | 63 +++++++++++++++++-- src/app/pages/auth/login/OidcLogin.tsx | 7 ++- .../pages/auth/login/PasswordLoginForm.tsx | 29 ++++----- src/app/pages/auth/login/TokenLogin.tsx | 8 ++- src/app/pages/auth/login/loginUtil.ts | 15 ++--- .../pages/auth/login/slidingSyncLogin.test.ts | 20 ++++++ src/app/pages/auth/login/slidingSyncLogin.ts | 25 ++++++++ 7 files changed, 133 insertions(+), 34 deletions(-) create mode 100644 src/app/pages/auth/login/slidingSyncLogin.test.ts create mode 100644 src/app/pages/auth/login/slidingSyncLogin.ts diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index c5b5b58422..4b399be619 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -1,5 +1,5 @@ -import { useMemo } from 'react'; -import { Box, Text, color } from 'folds'; +import { useMemo, useState } from 'react'; +import { Box, Switch, Text, Tooltip, TooltipProvider, color, config } from 'folds'; import { Link, useSearchParams } from 'react-router-dom'; import { SSOAction } from '$types/matrix-sdk'; import { RegisterFlowStatus, useAuthFlows } from '$hooks/useAuthFlows'; @@ -15,6 +15,8 @@ import { OrDivider } from '$pages/auth/OrDivider'; import { PasswordLoginForm } from './PasswordLoginForm'; import { TokenLogin } from './TokenLogin'; import { OidcLoginButton, OidcCallback } from './OidcLogin'; +import { sizedIcon, Info } from '$components/icons/phosphor'; +import { getPendingSlidingSyncLogin, setPendingSlidingSyncLogin } from './slidingSyncLogin'; // react-router ignores the real query string in hashRouter mode, so read callback params direct. const getExternalSearchParams = () => { @@ -38,6 +40,46 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar [searchParams] ); +type SlidingSyncLoginOptionProps = { + value: boolean; + onChange: (value: boolean) => void; +}; + +function SlidingSyncLoginOption({ value, onChange }: SlidingSyncLoginOptionProps) { + return ( + + + + Use sliding sync + + + + Sliding sync is faster and uses less bandwidth, but it can be buggier. You can + toggle it later in Settings at any time. + + + } + > + {(triggerRef) => ( + + {sizedIcon(Info, '100')} + + )} + + + + + ); +} + export function Login() { const server = useAuthServer(); const { hashRouter } = useClientConfig(); @@ -51,6 +93,12 @@ export function Login() { const oidcRedirectUri = usePathWithOrigin(getLoginPath(server), { ignoreHashRouter: true }); const external = getExternalSearchParams(); const absoluteLoginPath = usePathWithOrigin(getLoginPath(server)); + const [useSlidingSync, setUseSlidingSync] = useState(getPendingSlidingSyncLogin); + + const handleSlidingSyncChange = (enabled: boolean) => { + setUseSlidingSync(enabled); + setPendingSlidingSyncLogin(enabled); + }; if (hashRouter?.enabled && (external.loginToken || (external.code && external.state))) { window.location.replace( @@ -85,7 +133,7 @@ export function Login() { const showLegacySso = !showOidc && parsedFlows.sso !== undefined; if (showOidc && oidcCode && oidcState) { - return ; + return ; } return ( @@ -93,14 +141,21 @@ export function Login() { Login + {!showPassword && ( + + )} {parsedFlows.token && loginSearchParams.loginToken && ( - + )} {showPassword && ( <> + } /> {(showLegacySso || showOidc) && } diff --git a/src/app/pages/auth/login/OidcLogin.tsx b/src/app/pages/auth/login/OidcLogin.tsx index 06ab23bad5..58de6f9d55 100644 --- a/src/app/pages/auth/login/OidcLogin.tsx +++ b/src/app/pages/auth/login/OidcLogin.tsx @@ -83,8 +83,9 @@ export function OidcLoginButton({ type OidcCallbackProps = { code: string; state: string; + slidingSyncOptIn: boolean; }; -export function OidcCallback({ code, state }: OidcCallbackProps) { +export function OidcCallback({ code, state, slidingSyncOptIn }: OidcCallbackProps) { const commitSession = useCommitLoginSession(); const server = useAuthServer(); const navigate = useNavigate(); @@ -99,9 +100,9 @@ export function OidcCallback({ code, state }: OidcCallbackProps) { useEffect(() => { if (loginState.status === AsyncStatus.Success) { - commitSession(loginState.data.session); + commitSession(loginState.data.session, slidingSyncOptIn); } - }, [loginState, commitSession]); + }, [loginState, slidingSyncOptIn, commitSession]); return ( <> diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 5c18a386d2..943daf94e1 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -1,4 +1,4 @@ -import type { FormEventHandler, MouseEventHandler } from 'react'; +import type { FormEventHandler, MouseEventHandler, ReactNode } from 'react'; import { useCallback, useState } from 'react'; import type { RectCords } from 'folds'; import { @@ -13,7 +13,6 @@ import { OverlayCenter, PopOut, Spinner, - Switch, Text, config, } from 'folds'; @@ -107,8 +106,15 @@ function UsernameHint({ server }: { server: string }) { type PasswordLoginFormProps = { defaultUsername?: string; defaultEmail?: string; + slidingSyncOptIn: boolean; + slidingSyncOption?: ReactNode; }; -export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLoginFormProps) { +export function PasswordLoginForm({ + defaultUsername, + defaultEmail, + slidingSyncOptIn, + slidingSyncOption, +}: PasswordLoginFormProps) { const server = useAuthServer(); const clientConfig = useClientConfig(); @@ -121,11 +127,9 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog Parameters >(useCallback(login, [])); - const [useSlidingSync, setUseSlidingSync] = useState(false); - useLoginComplete( loginState.status === AsyncStatus.Success ? loginState.data : undefined, - useSlidingSync + slidingSyncOptIn ); const handleUsernameLogin = (username: string, password: string) => { @@ -271,18 +275,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog - - - - Use sliding sync (faster, but buggier) - - - + {slidingSyncOption} + )} + > + {(config) => {config.homeserverList?.[0]}} + + ); + + fireEvent.click(await screen.findByRole('button', { name: /503 Service Unavailable/ })); + expect(await screen.findByText('example.org')).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('rejects non-object JSON', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(successfulResponse([]))); + + render( + {String(error)}}> + {() => Configured app} + + ); + + expect(await screen.findByText(/must contain a JSON object/)).toBeInTheDocument(); + expect(screen.queryByText('Configured app')).not.toBeInTheDocument(); + }); + + it('uses deliberate defaults only after continuing', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); + + render( + ( + + )} + > + {(config) => {config.homeserverList?.join(',')}} + + ); + + fireEvent.click(await screen.findByRole('button', { name: 'Continue' })); + expect(screen.getByText(FALLBACK_CLIENT_CONFIG.homeserverList?.[0] ?? '')).toBeInTheDocument(); + }); + + it('applies matrix.to configuration before rendering links', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(successfulResponse({ matrixToBaseUrl: 'https://custom.example/' })) + ); + + render( + + {(config) => { + setMatrixToBase(config.matrixToBaseUrl); + return {getMatrixToRoom('!room:example.com')}; + }} + + ); + + await waitFor(() => + expect(screen.getByText('https://custom.example/#/!room:example.com')).toBeInTheDocument() + ); + }); +}); diff --git a/src/app/components/ClientConfigLoader.tsx b/src/app/components/ClientConfigLoader.tsx new file mode 100644 index 0000000000..6097be06ef --- /dev/null +++ b/src/app/components/ClientConfigLoader.tsx @@ -0,0 +1,110 @@ +import type { ReactNode } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Button, Dialog, Text, color, config } from 'folds'; +import type { ClientConfig } from '$hooks/useClientConfig'; +import { trimTrailingSlash } from '$utils/common'; +import { SplashScreen } from '$components/splash-screen'; + +export const FALLBACK_CLIENT_CONFIG: ClientConfig = { + defaultHomeserver: 0, + homeserverList: ['matrix.org'], + allowCustomHomeservers: false, + hashRouter: { enabled: false, basename: '/' }, +}; + +export const getClientConfig = async (): Promise => { + const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`; + const response = await fetch(url, { method: 'GET' }); + if (!response.ok) { + throw new Error(`Failed to load config.json (${response.status} ${response.statusText})`); + } + + const data: unknown = await response.json(); + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('config.json must contain a JSON object.'); + } + return data as ClientConfig; +}; + +type ClientConfigLoaderProps = { + fallback?: () => ReactNode; + error?: (error: unknown, retry: () => void, ignore: () => void) => ReactNode; + children: (config: ClientConfig) => ReactNode; +}; + +type ConfigLoadState = + | { status: 'loading' } + | { status: 'ready'; config: ClientConfig } + | { status: 'error'; error: unknown }; + +const renderConfigError = (error: unknown, retry: () => void, ignore: () => void) => ( + + + + + + Failed to load client configuration. + {error instanceof Error && ( + + {error.message} + + )} + + + + + + + +); + +export function ClientConfigLoader({ fallback, error, children }: ClientConfigLoaderProps) { + const [state, setState] = useState({ status: 'loading' }); + const [useFallback, setUseFallback] = useState(false); + const requestIdRef = useRef(0); + + const load = useCallback(() => { + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + setUseFallback(false); + setState({ status: 'loading' }); + + void getClientConfig().then( + (loadedConfig) => { + if (requestId === requestIdRef.current) { + setState({ status: 'ready', config: loadedConfig }); + } + }, + (loadError: unknown) => { + if (requestId === requestIdRef.current) setState({ status: 'error', error: loadError }); + } + ); + }, []); + + const ignoreError = useCallback(() => setUseFallback(true), []); + + useEffect(() => { + load(); + return () => { + requestIdRef.current += 1; + }; + }, [load]); + + if (state.status === 'loading') { + return fallback?.(); + } + if (state.status === 'error' && !useFallback) { + return (error ?? renderConfigError)(state.error, load, ignoreError); + } + + const clientConfig = state.status === 'ready' ? state.config : FALLBACK_CLIENT_CONFIG; + return children(clientConfig); +} diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 7fdab7fcc1..141f503765 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useRef, useState } from 'react'; +import { lazy, Suspense, useCallback, useMemo, useRef } from 'react'; import { Provider as JotaiProvider } from 'jotai'; import { createStore } from 'jotai/vanilla'; import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; @@ -6,6 +6,7 @@ import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; +import { ClientConfigLoader } from '$components/ClientConfigLoader'; import type { ClientConfig } from '$hooks/useClientConfig'; import { ClientConfigProvider } from '$hooks/useClientConfig'; import { setMatrixToBase } from '$plugins/matrix-to'; @@ -17,10 +18,6 @@ import { FeatureCheck } from './FeatureCheck'; import { createRouter } from './Router'; import { isReactQueryDevtoolsEnabled } from './reactQueryDevtoolsGate'; import { bootstrapSettingsStore } from '$state/settings'; -import { trimTrailingSlash } from '$utils/common'; -import { createLogger } from '$utils/debug'; - -const log = createLogger('App'); const queryClient = new QueryClient(); const ReactQueryDevtools = lazy(async () => { @@ -38,14 +35,15 @@ function BootstrappedAppShell({ clientConfig, screenSize }: BootstrappedAppShell const jotaiStoreRef = useRef>(); if (!jotaiStoreRef.current) { jotaiStoreRef.current = createStore(); + bootstrapSettingsStore(jotaiStoreRef.current, clientConfig.settingsDefaults); } - bootstrapSettingsStore(jotaiStoreRef.current, clientConfig.settingsDefaults); + const router = useMemo(() => createRouter(clientConfig, screenSize), [clientConfig, screenSize]); const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled(); return ( - + {reactQueryDevtoolsEnabled && ( @@ -65,40 +63,22 @@ function renderSentryErrorFallback({ error, eventId }: { error: unknown; eventId ); } -function useClientConfigLoader(): ClientConfig { - const [config, setConfig] = useState({}); - - useEffect(() => { - let cancelled = false; - - const loadConfig = async () => { - try { - const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`; - const res = await fetch(url, { method: 'GET' }); - const data = (await res.json()) as ClientConfig; - if (cancelled) return; - setConfig(data); - setMatrixToBase(data.matrixToBaseUrl); - } catch (err) { - log.error('Failed to load config.json, continuing with empty config:', err); - } - }; - - void loadConfig(); - return () => { - cancelled = true; - }; - }, []); - - return config; -} - function App() { const screenSize = useScreenSize(); useCompositionEndTracking(); const portalContainer = document.getElementById('portalContainer') ?? undefined; - const clientConfig = useClientConfigLoader(); + const renderConfiguredApp = useCallback( + (clientConfig: ClientConfig) => { + setMatrixToBase(clientConfig.matrixToBaseUrl); + return ( + + + + ); + }, + [screenSize] + ); return ( @@ -107,9 +87,7 @@ function App() { - - - + {renderConfiguredApp} From 4032d1f184f493edfb97f4c9260765e9b8c8965a Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 16 Jul 2026 14:11:35 -0500 Subject: [PATCH 24/33] remove stale rooms --- src/client/slidingSync.ts | 47 +++++++++++++++++++++++++++ src/client/slidingSyncSidebarCache.ts | 11 +++++++ 2 files changed, 58 insertions(+) diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 305760a29f..b18d3b3b05 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -217,6 +217,10 @@ export class SlidingSyncManager { private listsFullyLoaded = false; + private sidebarCacheReconciled = false; + + private readonly serverMembershipRoomIds = new Set(); + private initialSyncCompleted = false; private syncCount = 0; @@ -323,6 +327,8 @@ export class SlidingSyncManager { if (err || !resp || state !== SlidingSyncState.Complete) return; + this.recordServerMembershipRooms(resp); + this.roomDataAwaitingSyncCompletion.forEach((roomId) => this.notifyRoomSubscriptionStatus(roomId, false) ); @@ -498,6 +504,46 @@ export class SlidingSyncManager { }); } + private recordServerMembershipRooms(response: MSC3575SlidingSyncResponse): void { + const userId = this.mx.getSafeUserId(); + Object.entries(response.rooms ?? {}).forEach(([roomId, roomData]) => { + const membershipEvent = [ + ...(roomData.required_state ?? []), + ...(roomData.invite_state ?? []), + ].find( + (event) => event.type === (EventType.RoomMember as string) && event.state_key === userId + ); + const content = membershipEvent?.content; + const membership = + content && typeof content === 'object' + ? (content as { membership?: unknown }).membership + : undefined; + + if (membership === KnownMembership.Leave || membership === KnownMembership.Ban) { + this.serverMembershipRoomIds.delete(roomId); + } else { + this.serverMembershipRoomIds.add(roomId); + } + }); + } + + private reconcileSidebarCacheMembership(): void { + if (this.sidebarCacheReconciled) return; + this.sidebarCacheReconciled = true; + + const removedRoomIds = this.sidebarCache.reconcileRooms(this.serverMembershipRoomIds); + removedRoomIds.forEach((roomId) => { + this.mx.getRoom(roomId)?.updateMyMembership(KnownMembership.Leave); + this.unsubscribeFromRoom(roomId); + }); + + if (removedRoomIds.length > 0) { + debugLog.info('sync', 'Removed stale rooms from the sliding sync sidebar cache', { + removedRoomCount: removedRoomIds.length, + }); + } + } + public getDiagnostics(): SlidingSyncDiagnostics { return { baseUrl: this.baseUrl, @@ -639,6 +685,7 @@ export class SlidingSyncManager { if (allListsComplete) { if (!this.listsFullyLoaded) { this.listsFullyLoaded = true; + this.reconcileSidebarCacheMembership(); globalThis.setTimeout(() => this.flushDeferredSubscriptions(), 0); log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`); const totalRooms = this.listKeys.reduce( diff --git a/src/client/slidingSyncSidebarCache.ts b/src/client/slidingSyncSidebarCache.ts index d45afd842f..4aad4638d4 100644 --- a/src/client/slidingSyncSidebarCache.ts +++ b/src/client/slidingSyncSidebarCache.ts @@ -155,6 +155,17 @@ export class SlidingSyncSidebarCache { this.scheduleWrite(); } + public reconcileRooms(validRoomIds: ReadonlySet): string[] { + const removedRoomIds = Object.keys(this.data.rooms).filter( + (roomId) => !validRoomIds.has(roomId) + ); + if (removedRoomIds.length === 0) return removedRoomIds; + + removedRoomIds.forEach((roomId) => delete this.data.rooms[roomId]); + this.scheduleWrite(); + return removedRoomIds; + } + public cacheAccountData(event: MatrixEvent): void { if (!CACHED_ACCOUNT_DATA_TYPES.has(event.getType())) return; this.data.accountData[event.getType()] = event.getContent>(); From dcdb0b60f3bd032aae1e82f63ee44f85bce8441a Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 16 Jul 2026 14:29:04 -0500 Subject: [PATCH 25/33] improve the caching i think --- src/client/slidingSync.ts | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index b18d3b3b05..fe71244dc8 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -219,6 +219,8 @@ export class SlidingSyncManager { private sidebarCacheReconciled = false; + private sidebarCacheReconciliationQueued = false; + private readonly serverMembershipRoomIds = new Set(); private initialSyncCompleted = false; @@ -528,20 +530,25 @@ export class SlidingSyncManager { } private reconcileSidebarCacheMembership(): void { - if (this.sidebarCacheReconciled) return; - this.sidebarCacheReconciled = true; + if (this.sidebarCacheReconciled || this.sidebarCacheReconciliationQueued) return; + this.sidebarCacheReconciliationQueued = true; - const removedRoomIds = this.sidebarCache.reconcileRooms(this.serverMembershipRoomIds); - removedRoomIds.forEach((roomId) => { - this.mx.getRoom(roomId)?.updateMyMembership(KnownMembership.Leave); - this.unsubscribeFromRoom(roomId); - }); + void this.cacheHydrationPromise.then(() => { + if (this.disposed || this.sidebarCacheReconciled) return; + this.sidebarCacheReconciled = true; - if (removedRoomIds.length > 0) { - debugLog.info('sync', 'Removed stale rooms from the sliding sync sidebar cache', { - removedRoomCount: removedRoomIds.length, + const removedRoomIds = this.sidebarCache.reconcileRooms(this.serverMembershipRoomIds); + removedRoomIds.forEach((roomId) => { + this.mx.getRoom(roomId)?.updateMyMembership(KnownMembership.Leave); + this.unsubscribeFromRoom(roomId); }); - } + + if (removedRoomIds.length > 0) { + debugLog.info('sync', 'Removed stale rooms from the sliding sync sidebar cache', { + removedRoomCount: removedRoomIds.length, + }); + } + }); } public getDiagnostics(): SlidingSyncDiagnostics { From 1e0acd629ea2c26ada3640df9b4f589c2c4f6303 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 16 Jul 2026 14:44:58 -0500 Subject: [PATCH 26/33] Update improve-sliding-sync.md --- .changeset/improve-sliding-sync.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/improve-sliding-sync.md b/.changeset/improve-sliding-sync.md index 1bedc699ca..2f5c321d08 100644 --- a/.changeset/improve-sliding-sync.md +++ b/.changeset/improve-sliding-sync.md @@ -5,6 +5,7 @@ default: minor Improve sliding sync. - Make sliding sync easier to enable during login or loading and from settings. +- Move the sliding sync toggle from Experimental to the bottom of General settings. - Start faster and restore rooms and spaces sooner. - Improve room details, memberships, presence, and unread indicators. - Make startup loading smoother for both sliding and classic sync. From 4384599a79bf6d2e8553dd87fa7b7c31acb0cc26 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Thu, 16 Jul 2026 22:08:45 +0200 Subject: [PATCH 27/33] fix: close IndexedDB connections to prevent leaks --- src/app/features/call/callRingtoneStorage.ts | 50 ++++++++++++-------- src/app/theme/cache.ts | 34 +++++++++++-- src/app/utils/featureCheck.ts | 1 + 3 files changed, 61 insertions(+), 24 deletions(-) diff --git a/src/app/features/call/callRingtoneStorage.ts b/src/app/features/call/callRingtoneStorage.ts index f4c0bafe68..92f193dbbe 100644 --- a/src/app/features/call/callRingtoneStorage.ts +++ b/src/app/features/call/callRingtoneStorage.ts @@ -41,36 +41,48 @@ async function putCustomCallAudio( blob: file, }; - await new Promise((resolve, reject) => { - const tx = db.transaction(STORE, 'readwrite'); - tx.objectStore(STORE).put(entry); - tx.addEventListener('complete', () => resolve()); - tx.addEventListener('error', () => reject(tx.error)); - }); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(entry); + tx.addEventListener('complete', () => resolve()); + tx.addEventListener('error', () => reject(tx.error)); + }); + } finally { + db.close(); + } return entry; } async function getCustomCallAudio(key: string): Promise { const db = await openDb(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE, 'readonly'); - const req = tx.objectStore(STORE).get(key); - req.addEventListener('error', () => reject(req.error)); - req.addEventListener('success', () => { - resolve(req.result as StoredCallRingtone | undefined); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readonly'); + const req = tx.objectStore(STORE).get(key); + req.addEventListener('error', () => reject(req.error)); + req.addEventListener('success', () => { + resolve(req.result as StoredCallRingtone | undefined); + }); }); - }); + } finally { + db.close(); + } } async function clearCustomCallAudio(key: string): Promise { const db = await openDb(); - await new Promise((resolve, reject) => { - const tx = db.transaction(STORE, 'readwrite'); - tx.objectStore(STORE).delete(key); - tx.addEventListener('complete', () => resolve()); - tx.addEventListener('error', () => reject(tx.error)); - }); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(key); + tx.addEventListener('complete', () => resolve()); + tx.addEventListener('error', () => reject(tx.error)); + }); + } finally { + db.close(); + } } export const putCustomCallRingtone = ( diff --git a/src/app/theme/cache.ts b/src/app/theme/cache.ts index b10b68c1e4..59b4519bfa 100644 --- a/src/app/theme/cache.ts +++ b/src/app/theme/cache.ts @@ -82,6 +82,30 @@ function openDb(): Promise { }); } +let dbPromise: Promise | undefined; + +function getDb(): Promise { + if (dbPromise) return dbPromise; + const pending = openDb() + .then((db) => { + const forget = () => { + if (dbPromise === pending) dbPromise = undefined; + }; + db.addEventListener('close', forget); + db.addEventListener('versionchange', () => { + forget(); + db.close(); + }); + return db; + }) + .catch((error) => { + if (dbPromise === pending) dbPromise = undefined; + throw error; + }); + dbPromise = pending; + return pending; +} + export async function hashThemeCss(cssText: string): Promise { try { const bytes = new TextEncoder().encode(cssText); @@ -99,7 +123,7 @@ export async function hashThemeCss(cssText: string): Promise { } export async function getCachedThemeEntry(url: string): Promise { - const db = await openDb(); + const db = await getDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readonly'); const req = tx.objectStore(STORE).get(url); @@ -109,7 +133,7 @@ export async function getCachedThemeEntry(url: string): Promise { - const db = await openDb(); + const db = await getDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); tx.objectStore(STORE).put(entry); @@ -196,7 +220,7 @@ export async function revalidateCachedThemeCss( } export async function clearThemeCache(): Promise { - const db = await openDb(); + const db = await getDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); tx.objectStore(STORE).clear(); @@ -206,7 +230,7 @@ export async function clearThemeCache(): Promise { } export async function getThemeCacheStats(): Promise { - const db = await openDb(); + const db = await getDb(); const entries = await new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readonly'); const req = tx.objectStore(STORE).getAll(); @@ -240,7 +264,7 @@ export async function getThemeCacheStats(): Promise { /** Clears only refetchable network CSS. Uploaded local theme packages are preserved. */ export async function clearRemoteThemeCache(): Promise { - const db = await openDb(); + const db = await getDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); const store = tx.objectStore(STORE); diff --git a/src/app/utils/featureCheck.ts b/src/app/utils/featureCheck.ts index d5564e749d..16a82829ad 100644 --- a/src/app/utils/featureCheck.ts +++ b/src/app/utils/featureCheck.ts @@ -11,6 +11,7 @@ export const checkIndexedDBSupport = async (): Promise => { } db.addEventListener('success', () => { resolve(true); + db.result.close(); indexedDB.deleteDatabase(dbName); }); db.addEventListener('error', () => { From e0d740623847f3fa9e0756004c7f665ba0a71886 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 16 Jul 2026 15:09:55 -0500 Subject: [PATCH 28/33] hide connecting and add some diagnostics --- src/app/components/room-card/RoomCard.tsx | 4 +- .../common-settings/general/RoomProfile.tsx | 4 +- .../developer-tools/SyncDiagnostics.tsx | 35 +++++++++++++ src/app/pages/client/SyncStatus.test.ts | 20 ++++++++ src/app/pages/client/SyncStatus.tsx | 38 ++++++++++---- src/app/pages/client/space/Space.tsx | 11 +++- src/app/utils/mediaLoadDiagnostics.ts | 16 ++++++ src/client/slidingSync.test.ts | 51 +++++++++++++++++++ src/client/slidingSync.ts | 25 +++++++-- 9 files changed, 188 insertions(+), 16 deletions(-) create mode 100644 src/app/pages/client/SyncStatus.test.ts create mode 100644 src/app/utils/mediaLoadDiagnostics.ts diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 82abef0a17..1c00610a47 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -39,6 +39,7 @@ import * as css from './style.css'; import type { RoomBannerContent } from '$types/matrix-sdk-events'; import { CustomStateEvent } from '$types/matrix/room'; import colorMXID from '$utils/colorMXID'; +import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics'; type GridColumnCount = '1' | '2' | '3'; const getGridColumnCount = (gridWidth: number): GridColumnCount => { @@ -191,7 +192,7 @@ export const RoomCard = as<'div', RoomCardProps>( ? getStateEvent(joinedRoom, CustomStateEvent.RoomBanner) : undefined; const bannerMXC = bannerState?.getContent()?.url; - const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', true); + const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', useAuthentication); const roomName = joinedRoom?.name || name || fallbackName; const roomTopic = (topicEvent?.getContent().topic as string) || undefined || topic || fallbackTopic; @@ -238,6 +239,7 @@ export const RoomCard = as<'div', RoomCardProps>( src={bannerURI || avatar || undefined} alt={`${name} cover`} draggable="false" + onError={() => reportMediaLoadFailure('room_card_banner')} /> )} diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx index 6d2b764efe..ccfbb0e6a1 100644 --- a/src/app/features/common-settings/general/RoomProfile.tsx +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -59,6 +59,7 @@ import { CustomStateEvent } from '$types/matrix/room'; import { SettingTile } from '$components/setting-tile'; import { stopPropagation } from '$utils/keyboard'; import FocusTrap from 'focus-trap-react'; +import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics'; type RoomProfileEditProps = { canEditAvatar: boolean; @@ -404,6 +405,7 @@ function RoomBannerEdit({ bannerURI, permissions }: Readonly) { key={previewUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="Banner Preview" + onError={() => reportMediaLoadFailure('room_banner_preview')} /> ) : ( @@ -522,7 +524,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) { const bannerState = useStateEvent(room, CustomStateEvent.RoomBanner); const bannerMXC = bannerState?.getContent()?.url; - const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', true); + const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', useAuthentication); return ( diff --git a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx index 8fd1d0e293..c8d2352d85 100644 --- a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx +++ b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { Box, Button, Text } from 'folds'; import { CaretDown, CaretUp, menuIcon } from '$components/icons/phosphor'; import { SequenceCard } from '$components/sequence-card'; @@ -9,6 +10,9 @@ import { Direction, EventType, NotificationCountType, KnownMembership } from '$t import { SequenceCardStyle } from '$features/settings/styles.css'; import { getUnreadInfo, isNotificationEvent } from '$utils/room'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { allInvitesAtom } from '$state/room-list/inviteList'; +import { getMediaLoadFailureCounts } from '$utils/mediaLoadDiagnostics'; type RoomRenderingDiagnostics = { totalRooms: number; @@ -116,6 +120,8 @@ const formatListCoverage = (knownCount: number, rangeEnd: number): string => { export function SyncDiagnostics() { const mx = useMatrixClient(); + const listedJoinedRoomIds = useAtomValue(allRoomsAtom); + const listedInviteRoomIds = useAtomValue(allInvitesAtom); const [, setTick] = useState(0); const [expandSliding, setExpandSliding] = useState(false); @@ -127,6 +133,15 @@ export function SyncDiagnostics() { const diagnostics = getClientSyncDiagnostics(mx); const roomDiagnostics = getRoomRenderingDiagnostics(mx.getRooms()); const unreadDriftRooms = getUnreadDriftRooms(mx); + const listedJoinedRoomIdSet = new Set(listedJoinedRoomIds); + const sdkRoomsMissingFromList = mx + .getRooms() + .filter( + (room) => + room.getMyMembership() === (KnownMembership.Join as string) && + !listedJoinedRoomIdSet.has(room.roomId) + ); + const mediaLoadFailures = [...getMediaLoadFailureCounts()]; return ( @@ -144,6 +159,23 @@ export function SyncDiagnostics() { Room counts: {roomDiagnostics.totalRooms} total, {roomDiagnostics.joinedRooms} joined,{' '} {roomDiagnostics.inviteRooms} invites + + Room-list atoms: {listedJoinedRoomIds.length} joined, {listedInviteRoomIds.length} invites + + + SDK joined rooms missing from room list: {sdkRoomsMissingFromList.length} + + {sdkRoomsMissingFromList.slice(0, 10).map((room) => ( + + {room.name || room.roomId} ({room.roomId}) + + ))} + + Media load failures this session:{' '} + {mediaLoadFailures.length === 0 + ? 'none' + : mediaLoadFailures.map(([kind, count]) => `${kind}: ${count}`).join(', ')} + Rooms missing name: {roomDiagnostics.roomsMissingName} Rooms missing avatar: {roomDiagnostics.roomsMissingAvatar} @@ -185,6 +217,9 @@ export function SyncDiagnostics() { List `{list.key}` coverage:{' '} {formatListCoverage(list.knownCount, list.rangeEnd)} + {list.requestedRangeEnd > list.rangeEnd + ? ` (requested through ${list.requestedRangeEnd + 1})` + : ''} ))} diff --git a/src/app/pages/client/SyncStatus.test.ts b/src/app/pages/client/SyncStatus.test.ts new file mode 100644 index 0000000000..6a2f545cd0 --- /dev/null +++ b/src/app/pages/client/SyncStatus.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { SyncState } from '$types/matrix-sdk'; +import { shouldShowConnecting } from './SyncStatus'; + +describe('shouldShowConnecting', () => { + it('hides ordinary initial connection states', () => { + expect(shouldShowConnecting(false, SyncState.Prepared, null)).toBe(false); + expect(shouldShowConnecting(false, SyncState.Syncing, SyncState.Prepared)).toBe(false); + expect(shouldShowConnecting(false, SyncState.Catchup, null)).toBe(false); + }); + + it('shows recovery progress after a client has previously connected', () => { + expect(shouldShowConnecting(true, SyncState.Catchup, SyncState.Reconnecting)).toBe(true); + expect(shouldShowConnecting(true, SyncState.Syncing, SyncState.Catchup)).toBe(true); + }); + + it('hides the banner during steady syncing', () => { + expect(shouldShowConnecting(true, SyncState.Syncing, SyncState.Syncing)).toBe(false); + }); +}); diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index ae222b4f7e..a2b44af1e8 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -1,6 +1,6 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { SyncState } from '$types/matrix-sdk'; -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { Box, config, Line, Text } from 'folds'; import * as Sentry from '@sentry/react'; import { useSyncState } from '$hooks/useSyncState'; @@ -9,25 +9,50 @@ import { ContainerColor } from '$styles/ContainerColor.css'; type StateData = { current: SyncState | null; previous: SyncState | null | undefined; + showConnecting: boolean; }; +export const shouldShowConnecting = ( + hasConnected: boolean, + current: SyncState | null, + previous: SyncState | null | undefined +): boolean => + hasConnected && + (current === SyncState.Prepared || + current === SyncState.Syncing || + current === SyncState.Catchup) && + previous !== SyncState.Syncing; + type SyncStatusProps = { mx: MatrixClient; }; export function SyncStatus({ mx }: SyncStatusProps) { + const hasConnectedRef = useRef(false); const [stateData, setStateData] = useState({ current: null, previous: undefined, + showConnecting: false, }); useSyncState( mx, useCallback((current, previous) => { + const showConnecting = shouldShowConnecting( + hasConnectedRef.current, + current, + previous + ); + if (current === SyncState.Syncing) hasConnectedRef.current = true; + setStateData((s) => { - if (s.current === current && s.previous === previous) { + if ( + s.current === current && + s.previous === previous && + s.showConnecting === showConnecting + ) { return s; } - return { current, previous }; + return { current, previous, showConnecting }; }); if (current === SyncState.Reconnecting || current === SyncState.Error) { @@ -44,12 +69,7 @@ export function SyncStatus({ mx }: SyncStatusProps) { }, []) ); - if ( - (stateData.current === SyncState.Prepared || - stateData.current === SyncState.Syncing || - stateData.current === SyncState.Catchup) && - stateData.previous !== SyncState.Syncing - ) { + if (stateData.showConnecting) { return ( ()?.url; - const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', true); + const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', useAuthentication); const hasBanner = !!(bannerURI && !hideText && showBanners); const [bannerViewerOpen, setBannerViewerOpen] = useState(false); @@ -388,7 +389,13 @@ function SpaceHeader({ hideText, mx }: { hideText?: boolean; mx: MatrixClient }) } }} > - + reportMediaLoadFailure('room_banner')} + /> (); + +export const reportMediaLoadFailure = (kind: MediaLoadKind): void => { + failureCounts.set(kind, (failureCounts.get(kind) ?? 0) + 1); + debugLog.warn('network', 'Media failed to load', { kind }); + Sentry.metrics.count('sable.media.load_error', 1, { attributes: { kind } }); +}; + +export const getMediaLoadFailureCounts = (): ReadonlyMap => + new Map(failureCounts); diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index abe7f350b4..b718fdc7b3 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -61,6 +61,9 @@ function makeMockMx(overrides: Record = {}) { getSafeUserId: vi.fn<() => string>().mockReturnValue('@user:example.com'), isRoomEncrypted: vi.fn<() => boolean>().mockReturnValue(false), getRoom: vi.fn<() => null>().mockReturnValue(null), + getJoinedRooms: vi.fn<() => Promise<{ joined_rooms: string[] }>>().mockResolvedValue({ + joined_rooms: [], + }), on: vi.fn<() => void>(), off: vi.fn<() => void>(), removeListener: vi.fn<() => void>(), @@ -323,6 +326,54 @@ describe('SlidingSyncManager local membership reconciliation', () => { expect(manager.isRoomActive('!room:example.com')).toBe(false); expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith(new Set()); }); + + it('waits for cache hydration and unions authoritative joined rooms before reconciling', async () => { + let finishHydration: ((hydrated: boolean) => void) | undefined; + const hydration = new Promise((resolve) => { + finishHydration = resolve; + }); + const getJoinedRooms = vi.fn<() => Promise<{ joined_rooms: string[] }>>().mockResolvedValue({ + joined_rooms: ['!authoritative:example.com'], + }); + const manager = makeManager(makeMockMx({ getJoinedRooms })); + const internals = manager as unknown as { + cacheHydrationPromise: Promise; + reconcileSidebarCacheMembership: () => void; + serverMembershipRoomIds: Set; + sidebarCache: { reconcileRooms: (roomIds: ReadonlySet) => string[] }; + }; + internals.cacheHydrationPromise = hydration; + internals.serverMembershipRoomIds.add('!observed:example.com'); + const reconcileRooms = vi.spyOn(internals.sidebarCache, 'reconcileRooms').mockReturnValue([]); + + internals.reconcileSidebarCacheMembership(); + expect(getJoinedRooms).not.toHaveBeenCalled(); + + finishHydration?.(true); + await vi.waitFor(() => expect(reconcileRooms).toHaveBeenCalledOnce()); + + expect(getJoinedRooms).toHaveBeenCalledOnce(); + expect(reconcileRooms).toHaveBeenCalledWith( + new Set(['!observed:example.com', '!authoritative:example.com']) + ); + }); + + it('keeps cached rooms when authoritative membership cannot be fetched', async () => { + const getJoinedRooms = vi.fn<() => Promise<{ joined_rooms: string[] }>>().mockRejectedValue( + new Error('offline') + ); + const manager = makeManager(makeMockMx({ getJoinedRooms })); + const internals = manager as unknown as { + reconcileSidebarCacheMembership: () => void; + sidebarCache: { reconcileRooms: (roomIds: ReadonlySet) => string[] }; + }; + const reconcileRooms = vi.spyOn(internals.sidebarCache, 'reconcileRooms').mockReturnValue([]); + + internals.reconcileSidebarCacheMembership(); + await vi.waitFor(() => expect(getJoinedRooms).toHaveBeenCalledOnce()); + + expect(reconcileRooms).not.toHaveBeenCalled(); + }); }); // ── dispose() ──────────────────────────────────────────────────────────────── diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index fe71244dc8..98289188b1 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -60,6 +60,7 @@ export type SlidingSyncListDiagnostics = { key: string; knownCount: number; rangeEnd: number; + requestedRangeEnd: number; }; export type SlidingSyncDiagnostics = { @@ -533,11 +534,26 @@ export class SlidingSyncManager { if (this.sidebarCacheReconciled || this.sidebarCacheReconciliationQueued) return; this.sidebarCacheReconciliationQueued = true; - void this.cacheHydrationPromise.then(() => { + void this.cacheHydrationPromise.then(async () => { + if (this.disposed || this.sidebarCacheReconciled) return; + + let joinedRoomIds: string[]; + try { + const response = await this.mx.getJoinedRooms(); + joinedRoomIds = response.joined_rooms; + } catch (error) { + debugLog.warn('sync', 'Skipped sidebar cache reconciliation: joined rooms unavailable', { + error: error instanceof Error ? error.message : String(error), + }); + return; + } + if (this.disposed || this.sidebarCacheReconciled) return; this.sidebarCacheReconciled = true; - const removedRoomIds = this.sidebarCache.reconcileRooms(this.serverMembershipRoomIds); + const validRoomIds = new Set([...this.serverMembershipRoomIds, ...joinedRoomIds]); + + const removedRoomIds = this.sidebarCache.reconcileRooms(validRoomIds); removedRoomIds.forEach((roomId) => { this.mx.getRoom(roomId)?.updateMyMembership(KnownMembership.Leave); this.unsubscribeFromRoom(roomId); @@ -546,6 +562,8 @@ export class SlidingSyncManager { if (removedRoomIds.length > 0) { debugLog.info('sync', 'Removed stale rooms from the sliding sync sidebar cache', { removedRoomCount: removedRoomIds.length, + joinedRoomCount: joinedRoomIds.length, + observedRoomCount: this.serverMembershipRoomIds.size, }); } }); @@ -561,7 +579,8 @@ export class SlidingSyncManager { return { key, knownCount: listData?.joinedCount ?? 0, - rangeEnd: getListEndIndex(params), + rangeEnd: this.confirmedListRangeEnds.get(key) ?? -1, + requestedRangeEnd: getListEndIndex(params), }; }), }; From 2d95c353bf6b40fb4f3ecb5ee59fd2ebbbc36846 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 16 Jul 2026 16:11:32 -0500 Subject: [PATCH 29/33] optimize room data subscriptions --- .changeset/improve-sliding-sync.md | 1 + src/app/hooks/useSlidingSyncActiveRoom.ts | 9 +- src/client/slidingSync.test.ts | 124 +++++++++++++++- src/client/slidingSync.ts | 173 +++++++++++++++++++--- 4 files changed, 277 insertions(+), 30 deletions(-) diff --git a/.changeset/improve-sliding-sync.md b/.changeset/improve-sliding-sync.md index 2f5c321d08..e4569ce8fb 100644 --- a/.changeset/improve-sliding-sync.md +++ b/.changeset/improve-sliding-sync.md @@ -7,6 +7,7 @@ Improve sliding sync. - Make sliding sync easier to enable during login or loading and from settings. - Move the sliding sync toggle from Experimental to the bottom of General settings. - Start faster and restore rooms and spaces sooner. +- Reduce unnecessary background room data. - Improve room details, memberships, presence, and unread indicators. - Make startup loading smoother for both sliding and classic sync. - It's fast now :3 diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts index 66f9e9a1c5..a46e56b45c 100644 --- a/src/app/hooks/useSlidingSyncActiveRoom.ts +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -84,10 +84,15 @@ export const useSlidingSyncRouteRooms = (): void => { if (!manager || resolvingRoom || resolvingSpace) return undefined; const activeRoomIds = [...new Set([spaceId, roomId].filter(Boolean))] as string[]; - activeRoomIds.forEach((activeRoomId) => manager.subscribeToRoom(activeRoomId)); + manager.setActiveRoomSubscriptions(activeRoomIds); - return () => activeRoomIds.forEach((activeRoomId) => manager.unsubscribeFromRoom(activeRoomId)); + return undefined; }, [manager, resolvingRoom, resolvingSpace, roomId, spaceId]); + + useEffect(() => { + if (!manager) return undefined; + return () => manager.setActiveRoomSubscriptions([]); + }, [manager]); }; export const useSlidingSyncSpaceSubscriptions = (): void => { diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index b718fdc7b3..e3f8b8d7e5 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -94,13 +94,13 @@ function fireLifecycle(state: SlidingSyncState, response: unknown = {}) { lifecycle?.(state, response); } -function fireRoomData(roomId: string) { +function fireRoomData(roomId: string, data: Record = {}) { const roomDataHandler = mocks.slidingSyncInstance.on.mock.calls .toReversed() .find(([event]) => event === SlidingSyncEvent.RoomData)?.[1] as | ((dataRoomId: string, data: unknown) => void) | undefined; - roomDataHandler?.(roomId, {}); + roomDataHandler?.(roomId, data); } describe('SlidingSyncManager initial request', () => { @@ -109,6 +109,7 @@ describe('SlidingSyncManager initial request', () => { const lists = mocks.slidingSyncConstructorArgs?.[1] as Map; const joined = lists.get('joined'); + const updates = lists.get('updates'); const defaultSubscription = mocks.slidingSyncConstructorArgs?.[2] as { timeline_limit: number; }; @@ -118,6 +119,12 @@ describe('SlidingSyncManager initial request', () => { expect(joined?.required_state).toHaveLength(8); expect(joined?.required_state).toContainEqual([EventType.RoomJoinRules, '']); expect(joined?.required_state).not.toContainEqual(['m.space.child', '*']); + expect(updates).toMatchObject({ + ranges: [[0, 29]], + timeline_limit: 1, + required_state: [[EventType.RoomMember, '$ME']], + filters: { is_invite: false }, + }); expect(defaultSubscription.timeline_limit).toBe(30); expect(mocks.slidingSyncConstructorArgs?.[4]).toBe(45000); }); @@ -230,9 +237,78 @@ describe('SlidingSyncManager initial request', () => { rangeEnd: 29, }); }); + + it('keeps full lightweight event coverage after narrowing the detailed joined list', () => { + const listRanges = new Map([ + ['joined', [[0, 29]]], + ['updates', [[0, 29]]], + ]); + const manager = makeManager(makeMockMx()); + mocks.slidingSyncInstance.getListData.mockImplementation((key: string) => + key === 'joined' || key === 'updates' ? ({ joinedCount: 45 } as never) : null + ); + mocks.slidingSyncInstance.getListParams.mockImplementation( + (key: string) => ({ ranges: listRanges.get(key) ?? [[0, 29]] }) as never + ); + mocks.slidingSyncInstance.setListRanges.mockImplementation((key, ranges) => { + if (key === 'joined' || key === 'updates') { + listRanges.set(key, ranges as [number, number][]); + } + }); + manager.attach(); + + fireLifecycle(SlidingSyncState.Complete); + expect(listRanges.get('joined')).toEqual([[0, 44]]); + expect(listRanges.get('updates')).toEqual([[0, 44]]); + + fireLifecycle(SlidingSyncState.Complete); + fireLifecycle(SlidingSyncState.Complete); + expect(listRanges.get('joined')).toEqual([[0, 2]]); + expect(listRanges.get('updates')).toEqual([[0, 44]]); + }); + + it('keeps detailed coverage when the homeserver does not provide the updates list', () => { + let joinedRange: [number, number][] = [[0, 29]]; + const manager = makeManager(makeMockMx()); + mocks.slidingSyncInstance.getListData.mockImplementation((key: string) => + key === 'joined' ? ({ joinedCount: 45 } as never) : null + ); + mocks.slidingSyncInstance.getListParams.mockImplementation((key: string) => + key === 'joined' ? ({ ranges: joinedRange } as never) : ({ ranges: [[0, 29]] } as never) + ); + mocks.slidingSyncInstance.setListRanges.mockImplementation((key, ranges) => { + if (key === 'joined') joinedRange = ranges as [number, number][]; + }); + manager.attach(); + + fireLifecycle(SlidingSyncState.Complete); + fireLifecycle(SlidingSyncState.Complete); + + expect(joinedRange).toEqual([[0, 44]]); + }); }); describe('SlidingSyncManager room subscription coordination', () => { + it('replaces route subscriptions atomically without cycling the retained space', () => { + const manager = makeManager(makeMockMx()); + const spaceId = '!space:example.com'; + const oldRoomId = '!old:example.com'; + const newRoomId = '!new:example.com'; + + manager.setActiveRoomSubscriptions([spaceId, oldRoomId]); + mocks.slidingSyncInstance.modifyRoomSubscriptions.mockClear(); + + manager.setActiveRoomSubscriptions([spaceId, newRoomId]); + + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledOnce(); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledWith( + new Set([spaceId, newRoomId]) + ); + expect(manager.isRoomActive(spaceId)).toBe(true); + expect(manager.isRoomActive(oldRoomId)).toBe(false); + expect(manager.isRoomActive(newRoomId)).toBe(true); + }); + it('reports loading until the subscribed room returns data', () => { const manager = makeManager(makeMockMx()); const roomId = '!slow:example.com'; @@ -281,6 +357,44 @@ describe('SlidingSyncManager room subscription coordination', () => { ); }); + it('keeps space subscriptions active for future hierarchy changes', async () => { + const manager = makeManager(makeMockMx()); + const roomId = '!space:example.com'; + (manager as unknown as { listsFullyLoaded: boolean }).listsFullyLoaded = true; + manager.attach(); + + manager.setSpaceSubscriptions([roomId]); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith( + new Set([roomId]) + ); + + fireRoomData(roomId); + await Promise.resolve(); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith( + new Set([roomId]) + ); + }); + + it('hydrates sidebar state once for rooms first seen after startup', async () => { + const manager = makeManager(makeMockMx()); + const roomId = '!new:example.com'; + ( + manager as unknown as { initialListHydrationCompleted: boolean } + ).initialListHydrationCompleted = true; + manager.attach(); + + fireRoomData(roomId, { initial: true, notification_count: 0, highlight_count: 0 }); + await Promise.resolve(); + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'sidebar_room' + ); + + fireRoomData(roomId, { initial: true }); + await Promise.resolve(); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith(new Set()); + }); + it('removes image-pack subscriptions which are no longer configured', () => { const manager = makeManager(makeMockMx()); @@ -359,9 +473,9 @@ describe('SlidingSyncManager local membership reconciliation', () => { }); it('keeps cached rooms when authoritative membership cannot be fetched', async () => { - const getJoinedRooms = vi.fn<() => Promise<{ joined_rooms: string[] }>>().mockRejectedValue( - new Error('offline') - ); + const getJoinedRooms = vi + .fn<() => Promise<{ joined_rooms: string[] }>>() + .mockRejectedValue(new Error('offline')); const manager = makeManager(makeMockMx({ getJoinedRooms })); const internals = manager as unknown as { reconcileSidebarCacheMembership: () => void; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 98289188b1..5b090dfce1 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -30,16 +30,19 @@ const debugLog = createDebugLogger('slidingSync'); export const LIST_JOINED = 'joined'; export const LIST_INVITES = 'invites'; +export const LIST_UPDATES = 'updates'; export const LIST_SEARCH = 'search'; export const LIST_ROOM_SEARCH = 'room_search'; export const LIST_SPACE = 'space'; const LIST_TIMELINE_LIMIT = 1; const LIST_PAGE_SIZE = 30; +const STEADY_STATE_DETAILED_ROOMS = 3; const DEFAULT_POLL_TIMEOUT_MS = 45000; const LIST_SORT_ORDER = ['by_recency', 'by_name']; const ACTIVE_ROOM_SUBSCRIPTION_KEY = 'active_room'; +const SIDEBAR_ROOM_SUBSCRIPTION_KEY = 'sidebar_room'; const SPACE_SUBSCRIPTION_KEY = 'space'; const IMAGE_PACK_SUBSCRIPTION_KEY = 'image_packs'; const ACTIVE_ROOM_TIMELINE_LIMIT = 30; @@ -146,6 +149,11 @@ const buildSpaceSubscription = (): MSC3575RoomSubscription => ({ required_state: SPACE_REQUIRED_STATE, }); +const buildSidebarRoomSubscription = (): MSC3575RoomSubscription => ({ + timeline_limit: LIST_TIMELINE_LIMIT, + required_state: buildListRequiredState(), +}); + const buildLists = (): Map => { const lists = new Map(); const listRequiredState = buildListRequiredState(); @@ -166,6 +174,14 @@ const buildLists = (): Map => { filters: { is_invite: true }, }); + lists.set(LIST_UPDATES, { + ranges: [[0, LIST_PAGE_SIZE - 1]], + sort: LIST_SORT_ORDER, + timeline_limit: LIST_TIMELINE_LIMIT, + required_state: [[EventType.RoomMember, MSC3575_STATE_KEY_ME]], + filters: { is_invite: false }, + }); + return lists; }; @@ -181,12 +197,16 @@ export class SlidingSyncManager { private readonly activeRoomSubscriptions = new Set(); + private readonly sidebarRoomSubscriptions = new Set(); + private readonly spaceSubscriptions = new Set(); private deferredSpaceSubscriptions = new Set(); private readonly imagePackRoomSubscriptions = new Set(); + private roomSubscriptionSyncQueued = false; + private readonly roomTimelineLimit: number; private readonly sidebarCache: SlidingSyncSidebarCache; @@ -218,6 +238,8 @@ export class SlidingSyncManager { private listsFullyLoaded = false; + private initialListHydrationCompleted = false; + private sidebarCacheReconciled = false; private sidebarCacheReconciliationQueued = false; @@ -288,6 +310,10 @@ export class SlidingSyncManager { ACTIVE_ROOM_SUBSCRIPTION_KEY, buildUnencryptedSubscription(roomTimelineLimit) ); + this.slidingSync.addCustomSubscription( + SIDEBAR_ROOM_SUBSCRIPTION_KEY, + buildSidebarRoomSubscription() + ); this.slidingSync.addCustomSubscription( IMAGE_PACK_SUBSCRIPTION_KEY, buildImagePackSubscription() @@ -355,7 +381,7 @@ export class SlidingSyncManager { const currentCount = listData?.joinedCount ?? 0; const previousCount = this.previousListCounts.get(key) ?? 0; - totalRoomCount += currentCount; + if (key !== LIST_UPDATES) totalRoomCount += currentCount; if (currentCount !== previousCount) { changes[key] = { @@ -431,11 +457,35 @@ export class SlidingSyncManager { if (member.membership !== KnownMembership.Leave && member.membership !== KnownMembership.Ban) return; this.sidebarCache.removeRoom(member.roomId); - if (!this.activeRoomSubscriptions.has(member.roomId)) return; - this.unsubscribeFromRoom(member.roomId); + const removedSpaceSubscription = this.spaceSubscriptions.delete(member.roomId); + const removedSidebarSubscription = this.sidebarRoomSubscriptions.delete(member.roomId); + if (this.activeRoomSubscriptions.has(member.roomId)) { + this.unsubscribeFromRoom(member.roomId); + } else if (removedSpaceSubscription || removedSidebarSubscription) { + this.queueRoomSubscriptionSync(); + } }; - this.onCacheRoomData = (roomId, data) => this.sidebarCache.cacheRoom(roomId, data); + this.onCacheRoomData = (roomId, data) => { + this.sidebarCache.cacheRoom(roomId, data); + + if (!this.initialListHydrationCompleted || this.hydratingSidebarCache) return; + + let subscriptionsChanged = false; + const completedSidebarHydration = this.sidebarRoomSubscriptions.delete(roomId); + if (completedSidebarHydration) subscriptionsChanged = true; + + if ( + data.initial === true && + !completedSidebarHydration && + !this.activeRoomSubscriptions.has(roomId) + ) { + this.sidebarRoomSubscriptions.add(roomId); + subscriptionsChanged = true; + } + + if (subscriptionsChanged) this.queueRoomSubscriptionSync(); + }; this.onCacheAccountData = (event) => this.sidebarCache.cacheAccountData(event); for (const roomId of options.initialRoomIds ?? []) { @@ -623,6 +673,7 @@ export class SlidingSyncManager { private expandListsByPage(): void { if (!this.initialSyncCompleted) return; + if (this.initialListHydrationCompleted) return; let allListsComplete = true; let expandedAny = false; @@ -711,13 +762,14 @@ export class SlidingSyncManager { if (allListsComplete) { if (!this.listsFullyLoaded) { this.listsFullyLoaded = true; + this.initialListHydrationCompleted = true; this.reconcileSidebarCacheMembership(); globalThis.setTimeout(() => this.flushDeferredSubscriptions(), 0); + this.applySteadyStateListRanges(); log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`); - const totalRooms = this.listKeys.reduce( - (sum, key) => sum + (this.slidingSync.getListData(key)?.joinedCount ?? 0), - 0 - ); + const totalRooms = + (this.slidingSync.getListData(LIST_JOINED)?.joinedCount ?? 0) + + (this.slidingSync.getListData(LIST_INVITES)?.joinedCount ?? 0); const listsLoadedMs = this.attachTime != null ? Math.round(performance.now() - this.attachTime) : 0; Sentry.metrics.distribution('sable.sync.lists_loaded_ms', listsLoadedMs, { @@ -750,6 +802,34 @@ export class SlidingSyncManager { } } + private applySteadyStateListRanges(): void { + const joinedList = this.slidingSync.getListParams(LIST_JOINED); + const currentEnd = getListEndIndex(joinedList); + const steadyStateEnd = Math.min(currentEnd, STEADY_STATE_DETAILED_ROOMS - 1); + if (steadyStateEnd < 0 || steadyStateEnd === currentEnd) return; + + const joinedCount = this.slidingSync.getListData(LIST_JOINED)?.joinedCount ?? 0; + const updatesCount = this.slidingSync.getListData(LIST_UPDATES)?.joinedCount ?? 0; + const updatesConfirmedEnd = this.confirmedListRangeEnds.get(LIST_UPDATES) ?? -1; + if (updatesCount !== joinedCount || updatesConfirmedEnd < joinedCount - 1) { + debugLog.warn('sync', 'Kept detailed joined list fully covered: updates list unavailable', { + joinedCount, + updatesCount, + updatesConfirmedEnd, + }); + return; + } + + this.slidingSync.setListRanges(LIST_JOINED, [[0, steadyStateEnd]]); + this.requestedListRangeEnds.set(LIST_JOINED, steadyStateEnd); + debugLog.info('sync', 'Reduced detailed joined list to steady-state window', { + previousEnd: currentEnd, + newEnd: steadyStateEnd, + retainedDetailedRooms: steadyStateEnd + 1, + updatesCoverageEnd: updatesConfirmedEnd, + }); + } + public ensureListRegistered(listKey: string, updateArgs: PartialSlidingSyncRequest): MSC3575List { let list = this.slidingSync.getListParams(listKey); if (!list) { @@ -826,7 +906,13 @@ export class SlidingSyncManager { if (membership === KnownMembership.Leave) { this.sidebarCache.removeRoom(roomId); - this.unsubscribeFromRoom(roomId); + const removedSpaceSubscription = this.spaceSubscriptions.delete(roomId); + const removedSidebarSubscription = this.sidebarRoomSubscriptions.delete(roomId); + if (this.activeRoomSubscriptions.has(roomId)) { + this.unsubscribeFromRoom(roomId); + } else if (removedSpaceSubscription || removedSidebarSubscription) { + this.queueRoomSubscriptionSync(); + } } } @@ -838,9 +924,19 @@ export class SlidingSyncManager { this.setSpaceSubscriptions(spaces); } + private queueRoomSubscriptionSync(): void { + if (this.disposed || this.roomSubscriptionSyncQueued) return; + this.roomSubscriptionSyncQueued = true; + globalThis.queueMicrotask(() => { + this.roomSubscriptionSyncQueued = false; + if (!this.disposed) this.syncRoomSubscriptions(); + }); + } + private syncRoomSubscriptions(): void { const desiredSubscriptions = new Set([ ...this.activeRoomSubscriptions, + ...this.sidebarRoomSubscriptions, ...this.spaceSubscriptions, ...this.imagePackRoomSubscriptions, ]); @@ -848,6 +944,8 @@ export class SlidingSyncManager { desiredSubscriptions.forEach((roomId) => { if (this.activeRoomSubscriptions.has(roomId)) { this.slidingSync.useCustomSubscription(roomId, ACTIVE_ROOM_SUBSCRIPTION_KEY); + } else if (this.sidebarRoomSubscriptions.has(roomId)) { + this.slidingSync.useCustomSubscription(roomId, SIDEBAR_ROOM_SUBSCRIPTION_KEY); } else if (this.spaceSubscriptions.has(roomId)) { this.slidingSync.useCustomSubscription(roomId, SPACE_SUBSCRIPTION_KEY); } else { @@ -919,17 +1017,12 @@ export class SlidingSyncManager { this.roomSubscriptionStatusListeners.get(roomId)?.forEach((listener) => listener(loading)); } - public subscribeToRoom(roomId: string): void { - if (this.disposed) return; - if (this.activeRoomSubscriptions.has(roomId)) return; + private addActiveRoomSubscription(roomId: string): boolean { + if (this.activeRoomSubscriptions.has(roomId)) return false; const room = this.mx.getRoom(roomId); const isEncrypted = this.mx.isRoomEncrypted(roomId); this.activeRoomSubscriptions.add(roomId); this.pauseUnconfirmedListExpansion(); - this.syncRoomSubscriptions(); - Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { - attributes: { transport: 'sliding' }, - }); log.log(`Sliding Sync active room subscription added: ${roomId}`); debugLog.info('sync', 'Room subscription requested (sliding)', { encrypted: isEncrypted, @@ -981,11 +1074,11 @@ export class SlidingSyncManager { this.pendingRoomDataListeners.set(roomId, onFirstRoomData); this.slidingSync.on(SlidingSyncEvent.RoomData, onFirstRoomData); this.notifyRoomSubscriptionStatus(roomId, true); + return true; } - public unsubscribeFromRoom(roomId: string): void { - if (this.disposed) return; - if (!this.activeRoomSubscriptions.has(roomId)) return; + private removeActiveRoomSubscription(roomId: string): boolean { + if (!this.activeRoomSubscriptions.has(roomId)) return false; const pendingListener = this.pendingRoomDataListeners.get(roomId); if (pendingListener) { this.slidingSync.removeListener(SlidingSyncEvent.RoomData, pendingListener); @@ -994,15 +1087,49 @@ export class SlidingSyncManager { this.roomDataAwaitingSyncCompletion.delete(roomId); this.notifyRoomSubscriptionStatus(roomId, false); this.activeRoomSubscriptions.delete(roomId); - this.syncRoomSubscriptions(); - Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { - attributes: { transport: 'sliding' }, - }); log.log(`Sliding Sync active room subscription removed: ${roomId}`); debugLog.info('sync', 'Room subscription removed (sliding)', { remainingSubscriptions: this.activeRoomSubscriptions.size, syncCycle: this.syncCount, }); + return true; + } + + private reportActiveSubscriptionCount(): void { + Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { + attributes: { transport: 'sliding' }, + }); + } + + public setActiveRoomSubscriptions(roomIds: Iterable): void { + if (this.disposed) return; + const next = new Set(roomIds); + let changed = false; + + this.activeRoomSubscriptions.forEach((roomId) => { + if (!next.has(roomId)) changed = this.removeActiveRoomSubscription(roomId) || changed; + }); + next.forEach((roomId) => { + if (!this.activeRoomSubscriptions.has(roomId)) { + changed = this.addActiveRoomSubscription(roomId) || changed; + } + }); + + if (!changed) return; + this.syncRoomSubscriptions(); + this.reportActiveSubscriptionCount(); + } + + public subscribeToRoom(roomId: string): void { + if (this.disposed || !this.addActiveRoomSubscription(roomId)) return; + this.syncRoomSubscriptions(); + this.reportActiveSubscriptionCount(); + } + + public unsubscribeFromRoom(roomId: string): void { + if (this.disposed || !this.removeActiveRoomSubscription(roomId)) return; + this.syncRoomSubscriptions(); + this.reportActiveSubscriptionCount(); } public setSpaceSubscriptions(roomIds: Iterable): void { From c7c528a3a08735f676fc0689a9119770c6a76877 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 16 Jul 2026 16:15:21 -0500 Subject: [PATCH 30/33] formatting --- .../features/settings/developer-tools/SyncDiagnostics.tsx | 3 ++- src/app/pages/client/SyncStatus.tsx | 6 +----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx index c8d2352d85..e0b897d93f 100644 --- a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx +++ b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx @@ -160,7 +160,8 @@ export function SyncDiagnostics() { {roomDiagnostics.inviteRooms} invites - Room-list atoms: {listedJoinedRoomIds.length} joined, {listedInviteRoomIds.length} invites + Room-list atoms: {listedJoinedRoomIds.length} joined, {listedInviteRoomIds.length}{' '} + invites SDK joined rooms missing from room list: {sdkRoomsMissingFromList.length} diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index a2b44af1e8..65b9b2cd96 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -37,11 +37,7 @@ export function SyncStatus({ mx }: SyncStatusProps) { useSyncState( mx, useCallback((current, previous) => { - const showConnecting = shouldShowConnecting( - hasConnectedRef.current, - current, - previous - ); + const showConnecting = shouldShowConnecting(hasConnectedRef.current, current, previous); if (current === SyncState.Syncing) hasConnectedRef.current = true; setStateData((s) => { From b5e0ec814308f8426351546d5e4d0540f940c4f6 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 16 Jul 2026 16:41:27 -0500 Subject: [PATCH 31/33] cleanup unused stuff --- src/client/initMatrix.ts | 135 +-------------------------------------- 1 file changed, 2 insertions(+), 133 deletions(-) diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index f4c638ef9b..6e612b7be0 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -14,9 +14,8 @@ import { } from '$types/matrix-sdk'; import { clearNavToActivePathStore } from '$state/navToActivePath'; -import type { Session, Sessions, SessionStoreName } from '$state/sessions'; -import { getSessionStoreName, MATRIX_SESSIONS_KEY } from '$state/sessions'; -import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; +import type { Session, SessionStoreName } from '$state/sessions'; +import { getSessionStoreName } from '$state/sessions'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import * as Sentry from '@sentry/react'; @@ -191,14 +190,6 @@ const deleteDatabase = (name: string): Promise => req.addEventListener('blocked', () => resolve()); }); -const deleteSyncStoreGroup = async (syncStoreName: string): Promise => { - await Promise.all([ - deleteDatabase(syncStoreName), - deleteDatabase(syncStoreName.replace(/^sync/, 'crypto')), - deleteDatabase(`${syncStoreName}::matrix-sdk-crypto`), - ]); -}; - const deleteSessionStores = async (storeName: SessionStoreName): Promise => { await Promise.all([ deleteDatabase(storeName.sync), @@ -207,60 +198,6 @@ const deleteSessionStores = async (storeName: SessionStoreName): Promise = ]); }; -/** - * Reads the account stored in an IndexedDB sync store without opening a full MatrixClient. - * Returns undefined if the database doesn't exist or has no account record. - */ -const readStoredAccount = (dbName: string): Promise => - new Promise((resolve) => { - let settled = false; - const finish = (value: string | undefined) => { - if (settled) return; - settled = true; - resolve(value); - }; - const req = window.indexedDB.open(dbName); - req.addEventListener('error', () => finish(undefined)); - req.addEventListener('success', () => { - const db = req.result; - try { - if (!db.objectStoreNames.contains('account')) { - db.close(); - finish(undefined); - } else { - const tx = db.transaction('account', 'readonly'); - const store = tx.objectStore('account'); - const getReq = store.get('account'); - getReq.addEventListener('success', () => { - db.close(); - const record = getReq.result; - if (!record?.account_data) { - finish(undefined); - } else { - try { - const data = JSON.parse(record.account_data); - finish(data?.user_id ?? undefined); - } catch { - finish(undefined); - } - } - }); - getReq.addEventListener('error', () => { - db.close(); - finish(undefined); - }); - } - } catch { - try { - db.close(); - } catch { - /* ignore */ - } - finish(undefined); - } - }); - }); - const isMismatch = (err: unknown): boolean => { const msg = err instanceof Error ? err.message : String(err); return ( @@ -271,74 +208,6 @@ const isMismatch = (err: unknown): boolean => { ); }; -/** - * Pre-flight check: scans every IndexedDB database and deletes any that - * belong to a userId not present in the stored sessions list, or whose - * sync-store data contradicts the expected session userId. - * Call this once on startup before initClient. - */ -export const clearMismatchedStores = async (): Promise => { - const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []); - const knownUserIds = new Set(sessions.map((s) => s.userId)); - const knownStoreNames = new Set( - sessions.flatMap((s) => { - const sn = getSessionStoreName(s); - return [sn.sync, sn.crypto, `${sn.rustCryptoPrefix}::matrix-sdk-crypto`]; - }) - ); - - let allDbs: IDBDatabaseInfo[] = []; - try { - allDbs = await window.indexedDB.databases(); - } catch { - // databases() not supported in all browsers - } - - await Promise.all( - allDbs.map(async ({ name }) => { - if (!name) return; - - const containsKnownUser = Array.from(knownUserIds).some((uid) => name.includes(uid)); - const looksLikeUserDb = name.includes('@'); - if (looksLikeUserDb && !containsKnownUser && !knownStoreNames.has(name)) { - log.warn(`clearMismatchedStores: "${name}" has unknown user — deleting`); - await deleteDatabase(name); - return; - } - - if (!name.startsWith('sync')) return; - - const storedUserId = await readStoredAccount(name); - if (!storedUserId) return; - - if (!knownUserIds.has(storedUserId)) { - log.warn(`clearMismatchedStores: "${name}" has unknown user ${storedUserId} — deleting`); - await deleteSyncStoreGroup(name); - return; - } - - const expectedStore = `sync${storedUserId}`; - if (name !== expectedStore && !knownStoreNames.has(name)) { - log.warn(`clearMismatchedStores: "${name}" is misplaced for ${storedUserId} — deleting`); - await deleteSyncStoreGroup(name); - } - }) - ); - - await Promise.all( - sessions.map(async (session) => { - const sn = getSessionStoreName(session); - const storedUserId = await readStoredAccount(sn.sync); - if (storedUserId && storedUserId !== session.userId) { - log.warn( - `clearMismatchedStores: "${sn.sync}" has ${storedUserId} but session is ${session.userId} — deleting` - ); - await deleteSessionStores(sn); - } - }) - ); -}; - type BuiltClient = { mx: MatrixClient; indexedDBStore: IndexedDBStore; From 56bfccccc25bccd0bbb306713bbaf77b81ea79e5 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 16 Jul 2026 17:10:58 -0500 Subject: [PATCH 32/33] scope extensions doesn't work on c10y :( --- src/client/initMatrix.ts | 21 ++++++++++-------- src/client/slidingSync.test.ts | 40 +++++++++++++++++++++++++++++++++- src/client/slidingSync.ts | 27 +++++++++++++++++++++++ 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 6e612b7be0..fc29e68260 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -23,7 +23,7 @@ import { pushSessionToSW } from '../sw-session'; import { SessionOidcTokenRefresher } from './oidcTokenRefresher'; import { cryptoCallbacks } from './secretStorageKeys'; import type { SlidingSyncDiagnostics } from './slidingSync'; -import { SlidingSyncManager } from './slidingSync'; +import { scopeEphemeralExtensions, SlidingSyncManager } from './slidingSync'; import { PresenceSyncManager } from './presenceSync'; import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; @@ -106,7 +106,7 @@ type MatrixClientWithWritableFetchRoomEvent = MatrixClient & { const fetchRoomEventStartupCleanupByClient = new WeakMap void>(); -const slidingSyncConnIdCleanupByClient = new WeakMap void>(); +const slidingSyncRequestCleanupByClient = new WeakMap void>(); type SlidingSyncMethod = ( reqBody: MSC3575SlidingSyncRequest, @@ -124,8 +124,8 @@ type SlidingSyncRequestWithConnId = MSC3575SlidingSyncRequest & { const SLIDING_SYNC_CONN_ID = 'sable-main'; -function installSlidingSyncConnId(mx: MatrixClient): void { - slidingSyncConnIdCleanupByClient.get(mx)?.(); +function installSlidingSyncRequestPatch(mx: MatrixClient, manager: SlidingSyncManager): void { + slidingSyncRequestCleanupByClient.get(mx)?.(); const mxWritable = mx as MatrixClientWithWritableSlidingSync; const original = mx.slidingSync.bind(mx) as SlidingSyncMethod; @@ -135,11 +135,14 @@ function installSlidingSyncConnId(mx: MatrixClient): void { req.conn_id = SLIDING_SYNC_CONN_ID; } + const roomIds = manager.getActiveRoomSubscriptionIds(); + scopeEphemeralExtensions(req.extensions, roomIds); + return original(reqBody, baseUrl, abortSignal); }; - slidingSyncConnIdCleanupByClient.set(mx, () => { - slidingSyncConnIdCleanupByClient.delete(mx); + slidingSyncRequestCleanupByClient.set(mx, () => { + slidingSyncRequestCleanupByClient.delete(mx); mxWritable.slidingSync = original; }); } @@ -456,7 +459,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): }); installStartupFetchRoomEventPatch(mx, manager); - installSlidingSyncConnId(mx); + installSlidingSyncRequestPatch(mx, manager); manager.attach(); manager.prepareSidebarCacheHydration(); @@ -486,7 +489,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): stack: err instanceof Error ? err.stack : undefined, }); fetchRoomEventStartupCleanupByClient.get(mx)?.(); - slidingSyncConnIdCleanupByClient.get(mx)?.(); + slidingSyncRequestCleanupByClient.get(mx)?.(); membershipActionCleanupByClient.get(mx)?.(); disposeSlidingSync(mx); disposePresenceSync(mx); @@ -498,7 +501,7 @@ export const stopClient = (mx: MatrixClient): void => { log.log('stopClient', mx.getUserId()); debugLog.info('sync', 'Stopping client', { userId: mx.getUserId() }); fetchRoomEventStartupCleanupByClient.get(mx)?.(); - slidingSyncConnIdCleanupByClient.get(mx)?.(); + slidingSyncRequestCleanupByClient.get(mx)?.(); disposeSlidingSync(mx); disposePresenceSync(mx); mx.stopClient(); diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index e3f8b8d7e5..fc7d846d2f 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { MatrixClient, MSC3575List } from '$types/matrix-sdk'; import { EventType, KnownMembership, SlidingSyncEvent, SlidingSyncState } from '$types/matrix-sdk'; -import { SlidingSyncManager } from './slidingSync'; +import { scopeEphemeralExtensions, SlidingSyncManager } from './slidingSync'; // ── vi.hoisted mocks ───────────────────────────────────────────────────────── // Must be defined via vi.hoisted @@ -407,6 +407,44 @@ describe('SlidingSyncManager room subscription coordination', () => { }); }); +describe('scopeEphemeralExtensions', () => { + it('limits typing and receipts to active rooms without changing other extensions', () => { + const extensions = { + typing: { enabled: true }, + receipts: { enabled: true }, + account_data: { enabled: true }, + }; + + scopeEphemeralExtensions(extensions, ['!space:example.com', '!room:example.com']); + + expect(extensions).toEqual({ + typing: { + enabled: true, + lists: [], + rooms: ['!space:example.com', '!room:example.com'], + }, + receipts: { + enabled: true, + lists: [], + rooms: ['!space:example.com', '!room:example.com'], + }, + account_data: { enabled: true }, + }); + }); + + it('uses an empty room scope when no timeline is open', () => { + const extensions = { + typing: { enabled: true, lists: ['joined'], rooms: ['!old:example.com'] }, + receipts: { enabled: true, lists: ['joined'], rooms: ['!old:example.com'] }, + }; + + scopeEphemeralExtensions(extensions, []); + + expect(extensions.typing).toMatchObject({ lists: [], rooms: [] }); + expect(extensions.receipts).toMatchObject({ lists: [], rooms: [] }); + }); +}); + describe('SlidingSyncManager local membership reconciliation', () => { it('updates an existing invite immediately after a successful join', () => { const updateMyMembership = vi.fn<() => void>(); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 5b090dfce1..0e3a2c4697 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -190,6 +190,29 @@ const getListEndIndex = (list: MSC3575List | null): number => { return list.ranges.reduce((max, range) => Math.max(max, range[1] ?? -1), -1); }; +type RoomScopedExtension = { + enabled?: boolean; + lists?: string[]; + rooms?: string[]; +}; + +export const scopeEphemeralExtensions = ( + extensions: object | undefined, + roomIds: readonly string[] +): void => { + if (!extensions) return; + const extensionMap = extensions as Record; + + ['typing', 'receipts'].forEach((name) => { + const extension = extensionMap[name]; + if (!extension || typeof extension !== 'object') return; + + const scopedExtension = extension as RoomScopedExtension; + scopedExtension.lists = []; + scopedExtension.rooms = [...roomIds]; + }); +}; + export class SlidingSyncManager { private disposed = false; @@ -1120,6 +1143,10 @@ export class SlidingSyncManager { this.reportActiveSubscriptionCount(); } + public getActiveRoomSubscriptionIds(): string[] { + return [...this.activeRoomSubscriptions]; + } + public subscribeToRoom(roomId: string): void { if (this.disposed || !this.addActiveRoomSubscription(roomId)) return; this.syncRoomSubscriptions(); From c2dd174394e132de9ce2ac2fee6aeec350f7ea68 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 16 Jul 2026 17:25:45 -0500 Subject: [PATCH 33/33] add banners to subscription --- src/client/slidingSync.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 0e3a2c4697..eba55eea16 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -97,6 +97,7 @@ const SPACE_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ [EventType.RoomJoinRules, ''], [EventType.RoomEncryption, ''], [EventType.RoomTombstone, ''], + [CustomStateEvent.RoomBanner, ''], ['m.space.child', MSC3575_WILDCARD], ['m.space.parent', MSC3575_WILDCARD], ];