Skip to content

Commit a8ec578

Browse files
authored
perf(web): stop the hidden Automations tab fetching on launch (#7413)
`EntryShell` keeps every entry view mounted and hides the inactive ones with `display: none` + `inert` + `aria-hidden`. `TasksView` is therefore live from the first paint of Home, and its mount effect pulls four endpoints — the automation catalog, pending proposals, routines, and the project picker — for a tab the user has not opened. It also runs the whole set twice per launch. `refresh` is keyed on `tasksWorkspaceIdentity`, which changes when `/api/workspace/context` resolves, so the identity-change pass repeats every request the pre-context pass made. That accounts for `/api/automation-templates`, `/api/automation-proposals` and `/api/routines` each appearing twice in a cold Home load. The repository already has the answer for this: `ProjectsView` receives `isActive`, and `DesignSystemsTab` early-returns on it. `TasksView` was simply never given the prop. Add it and gate the refresh effect. `isActive` defaults to true, so the six suites that render `<TasksView />` directly — and any other caller — keep their current behaviour; only `EntryShell` passes the real value. The effect depends on `isActive` as well as `refresh`, so an identity change while hidden re-enters it, returns early, and the fetch happens when the tab is opened. This delays the work, it does not suppress it. Nothing outside the component depends on it having loaded: `TasksView` takes no callbacks and reports no counts upward, and it has a single mount site.
1 parent 7965b75 commit a8ec578

3 files changed

Lines changed: 111 additions & 2 deletions

File tree

apps/web/src/components/EntryShell.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1763,6 +1763,7 @@ export function EntryShell({
17631763
designTemplates={designTemplates}
17641764
connectors={connectors}
17651765
connectorsLoading={connectorsLoading}
1766+
isActive={view === 'tasks'}
17661767
/>
17671768
</div>
17681769
<div data-testid="entry-view-plugins" data-active={view === 'plugins' ? 'true' : 'false'} {...inactiveViewProps(view === 'plugins')}>

apps/web/src/components/TasksView.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,16 @@ interface Props {
6060
designTemplates?: SkillSummary[];
6161
connectors?: ConnectorDetail[];
6262
connectorsLoading?: boolean;
63+
/**
64+
* Whether this view is the one on screen. `EntryShell` keeps every entry view
65+
* mounted and hides the inactive ones with `display: none` + `inert`, so
66+
* without this flag Automations loads its whole data set on every Home launch
67+
* for a tab the user has not opened.
68+
*
69+
* Defaults to active: several suites and any other caller render this view
70+
* directly, and the gate is opt-in rather than a new requirement.
71+
*/
72+
isActive?: boolean;
6373
}
6474

6575
function buildStaticTemplates(t: TranslateFn): ReadonlyArray<AutomationTemplate> {
@@ -384,7 +394,7 @@ function errorMessage(err: unknown): string {
384394
return err instanceof Error ? err.message : String(err);
385395
}
386396

387-
export function TasksView({ skills = [], designTemplates = [], connectors = [] }: Props) {
397+
export function TasksView({ skills = [], designTemplates = [], connectors = [], isActive = true }: Props) {
388398
const t = useT();
389399
const analytics = useAnalytics();
390400
// Attaches the same workspace identity headers project reads already carry,
@@ -514,8 +524,18 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
514524
}, [routineHeaders, tasksWorkspaceIdentity]);
515525

516526
useEffect(() => {
527+
// Hidden views do not fetch. This one is mounted from the first paint of
528+
// Home, and `refresh` pulls four endpoints — the automation catalog, pending
529+
// proposals, routines and the project picker. It also runs twice per launch,
530+
// because `refresh` is keyed on `tasksWorkspaceIdentity` and that changes
531+
// when `/api/workspace/context` resolves.
532+
//
533+
// Re-running on activation is what keeps this a delay rather than a
534+
// suppression: an identity change while hidden re-enters this effect,
535+
// returns early, and the fetch happens when the user opens the tab.
536+
if (!isActive) return;
517537
void refresh();
518-
}, [refresh]);
538+
}, [isActive, refresh]);
519539

520540
const projectsById = useMemo(() => {
521541
const map = new Map<string, string>();
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// @vitest-environment jsdom
2+
3+
import { cleanup, render, screen, waitFor } from '@testing-library/react';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
6+
import { TasksView } from '../../src/components/TasksView';
7+
8+
const originalFetch = globalThis.fetch;
9+
10+
const LAUNCH_ENDPOINTS = [
11+
'/api/automation-templates',
12+
'/api/automation-proposals?status=pending-review',
13+
'/api/routines',
14+
];
15+
16+
function trackedFetch(seen: string[]) {
17+
return vi.fn(async (input: RequestInfo | URL) => {
18+
const url = input.toString();
19+
seen.push(url);
20+
if (url.startsWith('/api/routines')) {
21+
return new Response(JSON.stringify({ routines: [] }), { status: 200 });
22+
}
23+
if (url.startsWith('/api/automation-templates')) {
24+
return new Response(JSON.stringify({ templates: [] }), { status: 200 });
25+
}
26+
if (url.startsWith('/api/automation-proposals')) {
27+
return new Response(JSON.stringify({ proposals: [] }), { status: 200 });
28+
}
29+
if (url.startsWith('/api/projects') || url.includes('/projects')) {
30+
return new Response(JSON.stringify({ projects: [] }), { status: 200 });
31+
}
32+
return new Response(JSON.stringify({}), { status: 200 });
33+
}) as unknown as typeof fetch;
34+
}
35+
36+
describe('TasksView inactive view', () => {
37+
afterEach(() => {
38+
cleanup();
39+
globalThis.fetch = originalFetch;
40+
vi.restoreAllMocks();
41+
});
42+
43+
it('does not fetch automation data while the view is hidden', async () => {
44+
// `EntryShell` keeps every entry view mounted and hides the inactive ones
45+
// with `display: none` + `inert`, so Automations loads its catalog, its
46+
// proposals, its routines and the project picker on every Home launch —
47+
// for a tab the user has not opened. Worse, the whole set runs twice,
48+
// because `tasksWorkspaceIdentity` changes when `/api/workspace/context`
49+
// resolves and `refresh` is keyed on it.
50+
const seen: string[] = [];
51+
globalThis.fetch = trackedFetch(seen);
52+
53+
render(<TasksView isActive={false} />);
54+
55+
// Give the mount effects a chance to run before asserting the absence.
56+
await waitFor(() => expect(screen.getByRole('heading', { name: 'Automations' })).toBeTruthy());
57+
for (const endpoint of LAUNCH_ENDPOINTS) {
58+
expect(seen).not.toContain(endpoint);
59+
}
60+
});
61+
62+
it('fetches once the view becomes active', async () => {
63+
// The gate must not turn into "never loads": opening the tab has to fill it.
64+
const seen: string[] = [];
65+
globalThis.fetch = trackedFetch(seen);
66+
67+
const { rerender } = render(<TasksView isActive={false} />);
68+
await waitFor(() => expect(screen.getByRole('heading', { name: 'Automations' })).toBeTruthy());
69+
expect(seen).not.toContain('/api/routines');
70+
71+
rerender(<TasksView isActive />);
72+
await waitFor(() => expect(seen).toContain('/api/routines'));
73+
for (const endpoint of LAUNCH_ENDPOINTS) {
74+
expect(seen).toContain(endpoint);
75+
}
76+
});
77+
78+
it('still loads for callers that do not pass the flag', async () => {
79+
// Six existing suites render `<TasksView />` bare, and so may other callers;
80+
// the gate is opt-in, not a new requirement.
81+
const seen: string[] = [];
82+
globalThis.fetch = trackedFetch(seen);
83+
84+
render(<TasksView />);
85+
86+
await waitFor(() => expect(seen).toContain('/api/routines'));
87+
});
88+
});

0 commit comments

Comments
 (0)