From 4e11c125eb9037d4bf405374e41012eaa03c0d42 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sun, 12 Jul 2026 17:02:00 -0500 Subject: [PATCH 1/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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], ];