chore: gate high-signal react doctor rules

Adds a focused React Doctor oxlint gate for high-signal state/effect rules, disables the noisy js-combine-iterations React Doctor rule, and refactors the mobile new-worktree modal away from prop-change state reset effects.
This commit is contained in:
Neil
2026-05-31 22:35:15 -07:00
committed by GitHub
parent 4e162dff4e
commit 41bf76aef2
6 changed files with 221 additions and 57 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "../node_modules/oxlint/configuration_schema.json",
"plugins": [],
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"jsPlugins": [{ "name": "react-doctor", "specifier": "oxlint-plugin-react-doctor" }],
"rules": {
"react-doctor/no-adjust-state-on-prop-change": "warn",
"react-doctor/no-derived-state-effect": "warn",
"react-doctor/no-initialize-state": "warn"
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
}
@@ -0,0 +1,51 @@
import { existsSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit']
})
if (result.error) {
throw result.error
}
if (result.status !== 0) {
return []
}
return result.stdout.split(/\r?\n/).filter(Boolean)
}
const changedFiles = new Set([
...run('git', ['diff', '--name-only', '--diff-filter=ACMRTUB']),
...run('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMRTUB']),
...run('git', ['ls-files', '--others', '--exclude-standard'])
])
const lintTargets = [...changedFiles].filter(
(file) => SOURCE_FILE_PATTERN.test(file) && existsSync(file)
)
if (lintTargets.length === 0) {
process.exit(0)
}
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(
pnpm,
[
'exec',
'oxlint',
'--config',
'config/oxlint-react-doctor.json',
'--deny-warnings',
...lintTargets
],
{ stdio: 'inherit' }
)
if (result.error) {
throw result.error
}
process.exit(result.status ?? 1)
+86 -57
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import {
View,
Text,
@@ -63,6 +63,19 @@ type RepoHooksResponse = {
setupTrust?: SetupHookTrust
}
type SetupHookDetails = {
repoId: string
command: string | null
source: string | null
trust: SetupHookTrust | null
runPolicy: SetupRunPolicy
}
type DetectedAgentIdsState = {
connectionId: string | null
ids: Set<string>
}
type CreateOptions = {
setupOverride?: Exclude<SetupDecision, 'inherit'>
approvedSetupContentHash?: string
@@ -167,26 +180,58 @@ export function NewWorktreeModal({
existingWorktreePaths,
onCreated,
onClose
}: Props) {
const openEpochRef = useRef(0)
const wasVisibleRef = useRef(false)
const clientEpochRef = useRef({ client, epoch: 0 })
// Why: each drawer opening is a fresh form session; remounting resets local
// form state before paint instead of clearing it in a visible-prop Effect.
if (visible && !wasVisibleRef.current) {
openEpochRef.current += 1
}
wasVisibleRef.current = visible
if (clientEpochRef.current.client !== client) {
clientEpochRef.current = { client, epoch: clientEpochRef.current.epoch + 1 }
}
return (
<NewWorktreeModalContent
key={`${openEpochRef.current}:${clientEpochRef.current.epoch}`}
visible={visible}
client={client}
existingWorktreePaths={existingWorktreePaths}
onCreated={onCreated}
onClose={onClose}
/>
)
}
function NewWorktreeModalContent({
visible,
client,
existingWorktreePaths,
onCreated,
onClose
}: Props) {
const [repos, setRepos] = useState<Repo[]>([])
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
const [showRepoPicker, setShowRepoPicker] = useState(false)
const [selectedAgentState, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings | null>(null)
const [detectedAgentIds, setDetectedAgentIds] = useState<Set<string> | null>(null)
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
null
)
const [agentOverriddenState, setAgentOverridden] = useState(false)
const [showAgentPicker, setShowAgentPicker] = useState(false)
const [sshState, setSshState] = useState<SshConnectionState | null>(null)
const [sshConnecting, setSshConnecting] = useState(false)
const [sshConnectingTargetId, setSshConnectingTargetId] = useState<string | null>(null)
const [name, setName] = useState('')
const [note, setNote] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [setupCommand, setSetupCommand] = useState<string | null>(null)
const [setupSource, setSetupSource] = useState<string | null>(null)
const [setupTrust, setSetupTrust] = useState<SetupHookTrust | null>(null)
const [setupHookDetails, setSetupHookDetails] = useState<SetupHookDetails | null>(null)
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
const [setupTrustPrompt, setSetupTrustPrompt] = useState<SetupTrustPrompt | null>(null)
const [setupRunPolicy, setSetupRunPolicy] = useState<SetupRunPolicy>('run-by-default')
const [setupDecisionChoice, setSetupDecisionChoice] = useState<Exclude<
SetupDecision,
'inherit'
@@ -206,8 +251,19 @@ export function NewWorktreeModal({
const sshGate = deriveWorkspaceSshGate({
connectionId: selectedRepoConnectionId,
state: sshState,
connecting: sshConnecting
connecting: sshConnectingTargetId === selectedRepoConnectionId
})
const detectedAgentIds =
detectedAgentIdsState?.connectionId === selectedRepoConnectionId &&
(selectedRepoConnectionId === null || sshGate.status === 'connected')
? detectedAgentIdsState.ids
: null
const activeSetupHookDetails =
selectedRepo && setupHookDetails?.repoId === selectedRepo.id ? setupHookDetails : null
const setupCommand = activeSetupHookDetails?.command ?? null
const setupSource = activeSetupHookDetails?.source ?? null
const setupTrust = activeSetupHookDetails?.trust ?? null
const setupRunPolicy = activeSetupHookDetails?.runPolicy ?? 'run-by-default'
const selectedAgentResolution = resolveNewWorktreeAgentSelection({
visible,
selectedAgent: selectedAgentState,
@@ -227,37 +283,10 @@ export function NewWorktreeModal({
const selectedAgent = selectedAgentResolution.selectedAgent
useEffect(() => {
if (!visible) {
setShowRepoPicker(false)
setShowAgentPicker(false)
return
}
if (!client) {
if (!visible || !client) {
return
}
let stale = false
setName('')
setNote('')
setShowAdvanced(false)
setSetupCommand(null)
setSetupSource(null)
setSetupTrust(null)
setTrustedOrcaHooks({})
setSetupTrustPrompt(null)
setSetupRunPolicy('run-by-default')
setSetupDecisionChoice(null)
setRunSetup(true)
setError('')
setCreating(false)
setShowRepoPicker(false)
setShowAgentPicker(false)
setRuntimeSettings(null)
setDetectedAgentIds(null)
setAgentOverridden(false)
setSshState(null)
setSshConnecting(false)
setSelectedAgent(AGENT_OPTIONS[0]!)
setLoading(true)
void (async () => {
try {
@@ -305,8 +334,6 @@ export function NewWorktreeModal({
useEffect(() => {
if (!visible || !client || !selectedRepoConnectionId) {
setSshState(null)
setSshConnecting(false)
return
}
let stale = false
@@ -349,11 +376,9 @@ export function NewWorktreeModal({
return
}
if (selectedRepoConnectionId && sshGate.status !== 'connected') {
setDetectedAgentIds(null)
return
}
let stale = false
setDetectedAgentIds(null)
void (async () => {
try {
const response = selectedRepoConnectionId
@@ -364,12 +389,13 @@ export function NewWorktreeModal({
if (stale) {
return
}
setDetectedAgentIds(
response.ok ? new Set((response as RpcSuccess).result as string[]) : new Set()
)
setDetectedAgentIdsState({
connectionId: selectedRepoConnectionId,
ids: response.ok ? new Set((response as RpcSuccess).result as string[]) : new Set()
})
} catch {
if (!stale) {
setDetectedAgentIds(new Set())
setDetectedAgentIdsState({ connectionId: selectedRepoConnectionId, ids: new Set() })
}
}
})()
@@ -380,9 +406,6 @@ export function NewWorktreeModal({
useEffect(() => {
if (!client || !selectedRepo) {
setSetupCommand(null)
setSetupSource(null)
setSetupTrust(null)
return
}
let stale = false
@@ -398,10 +421,13 @@ export function NewWorktreeModal({
const result = (response as RpcSuccess).result as RepoHooksResponse
const cmd = result.hooks?.scripts?.setup?.trim() || null
const policy = result.setupRunPolicy ?? 'run-by-default'
setSetupCommand(cmd)
setSetupSource(result.source)
setSetupTrust(normalizeSetupHookTrust(result.setupTrust))
setSetupRunPolicy(policy)
setSetupHookDetails({
repoId: selectedRepo.id,
command: cmd,
source: result.source,
trust: normalizeSetupHookTrust(result.setupTrust),
runPolicy: policy
})
setSetupDecisionChoice(null)
setRunSetup(policy !== 'skip-by-default')
if (cmd && policy === 'ask') {
@@ -410,10 +436,13 @@ export function NewWorktreeModal({
}
} catch {
if (!stale) {
setSetupCommand(null)
setSetupSource(null)
setSetupTrust(null)
setSetupRunPolicy('run-by-default')
setSetupHookDetails({
repoId: selectedRepo.id,
command: null,
source: null,
trust: null,
runPolicy: 'run-by-default'
})
setSetupDecisionChoice(null)
}
}
@@ -427,7 +456,7 @@ export function NewWorktreeModal({
if (!client || !selectedRepoConnectionId) {
return
}
setSshConnecting(true)
setSshConnectingTargetId(selectedRepoConnectionId)
setSshState({
targetId: selectedRepoConnectionId,
status: 'connecting',
@@ -460,7 +489,7 @@ export function NewWorktreeModal({
reconnectAttempt: 0
})
} finally {
setSshConnecting(false)
setSshConnectingTargetId((current) => (current === selectedRepoConnectionId ? null : current))
}
}
+4
View File
@@ -12,6 +12,8 @@
"scripts": {
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs",
"lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json",
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
"lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet",
"prepare": "husky",
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
@@ -139,6 +141,7 @@
"monaco-editor": "^0.55.1",
"oxfmt": "^0.52.0",
"oxlint": "^1.67.0",
"oxlint-plugin-react-doctor": "0.2.10",
"oxlint-tsgolint": "0.23.0",
"pdfjs-dist": "^5.7.284",
"radix-ui": "^1.4.3",
@@ -178,6 +181,7 @@
"lint-staged": {
"*.{ts,tsx,js,jsx,mts,cts}": [
"oxlint",
"oxlint --config config/oxlint-react-doctor.json --deny-warnings",
"oxfmt --write"
],
"*.{json,css}": [
+55
View File
@@ -245,6 +245,9 @@ importers:
oxlint:
specifier: ^1.67.0
version: 1.67.0(oxlint-tsgolint@0.23.0)
oxlint-plugin-react-doctor:
specifier: 0.2.10
version: 0.2.10
oxlint-tsgolint:
specifier: 0.23.0
version: 0.23.0
@@ -2862,6 +2865,9 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -2954,6 +2960,10 @@ packages:
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
'@typescript-eslint/types@8.60.0':
resolution: {integrity: sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260421.2':
resolution: {integrity: sha512-fHv1r3ZmVo6zxuAIFmuX3w9QxbcauoG0SsWhmDwm6VmRubLlOJIcmTtlmV3JAb9oOnq8LuzZljzT7Q39fSMQDw==}
cpu: [arm64]
@@ -3952,11 +3962,27 @@ packages:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-scope@9.1.2:
resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint-visitor-keys@5.0.1:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
hasBin: true
esrecurse@4.3.0:
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
engines: {node: '>=4.0'}
estraverse@5.3.0:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
@@ -5150,6 +5176,10 @@ packages:
vite-plus:
optional: true
oxlint-plugin-react-doctor@0.2.10:
resolution: {integrity: sha512-n36QdOLz4k9EEWod+vhki9/h29x/PL4nS91nWSk6AIr7HAL+rc6fkwUcVLgg3NhUVlaL/VOfYiIm4cVxyIIdGg==}
engines: {node: ^20.19.0 || >=22.12.0}
oxlint-tsgolint@0.23.0:
resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==}
hasBin: true
@@ -8702,6 +8732,8 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/esrecurse@4.3.1': {}
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@@ -8801,6 +8833,8 @@ snapshots:
'@types/node': 25.6.0
optional: true
'@typescript-eslint/types@8.60.0': {}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260421.2':
optional: true
@@ -9902,8 +9936,23 @@ snapshots:
escape-string-regexp@5.0.0: {}
eslint-scope@9.1.2:
dependencies:
'@types/esrecurse': 4.3.1
'@types/estree': 1.0.8
esrecurse: 4.3.0
estraverse: 5.3.0
eslint-visitor-keys@5.0.1: {}
esprima@4.0.1: {}
esrecurse@4.3.0:
dependencies:
estraverse: 5.3.0
estraverse@5.3.0: {}
estree-util-is-identifier-name@3.0.0: {}
estree-walker@3.0.3:
@@ -11422,6 +11471,12 @@ snapshots:
'@oxfmt/binding-win32-ia32-msvc': 0.52.0
'@oxfmt/binding-win32-x64-msvc': 0.52.0
oxlint-plugin-react-doctor@0.2.10:
dependencies:
'@typescript-eslint/types': 8.60.0
eslint-scope: 9.1.2
eslint-visitor-keys: 5.0.1
oxlint-tsgolint@0.23.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.23.0
+5
View File
@@ -0,0 +1,5 @@
{
"rules": {
"react-doctor/js-combine-iterations": "off"
}
}