From 6af6e6277426e7c92abbb7175512f4844e1fa54b Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Fri, 15 May 2026 23:51:57 +0200 Subject: [PATCH] major perf improvements --- components/gmail/compose-modal.tsx | 9 +- components/gmail/contact-hover-card.tsx | 4 +- components/gmail/email-list-row.tsx | 49 +++++++++++ components/gmail/email-list.tsx | 61 ++++++++++---- components/gmail/email-view.tsx | 9 +- components/gmail/mobile-bottom-bar.tsx | 4 +- components/gmail/move-drag-indicator.tsx | 33 ++++---- components/gmail/sidebar.tsx | 4 +- lib/compose-context.tsx | 73 +++++++++++++---- lib/drag-context.tsx | 61 +++++++------- lib/drag-pointer-store.ts | 39 +++++++++ lib/mail-folder-filter.ts | 22 ++++- lib/mail-nav-metrics.ts | 24 ++++-- lib/stores/debounced-json-storage.ts | 100 +++++++++++++++++++++++ lib/stores/mail-store.ts | 2 + lib/stores/nav-store.ts | 2 + lib/stores/scheduled-store.ts | 2 + tsconfig.tsbuildinfo | 2 +- 18 files changed, 399 insertions(+), 101 deletions(-) create mode 100644 components/gmail/email-list-row.tsx create mode 100644 lib/drag-pointer-store.ts create mode 100644 lib/stores/debounced-json-storage.ts diff --git a/components/gmail/compose-modal.tsx b/components/gmail/compose-modal.tsx index f806d31..ecdbd37 100644 --- a/components/gmail/compose-modal.tsx +++ b/components/gmail/compose-modal.tsx @@ -67,7 +67,8 @@ import { DEFAULT_IDENTITIES, MOCK_CONTACTS, SIGNATURES, - useCompose, + useComposeActions, + useComposeWindows, } from "@/lib/compose-context" import { useScheduledMail } from "@/lib/scheduled-mail-context" import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail" @@ -987,7 +988,7 @@ function SignatureButton({ editor: Editor | null compose: ComposeState }) { - const { updateCompose } = useCompose() + const { updateCompose } = useComposeActions() const replaceSignature = useCallback( (sigId: string | null) => { @@ -1233,7 +1234,7 @@ export function ComposeWindow({ toggleMinimize, toggleMaximize, restoreComposeFromSnapshot, - } = useCompose() + } = useComposeActions() const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } = useScheduledMail() const isInline = compose.placement === "inline" @@ -2226,7 +2227,7 @@ export function ComposeWindow({ } export function ComposeModalManager() { - const { composeWindows } = useCompose() + const { composeWindows } = useComposeWindows() const isXs = useIsXs() const nonMaximized = composeWindows.filter( diff --git a/components/gmail/contact-hover-card.tsx b/components/gmail/contact-hover-card.tsx index d4dcca5..dab64e8 100644 --- a/components/gmail/contact-hover-card.tsx +++ b/components/gmail/contact-hover-card.tsx @@ -23,7 +23,7 @@ import { UserPlus, Video, } from "lucide-react" -import { useCompose } from "@/lib/compose-context" +import { useComposeActions } from "@/lib/compose-context" export interface ContactHoverCardProps { /** Champ expéditeur brut (liste, conversation, etc.) */ @@ -45,7 +45,7 @@ export function ContactHoverCard({ align = "start", side = "bottom", }: ContactHoverCardProps) { - const { openComposeWithInitial } = useCompose() + const { openComposeWithInitial } = useComposeActions() const [open, setOpen] = useState(false) const name = cleanSenderName(displayName) const email = resolveSenderEmail(displayName, emailOverride) diff --git a/components/gmail/email-list-row.tsx b/components/gmail/email-list-row.tsx new file mode 100644 index 0000000..66e367f --- /dev/null +++ b/components/gmail/email-list-row.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useCallback } from "react" +import type { Email } from "@/lib/email-data" +import { useMailStore } from "@/lib/stores/mail-store" + +type ListMailIndex = { + emailById: Map + scheduledIds: Set +} + +/** O(n) index for list row logic — avoids repeated `allEmails.some` / `find` per row. */ +export function buildListMailIndex(emails: Email[]): ListMailIndex { + const emailById = new Map() + const scheduledIds = new Set() + for (const e of emails) { + emailById.set(e.id, e) + if (e.labels?.includes("scheduled")) scheduledIds.add(e.id) + } + return { emailById, scheduledIds } +} + +export type MailRowFlags = { + isRead: boolean + isStarred: boolean + isImportant: boolean +} + +/** + * Per-row mail UI flags from the persisted mail store. + * Use inside a keyed `memo` row component (not a plain `.map` callback). + */ +export function useMailRowFlags(email: Email): MailRowFlags { + const id = email.id + const readOverride = useMailStore( + useCallback((s) => s.readOverrides[id], [id]) + ) + const starred = useMailStore( + useCallback((s) => s.starredIds.includes(id), [id]) + ) + const important = useMailStore( + useCallback((s) => s.importantIds.includes(id), [id]) + ) + return { + isRead: readOverride !== undefined ? readOverride : email.read, + isStarred: starred || email.starred, + isImportant: important || email.important, + } +} diff --git a/components/gmail/email-list.tsx b/components/gmail/email-list.tsx index c5992d0..1d53d7f 100644 --- a/components/gmail/email-list.tsx +++ b/components/gmail/email-list.tsx @@ -121,7 +121,12 @@ import { type MoveTarget, } from "@/components/gmail/move-to-menu-items" import { EmailView } from "./email-view" -import { useCompose, type Contact } from "@/lib/compose-context" +import { buildListMailIndex } from "./email-list-row" +import { + useComposeActions, + useComposeDrafts, + type Contact, +} from "@/lib/compose-context" import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics" import { effectiveLabels, @@ -595,12 +600,12 @@ export function EmailList({ }: EmailListProps) { const isViewMode = openMailId !== null + const { savedThreadReplyDrafts } = useComposeDrafts() const { openComposeWithInitial, closeAllInlineComposes, pruneInlineComposesToOpenThread, - savedThreadReplyDrafts, - } = useCompose() + } = useComposeActions() const { scheduledEmails, @@ -884,13 +889,26 @@ export function EmailList({ }, [isRefreshing, isViewMode, isXs, applyPullVisual]) const filteredEmails = useMemo(() => { - const visible = allEmails - .filter((email) => !hiddenEmailIds.includes(email.id)) - .map((e) => + const hiddenSet = new Set(hiddenEmailIds) + const subtreeIdsCache = new Map() + let visible = allEmails.filter((email) => !hiddenSet.has(email.id)) + const hasLabelEdits = + labelEdits && + (Object.keys(labelEdits.additions).length > 0 || + Object.keys(labelEdits.removals).length > 0) + if (hasLabelEdits || notSpamEmailIds.length > 0) { + visible = visible.map((e) => mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds) ) + } let rows = visible.filter((email) => - emailMatchesFolder(email, selectedFolder, folderFilterCtx, navMaps) + emailMatchesFolder( + email, + selectedFolder, + folderFilterCtx, + navMaps, + subtreeIdsCache + ) ) if (selectedFolder === "inbox") { rows = rows.filter((email) => email.category === inboxTab) @@ -947,6 +965,21 @@ export function EmailList({ return pagedEmails }, [isXs, isViewMode, filteredEmails, mobileVisibleCount, pagedEmails]) + const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails]) + + const listRowExtras = useMemo(() => { + const invitationById = new Map< + string, + ReturnType + >() + const attachmentsById = new Map() + for (const e of listEmails) { + invitationById.set(e.id, resolveParsedCalendarInvitation(e)) + attachmentsById.set(e.id, attachmentsForEmailList(e)) + } + return { invitationById, attachmentsById } + }, [listEmails]) + useEffect(() => { if (isXs) return if (listPage > totalPages) { @@ -2658,7 +2691,7 @@ export function EmailList({
{!isViewMode && (
0 && contextTargetIds.every((id) => - allEmails.some( - (e) => e.id === id && e.labels?.includes("scheduled") - ) + listMailIndex.scheduledIds.has(id) ) const scheduledCtxAnyUnread = allContextTargetsScheduled && contextTargetIds.some((id) => { - const em = allEmails.find((e) => e.id === id) + const em = listMailIndex.emailById.get(id) if (!em) return false return !(readOverrides[id] ?? em.read) }) diff --git a/components/gmail/email-view.tsx b/components/gmail/email-view.tsx index 92bc63c..e65f463 100644 --- a/components/gmail/email-view.tsx +++ b/components/gmail/email-view.tsx @@ -61,7 +61,9 @@ import { shouldUseAttachmentPillsInPreview, } from "@/lib/attachment-display" import { - useCompose, + useComposeActions, + useComposeDrafts, + useComposeWindows, DEFAULT_IDENTITIES, type ThreadComposeKind, savedThreadDraftToComposePreset, @@ -761,8 +763,9 @@ export function EmailView({ const mainSenderName = cleanSenderName(email.sender) const mainSenderAddr = email.senderEmail || `${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com` - const { composeWindows, openComposeWithInitial, savedThreadReplyDrafts } = - useCompose() + const { composeWindows } = useComposeWindows() + const { savedThreadReplyDrafts } = useComposeDrafts() + const { openComposeWithInitial } = useComposeActions() const inlineCompose = useMemo( () => composeWindows.find( diff --git a/components/gmail/mobile-bottom-bar.tsx b/components/gmail/mobile-bottom-bar.tsx index 303cacd..57adf54 100644 --- a/components/gmail/mobile-bottom-bar.tsx +++ b/components/gmail/mobile-bottom-bar.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect, useCallback } from "react" import { Menu, Search, X, ChevronLeft, Pencil } from "lucide-react" import { Button } from "@/components/ui/button" -import { useCompose } from "@/lib/compose-context" +import { useComposeActions } from "@/lib/compose-context" interface MobileBottomBarProps { sidebarOpen: boolean @@ -16,7 +16,7 @@ export function MobileBottomBar({ }: MobileBottomBarProps) { const [searchValue, setSearchValue] = useState("") const inputRef = useRef(null) - const { openCompose } = useCompose() + const { openCompose } = useComposeActions() const hasSearch = searchValue.length > 0 diff --git a/components/gmail/move-drag-indicator.tsx b/components/gmail/move-drag-indicator.tsx index 9433a90..2803e1a 100644 --- a/components/gmail/move-drag-indicator.tsx +++ b/components/gmail/move-drag-indicator.tsx @@ -1,9 +1,13 @@ "use client" -import { useEffect, useLayoutEffect, useRef, useState } from "react" +import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react" import { createPortal } from "react-dom" import { Mail } from "lucide-react" import { EMAIL_DRAG_RETURN_MS, useEmailDrag } from "@/lib/drag-context" +import { + getDragPointerSnapshot, + subscribeDragPointer, +} from "@/lib/drag-pointer-store" import { useIsXs } from "@/hooks/use-xs" export function MoveDragIndicator() { @@ -12,24 +16,16 @@ export function MoveDragIndicator() { const [mounted, setMounted] = useState(false) const elRef = useRef(null) + const livePointer = useSyncExternalStore( + subscribeDragPointer, + getDragPointerSnapshot, + () => ({ x: 0, y: 0 }) + ) + useEffect(() => { setMounted(true) }, []) - /** - * When the user releases the drag outside any valid target the provider - * flips `phase` to "returning". We then drive a Web-Animations API tween - * from the current cursor position back to the origin (where the drag - * started) while fading out, to clearly signal "no move happened". - * - * `useLayoutEffect` (not `useEffect`) so the animation is queued in the - * same paint as the phase flip — otherwise the indicator visibly pauses - * for one frame at the release point before animating. - * - * We animate the `translate` CSS property (NOT `transform`) so the - * inline `transform: translate(-50%, -50%)` used for centering stays - * untouched and composes additively with the animated translation. - */ useLayoutEffect(() => { if (!state) return if (state.phase !== "returning") return @@ -59,14 +55,17 @@ export function MoveDragIndicator() { const label = count > 1 ? `Déplacer ${count} conversations` : "Déplacer 1 conversation" + const x = state.phase === "returning" ? state.pointerX : livePointer.x + const y = state.phase === "returning" ? state.pointerY : livePointer.y + return createPortal(
>(() => new Set()) diff --git a/lib/compose-context.tsx b/lib/compose-context.tsx index cdd588c..4b69c39 100644 --- a/lib/compose-context.tsx +++ b/lib/compose-context.tsx @@ -393,10 +393,7 @@ function applySavedDraftsAfterClosingInline( return next } -type ComposeContextValue = { - composeWindows: ComposeState[] - /** Brouillons de réponse/transfert par id de conversation */ - savedThreadReplyDrafts: Record +export type ComposeActionsContextValue = { openCompose: () => void openComposeWithInitial: (preset: ComposeOpenPreset) => void /** Réouvre une fenêtre après annulation d’un envoi en cours (nouvel id, baseline recalculée). */ @@ -413,7 +410,22 @@ type ComposeContextValue = { saveDraft: (id: string) => void } -const ComposeContext = createContext(null) +export type ComposeDraftsContextValue = { + /** Brouillons de réponse/transfert par id de conversation */ + savedThreadReplyDrafts: Record +} + +export type ComposeWindowsContextValue = { + composeWindows: ComposeState[] +} + +export type ComposeContextValue = ComposeActionsContextValue & + ComposeDraftsContextValue & + ComposeWindowsContextValue + +const ComposeActionsContext = createContext(null) +const ComposeDraftsContext = createContext(null) +const ComposeWindowsContext = createContext(null) export function ComposeProvider({ children }: { children: React.ReactNode }) { const [composeWindows, setComposeWindows] = useState([]) @@ -660,10 +672,8 @@ export function ComposeProvider({ children }: { children: React.ReactNode }) { ) }, []) - const value = useMemo( + const actionsValue = useMemo( () => ({ - composeWindows, - savedThreadReplyDrafts, openCompose, openComposeWithInitial, restoreComposeFromSnapshot, @@ -677,8 +687,6 @@ export function ComposeProvider({ children }: { children: React.ReactNode }) { saveDraft, }), [ - composeWindows, - savedThreadReplyDrafts, openCompose, openComposeWithInitial, restoreComposeFromSnapshot, @@ -693,13 +701,50 @@ export function ComposeProvider({ children }: { children: React.ReactNode }) { ] ) + const draftsValue = useMemo( + () => ({ savedThreadReplyDrafts }), + [savedThreadReplyDrafts] + ) + + const windowsValue = useMemo( + () => ({ composeWindows }), + [composeWindows] + ) + return ( - {children} + + + + {children} + + + ) } -export function useCompose() { - const ctx = useContext(ComposeContext) - if (!ctx) throw new Error("useCompose must be used inside ") +export function useComposeActions(): ComposeActionsContextValue { + const ctx = useContext(ComposeActionsContext) + if (!ctx) throw new Error("useComposeActions must be used inside ") return ctx } + +export function useComposeDrafts(): ComposeDraftsContextValue { + const ctx = useContext(ComposeDraftsContext) + if (!ctx) throw new Error("useComposeDrafts must be used inside ") + return ctx +} + +export function useComposeWindows(): ComposeWindowsContextValue { + const ctx = useContext(ComposeWindowsContext) + if (!ctx) throw new Error("useComposeWindows must be used inside ") + return ctx +} + +/** Merged read — rerenders when windows, drafts, or any action identity changes. Prefer split hooks for perf. */ +export function useCompose(): ComposeContextValue { + return { + ...useComposeActions(), + ...useComposeDrafts(), + ...useComposeWindows(), + } +} diff --git a/lib/drag-context.tsx b/lib/drag-context.tsx index 1191ed2..e1e90cc 100644 --- a/lib/drag-context.tsx +++ b/lib/drag-context.tsx @@ -9,8 +9,12 @@ import { useRef, useState, } from "react" -import { flushSync } from "react-dom" import { useIsXs } from "@/hooks/use-xs" +import { + getDragPointerSnapshot, + notifyDragPointerMove, + resetDragPointer, +} from "@/lib/drag-pointer-store" export type EmailDragPhase = "dragging" | "returning" @@ -19,6 +23,7 @@ export type EmailDragState = { sourceFolderId: string hoveredTargetId: string | null hoveredTargetLabel: string | null + /** Last committed pointer (begin drag + snapshot when entering `returning`). */ pointerX: number pointerY: number originX: number @@ -57,6 +62,7 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) { if (!isXs) return stateRef.current = null setState(null) + resetDragPointer(0, 0) }, [isXs]) const beginDrag = useCallback( @@ -74,6 +80,7 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) { phase: "dragging", } stateRef.current = next + resetDragPointer(pointerX, pointerY) setState(next) }, [isXs] @@ -99,11 +106,17 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) { const cur = stateRef.current if (!cur) return if (cur.phase === "returning") return - const next: EmailDragState = { ...cur, phase: "returning", hoveredTargetId: null } + const p = getDragPointerSnapshot() + const next: EmailDragState = { + ...cur, + phase: "returning", + hoveredTargetId: null, + hoveredTargetLabel: null, + pointerX: p.x, + pointerY: p.y, + } stateRef.current = next - flushSync(() => { - setState(next) - }) + setState(next) }, []) const completeDrop = useCallback((targetId: string, targetLabel: string) => { @@ -111,9 +124,8 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) { if (!cur) return stateRef.current = null setState(null) + resetDragPointer(0, 0) if (targetId !== cur.sourceFolderId && onDropRef.current) { - // Defer to a microtask so we never call setState in another - // component synchronously from within this render/commit phase. const cb = onDropRef.current const ids = cur.ids queueMicrotask(() => cb(targetId, targetLabel, ids)) @@ -127,37 +139,27 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) { } }, []) - // Track cursor + handle native end-of-drag. + // Live pointer during native drag — updates external store only (no React state). useEffect(() => { - if (!state) return + if (!state || state.phase !== "dragging") return const onDragOver = (e: DragEvent) => { - setState((prev) => - prev && prev.phase === "dragging" - ? { ...prev, pointerX: e.clientX, pointerY: e.clientY } - : prev - ) + notifyDragPointerMove(e.clientX, e.clientY) } const onDragEnd = () => { - // If state still active and no drop was accepted, animate return. - // We deliberately keep `pointerX/Y` from the last `dragover` so the - // tween starts from the exact spot where the indicator was last - // painted. Snapping to `e.clientX/Y` would visibly shift the badge - // one frame before the animation begins. - // `flushSync` forces React to commit this state change synchronously - // so the indicator's `useLayoutEffect` (which starts the animation) - // fires in the same paint as the phase flip — no perceptible delay. const cur = stateRef.current if (!cur) return if (cur.phase === "returning") return + const p = getDragPointerSnapshot() const next: EmailDragState = { ...cur, phase: "returning", hoveredTargetId: null, + hoveredTargetLabel: null, + pointerX: p.x, + pointerY: p.y, } stateRef.current = next - flushSync(() => { - setState(next) - }) + setState(next) } window.addEventListener("dragover", onDragOver) window.addEventListener("dragend", onDragEnd) @@ -165,7 +167,7 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) { window.removeEventListener("dragover", onDragOver) window.removeEventListener("dragend", onDragEnd) } - }, [state !== null]) + }, [state]) // Auto-clear after the return animation finishes. useEffect(() => { @@ -173,6 +175,7 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) { const id = window.setTimeout(() => { stateRef.current = null setState(null) + resetDragPointer(0, 0) }, RETURN_ANIMATION_MS + 20) return () => window.clearTimeout(id) }, [state?.phase]) @@ -211,12 +214,6 @@ export function useEmailDrag() { export const EMAIL_DRAG_RETURN_MS = RETURN_ANIMATION_MS -/** - * Helper for sidebar/folder/label rows. Returns drop-event handlers + a flag - * indicating whether the current drag is hovering this target. If the target - * is the drag source (same id as `sourceFolderId`), the drop is rejected so - * the user just sees a "cancel" and no move happens. - */ const noopDropHandlers = { onDragEnter: () => {}, onDragOver: () => {}, diff --git a/lib/drag-pointer-store.ts b/lib/drag-pointer-store.ts new file mode 100644 index 0000000..469eef6 --- /dev/null +++ b/lib/drag-pointer-store.ts @@ -0,0 +1,39 @@ +"use client" + +type Point = { x: number; y: number } + +const listeners = new Set<() => void>() +let latest: Point = { x: 0, y: 0 } +let rafId: number | null = null + +function emit() { + rafId = null + for (const cb of listeners) cb() +} + +/** Throttle pointer notifications to at most once per animation frame. */ +export function notifyDragPointerMove(x: number, y: number) { + latest = { x, y } + if (rafId !== null) return + rafId = globalThis.requestAnimationFrame(emit) +} + +export function getDragPointerSnapshot(): Point { + return latest +} + +export function subscribeDragPointer(cb: () => void) { + listeners.add(cb) + return () => { + listeners.delete(cb) + } +} + +export function resetDragPointer(x: number, y: number) { + if (rafId !== null) { + globalThis.cancelAnimationFrame(rafId) + rafId = null + } + latest = { x, y } + for (const cb of listeners) cb() +} diff --git a/lib/mail-folder-filter.ts b/lib/mail-folder-filter.ts index 7f731a1..42641b1 100644 --- a/lib/mail-folder-filter.ts +++ b/lib/mail-folder-filter.ts @@ -75,9 +75,21 @@ function matchesFolderLabelRow( function matchesLabelNav( email: Email, folderId: string, - maps: MailNavFolderMaps + maps: MailNavFolderMaps, + subtreeIdsCache?: Map ): boolean { - const subtreeIds = collectSubtreeFolderIds(maps.folderTree, folderId) + let subtreeIds: string[] | null + if (subtreeIdsCache) { + if (!subtreeIdsCache.has(folderId)) { + subtreeIdsCache.set( + folderId, + collectSubtreeFolderIds(maps.folderTree, folderId) + ) + } + subtreeIds = subtreeIdsCache.get(folderId) ?? null + } else { + subtreeIds = collectSubtreeFolderIds(maps.folderTree, folderId) + } if (subtreeIds) { return subtreeIds.some((id) => matchesFolderLabelRow(email, id, maps)) } @@ -88,7 +100,9 @@ export function emailMatchesFolder( email: Email, folderId: string, ctx: MailFolderFilterCtx, - maps?: MailNavFolderMaps | null + maps?: MailNavFolderMaps | null, + /** Réutiliser entre appels (ex. `computeFolderUnreadCounts`) pour éviter de rescanner l’arbre à chaque mail. */ + subtreeIdsCache?: Map ): boolean { const nav = resolveNavMaps(maps) @@ -127,7 +141,7 @@ export function emailMatchesFolder( } if (nav.folderIdToLabel[folderId]) { - return matchesLabelNav(email, folderId, nav) + return matchesLabelNav(email, folderId, nav, subtreeIdsCache) } if (email.labels?.includes(folderId)) return true diff --git a/lib/mail-nav-metrics.ts b/lib/mail-nav-metrics.ts index 4631e52..3cd496c 100644 --- a/lib/mail-nav-metrics.ts +++ b/lib/mail-nav-metrics.ts @@ -76,13 +76,14 @@ export function countUnreadInFolder( ctx: MailFolderFilterCtx, hiddenIds: Set, readOverrides: Record, - maps?: MailNavFolderMaps | null + maps?: MailNavFolderMaps | null, + subtreeIdsCache?: Map ): number { if (folderId === "scheduled" || folderId === "snoozed") { let n = 0 for (const e of allEmails) { if (hiddenIds.has(e.id)) continue - if (!emailMatchesFolder(e, folderId, ctx, maps)) continue + if (!emailMatchesFolder(e, folderId, ctx, maps, subtreeIdsCache)) continue n++ } return n @@ -91,7 +92,7 @@ export function countUnreadInFolder( let n = 0 for (const e of allEmails) { if (hiddenIds.has(e.id)) continue - if (!emailMatchesFolder(e, folderId, ctx, maps)) continue + if (!emailMatchesFolder(e, folderId, ctx, maps, subtreeIdsCache)) continue if (!effectiveRead(e, readOverrides)) n++ } return n @@ -106,17 +107,28 @@ export function computeFolderUnreadCounts( labelEdits?: LabelEditState, notSpamEmailIds?: readonly string[] ): Record { - let pool = + let pool: Email[] = labelEdits && (Object.keys(labelEdits.additions).length > 0 || Object.keys(labelEdits.removals).length > 0) ? applyLabelEditsToEmails(allEmails, labelEdits) : allEmails - pool = pool.map((e) => mergeEmailNotSpam(e, notSpamEmailIds ?? [])) + if (notSpamEmailIds && notSpamEmailIds.length > 0) { + pool = pool.map((e) => mergeEmailNotSpam(e, notSpamEmailIds)) + } const hidden = new Set(hiddenEmailIds) + const subtreeIdsCache = new Map() const out: Record = {} for (const id of allSidebarNavFolderIds(maps)) { - out[id] = countUnreadInFolder(pool, id, ctx, hidden, readOverrides, maps) + out[id] = countUnreadInFolder( + pool, + id, + ctx, + hidden, + readOverrides, + maps, + subtreeIdsCache + ) } return out } diff --git a/lib/stores/debounced-json-storage.ts b/lib/stores/debounced-json-storage.ts new file mode 100644 index 0000000..ec815b3 --- /dev/null +++ b/lib/stores/debounced-json-storage.ts @@ -0,0 +1,100 @@ +"use client" + +import { + createJSONStorage, + type PersistStorage, + type StateStorage, + type StorageValue, +} from "zustand/middleware" + +const DEFAULT_DEBOUNCE_MS = 220 + +/** In-memory fallback when `localStorage` is missing (SSR) or throws (private mode, etc.). */ +function createMemoryStateStorage(): StateStorage { + const data = new Map() + return { + getItem: (name) => data.get(name) ?? null, + setItem: (name, value) => { + data.set(name, value) + }, + removeItem: (name) => { + data.delete(name) + }, + } +} + +function getPersistBackingStorage(): StateStorage { + if (typeof window === "undefined") { + return createMemoryStateStorage() + } + try { + return window.localStorage + } catch { + return createMemoryStateStorage() + } +} + +/** + * JSON persist storage that debounces writes to `localStorage` so rapid + * store updates do not block the main thread on every mutation. + * Flushes pending keys on a timer, `beforeunload`, and `pagehide`. + * Uses in-memory storage during SSR or when `localStorage` is unavailable. + */ +function buildDebouncedJsonStorage(): PersistStorage { + const base = + createJSONStorage(getPersistBackingStorage) ?? + createJSONStorage(() => createMemoryStateStorage()) + if (!base) { + throw new Error("[debounced-json-storage] failed to create JSON storage") + } + + const pending = new Map>() + const timers = new Map>() + + const flushKey = (name: string) => { + const t = timers.get(name) + if (t !== undefined) { + globalThis.clearTimeout(t) + timers.delete(name) + } + const value = pending.get(name) + if (value === undefined) return + pending.delete(name) + base.setItem(name, value) + } + + const flushAll = () => { + for (const name of [...pending.keys()]) flushKey(name) + } + + if (typeof window !== "undefined") { + window.addEventListener("beforeunload", flushAll) + window.addEventListener("pagehide", flushAll) + } + + return { + getItem: (name) => base.getItem(name), + setItem: (name, value) => { + pending.set(name, value) + const existing = timers.get(name) + if (existing !== undefined) globalThis.clearTimeout(existing) + const id = globalThis.setTimeout(() => { + timers.delete(name) + flushKey(name) + }, DEFAULT_DEBOUNCE_MS) + timers.set(name, id) + }, + removeItem: (name) => { + const existing = timers.get(name) + if (existing !== undefined) { + globalThis.clearTimeout(existing) + timers.delete(name) + } + pending.delete(name) + return base.removeItem(name) + }, + } +} + +/** Shared instance for all zustand `persist` stores in this app. */ +export const debouncedPersistJSONStorage = buildDebouncedJsonStorage() diff --git a/lib/stores/mail-store.ts b/lib/stores/mail-store.ts index 3a91fd9..4d2a145 100644 --- a/lib/stores/mail-store.ts +++ b/lib/stores/mail-store.ts @@ -2,6 +2,7 @@ import { create } from "zustand" import { persist } from "zustand/middleware" +import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage" /** * Persistent mail store — survives across navigations and page reloads. @@ -186,6 +187,7 @@ export const useMailStore = create()( }), { name: "ultimail-mail-state", + storage: debouncedPersistJSONStorage, version: 3, migrate: (persisted, version) => { const state = persisted as MailStoreState & { notSpamEmailIds?: string[] } diff --git a/lib/stores/nav-store.ts b/lib/stores/nav-store.ts index 99415b2..e8e273f 100644 --- a/lib/stores/nav-store.ts +++ b/lib/stores/nav-store.ts @@ -2,6 +2,7 @@ import { create } from "zustand" import { persist } from "zustand/middleware" +import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage" import { cloneDefaultFolderTree, cloneDefaultLabelRows, @@ -386,6 +387,7 @@ export const useNavStore = create()( }), { name: "ultimail-nav-state", + storage: debouncedPersistJSONStorage, version: 1, } ) diff --git a/lib/stores/scheduled-store.ts b/lib/stores/scheduled-store.ts index b7ff481..7330361 100644 --- a/lib/stores/scheduled-store.ts +++ b/lib/stores/scheduled-store.ts @@ -2,6 +2,7 @@ import { create } from "zustand" import { persist } from "zustand/middleware" +import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage" import type { Email } from "@/lib/email-data" export type ScheduleSendPayload = { @@ -242,6 +243,7 @@ export const useScheduledStore = create