Skip to content

Commit cd4f322

Browse files
Merge pull request #154 from laststance/codex/update-react-next-eslint-plugin
Enforce all react-next ESLint rules
2 parents b5ea95b + 470ff00 commit cd4f322

49 files changed

Lines changed: 1138 additions & 558 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.storybook/stories/Skills.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export const FilePreviewStates: Story = {
137137
expiresAt={new Date(Date.now() + 15_000).toISOString()}
138138
summary="Deleted 2 skills. 5 symlinks removed."
139139
onUndo={() => undefined}
140-
onUndoComplete={() => undefined}
140+
toastId="storybook-undo-toast"
141141
/>
142142
</StoryCard>
143143
</StoryGrid>

eslint.config.mjs

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,41 @@ import tsPrefixer from 'eslint-config-ts-prefixer'
33
import reactYouMightNotNeedAnEffect from 'eslint-plugin-react-you-might-not-need-an-effect'
44
import { defineConfig } from 'eslint/config'
55

6+
/**
7+
* Rules intentionally enabled for @laststance/react-next-eslint-plugin v2.2.0.
8+
* Keeping the list explicit means dependency upgrades cannot silently turn on a
9+
* new rule without a focused lint-fix pass in the same PR.
10+
*/
11+
const laststanceReactNextRuleNames = [
12+
'all-memo',
13+
'jsx-no-useless-fragment',
14+
'no-context-provider',
15+
'no-deopt-use-callback',
16+
'no-deopt-use-memo',
17+
'no-direct-use-effect',
18+
'no-duplicate-key',
19+
'no-forward-ref',
20+
'no-jsx-without-return',
21+
'no-missing-button-type',
22+
'no-missing-component-display-name',
23+
'no-missing-key',
24+
'no-nested-component-definitions',
25+
'no-set-state-prop-drilling',
26+
'no-use-reducer',
27+
'prefer-stable-context-value',
28+
'prefer-usecallback-for-memoized-component',
29+
'prefer-usecallback-might-work',
30+
'prefer-usememo-for-memoized-component',
31+
'prefer-usememo-might-work',
32+
]
33+
34+
const laststanceReactNextRules = Object.fromEntries(
35+
laststanceReactNextRuleNames.map((ruleName) => [
36+
`@laststance/react-next/${ruleName}`,
37+
'error',
38+
]),
39+
)
40+
641
export default defineConfig([
742
...tsPrefixer,
843
{
@@ -52,19 +87,12 @@ export default defineConfig([
5287
'@laststance/react-next': laststanceReactNextPlugin,
5388
},
5489
rules: {
55-
'@laststance/react-next/no-forward-ref': 'error',
56-
'@laststance/react-next/no-context-provider': 'error',
57-
'@laststance/react-next/no-missing-key': 'error',
58-
'@laststance/react-next/no-duplicate-key': 'error',
59-
'@laststance/react-next/no-jsx-without-return': 'error',
60-
'@laststance/react-next/all-memo': 'error',
61-
'@laststance/react-next/no-use-reducer': 'error',
90+
...laststanceReactNextRules,
91+
// Keep the stricter prop-drilling depth while still enforcing the rule at error severity.
6292
'@laststance/react-next/no-set-state-prop-drilling': [
6393
'error',
6494
{ depth: 1 },
6595
],
66-
'@laststance/react-next/no-deopt-use-callback': 'error',
67-
'@laststance/react-next/prefer-stable-context-value': 'error',
6896
},
6997
},
7098
])

src/renderer/settings/sections/About.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { ExternalLink } from 'lucide-react'
2-
import React, { useEffect, useState } from 'react'
2+
import React, { useCallback, useState } from 'react'
33
import { match } from 'ts-pattern'
44

55
import { Button } from '@/renderer/src/components/ui/button'
66
import { Separator } from '@/renderer/src/components/ui/separator'
7+
import { useComponentEffect } from '@/renderer/src/hooks/useComponentEffect'
78
import { SKILLS_DESKTOP_REPOSITORY_URL } from '@/shared/constants'
89

910
import { SectionFrame } from './SectionFrame'
@@ -68,7 +69,7 @@ export const About = React.memo(function About(): React.ReactElement {
6869
const isUpdaterAvailable = Boolean(updateApi)
6970
const [checkStatus, setCheckStatus] = useState<CheckStatus>({ kind: 'idle' })
7071

71-
useEffect(() => {
72+
useComponentEffect(() => {
7273
if (!updateApi) return
7374
const cleanups = [
7475
updateApi.onChecking(() => setCheckStatus({ kind: 'checking' })),
@@ -85,11 +86,11 @@ export const About = React.memo(function About(): React.ReactElement {
8586
}
8687
}, [updateApi])
8788

88-
const handleCheckForUpdates = (): void => {
89+
const handleCheckForUpdates = useCallback((): void => {
8990
if (!updateApi) return
9091
setCheckStatus({ kind: 'checking' })
9192
void updateApi.check()
92-
}
93+
}, [updateApi])
9394

9495
const status = statusLabel(checkStatus)
9596

src/renderer/settings/sections/Agents.tsx

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Eye, EyeOff } from 'lucide-react'
2-
import React, { useEffect } from 'react'
2+
import React, { useCallback } from 'react'
33

44
import { Button } from '@/renderer/src/components/ui/button'
55
import { Checkbox } from '@/renderer/src/components/ui/checkbox'
6+
import { useComponentEffect } from '@/renderer/src/hooks/useComponentEffect'
67
import { useUpdateSettings } from '@/renderer/src/hooks/useUpdateSettings'
78
import { cn, toggleArrayMember } from '@/renderer/src/lib/utils'
89
import { useAppDispatch, useAppSelector } from '@/renderer/src/redux/hooks'
@@ -43,7 +44,7 @@ export const Agents = React.memo(function Agents(): React.ReactElement {
4344
const hiddenAgentIds = useAppSelector(selectHiddenAgentIds)
4445
const updateSettings = useUpdateSettings()
4546

46-
useEffect(() => {
47+
useComponentEffect(() => {
4748
// Deliberately keyed on `agents.length` only (NOT `loading`). The
4849
// length value transitions 0 → N exactly once per mount lifecycle,
4950
// so the effect dispatches once and never re-fires within a mount.
@@ -67,20 +68,23 @@ export const Agents = React.memo(function Agents(): React.ReactElement {
6768
).length
6869
const hiddenCount = installed.length - visibleCount
6970

70-
const handleToggle = (agentId: AgentId): void => {
71-
// Inversion: "Show in sidebar" checkbox flip ⇄ membership in
72-
// hiddenAgentIds. We treat every click as a toggle relative to the
73-
// latest known state instead of trusting the next-state from the
74-
// checkbox event — the schema upstream already pins membership, so
75-
// either side of the flip lands on the correct array.
76-
updateSettings({
77-
hiddenAgentIds: toggleArrayMember(hiddenAgentIds, agentId),
78-
})
79-
}
71+
const handleToggle = useCallback(
72+
(agentId: AgentId): void => {
73+
// Inversion: "Show in sidebar" checkbox flip ⇄ membership in
74+
// hiddenAgentIds. We treat every click as a toggle relative to the
75+
// latest known state instead of trusting the next-state from the
76+
// checkbox event — the schema upstream already pins membership, so
77+
// either side of the flip lands on the correct array.
78+
updateSettings({
79+
hiddenAgentIds: toggleArrayMember(hiddenAgentIds, agentId),
80+
})
81+
},
82+
[hiddenAgentIds, updateSettings],
83+
)
8084

81-
const handleShowAll = (): void => {
85+
const handleShowAll = useCallback((): void => {
8286
updateSettings({ hiddenAgentIds: [] })
83-
}
87+
}, [updateSettings])
8488

8589
return (
8690
<SectionFrame
@@ -172,17 +176,22 @@ const AgentToggleRow = React.memo(function AgentToggleRow({
172176
onToggle,
173177
}: AgentToggleRowProps): React.ReactElement {
174178
const checkboxId = `agent-visibility-${agent.id}`
179+
const handleCheckedChange = useCallback(
180+
(next: boolean | 'indeterminate'): void => {
181+
// Radix passes `boolean | "indeterminate"`. Ignore the indeterminate
182+
// case — we never set it, but treating it like a flip would dispatch
183+
// a phantom hide/show.
184+
if (typeof next === 'boolean') onToggle(agent.id)
185+
},
186+
[agent.id, onToggle],
187+
)
188+
175189
return (
176190
<li className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-muted/30">
177191
<Checkbox
178192
id={checkboxId}
179193
checked={isVisible}
180-
onCheckedChange={(next) => {
181-
// Radix passes `boolean | "indeterminate"`. Ignore the indeterminate
182-
// case — we never set it, but treating it like a flip would dispatch
183-
// a phantom hide/show.
184-
if (typeof next === 'boolean') onToggle(agent.id)
185-
}}
194+
onCheckedChange={handleCheckedChange}
186195
aria-label={`Show ${agent.name} in sidebar`}
187196
/>
188197
<label

src/renderer/settings/sections/General.tsx

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { Files, Info } from 'lucide-react'
2-
import React, { useEffect, useState } from 'react'
2+
import React, { useCallback, useState } from 'react'
33

44
import { Button } from '@/renderer/src/components/ui/button'
55
import { Input } from '@/renderer/src/components/ui/input'
66
import {
77
ToggleGroup,
88
ToggleGroupItem,
99
} from '@/renderer/src/components/ui/toggle-group'
10+
import { useComponentEffect } from '@/renderer/src/hooks/useComponentEffect'
1011
import { useUpdateSettings } from '@/renderer/src/hooks/useUpdateSettings'
1112
import { useAppSelector } from '@/renderer/src/redux/hooks'
1213
import { TERMINAL_APP_IDS, TERMINAL_APP_UI_LABELS } from '@/shared/constants'
@@ -70,10 +71,13 @@ export const General = React.memo(function General(): React.ReactElement {
7071
settings.customTerminalAppName ?? '',
7172
)
7273

73-
const handleDefaultTabChange = (nextValue: string): void => {
74-
if (nextValue !== 'files' && nextValue !== 'info') return
75-
updateSettings({ defaultSkillTab: nextValue })
76-
}
74+
const handleDefaultTabChange = useCallback(
75+
(nextValue: string): void => {
76+
if (nextValue !== 'files' && nextValue !== 'info') return
77+
updateSettings({ defaultSkillTab: nextValue })
78+
},
79+
[updateSettings],
80+
)
7781

7882
const handlePreferredTerminalChange = (
7983
e: React.ChangeEvent<HTMLSelectElement>,
@@ -94,12 +98,19 @@ export const General = React.memo(function General(): React.ReactElement {
9498
* schema so the renderer never asks main to write a value that the
9599
* schema would reject.
96100
*/
97-
const handleCustomNameBlur = (): void => {
101+
const handleCustomNameChange = useCallback(
102+
(e: React.ChangeEvent<HTMLInputElement>): void => {
103+
setCustomNameDraft(e.target.value)
104+
},
105+
[],
106+
)
107+
108+
const handleCustomNameBlur = useCallback((): void => {
98109
const trimmed = customNameDraft.trim()
99110
if (trimmed === '') return
100111
if (trimmed === settings.customTerminalAppName) return
101112
updateSettings({ customTerminalAppName: trimmed })
102-
}
113+
}, [customNameDraft, settings.customTerminalAppName, updateSettings])
103114

104115
const isCustom = settings.preferredTerminal === 'custom'
105116
const isDraftBlank = customNameDraft.trim() === ''
@@ -118,7 +129,7 @@ export const General = React.memo(function General(): React.ReactElement {
118129
const [isMainWindowAvailable, setIsMainWindowAvailable] =
119130
useState<boolean>(true)
120131

121-
useEffect(() => {
132+
useComponentEffect(() => {
122133
let cancelled = false
123134
// Attach `.catch` explicitly — `void promise.then(...)` only suppresses
124135
// TypeScript's "promise not handled" hint, it does NOT silence a real
@@ -147,20 +158,28 @@ export const General = React.memo(function General(): React.ReactElement {
147158
* flag so the button's disabled state + inline message kick in instead
148159
* of failing silently. Disk write only happens on a real bounds value.
149160
*/
150-
const handleSaveCurrentSize = async (): Promise<void> => {
151-
const bounds = await window.electron.window.getMainBounds()
152-
if (bounds === null) {
161+
const handleSaveCurrentSize = useCallback(async (): Promise<void> => {
162+
try {
163+
const bounds = await window.electron.window.getMainBounds()
164+
if (bounds === null) {
165+
setIsMainWindowAvailable(false)
166+
return
167+
}
168+
updateSettings({ windowSize: bounds })
169+
} catch {
153170
setIsMainWindowAvailable(false)
154-
return
155171
}
156-
updateSettings({ windowSize: bounds })
157-
}
172+
}, [updateSettings])
158173

159-
const handleResetWindowSize = (): void => {
174+
const handleSaveCurrentSizeClick = useCallback((): void => {
175+
void handleSaveCurrentSize()
176+
}, [handleSaveCurrentSize])
177+
178+
const handleResetWindowSize = useCallback((): void => {
160179
// `undefined` removes the persisted size; on next launch the main
161180
// window falls back to the default 1200×800 + maximize() behavior.
162181
updateSettings({ windowSize: undefined })
163-
}
182+
}, [updateSettings])
164183

165184
const persistedWindowSize = settings.windowSize
166185
const hasCustomWindowSize = persistedWindowSize !== undefined
@@ -224,7 +243,7 @@ export const General = React.memo(function General(): React.ReactElement {
224243
<Input
225244
type="text"
226245
value={customNameDraft}
227-
onChange={(e) => setCustomNameDraft(e.target.value)}
246+
onChange={handleCustomNameChange}
228247
onBlur={handleCustomNameBlur}
229248
placeholder="e.g. Hyper"
230249
maxLength={CUSTOM_APP_NAME_MAX_LENGTH}
@@ -273,9 +292,7 @@ export const General = React.memo(function General(): React.ReactElement {
273292
type="button"
274293
variant="outline"
275294
size="sm"
276-
onClick={() => {
277-
void handleSaveCurrentSize()
278-
}}
295+
onClick={handleSaveCurrentSizeClick}
279296
disabled={!isMainWindowAvailable}
280297
>
281298
Use current window size

src/renderer/src/App.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ const toastClassNames = {
5353
'bg-popover text-muted-foreground border-border hover:bg-accent hover:text-foreground',
5454
} as const
5555

56+
const toastOptions = {
57+
classNames: toastClassNames,
58+
} satisfies React.ComponentProps<typeof Toaster>['toastOptions']
59+
5660
/**
5761
* Inline style on the Toaster itself. Sonner reads `--normal-bg` /
5862
* `--normal-border` / `--normal-text` off the toaster root and forwards them
@@ -136,7 +140,7 @@ const App = React.memo(function App(): React.ReactElement {
136140
theme={mode}
137141
className="toaster group"
138142
style={toasterStyle}
139-
toastOptions={{ classNames: toastClassNames }}
143+
toastOptions={toastOptions}
140144
/>
141145
</TooltipProvider>
142146
)

src/renderer/src/components/SkipToMainContentLink.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import type { ReactElement } from 'react'
3232
* <main id="main-content">...</main>
3333
*/
3434
export const SkipToMainContentLink = memo(
35-
(): ReactElement => {
35+
function SkipToMainContentLink(): ReactElement {
3636
return (
3737
<a
3838
href="#main-content"

0 commit comments

Comments
 (0)