perf(startup): lazy load task detail surfaces

This commit is contained in:
777genius 2026-05-23 15:42:29 +03:00
parent 6ac95505bc
commit f8b96d12d3
5 changed files with 155 additions and 9 deletions

View file

@ -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: <T,>(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<void> => {
await Promise.resolve();
await Promise.resolve();
await new Promise<void>((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<void> => {
await act(async () => {
root.render(<GlobalTaskDetailDialogSlot />);
await flushReact();
});
};
const waitForDialog = async (host: HTMLElement): Promise<void> => {
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);
});
});

View file

@ -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 (
<Suspense fallback={null}>
<GlobalTaskDetailDialog />
</Suspense>
);
};

View file

@ -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<void> => {
await Promise.resolve();
await Promise.resolve();
await new Promise<void>((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 () => {

View file

@ -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 && <DateGroupedSessions />}
{hasOpenedSessionsTab && (
<Suspense fallback={null}>
<DateGroupedSessions />
</Suspense>
)}
</div>
</div>

View file

@ -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 ? <DragOverlayTab tab={activeTab} /> : null}
</DragOverlay>
</DndContext>
<GlobalTaskDetailDialog />
<GlobalTaskDetailDialogSlot />
<UpdateDialog />
<WorkspaceIndicator />
</div>