Skip to content

Commit b9d3434

Browse files
authored
Merge pull request #931 from Wibias/codex/react-doctor-0.9.3
Fix 6 React Doctor findings and pin the CI engine to 0.9.3
2 parents 87c4790 + d233403 commit b9d3434

10 files changed

Lines changed: 40 additions & 38 deletions

File tree

.github/workflows/react-doctor.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ jobs:
5050
directory: gui
5151
# Pin the npm engine — the action wrapper would otherwise fetch
5252
# react-doctor@latest, silently skewing CI from the local pinned runs.
53-
version: "0.9.2"
53+
version: "0.9.3"
5454
# Fail the job on any finding (errors or warnings).
5555
blocking: warning
5656
comment: false

gui/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,6 @@ bun run setup:hooks # pre-push runs doctor when gui/ changed
4949
| Tool | Role |
5050
|------|------|
5151
| **ESLint** (`bun run lint`) | Hard gate in CI and expected before merge |
52-
| **React Doctor** (`bun run doctor`) | Gating React health check pinned to react-doctor 0.9.2 (`blocking: warning`). Pre-push runs it only if `gui/` changed and fails the push on findings. The CI workflow fails the job on any finding |
52+
| **React Doctor** (`bun run doctor`) | Gating React health check pinned to react-doctor 0.9.3 (`blocking: warning`). Pre-push runs it only if `gui/` changed and fails the push on findings. The CI workflow fails the job on any finding |
5353

5454
Fix ESLint errors first. Use `doctor` / `doctor:full` for deeper React triage.

