From 60d806135c86bbfa82791b520482fffc35fe3a01 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 30 May 2026 20:54:22 +0300 Subject: [PATCH] perf(renderer): lazy mount task context menus --- .../components/sidebar/TaskContextMenu.tsx | 103 +++++++------- .../sidebar/TaskContextMenu.test.tsx | 126 ++++++++++++++++++ 2 files changed, 180 insertions(+), 49 deletions(-) create mode 100644 test/renderer/components/sidebar/TaskContextMenu.test.tsx diff --git a/src/renderer/components/sidebar/TaskContextMenu.tsx b/src/renderer/components/sidebar/TaskContextMenu.tsx index b5a306ad..448e687f 100644 --- a/src/renderer/components/sidebar/TaskContextMenu.tsx +++ b/src/renderer/components/sidebar/TaskContextMenu.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import { useAppTranslation } from '@features/localization/renderer'; import { ContextMenu, @@ -34,63 +36,66 @@ export const TaskContextMenu = ({ children, }: TaskContextMenuProps): React.JSX.Element => { const { t } = useAppTranslation('common'); + const [open, setOpen] = useState(false); return ( - +
{children}
- e.preventDefault()}> - - {isPinned ? ( + {open ? ( + e.preventDefault()}> + + {isPinned ? ( + <> + + {t('taskContextMenu.unpin')} + + ) : ( + <> + + {t('taskContextMenu.pin')} + + )} + + + + + {t('taskContextMenu.rename')} + + + + + {t('taskContextMenu.markUnread')} + + + + + + {isArchived ? ( + <> + + {t('taskContextMenu.unarchive')} + + ) : ( + <> + + {t('taskContextMenu.archive')} + + )} + + + {onDelete && ( <> - - {t('taskContextMenu.unpin')} - - ) : ( - <> - - {t('taskContextMenu.pin')} + + + + {t('taskContextMenu.deleteTask')} + )} - - - - - {t('taskContextMenu.rename')} - - - - - {t('taskContextMenu.markUnread')} - - - - - - {isArchived ? ( - <> - - {t('taskContextMenu.unarchive')} - - ) : ( - <> - - {t('taskContextMenu.archive')} - - )} - - - {onDelete && ( - <> - - - - {t('taskContextMenu.deleteTask')} - - - )} - + + ) : null}
); }; diff --git a/test/renderer/components/sidebar/TaskContextMenu.test.tsx b/test/renderer/components/sidebar/TaskContextMenu.test.tsx new file mode 100644 index 00000000..a35e924f --- /dev/null +++ b/test/renderer/components/sidebar/TaskContextMenu.test.tsx @@ -0,0 +1,126 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { TaskContextMenu } from '@renderer/components/sidebar/TaskContextMenu'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GlobalTask } from '@shared/types'; + +const contextMenuMockState = vi.hoisted(() => ({ + autoOpen: false, +})); + +vi.mock('@features/localization/renderer', () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@renderer/components/ui/context-menu', () => ({ + ContextMenu: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + }) => { + React.useEffect(() => { + if (contextMenuMockState.autoOpen && open !== true) { + onOpenChange?.(true); + } + }, [onOpenChange, open]); + return React.createElement('div', { 'data-context-menu-open': open ? 'true' : 'false' }, children); + }, + ContextMenuTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + ContextMenuContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'task-context-menu-content' }, children), + ContextMenuItem: ({ + children, + onSelect, + className, + }: { + children: React.ReactNode; + onSelect?: () => void; + className?: string; + }) => + React.createElement( + 'button', + { className, type: 'button', onClick: () => onSelect?.() }, + children + ), + ContextMenuSeparator: () => React.createElement('hr'), +})); + +function renderTaskContextMenu(options?: { + autoOpen?: boolean; + onRename?: () => void; +}): { + host: HTMLDivElement; + root: ReturnType; +} { + contextMenuMockState.autoOpen = options?.autoOpen ?? false; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + act(() => { + root.render( + + Task row + + ); + }); + + return { host, root }; +} + +describe('TaskContextMenu', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + contextMenuMockState.autoOpen = false; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('does not mount menu content while closed', () => { + const { host, root } = renderTaskContextMenu(); + + expect(host.textContent).toBe('Task row'); + expect(host.querySelector('[data-testid="task-context-menu-content"]')).toBeNull(); + + act(() => root.unmount()); + }); + + it('mounts menu content when opened and keeps actions wired', () => { + const onRename = vi.fn(); + const { host, root } = renderTaskContextMenu({ autoOpen: true, onRename }); + + const content = host.querySelector('[data-testid="task-context-menu-content"]'); + expect(content).not.toBeNull(); + expect(host.textContent).toContain('taskContextMenu.rename'); + + const renameButton = [...host.querySelectorAll('button')].find((button) => + button.textContent?.includes('taskContextMenu.rename') + ); + expect(renameButton).not.toBeUndefined(); + act(() => renameButton?.click()); + expect(onRename).toHaveBeenCalledTimes(1); + + act(() => root.unmount()); + }); +});