fix: stop the mail list flashing a reload right after the page opens

This commit is contained in:
Linus Rath
2026-09-27 18:21:24 +02:00
parent 569c796522
commit aed0d27320
3 changed files with 80 additions and 4 deletions
+19 -4
View File
@@ -115,6 +115,7 @@ import {
parseScheduledMailboxId,
appPath,
buildMailPath,
isFolderLinkOpen,
parseMailPath,
resolveFolderRef,
type MailDeepLink,
@@ -1363,7 +1364,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// session and the mailbox list are up. Runs at most once per mount: after
// this, the URL is an output of the view (see buildMailUrl), not an input.
const deepLinkHandledRef = useRef(false);
const applyMailDeepLink = async (link: MailDeepLink) => {
const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean }) => {
// A permalink can name the account it belongs to. Ids are only meaningful
// within their account, so switch first - but only to a login that is
// actually connected; we can't authenticate on someone's behalf. A push
@@ -1371,7 +1372,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const linkAccountId = link.accountId ?? (link.slot !== undefined
? useAccountStore.getState().accounts.find((a) => a.cookieSlot === link.slot)?.id ?? `slot:${link.slot}`
: undefined);
if (linkAccountId && linkAccountId !== useAuthStore.getState().activeAccountId) {
const switchesAccount = !!linkAccountId && linkAccountId !== useAuthStore.getState().activeAccountId;
if (switchesAccount) {
const target = useAccountStore.getState().accounts.find(
(a) => a.id === linkAccountId && a.isConnected,
);
@@ -1386,11 +1388,24 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (!activeClient) return;
if (link.kind === 'folder') {
const mailboxId = resolveFolderRef(link.ref, useEmailStore.getState().mailboxes);
const state = useEmailStore.getState();
const mailboxId = resolveFolderRef(link.ref, state.mailboxes);
if (!mailboxId) {
toast.error(t('deep_link.folder_not_found'));
return;
}
// Reloading the page lands here with the folder the boot fetch has just
// loaded; selecting it again re-fetched it under the loading overlay,
// so the list flashed right after it appeared.
if (opts?.onLoad && !switchesAccount && isFolderLinkOpen(mailboxId, {
selectedMailbox: state.selectedMailbox,
isUnifiedView: state.isUnifiedView,
isScheduledView: state.isScheduledView,
selectedKeyword: state.selectedKeyword,
hasSearch: !!state.searchQuery || !isFilterEmpty(state.searchFilters),
})) {
return;
}
await handleMailboxSelect(mailboxId);
return;
}
@@ -1548,7 +1563,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
deepLinkHandledRef.current = true;
if (link) {
void applyMailDeepLinkRef.current(link);
void applyMailDeepLinkRef.current(link, { onLoad: true });
return;
}
+32
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import {
buildFolderRef,
resolveFolderRef,
isFolderLinkOpen,
buildMailPath,
parseMailPath,
buildCalendarPath,
@@ -121,6 +122,37 @@ describe('mail folder references', () => {
});
});
describe('isFolderLinkOpen', () => {
const plainInbox = {
selectedMailbox: 'a',
isUnifiedView: false,
isScheduledView: false,
selectedKeyword: null,
hasSearch: false,
};
it('treats the folder the list already shows as open (page reload)', () => {
expect(isFolderLinkOpen(resolveFolderRef('inbox', MAILBOXES)!, plainInbox)).toBe(true);
});
it('does not treat another folder as open', () => {
expect(isFolderLinkOpen('b', plainInbox)).toBe(false);
});
it('does not treat the folder as open while a tag, search or special view covers it', () => {
expect(isFolderLinkOpen('a', { ...plainInbox, selectedKeyword: 'work' })).toBe(false);
expect(isFolderLinkOpen('a', { ...plainInbox, hasSearch: true })).toBe(false);
expect(isFolderLinkOpen('a', { ...plainInbox, isUnifiedView: true })).toBe(false);
expect(isFolderLinkOpen('a', { ...plainInbox, isScheduledView: true })).toBe(false);
});
it('never treats a virtual view as open - the boot fetch does not load them', () => {
for (const id of [UNIFIED_MAILBOX_IDS.inbox, CROSS_VIEW_IDS.unread, SCHEDULED_MAILBOX_ID]) {
expect(isFolderLinkOpen(id, { ...plainInbox, selectedMailbox: id })).toBe(false);
}
});
});
describe('mail paths', () => {
it('round-trips folder, message and thread links', () => {
const folder = buildMailPath({ mailboxId: 'c', emailId: null, threadId: null }, MAILBOXES);
+29
View File
@@ -190,6 +190,35 @@ export function resolveFolderRef(ref: string, mailboxes: Mailbox[] = []): string
return decoded || null;
}
/** The parts of the mail list's state that decide what a folder link shows. */
export interface OpenFolderView {
selectedMailbox: string;
isUnifiedView: boolean;
isScheduledView: boolean;
selectedKeyword: string | null;
hasSearch: boolean;
}
/**
* Whether a folder link names the plain folder the list already shows.
*
* The address bar carries the open folder (`buildMailPath`), so reloading the
* page hands that folder back as a link once the boot fetch has loaded it.
* Selecting it again would only fetch the same list a second time under the
* loading overlay. Virtual views are never "already open" at boot: the boot
* fetch does not load them.
*/
export function isFolderLinkOpen(mailboxId: string, view: OpenFolderView): boolean {
if (VIRTUAL_ALIAS_BY_ID[mailboxId]) return false;
return (
mailboxId === view.selectedMailbox &&
!view.isUnifiedView &&
!view.isScheduledView &&
!view.selectedKeyword &&
!view.hasSearch
);
}
/**
* The canonical path for the current mail view. Returns `/mail` for the bare
* list so the address bar stays clean when nothing is open.