fix(client): preserve prefs on logout; recover from initial-sync failure (N98/N99)

- N98: logoutClient and handleLogout now call removeFallbackSession() (removes
  only the 4 session credential keys) instead of window.localStorage.clear(),
  so settings, unsent drafts, PiP position, and status are preserved across a
  normal logout. localStorage.clear() stays reserved for clearLoginData() (the
  explicit factory-reset path).
- N99: the useSyncState callback now handles ERROR/STOPPED. A sync failure
  before the first PREPARED (offline at startup, homeserver unreachable) shows
  a dedicated error splash with a Retry button (startMatrix) instead of an
  endless "Heating up" spinner alongside a contradictory "Connection Lost!"
  banner. Guarded by a hasPreparedRef so post-PREPARED transient errors still
  go through <SyncStatus>; PREPARED self-heals the splash on recovery, and the
  redundant banner is suppressed while the splash is shown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 11:27:36 -04:00
parent 78cb2acd6c
commit 34997bcbd1
2 changed files with 40 additions and 6 deletions
+36 -5
View File
@@ -15,7 +15,14 @@ import {
} from 'folds'; } from 'folds';
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient } from 'matrix-js-sdk'; import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react'; import React, {
MouseEventHandler,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { import {
clearCacheAndReload, clearCacheAndReload,
clearLoginData, clearLoginData,
@@ -35,7 +42,7 @@ import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus'; import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions'; import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { AutoDiscovery } from './AutoDiscovery'; import { AutoDiscovery } from './AutoDiscovery';
function ClientRootLoading() { function ClientRootLoading() {
@@ -130,7 +137,10 @@ const useLogoutListener = (mx?: MatrixClient) => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
mx?.stopClient(); mx?.stopClient();
await mx?.clearStores(); await mx?.clearStores();
window.localStorage.clear(); // Remove only the session credential keys — NOT settings, drafts, and
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
// window.localStorage.clear() is reserved for the explicit reset path.
removeFallbackSession();
window.location.reload(); window.location.reload();
}; };
@@ -146,6 +156,11 @@ type ClientRootProps = {
}; };
export function ClientRoot({ children }: ClientRootProps) { export function ClientRoot({ children }: ClientRootProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [syncError, setSyncError] = useState(false);
// Tracks whether the initial sync has ever reached PREPARED. After that,
// transient sync errors are handled by <SyncStatus>'s reconnection banner,
// so we must NOT pop the blocking error splash for them.
const hasPreparedRef = useRef(false);
const { baseUrl, userId } = getFallbackSession() ?? {}; const { baseUrl, userId } = getFallbackSession() ?? {};
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>( const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
@@ -180,7 +195,14 @@ export function ClientRoot({ children }: ClientRootProps) {
mx, mx,
useCallback((state) => { useCallback((state) => {
if (state === 'PREPARED') { if (state === 'PREPARED') {
hasPreparedRef.current = true;
setSyncError(false);
setLoading(false); setLoading(false);
} else if (state === 'ERROR' || state === 'STOPPED') {
// Only surface the blocking error splash when the INITIAL sync fails
// (offline at startup, homeserver unreachable, non-retryable /sync
// error). After the first PREPARED, <SyncStatus> owns reconnection UX.
if (!hasPreparedRef.current) setSyncError(true);
} }
}, []), }, []),
); );
@@ -188,9 +210,11 @@ export function ClientRoot({ children }: ClientRootProps) {
return ( return (
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}> <AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
<SpecVersions baseUrl={baseUrl!}> <SpecVersions baseUrl={baseUrl!}>
{mx && <SyncStatus mx={mx} />} {mx && !syncError && <SyncStatus mx={mx} />}
{loading && <ClientRootOptions mx={mx} />} {loading && <ClientRootOptions mx={mx} />}
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( {(loadState.status === AsyncStatus.Error ||
startState.status === AsyncStatus.Error ||
syncError) && (
<SplashScreen> <SplashScreen>
<Box <Box
direction="Column" direction="Column"
@@ -223,6 +247,13 @@ export function ClientRoot({ children }: ClientRootProps) {
{startState.status === AsyncStatus.Error && ( {startState.status === AsyncStatus.Error && (
<Text>{`Failed to start. ${startState.error.message}`}</Text> <Text>{`Failed to start. ${startState.error.message}`}</Text>
)} )}
{syncError &&
loadState.status !== AsyncStatus.Error &&
startState.status !== AsyncStatus.Error && (
<Text>
Failed to sync with your homeserver. Check your connection and try again.
</Text>
)}
{('error' in loadState ? (loadState as any).error?.message : undefined) !== {('error' in loadState ? (loadState as any).error?.message : undefined) !==
IDB_VERSION_CONFLICT && ( IDB_VERSION_CONFLICT && (
<Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}> <Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}>
+4 -1
View File
@@ -2,6 +2,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
import { cryptoCallbacks } from './secretStorageKeys'; import { cryptoCallbacks } from './secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { clearNavToActivePathStore } from '../app/state/navToActivePath';
import { removeFallbackSession } from '../app/state/sessions';
import { pushSessionToSW } from '../sw-session'; import { pushSessionToSW } from '../sw-session';
type Session = { type Session = {
@@ -75,7 +76,9 @@ export const logoutClient = async (mx: MatrixClient) => {
// ignore if failed to logout // ignore if failed to logout
} }
await mx.clearStores(); await mx.clearStores();
window.localStorage.clear(); // Remove only the session credential keys, preserving user preferences and
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
removeFallbackSession();
window.location.reload(); window.location.reload();
}; };