From 6139031689e4e8bb19bbd995c8463a2b9e445a56 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 16 Sep 2026 09:48:04 -0700 Subject: [PATCH] Load the composer, previews, dialogs and other sidebars on demand The main chunk carried everything the mail view might open: the file preview and its Markdown renderer, the composer and its editor, the contact editor, the filter and share dialogs, and the calendar, contacts and files sidebars. Each is now loaded when first shown. The composer is also fetched when the browser is idle after startup, so the first Compose does not wait on the network. Import the notification helpers statically where they already were: the dynamic imports beside those static ones split nothing. --- web/src/App.tsx | 10 ++++---- web/src/store/mail/index.ts | 2 +- web/src/views/AppShell.tsx | 18 ++++++++------ web/src/views/compose/ComposerDock.tsx | 23 ++++++++++++++---- .../compose/__tests__/composer-dock.test.tsx | 24 +++++++++++-------- web/src/views/mail/AddressMenu.tsx | 8 +++++-- web/src/views/mail/MailboxTree.tsx | 8 ++++--- web/src/views/mail/MessageList.tsx | 8 ++++--- web/src/views/mail/MessageView.tsx | 16 +++++++++---- 9 files changed, 76 insertions(+), 41 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 3645092..826824e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,7 +16,7 @@ import { LoginPage } from "@/views/Login"; import { AppShell } from "@/views/AppShell"; import { MailView } from "@/views/mail/MailView"; import { ComposerDock } from "@/views/compose/ComposerDock"; -import { setUnreadBadge } from "@/lib/notify/notify"; +import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify"; import { publishWorkerFacts } from "@/lib/sw/swFacts"; import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings"; import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync"; @@ -260,10 +260,8 @@ function AuthedApp() { }); const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; useEffect(() => { - void import("@/lib/notify/notify").then((m) => { - m.setBaseTitle(appName); - setUnreadBadge(inboxUnread); - }); + setBaseTitle(appName); + setUnreadBadge(inboxUnread); }, [inboxUnread, appName]); /* @@ -284,7 +282,7 @@ function AuthedApp() { // Request notification permission lazily when enabled const notif = useSettings((s) => s.settings.desktopNotifications); useEffect(() => { - if (notif) void import("@/lib/notify/notify").then((m) => m.requestNotificationPermission()); + if (notif) void requestNotificationPermission(); }, [notif]); // Nothing worth painting until the account's settings are in force; see the diff --git a/web/src/store/mail/index.ts b/web/src/store/mail/index.ts index 9b2ee3c..21fadf8 100644 --- a/web/src/store/mail/index.ts +++ b/web/src/store/mail/index.ts @@ -28,6 +28,7 @@ import { plural, t } from "@/lib/i18n"; import { withBase } from "@/lib/basePath"; import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props"; import { type ListQuery, type MailState } from "./types"; +import { playNewMailSound, showNotification } from "@/lib/notify/notify"; /* * `@/store/mail` stays the one public entry. The split below is about file @@ -1206,7 +1207,6 @@ async function notifyNewMail(created: Id[], get: () => MailState) { const emails = await get().getEmails(created); const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft); if (!fresh.length) return; - const { showNotification, playNewMailSound } = await import("@/lib/notify/notify"); if (s.notificationSound) playNewMailSound(); if (s.desktopNotifications) { for (const e of fresh.slice(0, 3)) { diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index f716ad9..d31181b 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react"; import { useSession } from "@/store/session"; @@ -13,9 +13,6 @@ import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { Splitter } from "@/ui/Splitter"; import { SearchBar } from "./SearchBar"; import { MailboxTree } from "./mail/MailboxTree"; -import { FilesTree } from "./files/FilesTree"; -import { ContactsSidebar } from "./contacts/ContactsSidebar"; -import { CalendarSidebar } from "./calendar/CalendarSidebar"; import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts"; import { MailboxPicker } from "./mail/MailboxPicker"; import { formatSize } from "@/lib/format"; @@ -26,6 +23,11 @@ import { hasAdministration } from "@/lib/admin/adminAccess"; import { usePermissions } from "./admin/usePermissions"; import { AdminNav } from "./admin/AdminNav"; +// The other sections' sidebars load with the section, as their views already do. +const FilesTree = lazy(() => import("./files/FilesTree").then((m) => ({ default: m.FilesTree }))); +const ContactsSidebar = lazy(() => import("./contacts/ContactsSidebar").then((m) => ({ default: m.ContactsSidebar }))); +const CalendarSidebar = lazy(() => import("./calendar/CalendarSidebar").then((m) => ({ default: m.CalendarSidebar }))); + /* * How far the sidebar edge can be dragged. Below about 228px the module bar * cuts "Calendar" and "Contacts" short in English; the floor sits a little @@ -252,9 +254,11 @@ export function AppShell({ children }: { children: ReactNode }) {
{(section === "mail" || section === "search") && } - {section === "calendar" && } - {section === "contacts" && } - {section === "files" && } + + {section === "calendar" && } + {section === "contacts" && } + {section === "files" && } + {section === "settings" &&
{t("Settings")}
} {section === "admin" && }
diff --git a/web/src/views/compose/ComposerDock.tsx b/web/src/views/compose/ComposerDock.tsx index 878e5ee..8685779 100644 --- a/web/src/views/compose/ComposerDock.tsx +++ b/web/src/views/compose/ComposerDock.tsx @@ -1,7 +1,20 @@ +import { lazy, Suspense } from "react"; import { useCompose } from "@/store/compose"; -import { Composer } from "./Composer"; import { useIsMobile } from "@/ui/misc"; +/* + * The composer -- the rich-text editor, the recipient and file pickers -- is + * loaded apart from the mail view, and fetched while the browser is idle + * after startup so the first Compose does not wait on the network. + */ +const loadComposer = () => import("./Composer"); +const Composer = lazy(() => loadComposer().then((m) => ({ default: m.Composer }))); +if (typeof window !== "undefined") { + const warm = () => void loadComposer().catch(() => {}); + if ("requestIdleCallback" in window) window.requestIdleCallback(warm, { timeout: 5000 }); + else setTimeout(warm, 2000); +} + export function ComposerDock() { const drafts = useCompose((s) => s.drafts); const activeKey = useCompose((s) => s.activeKey); @@ -13,9 +26,11 @@ export function ComposerDock() { const hasMaximized = !isMobile && drafts.some((d) => d.maximized && !d.minimized); return (
- {visible.map((d) => ( - - ))} + + {visible.map((d) => ( + + ))} +
); } diff --git a/web/src/views/compose/__tests__/composer-dock.test.tsx b/web/src/views/compose/__tests__/composer-dock.test.tsx index 0f79cef..498e9c5 100644 --- a/web/src/views/compose/__tests__/composer-dock.test.tsx +++ b/web/src/views/compose/__tests__/composer-dock.test.tsx @@ -42,35 +42,39 @@ describe("ComposerDock with a full-screen composer", () => { useCompose.setState({ drafts: [], activeKey: null }); }); - const render = (drafts: Draft[], activeKey: string) => { + // The composer is loaded on demand, so rendering waits for it to arrive. + const render = async (drafts: Draft[], activeKey: string) => { useCompose.setState({ drafts, activeKey }); - act(() => root.render()); + await act(async () => { + root.render(); + await import("../Composer"); + }); }; const dock = () => host.querySelector(".composer-dock")!; - it("marks the dock so the other composers are hidden behind it", () => { + it("marks the dock so the other composers are hidden behind it", async () => { setWidth(1300); - render([draft("a"), draft("b", { maximized: true }), draft("c")], "b"); + await render([draft("a"), draft("b", { maximized: true }), draft("c")], "b"); expect(dock().classList.contains("has-maximized")).toBe(true); // Every composer stays mounted: the hiding is the stylesheet's, so nothing being typed elsewhere is lost. expect(host.querySelectorAll(".composer").length).toBe(3); }); - it("leaves the dock alone while nobody is full screen", () => { + it("leaves the dock alone while nobody is full screen", async () => { setWidth(1300); - render([draft("a"), draft("b")], "b"); + await render([draft("a"), draft("b")], "b"); expect(dock().classList.contains("has-maximized")).toBe(false); }); - it("does not count a full-screen composer that has since been minimized", () => { + it("does not count a full-screen composer that has since been minimized", async () => { setWidth(1300); - render([draft("a"), draft("b", { maximized: true, minimized: true })], "a"); + await render([draft("a"), draft("b", { maximized: true, minimized: true })], "a"); expect(dock().classList.contains("has-maximized")).toBe(false); }); - it("is not a phone concern: there the active composer is already the only one open", () => { + it("is not a phone concern: there the active composer is already the only one open", async () => { setWidth(400); - render([draft("a"), draft("b", { maximized: true })], "b"); + await render([draft("a"), draft("b", { maximized: true })], "b"); expect(dock().classList.contains("has-maximized")).toBe(false); }); }); diff --git a/web/src/views/mail/AddressMenu.tsx b/web/src/views/mail/AddressMenu.tsx index d0da565..bd5d6cf 100644 --- a/web/src/views/mail/AddressMenu.tsx +++ b/web/src/views/mail/AddressMenu.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, type MouseEvent, type ReactNode } from "react"; +import { lazy, Suspense, useCallback, useState, type MouseEvent, type ReactNode } from "react"; import { Copy, Mail, Pencil, UserPlus } from "lucide-react"; import type { EmailAddress } from "@/jmap/types"; import { useContacts } from "@/store/contacts"; @@ -7,9 +7,11 @@ import { contactFromAddress } from "@/lib/contacts"; import { formatAddress } from "@/lib/address"; import { MenuItem, MenuSep, Popover, type Anchor } from "@/ui/popover"; import { toast } from "@/ui/toast"; -import { ContactEditor } from "../contacts/ContactEditor"; import { t } from "@/lib/i18n"; +// Loaded when first opened: it is not needed to show mail, and it is not small. +const ContactEditor = lazy(() => import("../contacts/ContactEditor").then((m) => ({ default: m.ContactEditor }))); + /** * Right-click on anyone named in a message — sender, recipients, Reply-To — to * add them to the address book. The contact editor opens prefilled rather than @@ -65,12 +67,14 @@ export function useAddressMenu() { )} {editing && ( + setEditing(null)} onSaved={() => setEditing(null)} /> + )} ); diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 9e64aef..222a139 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react"; +import { lazy, Suspense, useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; import { AlertOctagon, Archive, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react"; import { useMail } from "@/store/mail"; @@ -11,7 +11,6 @@ import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover"; import { CALENDAR_COLORS, useIsMobile, useIsTouch } from "@/ui/misc"; import { confirmDialog, promptDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; -import { ShareDialog } from "../settings/ShareDialog"; import { MailboxPicker } from "./MailboxPicker"; import { loadRaw, saveJson } from "@/lib/storage"; import { canDropFolder, canMoveFolderTo, folderColor, movable } from "@/lib/mailbox/folderMove"; @@ -19,6 +18,9 @@ import { haptic, useTouchRow } from "@/lib/input/touch"; import { plural, t } from "@/lib/i18n"; import { mailboxDisplayName } from "@/lib/mailbox/mailboxName"; +// Loaded when first opened: it is not needed to show mail, and it is not small. +const ShareDialog = lazy(() => import("../settings/ShareDialog").then((m) => ({ default: m.ShareDialog }))); + const ROLE_ICONS: Record = { inbox: , drafts: , @@ -273,7 +275,7 @@ export function MailboxTree() { }} /> )} - {shareTarget && setShareTarget(null)} />} + {shareTarget && setShareTarget(null)} />} ); } diff --git a/web/src/views/mail/MessageList.tsx b/web/src/views/mail/MessageList.tsx index 9af67a9..cd549c7 100644 --- a/web/src/views/mail/MessageList.tsx +++ b/web/src/views/mail/MessageList.tsx @@ -1,4 +1,4 @@ -import { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react"; +import { Fragment, lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useShallow } from "zustand/react/shallow"; import { Archive, ArrowLeft, CalendarDays, CalendarRange, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react"; @@ -21,9 +21,11 @@ import { startAppointment } from "@/lib/calendar/appointment"; import { toast } from "@/ui/toast"; import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/input/touch"; import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/input/swipe"; -import { FilterFromMessageDialog } from "./FilterFromMessage"; import { plural, t } from "@/lib/i18n"; +// Loaded when first opened: it is not needed to show mail, and it is not small. +const FilterFromMessageDialog = lazy(() => import("./FilterFromMessage").then((m) => ({ default: m.FilterFromMessageDialog }))); + /** * The glyph on the strip a swipe reveals. Sized larger than the toolbar's * icons: it is read at arm's length, in motion, out of the corner of an eye. @@ -560,7 +562,7 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId, } label={t("Filter messages like this…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} /> {hasCalendar && } label={t("Create event…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message)); }} />} - {filterFrom && setFilterFrom(null)} />} + {filterFrom && setFilterFrom(null)} />} ); } diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index e18dc5d..5aa22a4 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -1,7 +1,6 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MailPlus, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File as FileIcon, Eye, Calendar, CalendarPlus, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter, Share2 } from "lucide-react"; import { useLocation } from "wouter"; -import { FilterFromMessageDialog } from "./FilterFromMessage"; import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types"; import { useMail } from "@/store/mail"; import { useSettings } from "@/store/settings"; @@ -20,7 +19,10 @@ import { formatFullDate, formatListDate, formatSize } from "@/lib/format"; import { displayName, domainOf, formatAddress } from "@/lib/address"; import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html"; import { openableInTab, previewKind } from "@/lib/preview"; -import { FilePreviewDialog } from "@/ui/filepreview"; +// Loaded when first opened: it is not needed to show mail, and it is not small. +const FilterFromMessageDialog = lazy(() => import("./FilterFromMessage").then((m) => ({ default: m.FilterFromMessageDialog }))); +// The preview carries a Markdown renderer, which is most of its weight. +const FilePreviewDialog = lazy(() => import("@/ui/filepreview").then((m) => ({ default: m.FilePreviewDialog }))); import { findQuoteStart, htmlToText, textToHtml, withoutBidiControls } from "@/lib/text/text"; import { canShare, canShareFiles, shareFile, shareText } from "@/lib/share"; import { Avatar } from "@/ui/misc"; @@ -456,7 +458,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn )} {addrMenu.node} - {filterOpen && setFilterOpen(false)} />} + {filterOpen && setFilterOpen(false)} />} setShowSource(false)} title={translate("Original message")} size="xl"> {source === null ?
:
{source}
}
@@ -915,8 +917,10 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB )} + {preview && ( + setPreview(null)} caption={

{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}

} /> +
+ )} ); }