diff --git a/src/renderer/components/layout/GlobalTaskDetailDialogSlot.test.tsx b/src/renderer/components/layout/GlobalTaskDetailDialogSlot.test.tsx new file mode 100644 index 00000000..36da773e --- /dev/null +++ b/src/renderer/components/layout/GlobalTaskDetailDialogSlot.test.tsx @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/naming-convention -- Component mocks mirror PascalCase exports. */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockState = vi.hoisted(() => ({ + globalTaskDetail: null as null | { teamName: string; taskId: string }, + dialogModuleLoads: 0, + dialogRenders: 0, +})); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: typeof mockState) => T): T => selector(mockState), +})); + +vi.mock('../team/dialogs/GlobalTaskDetailDialog', () => { + mockState.dialogModuleLoads += 1; + return { + GlobalTaskDetailDialog: () => { + mockState.dialogRenders += 1; + return React.createElement('div', { 'data-testid': 'global-task-dialog' }, 'Task dialog'); + }, + }; +}); + +/* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */ + +import { GlobalTaskDetailDialogSlot } from './GlobalTaskDetailDialogSlot'; + +const roots: Root[] = []; + +const flushReact = async (): Promise => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +}; + +const createHarness = (): { host: HTMLDivElement; root: Root } => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + roots.push(root); + return { host, root }; +}; + +const renderSlot = async (root: Root): Promise => { + await act(async () => { + root.render(); + await flushReact(); + }); +}; + +const waitForDialog = async (host: HTMLElement): Promise => { + for (let attempt = 0; attempt < 10; attempt += 1) { + if (host.querySelector('[data-testid="global-task-dialog"]')) { + return; + } + + await act(async () => { + await flushReact(); + }); + } + + expect(host.querySelector('[data-testid="global-task-dialog"]')).not.toBeNull(); +}; + +describe('GlobalTaskDetailDialogSlot', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + mockState.globalTaskDetail = null; + mockState.dialogModuleLoads = 0; + mockState.dialogRenders = 0; + }); + + afterEach(async () => { + await act(async () => { + for (const root of roots.splice(0)) { + root.unmount(); + } + await flushReact(); + }); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('does not import the heavy task dialog until a global task is opened', async () => { + const { host, root } = createHarness(); + + await renderSlot(root); + + expect(host.querySelector('[data-testid="global-task-dialog"]')).toBeNull(); + expect(mockState.dialogModuleLoads).toBe(0); + expect(mockState.dialogRenders).toBe(0); + + mockState.globalTaskDetail = { teamName: 'team-a', taskId: 'task-1' }; + await renderSlot(root); + await waitForDialog(host); + + expect(mockState.dialogModuleLoads).toBe(1); + expect(mockState.dialogRenders).toBeGreaterThan(0); + }); +}); diff --git a/src/renderer/components/layout/GlobalTaskDetailDialogSlot.tsx b/src/renderer/components/layout/GlobalTaskDetailDialogSlot.tsx new file mode 100644 index 00000000..7f5509ce --- /dev/null +++ b/src/renderer/components/layout/GlobalTaskDetailDialogSlot.tsx @@ -0,0 +1,23 @@ +import { lazy, Suspense } from 'react'; + +import { useStore } from '@renderer/store'; + +const GlobalTaskDetailDialog = lazy(() => + import('../team/dialogs/GlobalTaskDetailDialog').then((module) => ({ + default: module.GlobalTaskDetailDialog, + })) +); + +export const GlobalTaskDetailDialogSlot = (): React.JSX.Element | null => { + const isOpen = useStore((state) => state.globalTaskDetail !== null); + + if (!isOpen) { + return null; + } + + return ( + + + + ); +}; diff --git a/src/renderer/components/layout/Sidebar.test.tsx b/src/renderer/components/layout/Sidebar.test.tsx index 43a34441..19e11ae1 100644 --- a/src/renderer/components/layout/Sidebar.test.tsx +++ b/src/renderer/components/layout/Sidebar.test.tsx @@ -9,6 +9,7 @@ const storeMock = vi.hoisted(() => ({ sidebarCollapsed: false, toggleSidebar: vi.fn(), }, + sessionsModuleLoads: 0, })); vi.mock('@renderer/store', () => ({ @@ -19,10 +20,13 @@ vi.mock('../sidebar/GlobalTaskList', () => ({ GlobalTaskList: () => React.createElement('div', { 'data-testid': 'tasks-panel' }, 'Tasks panel'), })); -vi.mock('../sidebar/DateGroupedSessions', () => ({ - DateGroupedSessions: () => - React.createElement('div', { 'data-testid': 'sessions-panel' }, 'Sessions panel'), -})); +vi.mock('../sidebar/DateGroupedSessions', () => { + storeMock.sessionsModuleLoads += 1; + return { + DateGroupedSessions: () => + React.createElement('div', { 'data-testid': 'sessions-panel' }, 'Sessions panel'), + }; +}); /* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */ @@ -33,6 +37,9 @@ const roots: Root[] = []; const flushReact = async (): Promise => { await Promise.resolve(); await Promise.resolve(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); }; const createHarness = (): { host: HTMLDivElement; root: Root } => { @@ -65,6 +72,7 @@ describe('Sidebar', () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeMock.state.sidebarCollapsed = false; storeMock.state.toggleSidebar.mockClear(); + storeMock.sessionsModuleLoads = 0; }); afterEach(async () => { @@ -85,6 +93,7 @@ describe('Sidebar', () => { expect(host.querySelector('[data-testid="tasks-panel"]')).not.toBeNull(); expect(host.querySelector('[data-testid="sessions-panel"]')).toBeNull(); + expect(storeMock.sessionsModuleLoads).toBe(0); }); it('mounts the sessions panel on first activation and keeps it mounted when hidden', async () => { diff --git a/src/renderer/components/layout/Sidebar.tsx b/src/renderer/components/layout/Sidebar.tsx index 8568a1b5..989a8e27 100644 --- a/src/renderer/components/layout/Sidebar.tsx +++ b/src/renderer/components/layout/Sidebar.tsx @@ -8,14 +8,13 @@ * - Collapsible: Cmd+B to toggle (Notion-style) */ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; import { formatShortcut } from '@renderer/utils/stringUtils'; import { PanelLeft } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { DateGroupedSessions } from '../sidebar/DateGroupedSessions'; import { GlobalTaskList } from '../sidebar/GlobalTaskList'; import { defaultTaskFiltersState } from '../sidebar/taskFiltersState'; @@ -23,6 +22,12 @@ import type { TaskFiltersState } from '../sidebar/taskFiltersState'; type SidebarTab = 'tasks' | 'sessions'; +const DateGroupedSessions = lazy(() => + import('../sidebar/DateGroupedSessions').then((module) => ({ + default: module.DateGroupedSessions, + })) +); + const MIN_WIDTH = 200; const MAX_WIDTH = 500; const DEFAULT_WIDTH = 280; @@ -202,7 +207,11 @@ export const Sidebar = (): React.JSX.Element => { hidden={sidebarTab !== 'sessions'} className="min-w-0 flex-1 overflow-hidden" > - {hasOpenedSessionsTab && } + {hasOpenedSessionsTab && ( + + + + )} diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx index cbaba50a..c5d47fbf 100644 --- a/src/renderer/components/layout/TabbedLayout.tsx +++ b/src/renderer/components/layout/TabbedLayout.tsx @@ -34,9 +34,9 @@ import { UpdateBanner } from '../common/UpdateBanner'; import { UpdateDialog } from '../common/UpdateDialog'; import { WorkspaceIndicator } from '../common/WorkspaceIndicator'; import { CommandPalette } from '../search/CommandPalette'; -import { GlobalTaskDetailDialog } from '../team/dialogs/GlobalTaskDetailDialog'; import { CustomTitleBar } from './CustomTitleBar'; +import { GlobalTaskDetailDialogSlot } from './GlobalTaskDetailDialogSlot'; import { PaneContainer } from './PaneContainer'; import { Sidebar } from './Sidebar'; import { DragOverlayTab } from './SortableTab'; @@ -186,7 +186,7 @@ export const TabbedLayout = (): React.JSX.Element => { {activeTab ? : null} - +