perf(startup): defer hidden renderer work

This commit is contained in:
777genius 2026-05-23 15:34:30 +03:00
parent b4f2be87df
commit 6ac95505bc
6 changed files with 392 additions and 60 deletions

View 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');
});
});

View file

@ -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>

View 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);
});
});

View file

@ -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>

View file

@ -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(),

View file

@ -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');