perf(startup): lazy load task detail surfaces
This commit is contained in:
parent
6ac95505bc
commit
f8b96d12d3
5 changed files with 155 additions and 9 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue