major perf improvements

This commit is contained in:
R3D347HR4Y 2026-05-15 23:51:57 +02:00
parent 22e7b8e1d2
commit 6af6e62774
18 changed files with 399 additions and 101 deletions

View File

@ -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(

View File

@ -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)

View File

@ -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<string, Email>
scheduledIds: Set<string>
}
/** O(n) index for list row logic — avoids repeated `allEmails.some` / `find` per row. */
export function buildListMailIndex(emails: Email[]): ListMailIndex {
const emailById = new Map<string, Email>()
const scheduledIds = new Set<string>()
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,
}
}

View File

@ -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<string, string[] | null>()
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<typeof resolveParsedCalendarInvitation>
>()
const attachmentsById = new Map<string, EmailAttachment[]>()
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({
<div
ref={listViewportRef}
className={cn(mainScrollClass, "relative overscroll-y-none", !isViewMode && "max-sm:pb-16")}
className={cn(mainScrollClass, "relative overscroll-y-none max-sm:pb-16")}
>
{!isViewMode && (
<div
@ -2770,8 +2803,10 @@ export function EmailList({
const senderForSearch = email.sender.replace(/\s+/g, " ").trim()
const isSelected = selectedEmails.includes(email.id)
const hasInvitation = email.hasInvitation === true
const parsedInvitation = resolveParsedCalendarInvitation(email)
const attachmentList = attachmentsForEmailList(email)
const parsedInvitation =
listRowExtras.invitationById.get(email.id) ?? null
const attachmentList =
listRowExtras.attachmentsById.get(email.id) ?? []
const isScheduled = email.labels?.includes("scheduled") === true
const contextTargetIds = contextMenuTargetIdsForRow(
email.id,
@ -2782,14 +2817,12 @@ export function EmailList({
const allContextTargetsScheduled =
contextTargetIds.length > 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)
})

View File

@ -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(

View File

@ -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<HTMLInputElement>(null)
const { openCompose } = useCompose()
const { openCompose } = useComposeActions()
const hasSearch = searchValue.length > 0

View File

@ -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<HTMLDivElement>(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(
<div
ref={elRef}
aria-hidden
className="pointer-events-none fixed z-100 select-none"
style={{
left: state.pointerX,
top: state.pointerY,
left: x,
top: y,
transform: "translate(-50%, -50%)",
willChange: "translate, opacity",
}}

View File

@ -34,7 +34,7 @@ import { cn, formatCount } from "@/lib/utils"
import { readXsMatches } from "@/hooks/use-xs"
import { useState, useRef, useEffect, useMemo, type ReactNode, type CSSProperties } from "react"
import { useEmailDropTarget } from "@/lib/drag-context"
import { useCompose } from "@/lib/compose-context"
import { useComposeActions } from "@/lib/compose-context"
import {
DropdownMenu,
DropdownMenuContent,
@ -504,7 +504,7 @@ export function Sidebar({
collapsed,
folderUnreadCounts = {},
}: SidebarProps) {
const { openCompose } = useCompose()
const { openCompose } = useComposeActions()
const [hoverExpanded, setHoverExpanded] = useState(false)
const [navMoreOpen, setNavMoreOpen] = useState(false)
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set())

View File

@ -393,10 +393,7 @@ function applySavedDraftsAfterClosingInline(
return next
}
type ComposeContextValue = {
composeWindows: ComposeState[]
/** Brouillons de réponse/transfert par id de conversation */
savedThreadReplyDrafts: Record<string, SavedThreadReplyDraft>
export type ComposeActionsContextValue = {
openCompose: () => void
openComposeWithInitial: (preset: ComposeOpenPreset) => void
/** Réouvre une fenêtre après annulation dun envoi en cours (nouvel id, baseline recalculée). */
@ -413,7 +410,22 @@ type ComposeContextValue = {
saveDraft: (id: string) => void
}
const ComposeContext = createContext<ComposeContextValue | null>(null)
export type ComposeDraftsContextValue = {
/** Brouillons de réponse/transfert par id de conversation */
savedThreadReplyDrafts: Record<string, SavedThreadReplyDraft>
}
export type ComposeWindowsContextValue = {
composeWindows: ComposeState[]
}
export type ComposeContextValue = ComposeActionsContextValue &
ComposeDraftsContextValue &
ComposeWindowsContextValue
const ComposeActionsContext = createContext<ComposeActionsContextValue | null>(null)
const ComposeDraftsContext = createContext<ComposeDraftsContextValue | null>(null)
const ComposeWindowsContext = createContext<ComposeWindowsContextValue | null>(null)
export function ComposeProvider({ children }: { children: React.ReactNode }) {
const [composeWindows, setComposeWindows] = useState<ComposeState[]>([])
@ -660,10 +672,8 @@ export function ComposeProvider({ children }: { children: React.ReactNode }) {
)
}, [])
const value = useMemo<ComposeContextValue>(
const actionsValue = useMemo<ComposeActionsContextValue>(
() => ({
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<ComposeDraftsContextValue>(
() => ({ savedThreadReplyDrafts }),
[savedThreadReplyDrafts]
)
const windowsValue = useMemo<ComposeWindowsContextValue>(
() => ({ composeWindows }),
[composeWindows]
)
return (
<ComposeContext.Provider value={value}>{children}</ComposeContext.Provider>
<ComposeActionsContext.Provider value={actionsValue}>
<ComposeDraftsContext.Provider value={draftsValue}>
<ComposeWindowsContext.Provider value={windowsValue}>
{children}
</ComposeWindowsContext.Provider>
</ComposeDraftsContext.Provider>
</ComposeActionsContext.Provider>
)
}
export function useCompose() {
const ctx = useContext(ComposeContext)
if (!ctx) throw new Error("useCompose must be used inside <ComposeProvider>")
export function useComposeActions(): ComposeActionsContextValue {
const ctx = useContext(ComposeActionsContext)
if (!ctx) throw new Error("useComposeActions must be used inside <ComposeProvider>")
return ctx
}
export function useComposeDrafts(): ComposeDraftsContextValue {
const ctx = useContext(ComposeDraftsContext)
if (!ctx) throw new Error("useComposeDrafts must be used inside <ComposeProvider>")
return ctx
}
export function useComposeWindows(): ComposeWindowsContextValue {
const ctx = useContext(ComposeWindowsContext)
if (!ctx) throw new Error("useComposeWindows must be used inside <ComposeProvider>")
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(),
}
}

View File

@ -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: () => {},

39
lib/drag-pointer-store.ts Normal file
View File

@ -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()
}

View File

@ -75,9 +75,21 @@ function matchesFolderLabelRow(
function matchesLabelNav(
email: Email,
folderId: string,
maps: MailNavFolderMaps
maps: MailNavFolderMaps,
subtreeIdsCache?: Map<string, string[] | null>
): 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 larbre à chaque mail. */
subtreeIdsCache?: Map<string, string[] | null>
): 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

View File

@ -76,13 +76,14 @@ export function countUnreadInFolder(
ctx: MailFolderFilterCtx,
hiddenIds: Set<string>,
readOverrides: Record<string, boolean>,
maps?: MailNavFolderMaps | null
maps?: MailNavFolderMaps | null,
subtreeIdsCache?: Map<string, string[] | null>
): 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<string, number> {
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<string, string[] | null>()
const out: Record<string, number> = {}
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
}

View File

@ -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<string, string>()
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<unknown> {
const base =
createJSONStorage(getPersistBackingStorage) ??
createJSONStorage(() => createMemoryStateStorage())
if (!base) {
throw new Error("[debounced-json-storage] failed to create JSON storage")
}
const pending = new Map<string, StorageValue<unknown>>()
const timers = new Map<string, ReturnType<typeof globalThis.setTimeout>>()
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()

View File

@ -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<MailStoreState & MailStoreActions>()(
}),
{
name: "ultimail-mail-state",
storage: debouncedPersistJSONStorage,
version: 3,
migrate: (persisted, version) => {
const state = persisted as MailStoreState & { notSpamEmailIds?: string[] }

View File

@ -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<NavStoreState & NavStoreActions>()(
}),
{
name: "ultimail-nav-state",
storage: debouncedPersistJSONStorage,
version: 1,
}
)

View File

@ -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<ScheduledStoreState & ScheduledStoreActi
}),
{
name: "ultimail-scheduled-state",
storage: debouncedPersistJSONStorage,
version: 1,
}
)

File diff suppressed because one or more lines are too long