Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/feat-sliding-sync-presence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
sable: minor
---

Rewrites the sliding sync implementation to match the Element Web approach (MSC4186).

- Room list sorted by notification level, recency, then name
- `include_old_rooms` added so tombstoned rooms pass predecessor state to replacements
- Active-room custom subscription: focused room receives `timeline_limit=50`
- `subscribeToRoom` / `unsubscribeFromRoom` API on `SlidingSyncManager`
- `useSlidingSyncActiveRoom` hook + `SlidingSyncActiveRoomSubscriber` component
- Registers a custom `ExtensionPresence` so `m.presence` events from the server are processed into the SDK's `User` model — fixes components using `useUserPresence` always showing stale/default presence
- Always reinitialises the timeline on `TimelineRefresh` events to fix a silent hang where the room timeline stops updating after a reconnect
Original file line number Diff line number Diff line change
Expand Up @@ -445,9 +445,11 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
Proxy: {syncDiagnostics.sliding.proxyBaseUrl}
</Text>
<Text size="T200">
Timeline limit: {syncDiagnostics.sliding.timelineLimit} | page
size: {syncDiagnostics.sliding.listPageSize} | adaptive:{' '}
{syncDiagnostics.sliding.adaptiveTimeline ? 'yes' : 'no'}
Room timeline: {syncDiagnostics.sliding.timelineLimit}
{syncDiagnostics.sliding.adaptiveTimeline
? ' (adaptive)'
: ''}{' '}
| page size: {syncDiagnostics.sliding.listPageSize}
</Text>
</>
) : (
Expand Down
63 changes: 57 additions & 6 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -858,10 +858,33 @@ export function RoomTimeline({
useLiveTimelineRefresh(
room,
useCallback(() => {
if (liveTimelineLinked || timeline.linkedTimelines.length === 0) {
setTimeline(getInitialTimeline(room));
}
}, [room, liveTimelineLinked, timeline.linkedTimelines.length])
// Always reinitialize on TimelineRefresh. With sliding sync, a limited
// response replaces the room's live EventTimeline with a brand-new object,
// firing TimelineRefresh. At that moment liveTimelineLinked is stale-false
// (the stored linkedTimelines still reference the old detached object),
// so the previous guard `if (liveTimelineLinked || ...)` would silently
// skip reinit. Back-pagination then calls paginateEventTimeline against
// the dead old timeline, which no-ops, and the IntersectionObserver never
// re-fires because intersection state didn't change — causing a permanent
// hang at the top of the timeline with no spinner and no history loaded.
// Unconditionally reinitializing is correct: TimelineRefresh signals that
// the SDK has replaced the timeline chain, so any stored range/indices
// against the old chain are invalid anyway.
//
// Also force atBottom=true and queue a scroll-to-bottom. The SDK fires
// TimelineRefresh before adding new events to the fresh live timeline, so
// getInitialTimeline captures range.end=0. Once events arrive the
// rangeAtEnd self-heal useEffect needs atBottom=true to run; the
// IntersectionObserver may have transiently fired isIntersecting=false
// during the render transition, leaving atBottom=false and causing the
// "Jump to Latest" button to stick permanently. Forcing atBottom here is
// correct: TimelineRefresh always reinits to the live end, so the user
// should be repositioned to the bottom regardless.
setTimeline(getInitialTimeline(room));
setAtBottom(true);
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
}, [room, setAtBottom])
);

// Re-render when non-live Replace relations arrive (bundled/historical edits
Expand All @@ -882,6 +905,21 @@ export function RoomTimeline({
setTimeline(getInitialTimeline(room));
}, [eventId, room, timeline.linkedTimelines.length]);

// Fix stale rangeAtEnd after a sliding sync TimelineRefresh. The SDK fires
// TimelineRefresh before adding new events to the freshly-created live
// EventTimeline, so getInitialTimeline captures range.end=0. New events then
// arrive via useLiveEventArrive, but its atLiveEndRef guard is stale-false
// (hasn't re-rendered yet), bypassing the range-advance path. The next render
// ends up with liveTimelineLinked=true but rangeAtEnd=false, making the
// "Jump to Latest" button appear while the user is already at the bottom.
// Re-running getInitialTimeline post-render (after events were added to the
// live EventTimeline object) snaps range.end to the correct event count.
useEffect(() => {
if (liveTimelineLinked && !rangeAtEnd && atBottom) {
setTimeline(getInitialTimeline(room));
}
}, [liveTimelineLinked, rangeAtEnd, atBottom, room]);

// Stay at bottom when room editor resize
useResizeObserver(
useMemo(() => {
Expand Down Expand Up @@ -2127,6 +2165,19 @@ export function RoomTimeline({
</Box>
);
} else if (timelineItems.length === 0) {
// When eventsLength===0 AND liveTimelineLinked the live EventTimeline was
// just reset by a sliding sync TimelineRefresh and new events haven't
// arrived yet. Attaching the IntersectionObserver anchor here would
// immediately fire a server-side /messages request before current events
// land — potentially causing a "/messages hangs → spinner stuck" scenario.
// Suppressing the anchor for this transient state is safe: the rangeAtEnd
// self-heal useEffect will call getInitialTimeline once events arrive, and
// at that point the correct anchor (below) will be re-observed.
// eventsLength>0 covers the range={K,K} case from recalibratePagination
// where items=0 but events exist — that needs the anchor for local range
// extension (no server call since start>0).
const placeholderBackAnchor =
eventsLength > 0 || !liveTimelineLinked ? observeBackAnchor : undefined;
backPaginationJSX =
messageLayout === MessageLayout.Compact ? (
<>
Expand All @@ -2142,7 +2193,7 @@ export function RoomTimeline({
<MessageBase>
<CompactPlaceholder />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<MessageBase ref={placeholderBackAnchor}>
<CompactPlaceholder />
</MessageBase>
</>
Expand All @@ -2154,7 +2205,7 @@ export function RoomTimeline({
<MessageBase>
<DefaultPlaceholder />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<MessageBase ref={placeholderBackAnchor}>
<DefaultPlaceholder />
</MessageBase>
</>
Expand Down
15 changes: 3 additions & 12 deletions src/app/features/settings/developer-tools/SyncDiagnostics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,18 +199,9 @@ export function SyncDiagnostics() {
<Box direction="Column" gap="100">
<Text size="T300">Sliding proxy: {diagnostics.sliding.proxyBaseUrl}</Text>
<Text size="T300">
Timeline limit: {diagnostics.sliding.timelineLimit} (page size:{' '}
{diagnostics.sliding.listPageSize})
</Text>
<Text size="T300">
Adaptive timeline: {diagnostics.sliding.adaptiveTimeline ? 'yes' : 'no'}
</Text>
<Text size="T300">
Device/network: saveData {diagnostics.sliding.device.saveData ? 'on' : 'off'} |
effectiveType {diagnostics.sliding.device.effectiveType ?? 'unknown'} | memory{' '}
{diagnostics.sliding.device.deviceMemoryGb ?? 'unknown'} GB | mobile{' '}
{diagnostics.sliding.device.mobile ? 'yes' : 'no'} | missing signals{' '}
{diagnostics.sliding.device.missingSignals}
Room timeline limit: {diagnostics.sliding.timelineLimit} (adaptive:{' '}
{diagnostics.sliding.adaptiveTimeline ? 'yes' : 'no'}) | page size:{' '}
{diagnostics.sliding.listPageSize}
</Text>
{diagnostics.sliding.lists.map((list) => (
<Text size="T300" key={list.key}>
Expand Down
27 changes: 27 additions & 0 deletions src/app/hooks/useSlidingSyncActiveRoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { getSlidingSyncManager } from '$client/initMatrix';
import { useSelectedRoom } from '$hooks/router/useSelectedRoom';

/**
* Subscribes the currently selected room to the sliding sync "active room"
* custom subscription (higher timeline limit) for the duration the room is open.
*
* Safe to call unconditionally — it is a no-op when classic sync is in use
* (i.e. when there is no SlidingSyncManager for the client).
*/
export const useSlidingSyncActiveRoom = (): void => {
const mx = useMatrixClient();
const roomId = useSelectedRoom();

useEffect(() => {
if (!roomId) return undefined;
const manager = getSlidingSyncManager(mx);
if (!manager) return undefined;

manager.subscribeToRoom(roomId);
return () => {
manager.unsubscribeFromRoom(roomId);
};
}, [mx, roomId]);
};
9 changes: 8 additions & 1 deletion src/app/pages/client/ClientNonUIFeatures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
resolveNotificationPreviewText,
} from '$utils/notificationStyle';
import { mobileOrTablet } from '$utils/user-agent';
import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom';
import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';

Expand Down Expand Up @@ -369,7 +370,7 @@ function MessageNotifications() {
if (document.visibilityState !== 'visible') return;

// Page is visible — show the themed in-app notification banner.
if (showNotifications && (isHighlightByRule || loudByRule || isDM)) {
if (showNotifications && (isHighlightByRule || isLoud)) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
const roomAvatar = avatarMxc
Expand Down Expand Up @@ -569,6 +570,11 @@ function SyncNotificationSettingsWithServiceWorker() {
return null;
}

function SlidingSyncActiveRoomSubscriber() {
useSlidingSyncActiveRoom();
return null;
}

export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
Expand All @@ -580,6 +586,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<MessageNotifications />
<BackgroundNotifications />
<SyncNotificationSettingsWithServiceWorker />
<SlidingSyncActiveRoomSubscriber />
{children}
</>
);
Expand Down
27 changes: 17 additions & 10 deletions src/client/initMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { SlidingSyncConfig, SlidingSyncDiagnostics, SlidingSyncManager } from '.
const log = createLogger('initMatrix');
const slidingSyncByClient = new WeakMap<MatrixClient, SlidingSyncManager>();
const FAST_SYNC_POLL_TIMEOUT_MS = 10000;
const SLIDING_SYNC_POLL_TIMEOUT_MS = 20000;
type SyncTransport = 'classic' | 'sliding';
type SyncTransportReason =
| 'sliding_active'
Expand Down Expand Up @@ -340,6 +341,9 @@ export const stopClient = (mx: MatrixClient): void => {
mx.stopClient();
};

export const getSlidingSyncManager = (mx: MatrixClient): SlidingSyncManager | undefined =>
slidingSyncByClient.get(mx);

export const startClient = async (mx: MatrixClient, config?: StartClientConfig) => {
log.log('startClient', mx.getUserId());
disposeSlidingSync(mx);
Expand Down Expand Up @@ -419,16 +423,11 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig)
}

const resolvedProxyBaseUrl = proxyBaseUrl;
const manager = new SlidingSyncManager(mx, resolvedProxyBaseUrl, {
...(slidingConfig ?? {}),
includeInviteList: true,
pollTimeoutMs: slidingConfig?.pollTimeoutMs ?? FAST_SYNC_POLL_TIMEOUT_MS,
});
const supported = await SlidingSyncManager.probe(
mx,
resolvedProxyBaseUrl,
manager.probeTimeoutMs
);
const probeTimeoutMs = (() => {
const v = slidingConfig?.probeTimeoutMs;
return typeof v === 'number' && !Number.isNaN(v) && v > 0 ? Math.round(v) : 5000;
})();
const supported = await SlidingSyncManager.probe(mx, resolvedProxyBaseUrl, probeTimeoutMs);
log.log('startClient sliding probe result', {
userId: mx.getUserId(),
requestedEnabled: slidingRequested,
Expand All @@ -442,7 +441,15 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig)
return;
}

const manager = new SlidingSyncManager(mx, resolvedProxyBaseUrl, {
...(slidingConfig ?? {}),
includeInviteList: true,
pollTimeoutMs: slidingConfig?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS,
});
manager.attach();
// Begin background spidering so all rooms are eventually indexed.
// Not awaited — this runs incrementally in the background.
manager.startSpidering(100, 50);
slidingSyncByClient.set(mx, manager);
syncTransportByClient.set(mx, {
transport: 'sliding',
Expand Down
Loading
Loading