From aed0d27320937545dc63bf92cf11e5fcb85d82a9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:21:24 +0200 Subject: [PATCH] fix: stop the mail list flashing a reload right after the page opens --- components/mail/mail-app.tsx | 23 +++++++++++++++++++---- lib/__tests__/deep-links.test.ts | 32 ++++++++++++++++++++++++++++++++ lib/deep-links.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 286b3a8bc..df2c339f0 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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; } diff --git a/lib/__tests__/deep-links.test.ts b/lib/__tests__/deep-links.test.ts index 3b988c289..e43e269b5 100644 --- a/lib/__tests__/deep-links.test.ts +++ b/lib/__tests__/deep-links.test.ts @@ -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); diff --git a/lib/deep-links.ts b/lib/deep-links.ts index 5ced1dfc1..d20ff3823 100644 --- a/lib/deep-links.ts +++ b/lib/deep-links.ts @@ -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.