From 4e11c125eb9037d4bf405374e41012eaa03c0d42 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sun, 12 Jul 2026 17:02:00 -0500 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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(); + } }