fix: stop the mail list skeleton flickering after a reload or account switch

This commit is contained in:
Linus Rath
2026-10-15 23:44:06 +02:00
parent dd2104cdda
commit 07e6838d90
6 changed files with 137 additions and 22 deletions
@@ -0,0 +1,46 @@
import { act, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { EmailList } from '../email-list';
import { useEmailStore } from '@/stores/email-store';
vi.mock('@/hooks/use-email-drag', () => ({
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
}));
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
vi.mock('@/stores/auth-store', async () => {
const { create } = await import('zustand');
return {
useAuthStore: create(() => ({ client: null, identities: [], username: null, activeAccountId: 'acct-1' })),
};
});
vi.mock('@/stores/account-store', () => {
const state = { getAccountById: () => undefined };
return {
useAccountStore: Object.assign(
(selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
{ getState: () => state },
),
};
});
const initialEmailState = useEmailStore.getState();
beforeEach(() => {
useEmailStore.setState(initialEmailState, true);
useEmailStore.setState({ selectedMailbox: 'inbox' });
});
describe('email list loading skeleton', () => {
// An account switch updates the store many times before the first page
// arrives. A remounted skeleton replays its fade-in, so it flickered.
it('stays the same element while the list re-renders', () => {
const view = render(<EmailList emails={[]} isLoading />);
const skeleton = screen.getByTestId('email-list-skeleton');
act(() => { useEmailStore.setState({ mailboxes: [], isLoadingMore: false, hasMoreEmails: true }); });
view.rerender(<EmailList emails={[]} isLoading selectedEmailId="x" />);
expect(screen.getByTestId('email-list-skeleton')).toBe(skeleton);
});
});
+25 -20
View File
@@ -82,6 +82,31 @@ interface EmailListProps {
/** Below this width the batch toolbar drops its labels (see batchNarrow). */
const BATCH_TOOLBAR_WIDE = 480;
// Declared out here so re-renders of the list keep the same element. Inside
// EmailList every render made a new component, which remounted the skeleton
// and replayed its fade-in each time the store changed while a list loaded.
function LoadingSkeleton() {
return (
<div className="animate-in fade-in duration-200" data-testid="email-list-skeleton">
{[...Array(8)].map((_, i) => (
<div key={i} className="border-b border-border px-4 py-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-muted/50 rounded-full" />
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<div className="h-4 bg-muted/50 rounded w-32" />
<div className="h-3 bg-muted/50 rounded w-16" />
</div>
<div className="h-4 bg-muted/50 rounded w-3/4 mb-2" />
<div className="h-3 bg-muted/50 rounded w-full" />
</div>
</div>
</div>
))}
</div>
);
}
export function EmailList({
emails,
selectedEmailId,
@@ -416,26 +441,6 @@ export function EmailList({
virtualizer.scrollToOffset(0);
}, [viewKey, virtualizer]);
const LoadingSkeleton = () => (
<div className="animate-in fade-in duration-200">
{[...Array(8)].map((_, i) => (
<div key={i} className="border-b border-border px-4 py-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-muted/50 rounded-full" />
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<div className="h-4 bg-muted/50 rounded w-32" />
<div className="h-3 bg-muted/50 rounded w-16" />
</div>
<div className="h-4 bg-muted/50 rounded w-3/4 mb-2" />
<div className="h-3 bg-muted/50 rounded w-full" />
</div>
</div>
</div>
))}
</div>
);
const hasSelection = selectedEmailKeys.size > 0;
// What plugin buttons in the selection toolbar act on: the JMAP ids of the
// selected messages, thread members included.
@@ -43,6 +43,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { useIdentityStore } from '@/stores/identity-store';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { evictAccount } from '@/lib/account-state-manager';
const acct = (username: string, host: string, slot: number) => ({
@@ -89,3 +90,29 @@ it("overlapping switches keep every account's data under its own key", async ()
expect(st.primaryIdentity?.email).toBe('a@x.test');
expect(useEmailStore.getState().emails.map((e) => e.subject)).toEqual(['mail of A']);
});
it("keeps the outgoing account's mail on screen while its settings are saved", async () => {
useAccountStore.setState({ accounts: [A, B] as never, activeAccountId: A.id, defaultAccountId: A.id });
useAuthStore.setState({ isAuthenticated: true, activeAccountId: A.id, client: null });
const sw = (id: string) => useAuthStore.getState().switchAccount(id);
// Connect both, so the next switch takes the seamless path.
await sw(B.id); await sw(A.id);
useEmailStore.setState({ emails: [{ id: 'mA', subject: 'mail of A' }] as never });
// The flush is a request; whatever the store holds meanwhile is on screen.
const flushSync = useSettingsStore.getState().flushSync;
let onScreen: string[] = [];
useSettingsStore.setState({
flushSync: async () => {
await sleep(0);
onScreen = useEmailStore.getState().emails.map((e) => e.subject ?? '');
},
});
try {
await sw(B.id);
} finally {
useSettingsStore.setState({ flushSync });
}
expect(onScreen).toEqual(['mail of A']);
expect(useAuthStore.getState().activeAccountId).toBe(B.id);
});
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useEmailStore } from '@/stores/email-store';
// On a first load the folders arrive before the first page of mail. If the
// folder fetch turned isLoading off, the list showed "No emails" until the
// page fetch turned it on again, then a fresh skeleton.
const initialState = useEmailStore.getState();
const clientWith = (mailboxes: unknown[]) => ({
getAllMailboxesWithState: async () => ({ mailboxes, states: {} }),
});
beforeEach(() => {
useEmailStore.setState(initialState, true);
});
describe('fetchMailboxes on a first load', () => {
it('leaves the list loading for the page fetch that follows', async () => {
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 3, unreadEmails: 0, sortOrder: 0 };
await useEmailStore.getState().fetchMailboxes(clientWith([inbox]) as never);
expect(useEmailStore.getState().selectedMailbox).toBe('inbox');
expect(useEmailStore.getState().isLoading).toBe(true);
});
it('stops loading when the server has no folders', async () => {
await useEmailStore.getState().fetchMailboxes(clientWith([]) as never);
expect(useEmailStore.getState().isLoading).toBe(false);
});
});
+3 -1
View File
@@ -2499,11 +2499,13 @@ export const useAuthStore = create<AuthState>()(
// clearing it), so snapshot + clear it now - synchronously, right before
// the restore and client swap below - so the UI never blanks between the
// two accounts. The network path already snapshotted and cleared above.
// The settings flush is a request, so it goes first: awaited after the
// clear, the list showed "No emails" until it came back.
if (wasConnected) {
snapshotAndClearForSwitch(get().activeAccountId);
await useSettingsStore.getState().flushSync();
useSettingsStore.getState().disableSync();
if (superseded()) return;
snapshotAndClearForSwitch(get().activeAccountId);
}
// Restore cached state or fetch fresh
+4 -1
View File
@@ -2449,7 +2449,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|| isUnifiedMailboxId(currentSelectedMailbox)
|| isCrossViewId(currentSelectedMailbox)
|| (currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox));
const loadingPatch = isInitialLoad
// A first load with folders leaves isLoading on: the first page is
// fetched next and turns it off. Dropping it here showed "No emails"
// between the two, and a new skeleton after it.
const loadingPatch = isInitialLoad && mailboxes.length === 0
? { isLoading: false, mailboxSyncStates: fetched.states }
: { mailboxSyncStates: fetched.states };
if (!selectionValid) {