Skip to content

Commit a17f4dc

Browse files
committed
feat(projects): move projects server state onto TanStack Query
The P1 spike from docs/plans/frontend-refactor.md. The projects query owns fetching; the cross-refetch page merge and the identity bail-out move into the query's structuralSharing function; every hand-rolled setProjects write (session_upserted, optimistic registration, deletes, session pages) becomes a setQueryData cache write; the mount fetch effect is gone; a degraded /api/projects response (auth failure) throws and therefore keeps the previous cache instead of clobbering it. The hook's return API is unchanged, so no consumer moved. New DOM-lane tests lock mount fetch, silent refetch, websocket upsert without a network call, optimistic sessions surviving a refetch, and the degraded-response path.
1 parent 538adc4 commit a17f4dc

6 files changed

Lines changed: 322 additions & 87 deletions

File tree

‎package-lock.json‎

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@
171171
"@puppeteer/browsers": "^2.13.2",
172172
"@replit/codemirror-minimap": "^0.5.2",
173173
"@tailwindcss/typography": "^0.5.16",
174+
"@tanstack/react-query": "^5.102.6",
174175
"@tauri-apps/plugin-shell": "2.3.0",
175176
"@uiw/react-codemirror": "^4.23.13",
176177
"@vscode/ripgrep": "^1.17.1",

‎src/App.tsx‎

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
12
import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom';
23
import { I18nextProvider } from 'react-i18next';
34

@@ -11,6 +12,14 @@ import i18n from './i18n/config.js';
1112

1213

1314
const DEPLOYMENT_ASSET_DIRECTORIES = new Set(['assets', 'static', 'icons', 'images']);
15+
const queryClient = new QueryClient({
16+
defaultOptions: {
17+
queries: {
18+
refetchOnWindowFocus: false,
19+
retry: 1,
20+
},
21+
},
22+
});
1423

