Skip to content

Commit ca25a85

Browse files
authored
fix(dashboard): reuse cached API reads (#1180)
Dashboard routes now reuse a shared TanStack Query cache instead of treating each mount as a fresh read. Shell identity and configuration have one owner, slower-changing metadata stays fresh for five minutes, reporting reads use the shared 30-second default, and request-backed queries consume Query's cancellation signal. Fresh conversation feeds no longer refetch merely because the window regains focus. Personal API token reads and writes now use the query and mutation caches without storing the one-time token secret. Cache updates cancel stale reads before applying results, rapid create clicks cannot start duplicate mutations, and plugin record actions are serialized so rapid clicks cannot issue duplicate deletes. Query keys now include every input used by conversation, plugin-page, and highlighted-code fetches, while conversation detail consumers subscribe only to the fields they use. The dashboard now runs TanStack Query's recommended lint rules through Oxlint in both the root lint task and pre-commit checks. ESLint is present only because the TanStack plugin requires its runtime as a peer; Oxlint remains the configured runner. Browser regression coverage exercises single `/api/me` ownership, route-level token cache reuse and mutation races, fresh versus stale focus behavior, and duplicate plugin actions.
1 parent 280d2c7 commit ca25a85

18 files changed

Lines changed: 1051 additions & 163 deletions

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"worktree:setup": "node scripts/worktree.mjs setup",
1313
"cloudflare:token": "node scripts/refresh-cloudflare-tunnel-token.mjs",
1414
"prepare": "simple-git-hooks",
15-
"lint": "pnpm file-length:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-vercel lint && pnpm ast-grep:lint && pnpm package:lint",
15+
"lint": "pnpm file-length:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-vercel lint && pnpm --filter @sentry/junior-dashboard lint && pnpm ast-grep:lint && pnpm package:lint",
1616
"lint:fix": "pnpm --filter @sentry/junior lint:fix",
1717
"file-length:check": "node --test scripts/check-file-length.test.mjs && node scripts/check-file-length.mjs",
1818
"test-architecture:check": "node --test scripts/check-test-architecture.test.mjs && node scripts/check-test-architecture.mjs",
@@ -40,6 +40,7 @@
4040
"pre-commit": "pnpm lint-staged"
4141
},
4242
"lint-staged": {
43+
"packages/junior-dashboard/src/**/*.{ts,tsx}": "pnpm --filter @sentry/junior-dashboard lint",
4344
"packages/junior/**/*.{js,jsx,ts,tsx,mjs,cjs}": "pnpm --filter @sentry/junior exec oxlint --config .oxlintrc.json --deny-warnings --fix",
4445
"*.{js,jsx,ts,tsx,mjs,cjs,json,md,mdx,yml,yaml}": [
4546
"pnpm file-length:check",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"$schema": "./node_modules/oxlint/configuration_schema.json",
3+
"jsPlugins": ["@tanstack/eslint-plugin-query"],
4+
"rules": {
5+
"@tanstack/query/exhaustive-deps": "error",
6+
"@tanstack/query/infinite-query-property-order": "error",
7+
"@tanstack/query/mutation-property-order": "error",
8+
"@tanstack/query/no-rest-destructuring": "error",
9+
"@tanstack/query/no-unstable-deps": "error",
10+
"@tanstack/query/no-void-query-fn": "error",
11+
"@tanstack/query/stable-query-client": "error"
12+
}
13+
}

packages/junior-dashboard/e2e/conversations.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,29 @@ test.beforeEach(async ({ page }) => {
2121
await mockDashboardApis(page);
2222
});
2323

24+
test("reuses the fresh conversation feed after window focus", async ({
25+
page,
26+
}) => {
27+
let requests = 0;
28+
await page.route("**/api/conversations?*", async (route) => {
29+
requests += 1;
30+
await route.fallback();
31+
});
32+
33+
await page.goto(server.baseURL);
34+
await expect(
35+
page.getByRole("heading", { name: "Conversations" }),
36+
).toBeVisible();
37+
expect(requests).toBe(1);
38+
39+
await page.evaluate(() => {
40+
window.dispatchEvent(new Event("visibilitychange"));
41+
});
42+
await page.waitForTimeout(100);
43+
44+
expect(requests).toBe(1);
45+
});
46+
2447
test("opens a conversation in the built dashboard", async ({ page }) => {
2548
await page.setViewportSize({ height: 900, width: 1600 });
2649
const browserErrors = collectBrowserErrors(page);
@@ -362,6 +385,7 @@ test("inspects and copies an advisor transcript", async ({ context, page }) => {
362385
test("archives and restores a conversation from the sidebar", async ({
363386
page,
364387
}) => {
388+
const initialTime = Date.now();
365389
await page.setViewportSize({ height: 900, width: 1600 });
366390
let archived = false;
367391
await page.route(/\/api\/conversations(?:\?.*)?$/, async (route) => {
@@ -414,6 +438,7 @@ test("archives and restores a conversation from the sidebar", async ({
414438
response.request().method() === "GET" &&
415439
/\/api\/conversations(?:\?.*)?$/.test(response.url()),
416440
);
441+
await page.clock.setFixedTime(new Date(initialTime + 31_000));
417442
await page.evaluate(() => {
418443
window.dispatchEvent(new Event("focus"));
419444
window.dispatchEvent(new Event("visibilitychange"));
@@ -447,6 +472,7 @@ test("archives and restores a conversation from the sidebar", async ({
447472
response.request().method() === "GET" &&
448473
/\/api\/conversations(?:\?.*)?$/.test(response.url()),
449474
);
475+
await page.clock.setFixedTime(new Date(initialTime + 62_000));
450476
await page.evaluate(() => {
451477
window.dispatchEvent(new Event("focus"));
452478
window.dispatchEvent(new Event("visibilitychange"));
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
import {
3+
collectBrowserErrors,
4+
type DashboardE2eServer,
5+
mockDashboardApis,
6+
startDashboardE2eServer,
7+
} from "./harness";
8+
9+
let server: DashboardE2eServer;
10+
11+
test.beforeAll(async () => {
12+
server = await startDashboardE2eServer();
13+
});
14+
15+
test.afterAll(async () => {
16+
await server.close();
17+
});
18+
19+
test.beforeEach(async ({ page }) => {
20+
await mockDashboardApis(page);
21+
});
22+
23+
test("reuses the personal token list across dashboard routes", async ({
24+
page,
25+
}) => {
26+
const browserErrors = collectBrowserErrors(page);
27+
let listRequests = 0;
28+
await page.route("**/api/personal-tokens", async (route) => {
29+
listRequests += 1;
30+
await route.fulfill({
31+
json: {
32+
tokens: [
33+
{
34+
createdAt: "2026-08-01T00:00:00.000Z",
35+
expiresAt: "2026-10-30T00:00:00.000Z",
36+
id: "00000000-0000-4000-8000-000000000001",
37+
lastUsedAt: null,
38+
name: "Local agent",
39+
tokenSuffix: "abcd",
40+
},
41+
],
42+
},
43+
});
44+
});
45+
46+
await page.goto(server.baseURL);
47+
await openPersonalTokens(page);
48+
await expect(page.getByText("Local agent", { exact: true })).toBeVisible();
49+
50+
await page.getByRole("link", { name: "System", exact: true }).click();
51+
await expect(page).toHaveURL(`${server.baseURL}/system`);
52+
await openPersonalTokens(page);
53+
54+
expect(listRequests).toBe(1);
55+
expect(browserErrors).toEqual([]);
56+
});
57+
58+
test("keeps a created token when a stale list refetch is in flight", async ({
59+
page,
60+
}) => {
61+
const browserErrors = collectBrowserErrors(page);
62+
const staleListStarted = promiseSignal();
63+
const releaseStaleList = promiseSignal();
64+
let listRequests = 0;
65+
await page.route("**/api/personal-tokens", async (route) => {
66+
if (route.request().method() === "POST") {
67+
await route.fulfill({
68+
json: {
69+
createdAt: "2026-08-01T00:01:00.000Z",
70+
expiresAt: "2026-10-30T00:01:00.000Z",
71+
id: "00000000-0000-4000-8000-000000000002",
72+
lastUsedAt: null,
73+
name: "Review token",
74+
token: "jr_pat_one-time-secret",
75+
tokenSuffix: "wxyz",
76+
},
77+
});
78+
return;
79+
}
80+
81+
listRequests += 1;
82+
if (listRequests === 2) {
83+
staleListStarted.resolve();
84+
await releaseStaleList.promise;
85+
}
86+
await route
87+
.fulfill({
88+
json: {
89+
tokens: [
90+
{
91+
createdAt: "2026-08-01T00:00:00.000Z",
92+
expiresAt: "2026-10-30T00:00:00.000Z",
93+
id: "00000000-0000-4000-8000-000000000001",
94+
lastUsedAt: null,
95+
name: "Local agent",
96+
tokenSuffix: "abcd",
97+
},
98+
],
99+
},
100+
})
101+
.catch(() => undefined);
102+
});
103+
104+
await page.goto(server.baseURL);
105+
await openPersonalTokens(page);
106+
await expect(page.getByText("Local agent", { exact: true })).toBeVisible();
107+
108+
await page.getByRole("link", { name: "System", exact: true }).click();
109+
await page.clock.setFixedTime(new Date(Date.now() + 31_000));
110+
await openPersonalTokens(page);
111+
await staleListStarted.promise;
112+
113+
await page.getByLabel("Token name").fill("Review token");
114+
await page.getByRole("button", { name: "Create token" }).click();
115+
await expect(page.getByText("jr_pat_one-time-secret")).toBeVisible();
116+
releaseStaleList.resolve();
117+
118+
await expect(page.getByText("Review token", { exact: true })).toBeVisible();
119+
expect(listRequests).toBe(2);
120+
expect(browserErrors).toEqual([]);
121+
});
122+
123+
test("starts only one token create for rapid clicks", async ({ page }) => {
124+
const browserErrors = collectBrowserErrors(page);
125+
const releaseCreate = promiseSignal();
126+
let createRequests = 0;
127+
await page.route("**/api/personal-tokens", async (route) => {
128+
if (route.request().method() === "POST") {
129+
createRequests += 1;
130+
await releaseCreate.promise;
131+
await route.fulfill({
132+
json: {
133+
createdAt: "2026-08-01T00:01:00.000Z",
134+
expiresAt: "2026-10-30T00:01:00.000Z",
135+
id: "00000000-0000-4000-8000-000000000002",
136+
lastUsedAt: null,
137+
name: "Review token",
138+
token: "jr_pat_one-time-secret",
139+
tokenSuffix: "wxyz",
140+
},
141+
});
142+
return;
143+
}
144+
145+
await route.fulfill({ json: { tokens: [] } });
146+
});
147+
148+
await page.goto(server.baseURL);
149+
await openPersonalTokens(page);
150+
await page.getByLabel("Token name").fill("Review token");
151+
await page
152+
.getByRole("button", { name: "Create token" })
153+
.evaluate((button) => {
154+
button.click();
155+
button.click();
156+
});
157+
await expect.poll(() => createRequests).toBe(1);
158+
releaseCreate.resolve();
159+
160+
await expect(page.getByText("jr_pat_one-time-secret")).toBeVisible();
161+
expect(createRequests).toBe(1);
162+
expect(browserErrors).toEqual([]);
163+
});
164+
165+
test("keeps cached tokens and mutation errors after a refetch fails", async ({
166+
page,
167+
}) => {
168+
const backgroundRefetchFinished = promiseSignal();
169+
let listRequests = 0;
170+
await page.route("**/api/personal-tokens", async (route) => {
171+
if (route.request().method() === "POST") {
172+
await route.fulfill({ status: 500 });
173+
return;
174+
}
175+
176+
listRequests += 1;
177+
if (listRequests === 2) {
178+
await route.fulfill({ status: 500 });
179+
backgroundRefetchFinished.resolve();
180+
return;
181+
}
182+
await route.fulfill({
183+
json: {
184+
tokens: [
185+
{
186+
createdAt: "2026-08-01T00:00:00.000Z",
187+
expiresAt: "2026-10-30T00:00:00.000Z",
188+
id: "00000000-0000-4000-8000-000000000001",
189+
lastUsedAt: null,
190+
name: "Local agent",
191+
tokenSuffix: "abcd",
192+
},
193+
],
194+
},
195+
});
196+
});
197+
198+
await page.goto(server.baseURL);
199+
await openPersonalTokens(page);
200+
await expect(page.getByText("Local agent", { exact: true })).toBeVisible();
201+
202+
await page.getByRole("link", { name: "System", exact: true }).click();
203+
await page.clock.setFixedTime(new Date(Date.now() + 31_000));
204+
await openPersonalTokens(page);
205+
await backgroundRefetchFinished.promise;
206+
await expect(page.getByText("Local agent", { exact: true })).toBeVisible();
207+
208+
await page.getByLabel("Token name").fill("Review token");
209+
await page.getByRole("button", { name: "Create token" }).click();
210+
await expect(
211+
page.getByText("Could not create the API token. Try again."),
212+
).toBeVisible();
213+
await expect(
214+
page.getByText("Could not load API tokens. Try again."),
215+
).toHaveCount(0);
216+
expect(listRequests).toBe(2);
217+
});
218+
219+
async function openPersonalTokens(page: Page) {
220+
await page.getByRole("button", { name: /Open profile menu/ }).click();
221+
await page.getByRole("link", { name: "API tokens", exact: true }).click();
222+
await expect(
223+
page.getByRole("heading", { name: "Personal API Tokens" }),
224+
).toBeVisible();
225+
}
226+
227+
function promiseSignal() {
228+
let resolve!: () => void;
229+
const promise = new Promise<void>((complete) => {
230+
resolve = complete;
231+
});
232+
return { promise, resolve };
233+
}

packages/junior-dashboard/e2e/system.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@ test.beforeEach(async ({ page }) => {
2323
test("shows system usage and plugin details", async ({ page }) => {
2424
await page.setViewportSize({ height: 900, width: 1600 });
2525
const browserErrors = collectBrowserErrors(page);
26+
let identityRequests = 0;
27+
page.on("request", (request) => {
28+
if (new URL(request.url()).pathname === "/api/me") identityRequests += 1;
29+
});
2630
await page.goto(`${server.baseURL}/system`);
2731

2832
await expect(page.getByText("Usage over time")).toBeVisible();
33+
expect(identityRequests).toBe(1);
2934
await expect(page.getByText("Model spend")).toBeVisible();
3035
await expect(page.getByRole("region", { name: "Plugins" })).toHaveCount(0);
3136

packages/junior-dashboard/e2e/user-pages.spec.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ test("searches, paginates, and forgets plugin page records", async ({
195195
page,
196196
}) => {
197197
let forgotMemory = false;
198+
let forgetRequests = 0;
198199
let dashboardRequestCount = 0;
199200
await page.route("**/api/plugins/memory/dashboard", async (route) => {
200201
dashboardRequestCount += 1;
@@ -243,6 +244,7 @@ test("searches, paginates, and forgets plugin page records", async ({
243244
await page.route(
244245
"**/api/plugins/memory/memories/memory-search",
245246
async (route) => {
247+
forgetRequests += 1;
246248
expect(route.request().method()).toBe("DELETE");
247249
forgotMemory = true;
248250
await route.fulfill({ status: 204 });
@@ -290,10 +292,16 @@ test("searches, paginates, and forgets plugin page records", async ({
290292
await page
291293
.getByRole("button", { name: /^Deploy runbooks live in Notion/ })
292294
.click();
293-
await page.getByRole("button", { name: "Forget this memory" }).click();
295+
await page
296+
.getByRole("button", { name: "Forget this memory" })
297+
.evaluate((button) => {
298+
button.click();
299+
button.click();
300+
});
294301
await expect(
295302
page.getByText("No memories matched your search."),
296303
).toBeVisible();
304+
expect(forgetRequests).toBe(1);
297305
await expect.poll(() => dashboardRequestCount).toBeGreaterThan(1);
298306

299307
await searchbox.fill("");

packages/junior-dashboard/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"build:client": "tsup --config tsup.client.config.ts",
2727
"build:css": "tailwindcss -i src/tailwind.css -o dist/tailwind.css --minify",
2828
"build:server": "tsup --config tsup.config.ts",
29+
"lint": "oxlint --config .oxlintrc.json --deny-warnings src",
2930
"prepare": "pnpm run build",
3031
"prepack": "pnpm run build",
3132
"test": "vitest run -c vitest.config.ts",
@@ -49,10 +50,13 @@
4950
},
5051
"devDependencies": {
5152
"@tailwindcss/cli": "^4.3.0",
53+
"@tanstack/eslint-plugin-query": "^5.101.4",
5254
"@types/node": "^25.9.1",
5355
"@types/react": "^19.2.15",
5456
"@types/react-dom": "^19.2.3",
5557
"@vitest/coverage-v8": "4.1.7",
58+
"eslint": "^10.8.0",
59+
"oxlint": "^1.66.0",
5660
"tailwindcss": "^4.3.0",
5761
"tsup": "^8.5.1",
5862
"typescript": "^6.0.3",

0 commit comments

Comments
 (0)