Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
4e11c12
move SSS to general, add toggle on login and load, remove config option
7w1 Jul 12, 2026
6bc3186
render UI sooner
7w1 Jul 13, 2026
ee7a745
remove fallback loading screen stages to prevent flicker
7w1 Jul 13, 2026
62fc8b7
make config and version fetches async
7w1 Jul 13, 2026
82b1fee
concurrent crypto and sync store start up
7w1 Jul 13, 2026
5855969
remove maxRooms and reduce initial sync
7w1 Jul 13, 2026
67cb915
remove the rest of sliding sync config
7w1 Jul 13, 2026
9b2fa13
add join rules to request data
7w1 Jul 13, 2026
3713694
fetch image packs when needed
7w1 Jul 13, 2026
6be3d0a
gate ui behind first usable sync response
7w1 Jul 13, 2026
5f5e0a8
delay presence until first sync
7w1 Jul 13, 2026
04a0649
remove full cleanup from every start
7w1 Jul 13, 2026
b64cb8b
fix sync when open room, and clean up sync sizes and data, other stuf…
7w1 Jul 14, 2026
25a3c21
Merge branch 'dev' into sliding-sync-stablization
7w1 Jul 14, 2026
9fbd2e5
some clean up
7w1 Jul 14, 2026
0f8ce00
loader for rooms
7w1 Jul 14, 2026
c3975cd
load initial room with first sync and bump poll time
7w1 Jul 14, 2026
a463709
cache space and room layout
7w1 Jul 14, 2026
4879554
Merge branch 'dev' into sliding-sync-stablization
7w1 Jul 15, 2026
1300e3a
stabilize sliding sync room state and membership updates
7w1 Jul 15, 2026
539125c
fix presence sync and member list subscription
7w1 Jul 15, 2026
071b29b
adjust sliding sync toggle style and login with sso compatibility
7w1 Jul 15, 2026
b68bc73
fix unreads
7w1 Jul 15, 2026
7c29062
Merge remote-tracking branch 'origin/dev' into sliding-sync-stablization
7w1 Jul 16, 2026
aadf445
changeset and disable presence manager from dev
7w1 Jul 16, 2026
f24c033
extract client config and have it block load
7w1 Jul 16, 2026
4032d1f
remove stale rooms
7w1 Jul 16, 2026
dcdb0b6
improve the caching i think
7w1 Jul 16, 2026
1e0acd6
Update improve-sliding-sync.md
7w1 Jul 16, 2026
4384599
fix: close IndexedDB connections to prevent leaks
eleboucher Jul 16, 2026
e0d7406
hide connecting and add some diagnostics
7w1 Jul 16, 2026
303fa70
fix: close IndexedDB connections to prevent leaks (#1100)
7w1 Jul 16, 2026
2d95c35
optimize room data subscriptions
7w1 Jul 16, 2026
f2078ac
Merge branch 'sliding-sync-stablization' of https://github.com/SableC…
7w1 Jul 16, 2026
c7c528a
formatting
7w1 Jul 16, 2026
01587d9
Merge branch 'dev' into sliding-sync-stablization
7w1 Jul 16, 2026
b5e0ec8
cleanup unused stuff
7w1 Jul 16, 2026
56bfccc
scope extensions
7w1 Jul 16, 2026
c2dd174
add banners to subscription
7w1 Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/improve-sliding-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
default: minor
---

Improve sliding sync.

- Make sliding sync easier to enable during login or loading and from settings.
- Move the sliding sync toggle from Experimental to the bottom of General settings.
- Start faster and restore rooms and spaces sooner.
- Reduce unnecessary background room data.
- Improve room details, memberships, presence, and unread indicators.
- Make startup loading smoother for both sliding and classic sync.
- It's fast now :3
4 changes: 0 additions & 4 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
18 changes: 18 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@

<link id="favicon" rel="shortcut icon" href="./public/favicon.png" />

<style>
html,
body {
background-color: #1b1a21;
background-image: radial-gradient(#2d2c36 2px, #1b1a21 2px);
background-size: 40px 40px;
color: #eae8f0;
}
@media (prefers-color-scheme: light) {
html,
body {
background-color: #ffffff;
background-image: radial-gradient(#e4e4e7 2px, #ffffff 2px);
color: #18181b;
}
}
</style>

<link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Sable" />
Expand Down
147 changes: 90 additions & 57 deletions src/app/components/ClientConfigLoader.test.tsx
Original file line number Diff line number Diff line change
@@ -1,99 +1,132 @@
/**
* Integration tests: exercises the full config-load → setMatrixToBase → URL
* generation pipeline that App.tsx runs on startup.
*
* The pattern under test mirrors App.tsx:
* <ClientConfigLoader>
* {(config) => { setMatrixToBase(config.matrixToBaseUrl); ... }}
* </ClientConfigLoader>
*
* We mock fetch so we don't need a real config.json or a live matrix.to instance.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { setMatrixToBase, getMatrixToRoom, getMatrixToUser } from '$plugins/matrix-to';
import { ClientConfigLoader } from './ClientConfigLoader';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { getMatrixToRoom, setMatrixToBase } from '$plugins/matrix-to';
import { ClientConfigLoader, FALLBACK_CLIENT_CONFIG } from './ClientConfigLoader';

const successfulResponse = (config: unknown) => ({
ok: true,
status: 200,
statusText: 'OK',
json: vi.fn<() => Promise<unknown>>().mockResolvedValue(config),
});

afterEach(() => {
setMatrixToBase(); // reset module state to 'https://matrix.to'
setMatrixToBase();
vi.unstubAllGlobals();
});

const mockFetch = (config: object) =>
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve(config) }));

describe('ClientConfigLoader + matrix-to wiring', () => {
it('generates a standard matrix.to URL when no custom base is configured', async () => {
mockFetch({});
describe('ClientConfigLoader', () => {
it('does not render config-dependent children while loading', () => {
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>(() => new Promise<Response>(() => undefined))
);

render(
<ClientConfigLoader>
{(config) => {
setMatrixToBase(config.matrixToBaseUrl);
return <span data-testid="link">{getMatrixToRoom('!room:example.com')}</span>;
}}
<ClientConfigLoader fallback={() => <span>Loading</span>}>
{() => <span>Configured app</span>}
</ClientConfigLoader>
);

await waitFor(() =>
expect(screen.getByTestId('link')).toHaveTextContent('https://matrix.to/#/!room:example.com')
);
expect(screen.getByText('Loading')).toBeInTheDocument();
expect(screen.queryByText('Configured app')).not.toBeInTheDocument();
});

it('generates a custom-base URL for rooms when matrixToBaseUrl is set', async () => {
mockFetch({ matrixToBaseUrl: 'https://custom.example.org' });
it('loads the config before rendering children', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(successfulResponse({ allowCustomHomeservers: true }))
);

render(
<ClientConfigLoader>
{(config) => {
setMatrixToBase(config.matrixToBaseUrl);
return <span data-testid="link">{getMatrixToRoom('!room:example.com')}</span>;
}}
{(config) => <span>{String(config.allowCustomHomeservers)}</span>}
</ClientConfigLoader>
);

await waitFor(() =>
expect(screen.getByTestId('link')).toHaveTextContent(
'https://custom.example.org/#/!room:example.com'
)
expect(await screen.findByText('true')).toBeInTheDocument();
});

it('reports non-success responses and retries', async () => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce({
ok: false,
status: 503,
statusText: 'Service Unavailable',
json: vi.fn<() => Promise<unknown>>(),
} as unknown as Response)
.mockResolvedValueOnce(
successfulResponse({ homeserverList: ['example.org'] }) as unknown as Response
);
vi.stubGlobal('fetch', fetchMock);

render(
<ClientConfigLoader
error={(error, retry) => (
<button type="button" onClick={retry}>
{error instanceof Error ? error.message : 'Retry'}
</button>
)}
>
{(config) => <span>{config.homeserverList?.[0]}</span>}
</ClientConfigLoader>
);

fireEvent.click(await screen.findByRole('button', { name: /503 Service Unavailable/ }));
expect(await screen.findByText('example.org')).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('generates a custom-base URL for users when matrixToBaseUrl is set', async () => {
mockFetch({ matrixToBaseUrl: 'https://custom.example.org' });
it('rejects non-object JSON', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(successfulResponse([])));

render(
<ClientConfigLoader>
{(config) => {
setMatrixToBase(config.matrixToBaseUrl);
return <span data-testid="user">{getMatrixToUser('@alice:example.com')}</span>;
}}
<ClientConfigLoader error={(error) => <span>{String(error)}</span>}>
{() => <span>Configured app</span>}
</ClientConfigLoader>
);

await waitFor(() =>
expect(screen.getByTestId('user')).toHaveTextContent(
'https://custom.example.org/#/@alice:example.com'
)
expect(await screen.findByText(/must contain a JSON object/)).toBeInTheDocument();
expect(screen.queryByText('Configured app')).not.toBeInTheDocument();
});

it('uses deliberate defaults only after continuing', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new Error('offline')));

render(
<ClientConfigLoader
error={(_error, _retry, ignore) => (
<button type="button" onClick={ignore}>
Continue
</button>
)}
>
{(config) => <span>{config.homeserverList?.join(',')}</span>}
</ClientConfigLoader>
);

fireEvent.click(await screen.findByRole('button', { name: 'Continue' }));
expect(screen.getByText(FALLBACK_CLIENT_CONFIG.homeserverList?.[0] ?? '')).toBeInTheDocument();
});

it('strips a trailing slash from matrixToBaseUrl', async () => {
mockFetch({ matrixToBaseUrl: 'https://custom.example.org/' });
it('applies matrix.to configuration before rendering links', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(successfulResponse({ matrixToBaseUrl: 'https://custom.example/' }))
);

render(
<ClientConfigLoader>
{(config) => {
setMatrixToBase(config.matrixToBaseUrl);
return <span data-testid="link">{getMatrixToRoom('!room:example.com')}</span>;
return <span>{getMatrixToRoom('!room:example.com')}</span>;
}}
</ClientConfigLoader>
);

await waitFor(() =>
expect(screen.getByTestId('link')).toHaveTextContent(
'https://custom.example.org/#/!room:example.com'
)
expect(screen.getByText('https://custom.example/#/!room:example.com')).toBeInTheDocument()
);
});
});
103 changes: 87 additions & 16 deletions src/app/components/ClientConfigLoader.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,110 @@
import type { ReactNode } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Dialog, Text, color, config } from 'folds';
import type { ClientConfig } from '$hooks/useClientConfig';
import { trimTrailingSlash } from '$utils/common';
import { SplashScreen } from '$components/splash-screen';

const getClientConfig = async (): Promise<ClientConfig> => {
export const FALLBACK_CLIENT_CONFIG: ClientConfig = {
defaultHomeserver: 0,
homeserverList: ['matrix.org'],
allowCustomHomeservers: false,
hashRouter: { enabled: false, basename: '/' },
};

export const getClientConfig = async (): Promise<ClientConfig> => {
const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`;
const config = await fetch(url, { method: 'GET' });
return config.json();
const response = await fetch(url, { method: 'GET' });
if (!response.ok) {
throw new Error(`Failed to load config.json (${response.status} ${response.statusText})`);
}

const data: unknown = await response.json();
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('config.json must contain a JSON object.');
}
return data as ClientConfig;
};

type ClientConfigLoaderProps = {
fallback?: () => ReactNode;
error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode;
error?: (error: unknown, retry: () => void, ignore: () => void) => ReactNode;
children: (config: ClientConfig) => ReactNode;
};

type ConfigLoadState =
| { status: 'loading' }
| { status: 'ready'; config: ClientConfig }
| { status: 'error'; error: unknown };

const renderConfigError = (error: unknown, retry: () => void, ignore: () => void) => (
<SplashScreen>
<Box grow="Yes" direction="Column" gap="400" alignItems="Center" justifyContent="Center">
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text>Failed to load client configuration.</Text>
{error instanceof Error && (
<Text size="T300" style={{ color: color.Critical.Main }}>
{error.message}
</Text>
)}
</Box>
<Button variant="Critical" onClick={retry}>
<Text as="span" size="B400">
Retry
</Text>
</Button>
<Button variant="Critical" onClick={ignore} fill="Soft">
<Text as="span" size="B400">
Continue with defaults
</Text>
</Button>
</Box>
</Dialog>
</Box>
</SplashScreen>
);

export function ClientConfigLoader({ fallback, error, children }: ClientConfigLoaderProps) {
const [state, load] = useAsyncCallback(getClientConfig);
const [ignoreError, setIgnoreError] = useState(false);
const [state, setState] = useState<ConfigLoadState>({ status: 'loading' });
const [useFallback, setUseFallback] = useState(false);
const requestIdRef = useRef(0);

const load = useCallback(() => {
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
setUseFallback(false);
setState({ status: 'loading' });

const ignoreCallback = useCallback(() => setIgnoreError(true), []);
void getClientConfig().then(
(loadedConfig) => {
if (requestId === requestIdRef.current) {
setState({ status: 'ready', config: loadedConfig });
}
},
(loadError: unknown) => {
if (requestId === requestIdRef.current) setState({ status: 'error', error: loadError });
}
);
}, []);

const ignoreError = useCallback(() => setUseFallback(true), []);

useEffect(() => {
load();
return () => {
requestIdRef.current += 1;
};
}, [load]);

if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
if (state.status === 'loading') {
return fallback?.();
}

if (!ignoreError && state.status === AsyncStatus.Error) {
return error?.(state.error, load, ignoreCallback);
if (state.status === 'error' && !useFallback) {
return (error ?? renderConfigError)(state.error, load, ignoreError);
}

const config: ClientConfig = state.status === AsyncStatus.Success ? state.data : {};

return children(config);
const clientConfig = state.status === 'ready' ? state.config : FALLBACK_CLIENT_CONFIG;
return children(clientConfig);
}
6 changes: 4 additions & 2 deletions src/app/components/SpecVersionsLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@ import { specVersions } from '../cs-api';

type SpecVersionsLoaderProps = {
baseUrl: string;
loadVersions?: () => Promise<SpecVersions>;
fallback?: () => ReactNode;
error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode;
children: (versions: SpecVersions) => ReactNode;
};
export function SpecVersionsLoader({
baseUrl,
loadVersions,
fallback,
error,
children,
}: SpecVersionsLoaderProps) {
const [state, load] = useAsyncCallback(
useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
useCallback(() => loadVersions?.() ?? specVersions(fetch, baseUrl), [baseUrl, loadVersions])
);
const [ignoreError, setIgnoreError] = useState(false);

Expand All @@ -32,7 +34,7 @@ export function SpecVersionsLoader({
}

if (!ignoreError && state.status === AsyncStatus.Error) {
return error?.(state.error, load, ignoreCallback);
if (error) return error(state.error, load, ignoreCallback);
}

return children(
Expand Down
Loading
Loading