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,
|
sidebarCollapsed: false,
|
||||||
toggleSidebar: vi.fn(),
|
toggleSidebar: vi.fn(),
|
||||||
},
|
},
|
||||||
|
sessionsModuleLoads: 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/store', () => ({
|
vi.mock('@renderer/store', () => ({
|
||||||
|
|
@ -19,10 +20,13 @@ vi.mock('../sidebar/GlobalTaskList', () => ({
|
||||||
GlobalTaskList: () => React.createElement('div', { 'data-testid': 'tasks-panel' }, 'Tasks panel'),
|
GlobalTaskList: () => React.createElement('div', { 'data-testid': 'tasks-panel' }, 'Tasks panel'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../sidebar/DateGroupedSessions', () => ({
|
vi.mock('../sidebar/DateGroupedSessions', () => {
|
||||||
DateGroupedSessions: () =>
|
storeMock.sessionsModuleLoads += 1;
|
||||||
React.createElement('div', { 'data-testid': 'sessions-panel' }, 'Sessions panel'),
|
return {
|
||||||
}));
|
DateGroupedSessions: () =>
|
||||||
|
React.createElement('div', { 'data-testid': 'sessions-panel' }, 'Sessions panel'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
/* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */
|
/* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */
|
||||||
|
|
||||||
|
|
@ -33,6 +37,9 @@ const roots: Root[] = [];
|
||||||
const flushReact = async (): Promise<void> => {
|
const flushReact = async (): Promise<void> => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, 0);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const createHarness = (): { host: HTMLDivElement; root: Root } => {
|
const createHarness = (): { host: HTMLDivElement; root: Root } => {
|
||||||
|
|
@ -65,6 +72,7 @@ describe('Sidebar', () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeMock.state.sidebarCollapsed = false;
|
storeMock.state.sidebarCollapsed = false;
|
||||||
storeMock.state.toggleSidebar.mockClear();
|
storeMock.state.toggleSidebar.mockClear();
|
||||||
|
storeMock.sessionsModuleLoads = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|
@ -85,6 +93,7 @@ describe('Sidebar', () => {
|
||||||
|
|
||||||
expect(host.querySelector('[data-testid="tasks-panel"]')).not.toBeNull();
|
expect(host.querySelector('[data-testid="tasks-panel"]')).not.toBeNull();
|
||||||
expect(host.querySelector('[data-testid="sessions-panel"]')).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 () => {
|
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)
|
* - 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 { useStore } from '@renderer/store';
|
||||||
import { formatShortcut } from '@renderer/utils/stringUtils';
|
import { formatShortcut } from '@renderer/utils/stringUtils';
|
||||||
import { PanelLeft } from 'lucide-react';
|
import { PanelLeft } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { DateGroupedSessions } from '../sidebar/DateGroupedSessions';
|
|
||||||
import { GlobalTaskList } from '../sidebar/GlobalTaskList';
|
import { GlobalTaskList } from '../sidebar/GlobalTaskList';
|
||||||
import { defaultTaskFiltersState } from '../sidebar/taskFiltersState';
|
import { defaultTaskFiltersState } from '../sidebar/taskFiltersState';
|
||||||
|
|
||||||
|
|
@ -23,6 +22,12 @@ import type { TaskFiltersState } from '../sidebar/taskFiltersState';
|
||||||
|
|
||||||
type SidebarTab = 'tasks' | 'sessions';
|
type SidebarTab = 'tasks' | 'sessions';
|
||||||
|
|
||||||
|
const DateGroupedSessions = lazy(() =>
|
||||||
|
import('../sidebar/DateGroupedSessions').then((module) => ({
|
||||||
|
default: module.DateGroupedSessions,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const MIN_WIDTH = 200;
|
const MIN_WIDTH = 200;
|
||||||
const MAX_WIDTH = 500;
|
const MAX_WIDTH = 500;
|
||||||
const DEFAULT_WIDTH = 280;
|
const DEFAULT_WIDTH = 280;
|
||||||
|
|
@ -202,7 +207,11 @@ export const Sidebar = (): React.JSX.Element => {
|
||||||
hidden={sidebarTab !== 'sessions'}
|
hidden={sidebarTab !== 'sessions'}
|
||||||
className="min-w-0 flex-1 overflow-hidden"
|
className="min-w-0 flex-1 overflow-hidden"
|
||||||
>
|
>
|
||||||
{hasOpenedSessionsTab && <DateGroupedSessions />}
|
{hasOpenedSessionsTab && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DateGroupedSessions />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,9 @@ import { UpdateBanner } from '../common/UpdateBanner';
|
||||||
import { UpdateDialog } from '../common/UpdateDialog';
|
import { UpdateDialog } from '../common/UpdateDialog';
|
||||||
import { WorkspaceIndicator } from '../common/WorkspaceIndicator';
|
import { WorkspaceIndicator } from '../common/WorkspaceIndicator';
|
||||||
import { CommandPalette } from '../search/CommandPalette';
|
import { CommandPalette } from '../search/CommandPalette';
|
||||||
import { GlobalTaskDetailDialog } from '../team/dialogs/GlobalTaskDetailDialog';
|
|
||||||
|
|
||||||
import { CustomTitleBar } from './CustomTitleBar';
|
import { CustomTitleBar } from './CustomTitleBar';
|
||||||
|
import { GlobalTaskDetailDialogSlot } from './GlobalTaskDetailDialogSlot';
|
||||||
import { PaneContainer } from './PaneContainer';
|
import { PaneContainer } from './PaneContainer';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { DragOverlayTab } from './SortableTab';
|
import { DragOverlayTab } from './SortableTab';
|
||||||
|
|
@ -186,7 +186,7 @@ export const TabbedLayout = (): React.JSX.Element => {
|
||||||
{activeTab ? <DragOverlayTab tab={activeTab} /> : null}
|
{activeTab ? <DragOverlayTab tab={activeTab} /> : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
<GlobalTaskDetailDialog />
|
<GlobalTaskDetailDialogSlot />
|
||||||
<UpdateDialog />
|
<UpdateDialog />
|
||||||
<WorkspaceIndicator />
|
<WorkspaceIndicator />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue