perf(startup): defer hidden renderer work
This commit is contained in:
parent
b4f2be87df
commit
6ac95505bc
6 changed files with 392 additions and 60 deletions
136
src/renderer/components/layout/PaneContent.test.tsx
Normal file
136
src/renderer/components/layout/PaneContent.test.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/* 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';
|
||||
|
||||
import type { Pane } from '@renderer/types/panes';
|
||||
import type { Tab } from '@renderer/types/tabs';
|
||||
|
||||
vi.mock('../dashboard/DashboardView', () => ({
|
||||
DashboardView: () => React.createElement('div', { 'data-view': 'dashboard' }, 'Dashboard view'),
|
||||
}));
|
||||
|
||||
vi.mock('../extensions/ExtensionStoreView', () => ({
|
||||
ExtensionStoreView: () =>
|
||||
React.createElement('div', { 'data-view': 'extensions' }, 'Extension store view'),
|
||||
}));
|
||||
|
||||
/* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */
|
||||
|
||||
import { PaneContent } from './PaneContent';
|
||||
|
||||
const flushReact = async (): Promise<void> => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
};
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
const dashboardTab: Tab = {
|
||||
id: 'tab-dashboard',
|
||||
type: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const extensionTab: Tab = {
|
||||
id: 'tab-extensions',
|
||||
type: 'extensions',
|
||||
label: 'Extensions',
|
||||
createdAt: 2,
|
||||
};
|
||||
|
||||
const createPane = (tabs: Tab[], activeTabId: string | null): Pane => ({
|
||||
id: 'pane-main',
|
||||
tabs,
|
||||
activeTabId,
|
||||
selectedTabIds: [],
|
||||
widthFraction: 1,
|
||||
});
|
||||
|
||||
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 renderPane = async (root: Root, pane: Pane): Promise<void> => {
|
||||
await act(async () => {
|
||||
root.render(<PaneContent pane={pane} isPaneFocused />);
|
||||
await flushReact();
|
||||
});
|
||||
};
|
||||
|
||||
const waitForText = async (host: HTMLElement, text: string): Promise<void> => {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
if (host.textContent?.includes(text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await flushReact();
|
||||
});
|
||||
}
|
||||
|
||||
expect(host.textContent).toContain(text);
|
||||
};
|
||||
|
||||
describe('PaneContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
for (const root of roots.splice(0)) {
|
||||
root.unmount();
|
||||
}
|
||||
await flushReact();
|
||||
});
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders the default dashboard without suspending when no tabs are open', async () => {
|
||||
const { host, root } = createHarness();
|
||||
|
||||
await renderPane(root, createPane([], null));
|
||||
|
||||
expect(host.textContent).toContain('Dashboard view');
|
||||
expect(host.querySelector('[role="status"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mount inactive lazy tab content during initial pane render', async () => {
|
||||
const { host, root } = createHarness();
|
||||
|
||||
await renderPane(root, createPane([dashboardTab, extensionTab], dashboardTab.id));
|
||||
|
||||
expect(host.textContent).toContain('Dashboard view');
|
||||
expect(host.textContent).not.toContain('Extension store view');
|
||||
expect(host.querySelector('[role="status"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('loads a lazy tab on first activation and keeps it mounted after switching away', async () => {
|
||||
const { host, root } = createHarness();
|
||||
|
||||
await renderPane(root, createPane([dashboardTab, extensionTab], dashboardTab.id));
|
||||
expect(host.textContent).not.toContain('Extension store view');
|
||||
|
||||
await renderPane(root, createPane([dashboardTab, extensionTab], extensionTab.id));
|
||||
await waitForText(host, 'Extension store view');
|
||||
|
||||
const extensionView = host.querySelector<HTMLElement>('[data-view="extensions"]');
|
||||
expect(extensionView).not.toBeNull();
|
||||
|
||||
await renderPane(root, createPane([dashboardTab, extensionTab], dashboardTab.id));
|
||||
|
||||
expect(host.querySelector('[data-view="extensions"]')).toBe(extensionView);
|
||||
expect(extensionView?.closest<HTMLElement>('.absolute')?.style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
|
@ -3,27 +3,135 @@
|
|||
* Uses CSS display-toggle to keep all tabs mounted (preserving state).
|
||||
*/
|
||||
|
||||
import { TeamGraphTab } from '@features/agent-graph/renderer';
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import { TabUIProvider } from '@renderer/contexts/TabUIContext';
|
||||
|
||||
import { DashboardView } from '../dashboard/DashboardView';
|
||||
import { ExtensionStoreView } from '../extensions/ExtensionStoreView';
|
||||
import { NotificationsView } from '../notifications/NotificationsView';
|
||||
import { SessionReportTab } from '../report/SessionReportTab';
|
||||
import { SchedulesView } from '../schedules/SchedulesView';
|
||||
import { SettingsView } from '../settings/SettingsView';
|
||||
import { TeamDetailView } from '../team/TeamDetailView';
|
||||
import { TeamListView } from '../team/TeamListView';
|
||||
|
||||
import { SessionTabContent } from './SessionTabContent';
|
||||
|
||||
import type { Pane } from '@renderer/types/panes';
|
||||
import type { Tab } from '@renderer/types/tabs';
|
||||
|
||||
const ExtensionStoreView = lazy(() =>
|
||||
import('../extensions/ExtensionStoreView').then((module) => ({
|
||||
default: module.ExtensionStoreView,
|
||||
}))
|
||||
);
|
||||
const NotificationsView = lazy(() =>
|
||||
import('../notifications/NotificationsView').then((module) => ({
|
||||
default: module.NotificationsView,
|
||||
}))
|
||||
);
|
||||
const SessionReportTab = lazy(() =>
|
||||
import('../report/SessionReportTab').then((module) => ({
|
||||
default: module.SessionReportTab,
|
||||
}))
|
||||
);
|
||||
const SchedulesView = lazy(() =>
|
||||
import('../schedules/SchedulesView').then((module) => ({
|
||||
default: module.SchedulesView,
|
||||
}))
|
||||
);
|
||||
const SettingsView = lazy(() =>
|
||||
import('../settings/SettingsView').then((module) => ({
|
||||
default: module.SettingsView,
|
||||
}))
|
||||
);
|
||||
const TeamDetailView = lazy(() =>
|
||||
import('../team/TeamDetailView').then((module) => ({
|
||||
default: module.TeamDetailView,
|
||||
}))
|
||||
);
|
||||
const TeamListView = lazy(() =>
|
||||
import('../team/TeamListView').then((module) => ({
|
||||
default: module.TeamListView,
|
||||
}))
|
||||
);
|
||||
const SessionTabContent = lazy(() =>
|
||||
import('./SessionTabContent').then((module) => ({
|
||||
default: module.SessionTabContent,
|
||||
}))
|
||||
);
|
||||
const TeamGraphTab = lazy(() =>
|
||||
import('@features/agent-graph/renderer').then((module) => ({
|
||||
default: module.TeamGraphTab,
|
||||
}))
|
||||
);
|
||||
|
||||
interface PaneContentProps {
|
||||
pane: Pane;
|
||||
isPaneFocused: boolean;
|
||||
}
|
||||
|
||||
interface PaneTabSlotProps {
|
||||
tab: Tab;
|
||||
isActive: boolean;
|
||||
isPaneFocused: boolean;
|
||||
}
|
||||
|
||||
const PaneLazyFallback = (): React.JSX.Element => (
|
||||
<div className="flex flex-1 items-center justify-center bg-surface">
|
||||
<div
|
||||
className="size-5 animate-spin rounded-full border border-border border-t-text-muted"
|
||||
aria-label="Loading tab"
|
||||
role="status"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PaneTabSlot = ({ tab, isActive, isPaneFocused }: PaneTabSlotProps): React.JSX.Element => {
|
||||
const [hasActivated, setHasActivated] = useState(isActive);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setHasActivated(true);
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex" style={{ display: isActive ? 'flex' : 'none' }}>
|
||||
{hasActivated && (
|
||||
<Suspense fallback={<PaneLazyFallback />}>
|
||||
{tab.type === 'dashboard' && <DashboardView />}
|
||||
{tab.type === 'notifications' && <NotificationsView />}
|
||||
{tab.type === 'settings' && <SettingsView />}
|
||||
{tab.type === 'teams' && <TeamListView />}
|
||||
{tab.type === 'team' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<TeamDetailView
|
||||
teamName={tab.teamName ?? ''}
|
||||
isActive={isActive}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'session' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<SessionTabContent tab={tab} isActive={isActive} />
|
||||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'report' && <SessionReportTab tab={tab} />}
|
||||
{tab.type === 'extensions' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<ExtensionStoreView />
|
||||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'schedules' && <SchedulesView />}
|
||||
{tab.type === 'graph' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<TeamGraphTab
|
||||
teamName={tab.teamName ?? ''}
|
||||
isActive={isActive}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</TabUIProvider>
|
||||
)}
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PaneContent = ({ pane, isPaneFocused }: PaneContentProps): React.JSX.Element => {
|
||||
const activeTabId = pane.activeTabId;
|
||||
|
||||
|
|
@ -41,46 +149,7 @@ export const PaneContent = ({ pane, isPaneFocused }: PaneContentProps): React.JS
|
|||
{pane.tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="absolute inset-0 flex"
|
||||
style={{ display: isActive ? 'flex' : 'none' }}
|
||||
>
|
||||
{tab.type === 'dashboard' && <DashboardView />}
|
||||
{tab.type === 'notifications' && <NotificationsView />}
|
||||
{tab.type === 'settings' && <SettingsView />}
|
||||
{tab.type === 'teams' && <TeamListView />}
|
||||
{tab.type === 'team' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<TeamDetailView
|
||||
teamName={tab.teamName ?? ''}
|
||||
isActive={isActive}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'session' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<SessionTabContent tab={tab} isActive={isActive} />
|
||||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'report' && <SessionReportTab tab={tab} />}
|
||||
{tab.type === 'extensions' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<ExtensionStoreView />
|
||||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'schedules' && <SchedulesView />}
|
||||
{tab.type === 'graph' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<TeamGraphTab
|
||||
teamName={tab.teamName ?? ''}
|
||||
isActive={isActive}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</TabUIProvider>
|
||||
)}
|
||||
</div>
|
||||
<PaneTabSlot key={tab.id} tab={tab} isActive={isActive} isPaneFocused={isPaneFocused} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
111
src/renderer/components/layout/Sidebar.test.tsx
Normal file
111
src/renderer/components/layout/Sidebar.test.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/* 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 storeMock = vi.hoisted(() => ({
|
||||
state: {
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: <T,>(selector: (state: typeof storeMock.state) => T): T => selector(storeMock.state),
|
||||
}));
|
||||
|
||||
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'),
|
||||
}));
|
||||
|
||||
/* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */
|
||||
|
||||
import { Sidebar } from './Sidebar';
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
const flushReact = async (): Promise<void> => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
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 renderSidebar = async (root: Root): Promise<void> => {
|
||||
await act(async () => {
|
||||
root.render(<Sidebar />);
|
||||
await flushReact();
|
||||
});
|
||||
};
|
||||
|
||||
function findButtonByText(host: HTMLElement, text: string): HTMLButtonElement {
|
||||
const button = Array.from(host.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === text
|
||||
);
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error(`Button not found: ${text}`);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeMock.state.sidebarCollapsed = false;
|
||||
storeMock.state.toggleSidebar.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
for (const root of roots.splice(0)) {
|
||||
root.unmount();
|
||||
}
|
||||
await flushReact();
|
||||
});
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not mount the sessions panel before the sessions tab is opened', async () => {
|
||||
const { host, root } = createHarness();
|
||||
|
||||
await renderSidebar(root);
|
||||
|
||||
expect(host.querySelector('[data-testid="tasks-panel"]')).not.toBeNull();
|
||||
expect(host.querySelector('[data-testid="sessions-panel"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('mounts the sessions panel on first activation and keeps it mounted when hidden', async () => {
|
||||
const { host, root } = createHarness();
|
||||
await renderSidebar(root);
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(host, 'Sessions').click();
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
const sessionsPanel = host.querySelector('[data-testid="sessions-panel"]');
|
||||
expect(sessionsPanel).not.toBeNull();
|
||||
expect(sessionsPanel?.closest<HTMLElement>('[role="tabpanel"]')?.hidden).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(host, 'Tasks').click();
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="sessions-panel"]')).toBe(sessionsPanel);
|
||||
expect(sessionsPanel?.closest<HTMLElement>('[role="tabpanel"]')?.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -37,6 +37,7 @@ export const Sidebar = (): React.JSX.Element => {
|
|||
const [width, setWidth] = useState(DEFAULT_WIDTH);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [sidebarTab, setSidebarTab] = useState<SidebarTab>('tasks');
|
||||
const [hasOpenedSessionsTab, setHasOpenedSessionsTab] = useState(false);
|
||||
const [taskFilters, setTaskFilters] = useState<TaskFiltersState>(defaultTaskFiltersState);
|
||||
const [taskFiltersPopoverOpen, setTaskFiltersPopoverOpen] = useState(false);
|
||||
const [isCollapseHovered, setIsCollapseHovered] = useState(false);
|
||||
|
|
@ -77,6 +78,12 @@ export const Sidebar = (): React.JSX.Element => {
|
|||
};
|
||||
}, [isResizing, handleMouseMove, handleMouseUp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarTab === 'sessions') {
|
||||
setHasOpenedSessionsTab(true);
|
||||
}
|
||||
}, [sidebarTab]);
|
||||
|
||||
const handleResizeStart = (e: React.MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
|
|
@ -195,7 +202,7 @@ export const Sidebar = (): React.JSX.Element => {
|
|||
hidden={sidebarTab !== 'sessions'}
|
||||
className="min-w-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<DateGroupedSessions />
|
||||
{hasOpenedSessionsTab && <DateGroupedSessions />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -215,11 +215,10 @@ export function initializeNotificationListeners(): () => void {
|
|||
cleanupFns.push(() => {
|
||||
useStore.getState().unsubscribeProvisioningProgress();
|
||||
});
|
||||
// Initial data fetches. Config loads first (needed for theme), then the rest
|
||||
// run in parallel (no data dependencies between them). UV_THREADPOOL_SIZE=16
|
||||
// prevents thread pool saturation even with concurrent I/O on Windows.
|
||||
// Components also fire these from useEffect — loading guards in each action
|
||||
// prevent duplicate IPC calls (whichever caller starts first wins).
|
||||
// Initial data fetches. Config loads first (needed for theme), then the
|
||||
// immediately visible data follows. Repository grouping is owned by the
|
||||
// Sessions sidebar tab when it first mounts; scanning it here made startup
|
||||
// pay for a hidden panel.
|
||||
void (async () => {
|
||||
// Config: fast (in-memory read) — needed for theme before first paint.
|
||||
await useStore.getState().fetchConfig();
|
||||
|
|
@ -262,10 +261,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
runtimeStatusTimer = null;
|
||||
}, STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS);
|
||||
|
||||
// Remaining fetches have no data dependency on each other — run in parallel
|
||||
// to avoid blocking teams/notifications behind a slow repository scan.
|
||||
// Remaining visible startup fetches have no data dependency on each other.
|
||||
await Promise.all([
|
||||
useStore.getState().fetchRepositoryGroups(),
|
||||
useStore.getState().fetchAllTasks(),
|
||||
useStore.getState().fetchTeams(),
|
||||
useStore.getState().fetchNotifications(),
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ vi.mock('@renderer/api', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
import { initializeNotificationListeners, useStore } from '../../../src/renderer/store';
|
||||
import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice';
|
||||
import {
|
||||
|
|
@ -90,7 +92,6 @@ import {
|
|||
summarizeTeamRefreshFanout,
|
||||
type TeamRefreshFanoutSnapshot,
|
||||
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
describe('team change throttling', () => {
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
|
@ -190,6 +191,17 @@ describe('team change throttling', () => {
|
|||
expect(fetchTeamsSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not scan repository groups during centralized startup initialization', async () => {
|
||||
const getRepositoryGroupsSpy = vi.mocked(api.getRepositoryGroups);
|
||||
getRepositoryGroupsSpy.mockClear();
|
||||
|
||||
cleanup?.();
|
||||
cleanup = initializeNotificationListeners();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(getRepositoryGroupsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows next refresh after throttle window passes', async () => {
|
||||
const state = useStore.getState();
|
||||
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
||||
|
|
|
|||
Loading…
Reference in a new issue