mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
perf: show the cached inbox at start before the server's session arrives
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user