perf: show the cached inbox at start before the server's session arrives

This commit is contained in:
Linus Rath
2026-10-22 22:04:33 +02:00
parent d13769a9f2
commit 3424bcf2e9
4 changed files with 321 additions and 1 deletions
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const secure = new Map<string, string>();
vi.mock('expo-secure-store', () => ({
setItemAsync: vi.fn(async (k: string, v: string) => { secure.set(k, v); }),
getItemAsync: vi.fn(async (k: string) => secure.get(k) ?? null),
deleteItemAsync: vi.fn(async (k: string) => { secure.delete(k); }),
}));
import AsyncStorage from '@react-native-async-storage/async-storage';
import { JMAPClient, AuthenticationError } from '../jmap-client';
import type { JMAPSession } from '../types';
// Start-up used to wait for the server's session document before the
// account could show. The client now keeps the last one per account and
// starts from it (loadAccountFromCache), then fetches a fresh one.
const ACCOUNT = 'user@mail.example.com';
const CREDS = { serverUrl: 'https://mail.example.com', username: 'user', password: 'pass' };
const SESSION: JMAPSession = {
apiUrl: 'https://mail.example.com/jmap/',
downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}',
uploadUrl: 'https://mail.example.com/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/eventsource/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-1', 'urn:ietf:params:jmap:core': 'acc-1' },
accounts: { 'acc-1': { name: 'user@example.com', isPersonal: true, isReadOnly: false } },
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} },
state: 'state-1',
};
function mockFetch(responses: Array<{ status: number; json?: unknown }>) {
let i = 0;
const fn = vi.fn(async (url: string) => {
const r = responses[i++] ?? responses[responses.length - 1];
return {
ok: r.status >= 200 && r.status < 300,
status: r.status,
statusText: r.status === 200 ? 'OK' : 'Error',
url,
redirected: false,
headers: { get: () => null },
json: async () => r.json,
text: async () => (r.json === undefined ? '' : JSON.stringify(r.json)),
};
});
vi.stubGlobal('fetch', fn);
return fn;
}
async function storeCredentials() {
const SecureStore = await import('expo-secure-store');
await SecureStore.setItemAsync(`jmap_credentials__${ACCOUNT.replace(/[^a-zA-Z0-9._-]/g, '_')}`, JSON.stringify(CREDS));
}
beforeEach(async () => {
secure.clear();
await AsyncStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('saved session', () => {
it('starts from the session of the last connect, without a request', async () => {
await storeCredentials();
mockFetch([{ status: 200, json: SESSION }]);
expect(await new JMAPClient().loadAccount(ACCOUNT)).toBe(true);
await vi.waitFor(async () => expect(await AsyncStorage.getAllKeys()).toHaveLength(1));
const fetch = mockFetch([{ status: 500 }]);
const client = new JMAPClient();
expect(await client.loadAccountFromCache(ACCOUNT)).toBe(true);
expect(fetch).not.toHaveBeenCalled();
expect(client.accountId).toBe('acc-1');
expect(client.currentSession?.apiUrl).toBe(SESSION.apiUrl);
});
it('has nothing to start from before the first connect', async () => {
await storeCredentials();
expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false);
});
it('ignores a session saved for another server', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: 'https://old.example.com', session: SESSION }),
);
expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false);
});
it('is dropped with the credentials', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
);
await new JMAPClient().clearAccountCredentials(ACCOUNT);
expect(await AsyncStorage.getItem(`jmap_session_cache__${ACCOUNT}`)).toBeNull();
});
it('revalidates against the server and reports refused credentials', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
);
const client = new JMAPClient();
await client.loadAccountFromCache(ACCOUNT);
mockFetch([{ status: 401 }]);
await expect(client.revalidateSession()).rejects.toBeInstanceOf(AuthenticationError);
});
it('drops a revalidation that finishes after a switch to another account', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
);
const client = new JMAPClient();
await client.loadAccountFromCache(ACCOUNT);
mockFetch([{ status: 200, json: { ...SESSION, apiUrl: 'https://mail.example.com/jmap2/' } }]);
const pending = client.revalidateSession();
client.reset();
expect(await pending).toBeNull();
expect(client.currentSession).toBeNull();
});
});
+84
View File
@@ -1,4 +1,5 @@
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type {
JMAPSession,
JMAPMethodCall,
@@ -44,6 +45,20 @@ function credentialsKey(accountId: string): string {
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
}
// The session document from the account's last connect (URLs, account ids,
// capabilities; no secrets). Start-up uses it to show the account without
// waiting for the network, then fetches a fresh one (revalidateSession).
const SESSION_CACHE_PREFIX = 'jmap_session_cache__';
function sessionCacheKey(accountId: string): string {
return SESSION_CACHE_PREFIX + accountId;
}
interface CachedSession {
serverUrl: string;
session: JMAPSession;
}
export interface StoredCredentials {
serverUrl: string;
username: string;
@@ -524,9 +539,74 @@ export class JMAPClient {
);
}
this._accountId = this.resolveAccountId(this.session);
this.rememberSession(accountId);
return true;
}
/**
* Start-up without a network round trip: the account's credentials and the
* session document saved at its last connect. Returns false when either is
* missing (or the saved one is for another server); the caller then uses
* loadAccount. A wrong password or revoked token surfaces on the first
* request (401, onAuthFailure) or in revalidateSession.
*/
async loadAccountFromCache(accountId: string): Promise<boolean> {
const [stored, cachedRaw] = await Promise.all([
SecureStore.getItemAsync(credentialsKey(accountId)),
AsyncStorage.getItem(sessionCacheKey(accountId)).catch(() => null),
]);
if (!stored || !cachedRaw) return false;
let creds: StoredCredentials;
let cached: CachedSession;
try {
creds = JSON.parse(stored);
cached = JSON.parse(cachedRaw);
} catch {
return false;
}
if (!cached?.session?.apiUrl || cached.serverUrl !== creds.serverUrl) return false;
let accountIdInSession: string;
try {
accountIdInSession = this.resolveAccountId(cached.session);
} catch {
return false;
}
this.credentials = creds;
this.firstTouchGate.reset();
this.session = cached.session;
this._accountId = accountIdInSession;
return true;
}
/**
* Fetch a fresh session document for the account the client holds now,
* after a start from the saved one. Resolves null when the client moved to
* another account meanwhile (the result is dropped). Throws
* AuthenticationError / NetworkError like loadAccount.
*/
async revalidateSession(): Promise<JMAPSession | null> {
const creds = this.credentials;
if (!creds) return null;
let fresh: JMAPSession;
try {
fresh = this.rewriteSessionUrls(await this.fetchSession(creds.serverUrl), creds.serverUrl);
} catch (err) {
if (this.credentials !== creds) return null;
throw err;
}
if (this.credentials !== creds) return null;
this.session = fresh;
this._accountId = this.resolveAccountId(fresh);
this.rememberSession(generateAccountId(creds.username, creds.serverUrl));
return fresh;
}
private rememberSession(accountId: string): void {
if (!this.session || !this.credentials) return;
const entry: CachedSession = { serverUrl: this.credentials.serverUrl, session: this.session };
void AsyncStorage.setItem(sessionCacheKey(accountId), JSON.stringify(entry)).catch(() => undefined);
}
/**
* Snapshot of the live connection so a failed "add account" / switch can put
* the previous account back without a network round-trip.
@@ -548,12 +628,14 @@ export class JMAPClient {
async clearAccountCredentials(accountId: string): Promise<void> {
await SecureStore.deleteItemAsync(credentialsKey(accountId));
await AsyncStorage.removeItem(sessionCacheKey(accountId)).catch(() => undefined);
}
async clearAllCredentials(accountIds: string[]): Promise<void> {
await Promise.all([
SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY),
...accountIds.map((id) => SecureStore.deleteItemAsync(credentialsKey(id))),
...accountIds.map((id) => AsyncStorage.removeItem(sessionCacheKey(id)).catch(() => undefined)),
]);
}
@@ -616,6 +698,7 @@ export class JMAPClient {
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
if (currentAccountId) {
await SecureStore.deleteItemAsync(credentialsKey(currentAccountId));
await AsyncStorage.removeItem(sessionCacheKey(currentAccountId)).catch(() => undefined);
}
}
@@ -835,6 +918,7 @@ export class JMAPClient {
const baseUrl = this.credentials.serverUrl;
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
this._accountId = this.resolveAccountId(this.session);
this.rememberSession(generateAccountId(this.credentials.username, baseUrl));
return this.session;
}
+60 -1
View File
@@ -6,6 +6,8 @@ vi.mock('../../api/jmap-client', () => ({
logout: vi.fn(),
restoreSession: vi.fn(),
loadAccount: vi.fn(),
loadAccountFromCache: vi.fn(async () => false),
revalidateSession: vi.fn(async () => null),
consumeLegacyCredentials: vi.fn(async () => null),
clearAccountCredentials: vi.fn(async () => undefined),
clearAllCredentials: vi.fn(async () => undefined),
@@ -35,7 +37,7 @@ vi.mock('../../lib/push-notifications', () => ({
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
}));
import { jmapClient } from '../../api/jmap-client';
import { jmapClient, AuthenticationError } from '../../api/jmap-client';
import { useAuthStore, HYDRATION_TIMEOUT_MS } from '../auth-store';
import { useAccountStore } from '../account-store';
import { useCalendarStore } from '../calendar-store';
@@ -49,6 +51,8 @@ import { lastBodyHeight, rememberBodyHeight } from '../../lib/body-heights';
const mockConnect = jmapClient.connect as ReturnType<typeof vi.fn>;
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
const mockLoadAccount = jmapClient.loadAccount as ReturnType<typeof vi.fn>;
const mockLoadFromCache = jmapClient.loadAccountFromCache as ReturnType<typeof vi.fn>;
const mockRevalidate = jmapClient.revalidateSession as ReturnType<typeof vi.fn>;
function resetAccountStore(): void {
useAccountStore.setState({
@@ -236,6 +240,61 @@ describe('auth-store', () => {
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
describe('from the session saved at the last connect', () => {
function registerAccount() {
useAccountStore.setState({
accounts: [{
id: 'acc-1', serverUrl: 'https://mail.example.com', username: 'user',
displayName: 'user', email: 'user', avatarColor: '#000', lastLoginAt: 0,
isConnected: true, hasError: false, isDefault: true,
}],
activeAccountId: 'acc-1',
defaultAccountId: 'acc-1',
});
}
// Start-up used to wait for the server's session document before the
// cached inbox could show.
it('shows the account without waiting for the server', async () => {
registerAccount();
mockLoadFromCache.mockResolvedValueOnce(true);
mockRevalidate.mockReturnValueOnce(new Promise(() => undefined));
const restored = await useAuthStore.getState().restoreSession();
expect(restored).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
expect(mockLoadAccount).not.toHaveBeenCalled();
expect(mockRevalidate).toHaveBeenCalledTimes(1);
});
it('signs out when the server refuses the stored credentials', async () => {
registerAccount();
mockLoadFromCache.mockResolvedValueOnce(true);
mockRevalidate.mockRejectedValueOnce(new AuthenticationError('Invalid credentials'));
mockLoadAccount.mockRejectedValueOnce(new AuthenticationError('Invalid credentials'));
await useAuthStore.getState().restoreSession();
await vi.waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(false));
expect(useAuthStore.getState().error).toBe('Session expired');
expect(useAccountStore.getState().accounts).toEqual([]);
});
it('stays signed in and marks the account offline without network', async () => {
registerAccount();
mockLoadFromCache.mockResolvedValueOnce(true);
mockRevalidate.mockRejectedValueOnce(new Error('Network request failed'));
await useAuthStore.getState().restoreSession();
await vi.waitFor(() => expect(useAccountStore.getState().accounts[0].hasError).toBe(true));
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAccountStore.getState().accounts[0].isConnected).toBe(false);
});
});
it('stops waiting for a persisted store that never finishes hydrating', async () => {
vi.useFakeTimers();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+47
View File
@@ -811,6 +811,24 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// data from a previous session.
useEmailStore.getState().setActiveAccount(target.id);
// Start from the session saved at the last connect: the cached mail
// shows without waiting for the server, and a fresh session follows.
let fromCache = false;
try {
fromCache = await jmapClient.loadAccountFromCache(target.id);
} catch {
fromCache = false;
}
if (fromCache) {
accountStore.setActiveAccount(target.id);
applyConnectedState(set, jmapClient.currentSession!, target.serverUrl, target.username, target.id);
perfMark('restore-done', { cached: true });
refetchFeatureStores();
void revalidateRestoredSession(target.id);
void syncAccountDisplayName(target.id);
return true;
}
try {
const ok = await jmapClient.loadAccount(target.id);
perfMark('restore-session');
@@ -929,6 +947,35 @@ export const useAuthStore = create<AuthState>((set, get) => ({
clearError: () => set({ error: null }),
}));
/**
* After a start from the saved session: fetch a fresh one. New capabilities
* or URLs replace the saved ones; bad credentials go the way of any 401 on a
* live session (retrySession, then the sign-in screen); no network marks the
* account offline, as a start without network always did.
*/
async function revalidateRestoredSession(accountId: string): Promise<void> {
const before = jmapClient.accountId;
try {
const fresh = await jmapClient.revalidateSession();
if (!fresh || useAuthStore.getState().activeAccountId !== accountId) return;
useAuthStore.setState({ session: fresh, accountId: jmapClient.accountId });
useAccountStore.getState().updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
if (jmapClient.accountId !== before) refetchFeatureStores();
} catch (err) {
if (useAuthStore.getState().activeAccountId !== accountId) return;
if (err instanceof AuthenticationError) {
useAuthStore.setState({ session: null });
await useAuthStore.getState().retrySession();
return;
}
useAccountStore.getState().updateAccount(accountId, {
isConnected: false,
hasError: true,
errorMessage: err instanceof Error ? err.message : String(err),
});
}
}
// A 401 on a live session (revoked password/token, expired refresh token)
// used to leave the user on a dead session until relaunch: nothing outside
// the login flow handled `AuthenticationError`, and `retrySession` bailed