Skip to content
Open
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
2 changes: 2 additions & 0 deletions app/(main)/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIdentitySync } from "@/hooks/use-identity-sync";
import { useResumeConnectivity } from "@/hooks/use-resume-connectivity";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProTabStore } from "@/stores/pro-tab-store";
import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailboxes";
Expand Down Expand Up @@ -125,6 +126,7 @@ export default function Home() {
const { identities } = useIdentityStore();
const multiAccountIdentities = useProMultiAccountIdentities();
useIdentitySync();
useResumeConnectivity();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore();
Expand Down
48 changes: 48 additions & 0 deletions hooks/use-resume-connectivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use client';

import { useEffect } from 'react';
import { useAuthStore } from '@/stores/auth-store';

/**
* When the tab becomes visible again or the browser reports that the network
* came back, force an immediate connectivity check on the active JMAP client
* (bypassing the keep-alive backoff).
*
* Without this hook the keep-alive loop backs off to ~5 minutes between
* pings after a few consecutive failures. That is fine while the tab is
* dormant, but it means a user who suspends their laptop / switches Wi-Fi /
* returns from lunch can watch the "Attempting to reconnect…" banner sit
* for minutes on end even though the server is reachable again.
*
* The hook is a no-op when there is no active client (login screen, demo
* bootstrap in flight) or when the connection is already healthy — the
* client's `resumeConnectivity()` short-circuits both of those.
*/
export function useResumeConnectivity() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const client = useAuthStore((s) => s.client);

useEffect(() => {
if (!isAuthenticated || !client) return;

const attempt = () => {
// Fire-and-forget: the client handles its own retry/backoff internally,
// and the connectionChange callback drives the banner state.
void client.resumeConnectivity();
};

const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') attempt();
};

document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('online', attempt);
window.addEventListener('focus', attempt);

return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('online', attempt);
window.removeEventListener('focus', attempt);
};
}, [isAuthenticated, client]);
}
5 changes: 5 additions & 0 deletions lib/demo/demo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export class DemoJMAPClient implements IJMAPClient {

async reconnect(): Promise<void> { /* no-op */ }
async ping(): Promise<void> { /* no-op */ }
async resumeConnectivity(): Promise<void> {
// The demo client is always "connected"; just re-notify listeners so any
// stale banner clears.
this.connectionCallback?.(true);
}

// ── Session / auth accessors ──────────────────────────────────

Expand Down
107 changes: 107 additions & 0 deletions lib/jmap/__tests__/resume-connectivity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '../client';

// Non-exhaustive helpers to reach into the private state we care about
// (backoff counters, callbacks) without exposing them on the public surface.
interface ClientInternals {
pingSkipRemaining: number;
pingFailureCount: number;
apiUrl: string;
intentionallyDisconnected: boolean;
connectionChangeCallback: ((connected: boolean) => void) | null;
rateLimitedUntil: number;
}

function internals(client: JMAPClient): ClientInternals {
return client as unknown as ClientInternals;
}

describe('JMAPClient.resumeConnectivity', () => {
let client: JMAPClient;

beforeEach(() => {
vi.restoreAllMocks();
client = new JMAPClient('https://example.test', 'user@example.test', 'pw');
// Simulate a fully connected client (apiUrl set by a prior connect()).
internals(client).apiUrl = 'https://example.test/jmap';
internals(client).pingSkipRemaining = 5;
internals(client).pingFailureCount = 3;
});

it('fires the connection callback on a successful ping and resets backoff', async () => {
const onChange = vi.fn();
client.onConnectionChange(onChange);
const pingSpy = vi.spyOn(client, 'ping').mockResolvedValue();
const reconnectSpy = vi.spyOn(client, 'reconnect').mockResolvedValue();

await client.resumeConnectivity();

expect(pingSpy).toHaveBeenCalledOnce();
expect(reconnectSpy).not.toHaveBeenCalled();
// Backoff must be wiped so the next scheduled tick fires immediately if
// things flip again — this is the whole point of the manual resume.
expect(internals(client).pingSkipRemaining).toBe(0);
expect(internals(client).pingFailureCount).toBe(0);
expect(onChange).toHaveBeenCalledWith(true);
});

it('falls through to reconnect() when the ping throws', async () => {
const onChange = vi.fn();
client.onConnectionChange(onChange);
vi.spyOn(client, 'ping').mockRejectedValue(new Error('boom'));
const reconnectSpy = vi.spyOn(client, 'reconnect').mockResolvedValue();

await client.resumeConnectivity();

expect(reconnectSpy).toHaveBeenCalledOnce();
expect(internals(client).pingFailureCount).toBe(0);
expect(onChange).toHaveBeenLastCalledWith(true);
});

it('goes straight to reconnect() when apiUrl is empty (never connected)', async () => {
internals(client).apiUrl = '';
const pingSpy = vi.spyOn(client, 'ping').mockResolvedValue();
const reconnectSpy = vi.spyOn(client, 'reconnect').mockResolvedValue();

await client.resumeConnectivity();

expect(pingSpy).not.toHaveBeenCalled();
expect(reconnectSpy).toHaveBeenCalledOnce();
});

it('is a no-op after intentional disconnect', async () => {
internals(client).intentionallyDisconnected = true;
const pingSpy = vi.spyOn(client, 'ping').mockResolvedValue();
const reconnectSpy = vi.spyOn(client, 'reconnect').mockResolvedValue();

await client.resumeConnectivity();

expect(pingSpy).not.toHaveBeenCalled();
expect(reconnectSpy).not.toHaveBeenCalled();
});

it('is a no-op while rate-limited', async () => {
internals(client).rateLimitedUntil = Date.now() + 60_000;
const pingSpy = vi.spyOn(client, 'ping').mockResolvedValue();
const reconnectSpy = vi.spyOn(client, 'reconnect').mockResolvedValue();

await client.resumeConnectivity();

expect(pingSpy).not.toHaveBeenCalled();
expect(reconnectSpy).not.toHaveBeenCalled();
});

it('keeps quiet when both ping and reconnect fail (banner state stays)', async () => {
const onChange = vi.fn();
client.onConnectionChange(onChange);
vi.spyOn(client, 'ping').mockRejectedValue(new Error('ping down'));
vi.spyOn(client, 'reconnect').mockRejectedValue(new Error('reconnect down'));

// Should not throw — the caller (visibilitychange handler) has nothing
// useful to do with the error.
await expect(client.resumeConnectivity()).resolves.toBeUndefined();

// Never claim a successful re-establishment.
expect(onChange).not.toHaveBeenCalledWith(true);
});
});
9 changes: 9 additions & 0 deletions lib/jmap/client-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ export interface IJMAPClient {
disconnect(): void;
reconnect(): Promise<void>;
ping(): Promise<void>;
/**
* Force an immediate connectivity check, bypassing the keep-alive backoff.
* Called when the tab becomes visible again or the browser reports the
* network came back — situations where we know something changed and the
* ~5-min backoff would otherwise strand the "reconnecting…" banner.
* Fires the connectionChange callback on success, and triggers a reconnect
* attempt on failure.
*/
resumeConnectivity(): Promise<void>;

// ── Session / auth accessors ──────────────────────────────────
getServerUrl(): string;
Expand Down
46 changes: 46 additions & 0 deletions lib/jmap/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,52 @@ export class JMAPClient implements IJMAPClient {
await this.connect();
}

/**
* Force an immediate connectivity check that bypasses `pingSkipRemaining`.
*
* The keep-alive loop backs off exponentially on failure (up to ~5 min
* between attempts). That's the right behaviour while the tab is dormant,
* but wrong when we have a reason to believe things have changed: the tab
* just became visible, the network came back, the user's about to interact.
* In those cases the "reconnecting…" banner would otherwise stick around
* for minutes despite the underlying issue being gone.
*
* On success this fires the connection callback (clearing the banner) and
* resets the backoff counters. On failure it drops through to `reconnect()`
* so the caller doesn't have to duplicate the re-establish logic.
*/
async resumeConnectivity(): Promise<void> {
if (this.intentionallyDisconnected) return;
if (this.isRateLimited()) return;
// Reset the skip counter so the next scheduled tick fires immediately if
// this manual attempt also fails.
this.pingSkipRemaining = 0;
try {
// ping() throws if !this.apiUrl, so short-circuit through reconnect()
// in that case (session was never fully established).
if (!this.apiUrl) {
await this.reconnect();
} else {
await this.ping();
}
this.pingFailureCount = 0;
this.connectionChangeCallback?.(true);
} catch {
// Ping/session refresh failed — try a full reconnect once. Errors bubble
// up so callers can log; the banner state is already false from the
// last ping tick or will be set by the next scheduled ping.
try {
await this.reconnect();
this.pingFailureCount = 0;
this.pingSkipRemaining = 0;
this.connectionChangeCallback?.(true);
} catch (reconnectError) {
// Leave the banner as-is; the next keep-alive tick will retry.
console.error('resumeConnectivity: reconnect failed:', reconnectError);
}
}
}

disconnect(): void {
this.intentionallyDisconnected = true;
this.stopKeepAlive();
Expand Down
Loading