mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: stop the mail list skeleton flickering after a reload or account switch
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user