gui/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
"lint": "eslint .",
1010
"test": "bun test tests",
1111
"lint:i18n": "eslint src/pages src/components src/App.tsx src/ui.tsx",
12-
"doctor": "npx --yes react-doctor@0.9.2 --verbose --scope changed --base origin/main --no-telemetry",
13-
"doctor:full": "npx --yes react-doctor@0.9.2 --verbose --scope full --no-telemetry",
12+
"doctor": "npx --yes react-doctor@0.9.3 --verbose --scope changed --base origin/main --no-telemetry",
13+
"doctor:full": "npx --yes react-doctor@0.9.3 --verbose --scope full --no-telemetry",
1414
"preview": "vite preview"
1515
},
1616
"dependencies": {

gui/src/components/data-surface.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { CSSProperties, ReactNode } from "react";
1313
* Lets a page mirror its ready geometry without exposing placeholder values to assistive
1414
* technology. The surrounding skeleton owns the single announced sentence.
1515
*/
16-
export function DataSurfaceSkeletonBlock({
16+
function DataSurfaceSkeletonBlock({
1717
className,
1818
style,
1919
}: {

gui/src/hooks/useCodexAccountPool.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
110110
// Pause leases live in a ref: pausing must not re-render, and the effect below reads
111111
// the live set rather than a captured snapshot.
112112
const [pauseCount, setPauseCount] = useState(0);
113-
const pauseTokensRef = useRef<Set<PauseToken>>(new Set());
113+
const pauseTokensRef = useRef<Set<PauseToken> | null>(null);
114+
if (pauseTokensRef.current === null) pauseTokensRef.current = new Set();
114115
// Which apiBase this instance has already kicked its initial load for. StrictMode double-invokes
115116
// the mount effect, and the deferred load is deliberately uncancellable, so the guard has to live
116117
// here rather than in the effect's cleanup.
@@ -119,7 +120,8 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
119120
// Set by switchAccount so a background load already in flight cannot roll the active
120121
// id back to a value the server had not yet committed when that request was issued.
121122
const pendingActiveIdRef = useRef<{ id: string | null } | null>(null);
122-
const observersRef = useRef<Set<CodexAccountLoadObserver>>(new Set());
123+
const observersRef = useRef<Set<CodexAccountLoadObserver> | null>(null);
124+
if (observersRef.current === null) observersRef.current = new Set();
123125
// Last /active payload an actual read returned. Surfaces that mount after a
124126
// load already finished read it to seed their UI instead of waiting a poll interval.
125127
const lastActiveRef = useRef<{ value: unknown } | null>(null);
@@ -131,13 +133,13 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
131133
const pauseMutationRef = useRef<"bulk" | { accountId: string } | null>(null);
132134

133135
const subscribeLoadObserver = useCallback((observer: CodexAccountLoadObserver) => {
134-
observersRef.current.add(observer);
136+
observersRef.current!.add(observer);
135137
// Subscribing stays silent. `acceptActiveRead` means "a read that started at this
136138
// revision came back", and useCodexAutoSwitch / CodexPoolStrategySetting decide their
137139
// editing and saving disposition from that. Synthesising one on subscribe can overwrite
138140
// an in-flight draft or arm a spurious post-save refresh. Late surfaces seed themselves
139141
// from readLastThreshold()/readLastActive(), which apply only while uninitialized.
140-
return () => { observersRef.current.delete(observer); };
142+
return () => { observersRef.current!.delete(observer); };
141143
}, []);
142144

143145
/** Last threshold an actual read returned, or undefined when none has succeeded yet. */
@@ -156,7 +158,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
156158
// observer snapshot below cannot leave the counter stuck above zero.
157159
try {
158160
// Snapshot subscribers so an unsubscribe mid-flight cannot desync begin/accept pairs.
159-
const observers = [...observersRef.current];
161+
const observers = [...observersRef.current!];
160162
const revisions = new Map<CodexAccountLoadObserver, number>();
161163
for (const observer of observers) revisions.set(observer, observer.beginActiveRead());
162164
// Soft refresh when boxes are already on screen — avoid full-page loading flash.
@@ -282,14 +284,14 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
282284

283285
const pauseRefresh = useCallback((): PauseToken => {
284286
const token = {} as PauseToken;
285-
pauseTokensRef.current.add(token);
286-
setPauseCount(pauseTokensRef.current.size);
287+
pauseTokensRef.current!.add(token);
288+
setPauseCount(pauseTokensRef.current!.size);
287289
return token;
288290
}, []);
289291

290292
const resumeRefresh = useCallback((token: PauseToken) => {
291-
if (!pauseTokensRef.current.delete(token)) return;
292-
setPauseCount(pauseTokensRef.current.size);
293+
if (!pauseTokensRef.current!.delete(token)) return;
294+
setPauseCount(pauseTokensRef.current!.size);
293295
}, []);
294296

295297
const switchAccount = useCallback(async (id: string | null) => {

gui/src/icons.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ export const IconSun = (p: P) => (<svg {...S(p)}><circle cx="12" cy="12" r="4"/>
4343
export const IconMoon = (p: P) => (<svg {...S(p)}><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/></svg>);
4444
export const IconMonitor = (p: P) => (<svg {...S(p)}><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>);
4545
export const IconGlobe = (p: P) => (<svg {...S(p)}><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>);
46-
export const IconSparkle = (p: P) => (<svg {...S(p)}><path d="M12 3v18M5.6 5.6l12.8 12.8M3 12h18M5.6 18.4 18.4 5.6"/></svg>);
4746
/** Crossed arrows — Combos workspace nav / rail marker (load-balance / hop). */
4847
export const IconShuffle = (p: P) => (
4948
<svg {...S(p)}>

gui/src/pages/Integrations.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ export default function Integrations({ apiBase }: { apiBase: string }) {
7272
const [mounted, setMounted] = useState<ReadonlySet<IntegrationTab>>(
7373
() => new Set([readIntegrationTab()]),
7474
);
75-
const tabRefs = useRef(new Map<IntegrationTab, HTMLButtonElement>());
75+
const tabRefs = useRef<Map<IntegrationTab, HTMLButtonElement> | null>(null);
76+
if (tabRefs.current === null) tabRefs.current = new Map();
7677

7778
/*
7879
* Every tab change goes through here, whether it came from a click or from
@@ -102,7 +103,7 @@ export default function Integrations({ apiBase }: { apiBase: string }) {
102103
activateTab(next);
103104
if (moveFocus) {
104105
window.requestAnimationFrame(() => {
105-
tabRefs.current.get(next)?.focus({ preventScroll: true });
106+
tabRefs.current!.get(next)?.focus({ preventScroll: true });
106107
});
107108
}
108109
};
@@ -131,8 +132,8 @@ export default function Integrations({ apiBase }: { apiBase: string }) {
131132
<button
132133
key={definition.id}
133134
ref={node => {
134-
if (node) tabRefs.current.set(definition.id, node);
135-
else tabRefs.current.delete(definition.id);
135+
if (node) tabRefs.current!.set(definition.id, node);
136+
else tabRefs.current!.delete(definition.id);
136137
}}
137138
type="button"
138139
role="tab"

gui/src/pages/Providers.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ export default function Providers({ apiBase }: { apiBase: string }) {
4545
// effect and its deferred load is deliberately uncancellable, so the guard lives here.
4646
const bootstrapKeyRef = useRef<string | null>(null);
4747
const removeBusyRef = useRef(false);
48-
const oauthLoginGenerationRef = useRef<Map<string, number>>(new Map());
4948

5049
const notify = useCallback((msg: string, ok: boolean = true) => {
5150
setStatus(msg);
@@ -174,7 +173,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {
174173
const bumpModelsRefresh = () => setModelsRefreshToken(n => n + 1);
175174

176175
const { cancelLoginOAuth, loginOAuth, logoutOAuth } = useProvidersOAuth({
177-
apiBase, t, aliveRef, oauthLoginGenerationRef, accountSets,
176+
apiBase, t, aliveRef, accountSets,
178177
setBusy, setStatus, setLoginInfo, setOauthStatus, notify,
179178
fetchConfig, fetchOauth, fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh,
180179
});

gui/src/pages/use-providers-oauth.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback } from "react";
1+
import { useCallback, useRef } from "react";
22
import type { TFn } from "../i18n/shared";
33
import { readJsonIfOk } from "../fetch-json";
44
import type { OAuthAccount, OAuthStatus } from "./providers-shared";
@@ -8,7 +8,6 @@ export function useProvidersOAuth({
88
apiBase,
99
t,
1010
aliveRef,
11-
oauthLoginGenerationRef,
1211
accountSets,
1312
setBusy,
1413
setStatus,
@@ -24,7 +23,6 @@ export function useProvidersOAuth({
2423
apiBase: string;
2524
t: TFn;
2625
aliveRef: React.MutableRefObject<boolean>;
27-
oauthLoginGenerationRef: React.MutableRefObject<Map<string, number>>;
2826
accountSets: Record<string, { accounts: OAuthAccount[] }>;
2927
setBusy: React.Dispatch<React.SetStateAction<string | null>>;
3028
setStatus: React.Dispatch<React.SetStateAction<string>>;
@@ -37,9 +35,12 @@ export function useProvidersOAuth({
3735
fetchProviderQuotas: (refresh?: boolean) => Promise<void>;
3836
bumpModelsRefresh: () => void;
3937
}) {
38+
const oauthLoginGenerationRef = useRef<Map<string, number> | null>(null);
39+
if (oauthLoginGenerationRef.current === null) oauthLoginGenerationRef.current = new Map();
40+
4041
const cancelLoginOAuth = useCallback(async (provider: string) => {
41-
const gen = (oauthLoginGenerationRef.current.get(provider) ?? 0) + 1;
42-
oauthLoginGenerationRef.current.set(provider, gen);
42+
const gen = (oauthLoginGenerationRef.current!.get(provider) ?? 0) + 1;
43+
oauthLoginGenerationRef.current!.set(provider, gen);
4344
try {
4445
await fetch(`${apiBase}/api/oauth/login/cancel`, {
4546
method: "POST",
@@ -48,16 +49,16 @@ export function useProvidersOAuth({
4849
});
4950
} catch { /* ignore */ }
5051
if (!aliveRef.current) return;
51-
if (oauthLoginGenerationRef.current.get(provider) === gen) {
52+
if (oauthLoginGenerationRef.current!.get(provider) === gen) {
5253
setBusy(current => current === provider ? null : current);
5354
setLoginInfo(current => current?.provider === provider ? null : current);
5455
}
5556
notify(t("prov.loginCancelled", { provider: oauthLabel(provider) }), false);
56-
}, [aliveRef, apiBase, notify, oauthLoginGenerationRef, setBusy, setLoginInfo, t]);
57+
}, [aliveRef, apiBase, notify, setBusy, setLoginInfo, t]);
5758

5859
const loginOAuth = async (provider: string, addAccount = false, accountId?: string) => {
59-
const nextGen = (oauthLoginGenerationRef.current.get(provider) ?? 0) + 1;
60-
oauthLoginGenerationRef.current.set(provider, nextGen);
60+
const nextGen = (oauthLoginGenerationRef.current!.get(provider) ?? 0) + 1;
61+
oauthLoginGenerationRef.current!.set(provider, nextGen);
6162
const generation = nextGen;
6263
const reauthTargetId = accountId?.trim() || undefined;
6364
setBusy(provider);
@@ -73,7 +74,7 @@ export function useProvidersOAuth({
7374
...(reauthTargetId ? { accountId: reauthTargetId, reauth: true } : {}),
7475
}),
7576
});
76-
if (oauthLoginGenerationRef.current.get(provider) !== generation || !aliveRef.current) return;
77+
if (oauthLoginGenerationRef.current!.get(provider) !== generation || !aliveRef.current) return;
7778
if (!res.ok) {
7879
const data = await res.json().catch(() => ({})) as { error?: string };
7980
notify(data.error || t("prov.loginFailStart", { provider: oauthLabel(provider) }), false);
@@ -85,9 +86,9 @@ export function useProvidersOAuth({
8586
}
8687
const baselineCount = accountSets[provider]?.accounts.length ?? 0;
8788
let finished = false;
88-
for (let i = 0; i < 150 && aliveRef.current && oauthLoginGenerationRef.current.get(provider) === generation; i++) {
89+
for (let i = 0; i < 150 && aliveRef.current && oauthLoginGenerationRef.current!.get(provider) === generation; i++) {
8990
await new Promise(r => setTimeout(r, 2000));
90-
if (oauthLoginGenerationRef.current.get(provider) !== generation || !aliveRef.current) return;
91+
if (oauthLoginGenerationRef.current!.get(provider) !== generation || !aliveRef.current) return;
9192
const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${provider}`).catch(() => null);
9293
const s: (OAuthStatus & { accounts?: OAuthAccount[] }) | null = sRes
9394
? ((await readJsonIfOk<OAuthStatus & { accounts?: OAuthAccount[] }>(sRes)) ?? null)
@@ -138,7 +139,7 @@ export function useProvidersOAuth({
138139
break;
139140
}
140141
}
141-
if (!finished && oauthLoginGenerationRef.current.get(provider) === generation && aliveRef.current) {
142+
if (!finished && oauthLoginGenerationRef.current!.get(provider) === generation && aliveRef.current) {
142143
await fetch(`${apiBase}/api/oauth/login/cancel`, {
143144
method: "POST",
144145
headers: { "Content-Type": "application/json" },
@@ -148,11 +149,11 @@ export function useProvidersOAuth({
148149
setLoginInfo(null);
149150
}
150151
} catch {
151-
if (oauthLoginGenerationRef.current.get(provider) === generation) {
152+
if (oauthLoginGenerationRef.current!.get(provider) === generation) {
152153
notify(t("prov.loginRequestFail", { provider: oauthLabel(provider) }), false);
153154
}
154155
} finally {
155-
if (aliveRef.current && oauthLoginGenerationRef.current.get(provider) === generation) setBusy(null);
156+
if (aliveRef.current && oauthLoginGenerationRef.current!.get(provider) === generation) setBusy(null);
156157
}
157158
};
158159

tests/ci-workflows.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2349,7 +2349,7 @@ describe("GitHub Actions hardening", () => {
23492349
);
23502350

23512351
// Engine pin: the action wrapper would fetch react-doctor@latest without it.
2352-
expect(workflow).toContain('version: "0.9.2"');
2352+
expect(workflow).toContain('version: "0.9.3"');
23532353

23542354
// Action pin must accept CLI JSON schemaVersion 3 (baseline reports from 0.9.x).
23552355
// v2.1.0's ensure-json-report only knew schemas 1–2 and failed every PR scan.
@@ -2371,7 +2371,7 @@ describe("GitHub Actions hardening", () => {
23712371
const rootPkg = await readText("package.json");
23722372
const doctorConfig = await readText("gui/doctor.config.json");
23732373

2374-
expect(guiPkg).toContain("react-doctor@0.9.2");
2374+
expect(guiPkg).toContain("react-doctor@0.9.3");
23752375
expect(guiPkg).not.toContain("react-doctor@latest");
23762376
expect(rootPkg).not.toContain("react-doctor@latest");
23772377
expect(doctorConfig).toContain('"blocking": "warning"');

0 commit comments

Comments
 (0)