1524
/**
1625
* Detect the router basename from explicit runtime config or deployment hints.
@@ -106,19 +115,21 @@ export default function App() {
106115
return (
107116
<I18nextProvider i18n={i18n}>
108117
<ThemeProvider>
109-
<AuthProvider>
110-
<WebSocketProvider>
111-
<ProtectedRoute>
112-
<Router basename={routerBasename}>
113-
<DesktopDeepLinkBridge />
114-
<Routes>
115-
{appShellRoutePaths.map((path) => <Route key={path} path={path} element={<AppContent />} />)}
116-
<Route path={rootFallbackRoutePath} element={<Navigate to="/" replace />} />
117-
</Routes>
118-
</Router>
119-
</ProtectedRoute>
120-
</WebSocketProvider>
121-
</AuthProvider>
118+
<QueryClientProvider client={queryClient}>
119+
<AuthProvider>
120+
<WebSocketProvider>
121+
<ProtectedRoute>
122+
<Router basename={routerBasename}>
123+
<DesktopDeepLinkBridge />
124+
<Routes>
125+
{appShellRoutePaths.map((path) => <Route key={path} path={path} element={<AppContent />} />)}
126+
<Route path={rootFallbackRoutePath} element={<Navigate to="/" replace />} />
127+
</Routes>
128+
</Router>
129+
</ProtectedRoute>
130+
</WebSocketProvider>
131+
</AuthProvider>
132+
</QueryClientProvider>
122133
</ThemeProvider>
123134
</I18nextProvider>
124135
);
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import assert from 'node:assert/strict';
2+
import { afterEach, test } from 'node:test';
3+
4+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5+
import { act, cleanup, render, waitFor } from '@testing-library/react';
6+
import { createElement } from 'react';
7+
8+
import type { ServerEvent } from '../contexts/WebSocketContext';
9+
import type { Project } from '../types/app';
10+
11+
import { useProjectsState } from './useProjectsState';
12+
13+
const project = (sessions: Project['sessions'] = []): Project => ({
14+
projectId: 'project-1',
15+
path: '/workspace/project',
16+
fullPath: '/workspace/project',
17+
displayName: 'Project one',
18+
origin: 'explicit',
19+
isStarred: false,
20+
sessions,
21+
sessionMeta: { hasMore: false, total: sessions.length },
22+
});
23+
24+
type HookState = ReturnType<typeof useProjectsState>;
25+
26+
type Harness = {
27+
getState: () => HookState;
28+
emit: (event: ServerEvent) => void;
29+
loadingStates: boolean[];
30+
};
31+
32+
const renderHarness = (): Harness => {
33+
let state: HookState | null = null;
34+
let listener: ((event: ServerEvent) => void) | null = null;
35+
const loadingStates: boolean[] = [];
36+
const queryClient = new QueryClient({
37+
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: false } },
38+
});
39+
40+
const HarnessComponent = () => {
41+
const hookState = useProjectsState({
42+
sessionId: null,
43+
navigate: (() => undefined) as never,
44+
subscribe: (nextListener) => {
45+
listener = nextListener;
46+
return () => {
47+
listener = null;
48+
};
49+
},
50+
isMobile: false,
51+
activeSessions: new Map(),
52+
});
53+
state = hookState;
54+
if (loadingStates.at(-1) !== hookState.isLoadingProjects) {
55+
loadingStates.push(hookState.isLoadingProjects);
56+
}
57+
58+
return createElement('output', { 'data-testid': 'projects-state' }, JSON.stringify({
59+
projectNames: hookState.projects.map((item) => item.displayName),
60+
isLoadingProjects: hookState.isLoadingProjects,
61+
sessionIds: hookState.projects.flatMap((item) => item.sessions?.map((session) => session.id) ?? []),
62+
}));
63+
};
64+
65+
render(createElement(QueryClientProvider, { client: queryClient }, createElement(HarnessComponent)));
66+
67+
return {
68+
getState: () => {
69+
assert.ok(state, 'hook state is available after rendering');
70+
return state;
71+
},
72+
emit: (event) => {
73+
assert.ok(listener, 'websocket listener is registered');
74+
listener(event);
75+
},
76+
loadingStates,
77+
};
78+
};
79+
80+
const installFetch = (responses: Array<{ status?: number; body: unknown }>) => {
81+
const originalFetch = globalThis.fetch;
82+
let calls = 0;
83+
const urls: string[] = [];
84+
globalThis.fetch = async (input) => {
85+
urls.push(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
86+
const response = responses[calls++];
87+
assert.ok(response, 'unexpected network request');
88+
return new Response(JSON.stringify(response.body), { status: response.status ?? 200 });
89+
};
90+
return {
91+
calls: () => calls,
92+
urls: () => urls,
93+
restore: () => {
94+
globalThis.fetch = originalFetch;
95+
},
96+
};
97+
};
98+
99+
afterEach(cleanup);
100+
101+
test('mount fetches projects once and exposes initial loading state', async () => {
102+
const fetch = installFetch([{ body: [project()] }]);
103+
try {
104+
const harness = renderHarness();
105+
106+
await waitFor(() => assert.deepEqual(harness.getState().projects.map((item) => item.displayName), ['Project one']));
107+
108+
assert.equal(fetch.calls(), 1);
109+
assert.equal(new URL(fetch.urls()[0], window.location.origin).pathname, '/api/projects');
110+
assert.deepEqual(harness.loadingStates, [true, false]);
111+
} finally {
112+
fetch.restore();
113+
}
114+
});
115+
116+
test('silent refresh does not return to initial loading state', async () => {
117+
const fetch = installFetch([{ body: [project()] }, { body: [project()] }]);
118+
try {
119+
const harness = renderHarness();
120+
await waitFor(() => assert.equal(harness.getState().isLoadingProjects, false));
121+
122+
await act(async () => {
123+
await harness.getState().refreshProjectsSilently();
124+
});
125+
126+
assert.equal(fetch.calls(), 2);
127+
assert.deepEqual(harness.loadingStates, [true, false]);
128+
} finally {
129+
fetch.restore();
130+
}
131+
});
132+
133+
test('session upserts write directly to the project cache', async () => {
134+
const fetch = installFetch([{ body: [project()] }]);
135+
try {
136+
const harness = renderHarness();
137+
await waitFor(() => assert.equal(harness.getState().projects.length, 1));
138+
139+
act(() => {
140+
harness.emit({
141+
kind: 'session_upserted',
142+
sessionId: 'session-ws',
143+
provider: 'gjc',
144+
session: { id: 'session-ws', summary: 'From websocket' },
145+
project: {
146+
projectId: 'project-1',
147+
path: '/workspace/project',
148+
fullPath: '/workspace/project',
149+
displayName: 'Project one',
150+
isStarred: false,
151+
},
152+
timestamp: new Date().toISOString(),
153+
} as ServerEvent);
154+
});
155+
156+
await waitFor(() => assert.deepEqual(harness.getState().projects[0]?.sessions?.map((session) => session.id), ['session-ws']));
157+
assert.equal(fetch.calls(), 1);
158+
} finally {
159+
fetch.restore();
160+
}
161+
});
162+
163+
test('optimistic sessions survive a shorter refetch page', async () => {
164+
const fetch = installFetch([{ body: [project()] }, { body: [project()] }]);
165+
try {
166+
const harness = renderHarness();
167+
await waitFor(() => assert.equal(harness.getState().projects.length, 1));
168+
169+
act(() => {
170+
harness.getState().registerOptimisticSession({
171+
sessionId: 'session-optimistic',
172+
provider: 'gjc',
173+
project: project(),
174+
summary: 'Optimistic',
175+
});
176+
});
177+
await waitFor(() => assert.deepEqual(harness.getState().projects[0]?.sessions?.map((session) => session.id), ['session-optimistic']));
178+
179+
await act(async () => {
180+
await harness.getState().refreshProjectsSilently();
181+
});
182+
183+
assert.deepEqual(harness.getState().projects[0]?.sessions?.map((session) => session.id), ['session-optimistic']);
184+
} finally {
185+
fetch.restore();
186+
}
187+
});
188+
189+
test('degraded refetches retain the previous project cache', async () => {
190+
const fetch = installFetch([
191+
{ body: [project()] },
192+
{ status: 401, body: { error: 'Unauthorized' } },
193+
]);
194+
try {
195+
const harness = renderHarness();
196+
await waitFor(() => assert.deepEqual(harness.getState().projects.map((item) => item.displayName), ['Project one']));
197+
198+
await act(async () => {
199+
await harness.getState().refreshProjectsSilently();
200+
});
201+
202+
assert.deepEqual(harness.getState().projects.map((item) => item.displayName), ['Project one']);
203+
assert.equal(fetch.calls(), 2);
204+
} finally {
205+
fetch.restore();
206+
}
207+
});

0 commit comments

Comments
 (0)