lint(desktop): ban atom-mirrored refs via no-restricted-syntax eslint rule

A ref synced from a nanostores atom via useEffect lags the atom by one
render. Callbacks that read the ref instead of the atom get stale values —
cancelRun sent session.interrupt to the wrong session (#66485), and
steerPrompt/restoreToMessage/editMessage had closure-priority stale reads.

The rule catches the three shapes of this antipattern:
  - useEffect(() => { ref.current = value }, [value])
  - useEffect(() => { ref.current = value; ... }, [value])
  - useEffect(() => { setMutableRef(ref, value) }, [value])

All 79 existing hits get eslint-disable-next-line with a comment. The
legitimate ref writes (DOM instances, mount flags, request tokens,
prev-value tracking, prop mirrors) stay suppressed; the 11 real bug
sites will be fixed in the next commit so their suppressions can be
removed.

Rule is scoped to apps/desktop only — ui-tui and tests-js don't use
nanostores and don't have this pattern.
This commit is contained in:
ethernet 2026-07-17 16:30:58 -04:00 committed by Teknium
parent 72de75c0ab
commit e026fd61d8
46 changed files with 115 additions and 0 deletions

View file

@ -39,5 +39,43 @@ export default [
rules: {
'no-restricted-globals': ['warn', 'document']
}
},
{
// Ban mirroring reactive values into refs via useEffect — the "atom-mirrored
// ref" antipattern. A ref synced from a nanostores atom via useEffect lags the
// atom by one render, which creates stale-read bugs in callbacks that read the
// ref (cancelRun sent session.interrupt to the wrong session; steerPrompt,
// restoreToMessage, editMessage all had closure-priority stale reads). The fix
// is to read $atom.get() directly in callbacks instead. This rule catches the
// mirroring effect at lint time so the pattern can't reappear. Legitimate
// non-atom ref writes inside useEffect (DOM instance refs, mount flags, request
// tokens, prop mirrors) get an eslint-disable-next-line with a comment.
files: ['src/**/*.{ts,tsx}'],
rules: {
'no-restricted-syntax': [
'error',
{
// useEffect(() => { someRef.current = value }, [value])
selector:
'CallExpression[callee.name="useEffect"] > ArrowFunctionExpression[body.type="AssignmentExpression"][body.left.type="MemberExpression"][body.left.property.name="current"]',
message:
'Do not mirror reactive values into refs via useEffect. Read $atom.get() directly in callbacks instead — refs synced from atoms lag one render and cause stale-read bugs.'
},
{
// useEffect(() => { someRef.current = value; ... }, [value])
selector:
'CallExpression[callee.name="useEffect"] > ArrowFunctionExpression[body.type="BlockStatement"]:has(AssignmentExpression[left.type="MemberExpression"][left.property.name="current"])',
message:
'Do not mirror reactive values into refs via useEffect. Read $atom.get() directly in callbacks instead — refs synced from atoms lag one render and cause stale-read bugs.'
},
{
// useEffect(() => { setMutableRef(ref, value) }, [value])
selector:
'CallExpression[callee.name="useEffect"] > ArrowFunctionExpression[body.type="BlockStatement"]:has(CallExpression[callee.name="setMutableRef"])',
message:
'Do not mirror reactive values into refs via useEffect (setMutableRef included). Read $atom.get() directly in callbacks instead — refs synced from atoms lag one render and cause stale-read bugs.'
}
]
}
}
]

View file

@ -246,6 +246,7 @@ export function useComposerDraft({
// source otherwise), and (3) schedule the debounced per-session stash.
// Browsing history / editing a queued prompt suppress the stash so recalled
// text never clobbers the draft.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const sync = () => {
const text = composerRuntime.getState().text
@ -342,6 +343,7 @@ export function useComposerDraft({
// window (e.g. a fast session switch immediately followed by Enter) would
// ship A's attachments into B's turn (#59305). useLayoutEffect closes the
// window by running before paint.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useLayoutEffect(() => {
// A pending debounce timer from the outgoing session is now stale — its
// scope was correct when scheduled, but the authoritative stash below
@ -368,6 +370,7 @@ export function useComposerDraft({
// pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
// inside the debounce/rAF window would drop trailing keystrokes without this.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const flushPendingDraftPersist = () => {
const scope = draftScopeRef.current

View file

@ -29,6 +29,7 @@ export function useComposerPlaceholder({ disabled, reconnecting, sessionId }: Us
const prevSessionIdRef = useRef(sessionId)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const prev = prevSessionIdRef.current
prevSessionIdRef.current = sessionId

View file

@ -322,6 +322,7 @@ export function useComposerQueue({
// never churns, so a change there is a real session switch and must NOT
// migrate; only the runtime-derived key (queueSessionKey falsy → key is
// sessionId) churns on a backend bounce/resume of the same conversation.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const prev = prevQueueKeyRef.current
prevQueueKeyRef.current = activeQueueSessionKey

View file

@ -49,6 +49,7 @@ export function useLiveCompletionAdapter(options: {
useEffect(() => () => cancelTimer(), [cancelTimer])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (enabled) {
return

View file

@ -231,6 +231,7 @@ export function useComposerPopoutGestures({
[clearTimer, poppedOut]
)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
// Coalesce drag updates to one per frame — pointermove can fire several times
// between paints on high-Hz mice, and each update re-renders + clamps.

View file

@ -60,18 +60,22 @@ export function useVoiceConversation({
const statusRef = useRef<ConversationStatus>('idle')
const wasEnabledRef = useRef(enabled)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
enabledRef.current = enabled
}, [enabled])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
mutedRef.current = muted
}, [muted])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
busyRef.current = busy
}, [busy])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
statusRef.current = status
}, [status])
@ -508,6 +512,7 @@ export function useVoiceConversation({
// Drive the loop: when a voice-submitted reply appears, open a live speech
// session (which feeds itself from then on). Otherwise start listening when
// idle between turns.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!enabled || muted) {
return
@ -542,6 +547,7 @@ export function useVoiceConversation({
}
}, [busy, enabled, muted, openLiveSpeech, pendingResponse, startListening, status])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (enabled && !wasEnabledRef.current) {
void start()

View file

@ -91,6 +91,7 @@ export const CodingStatusRow = memo(function CodingStatusRow({
const worktreeReq = useStore($newWorktreeRequest)
const lastWorktreeReqRef = useRef(worktreeReq)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (worktreeReq === lastWorktreeReqRef.current) {
return

View file

@ -44,6 +44,7 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt
// DnD can't be cancelled at the OS level, so we drop the overlay and arm a
// guard that swallows the trailing drop instead. Top escape layer + capture
// stop so it doesn't also fire a handler behind the drag (see drag-session).
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (dragKind === null) {
return

View file

@ -166,6 +166,7 @@ export function PreviewConsolePanel({
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
const stickScrollRafRef = useRef<number | null>(null)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!consoleShouldStickRef.current) {
return

View file

@ -591,6 +591,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
const filePath = filePathForTarget(target)
const isImage = target.previewKind === 'image'
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
setUserMode(null)
setEditing(false)

View file

@ -319,6 +319,7 @@ export function PreviewPane({
return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, [])
}, [consoleOpen, consoleState, copy, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!consoleOpen) {
return
@ -334,6 +335,7 @@ export function PreviewPane({
return () => window.cancelAnimationFrame(handle)
}, [consoleOpen])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (
!previewServerRestart ||
@ -392,6 +394,7 @@ export function PreviewPane({
return () => window.clearTimeout(timer)
}, [copy.stillWorking, previewServerRestart, restartingServer])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (reloadRequest === lastReloadRequestRef.current) {
return
@ -498,6 +501,7 @@ export function PreviewPane({
}
}, [appendConsoleEntry, copy, reloadPreview, target.kind, target.url])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const host = hostRef.current

View file

@ -253,6 +253,7 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
// session.resume before the gateway is OPEN. Persisted tiles mount at boot
// while it's still connecting — an ungated resume rejected there and
// latched every restored tile into the error card.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!gatewayOpen || runtimeId || tile?.error || resumingRef.current) {
return

View file

@ -718,6 +718,7 @@ export function ChatSidebar({
// only the cheap per-repo `git worktree list`, never the heavy tree scan.
const prevWorkingIdsRef = useRef<readonly string[]>(workingSessionIds)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const prev = prevWorkingIdsRef.current
prevWorkingIdsRef.current = workingSessionIds
@ -754,6 +755,7 @@ export function ChatSidebar({
[currentCwd]
)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!inProject || !enteredProject) {
lastProjectCwdSyncRef.current = null

View file

@ -213,6 +213,7 @@ export function ProfileRail() {
const createRequest = useStore($profileCreateRequest)
const lastCreateRef = useRef(createRequest)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (createRequest === lastCreateRef.current) {
return

View file

@ -87,6 +87,7 @@ export function useDesktopIntegrations({
// Restore once on cold start — only when the renderer booted at the default
// route (a hidden-then-shown window keeps its own route). Prefer the full
// remembered route (covers pages); fall back to the last session id.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (restoredRef.current || locationPathname !== NEW_CHAT_ROUTE) {
restoredRef.current = true

View file

@ -450,6 +450,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const freshSessionRequest = useStore($freshSessionRequest)
const lastFreshRef = useRef(freshSessionRequest)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (freshSessionRequest === lastFreshRef.current) {
return
@ -464,6 +465,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// invalidateQueries on swap doesn't touch them).
const lastGatewayProfileRef = useRef(activeGatewayProfile)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (activeGatewayProfile === lastGatewayProfileRef.current) {
return
@ -500,6 +502,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const startWorkSessionRequest = useStore($startWorkSessionRequest)
const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) {
return

View file

@ -327,6 +327,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
// Sidebar → "open this job": resolve the focus id (or name) to a job, select
// it, queue a scroll, then clear the one-shot focus so re-opening cron
// normally doesn't re-trigger it.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!focusJobId) {
return
@ -355,6 +356,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
)
// Scroll a sidebar-opened job into view once its list row is mounted.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const target = pendingScrollRef.current

View file

@ -22,6 +22,7 @@ export function useGatewayRequest() {
// message instead of the opaque "connection closed" that triggered the retry.
const reauthErrorRef = useRef<unknown>(null)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
gatewayStateRef.current = gatewayState
}, [gatewayState])

View file

@ -10,6 +10,7 @@ export function useOnProfileSwitch(onSwitch: () => void): void {
const profile = useStore($activeGatewayProfile)
const first = useRef(true)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (first.current) {
first.current = false

View file

@ -87,6 +87,7 @@ export function PetOverlayApp() {
}
// Mirror pushed state into the shared atoms so PetSprite/PetBubble just work.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const off = window.hermesDesktop?.petOverlay?.onState(payload => {
setPetInfo(payload.info)
@ -185,6 +186,7 @@ export function PetOverlayApp() {
// input keeps focus); focus it on open. The overlay is a non-activating panel
// (so it never steals the app's cmd/alt-tab anchor) — flip it focusable while
// the composer needs the keyboard, then back to non-activating when it closes.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
composerOpenRef.current = composerOpen
@ -317,6 +319,7 @@ export function PetOverlayApp() {
// wheel anchor we zoom toward the cursor (keep the pixel under it fixed);
// otherwise we anchor the bottom-center (the pet's feet stay planted). New
// bounds are persisted so the pet reopens at the right size.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!info.enabled || !info.spritesheetBase64) {
return

View file

@ -388,6 +388,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
const [error, setError] = useState<null | string>(null)
const requestRef = useRef<string>(profileName)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
requestRef.current = profileName
setLoading(true)

View file

@ -105,6 +105,7 @@ export function ReviewFileTree() {
const [animate, setAnimate] = useState(false)
const armed = useRef(false)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!open) {
armed.current = false
@ -112,6 +113,7 @@ export function ReviewFileTree() {
}
}, [open])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (open && !loading && !armed.current) {
armed.current = true

View file

@ -28,6 +28,7 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
return { ...terminalTheme(renderedMode, ansi), background: surface, cursorAccent: surface }
}
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const host = hostRef.current

View file

@ -421,6 +421,7 @@ export function useTerminalSession({
const [selectionStyle, setSelectionStyle] = useState<CSSProperties | null>(null)
const [shellName, setShellName] = useState('shell')
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
onAddSelectionToChatRef.current = onAddSelectionToChat
onShellRef.current = onShell
@ -478,6 +479,7 @@ export function useTerminalSession({
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [addSelectionToChat, readSelection])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const host = hostRef.current
const terminalApi = window.hermesDesktop?.terminal
@ -974,6 +976,7 @@ export function useTerminalSession({
// the subscribe fires immediately, so a command set before this pane mounted
// runs as soon as the session is ready. Cleared after writing so a later
// remount can't replay a stale command.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!active || status !== 'open') {
return

View file

@ -52,6 +52,7 @@ export function useBackgroundQueueDrain({
const retryTimersRef = useRef<number[]>([])
const [retryTick, setRetryTick] = useState(0)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
submitTextRef.current = submitText
}, [submitText])

View file

@ -96,6 +96,7 @@ export function useRouteResume({
// never touches this latch, so it can't spuriously trigger the reset).
const prevResumeExhaustedRef = useRef<string | null>(null)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const gatewayOpen = gatewayState === 'open'
const pathnameChanged = lastPathnameRef.current !== locationPathname
@ -187,6 +188,7 @@ export function useRouteResume({
// again. resumeSession clears resumeFailedSessionId on its next attempt; a
// success keeps it clear (the effect's guard then no-ops), a repeat failure
// re-arms it and we back off further, capped at MAX_RESUME_RETRIES.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
// Detect the exhausted-latch armed->cleared edge for the current route. Only
// resumeSession clears $resumeExhaustedSessionId (manual Retry / reconnect /

View file

@ -225,6 +225,7 @@ export function useSessionActions({
// by A's delayed session.info event and visibly jump back to A.
const storedIdRotation = useStore($activeSessionStoredIdRotation)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!storedIdRotation) {
return

View file

@ -87,6 +87,7 @@ export function useSessionStateCache({
// flush below tell a same-session refresh from a thread switch.
const viewSessionIdRef = useRef<string | null>(null)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
setMutableRef(busyRef, busy)
}, [busy, busyRef])

View file

@ -76,6 +76,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
}
}, [])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
activeRef.current = true
void refresh()

View file

@ -84,6 +84,7 @@ export function ConfigSettings({
// Background refetches thereafter must not clobber in-progress edits.
const configSeeded = useRef(false)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (loadedConfig && !configSeeded.current) {
configSeeded.current = true

View file

@ -88,6 +88,7 @@ export function FallbackModelsField({
// Resync on real external changes (profile switch / config reload). Skip
// when `value` is just our own commit echoing through the parent.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const persisted = normalizeEntries(value)

View file

@ -353,6 +353,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
// setState updater) and hand it straight to the debounced autosave.
const moaRef = useRef<MoaConfigResponse | null>(null)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
moaRef.current = moa
}, [moa])

View file

@ -243,6 +243,7 @@ function PostSetupRunner({ toolset, postSetupKey, installed = false, onComplete
// Guard against overlapping polls / state updates after unmount.
const activeRef = useRef(false)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
return () => {
activeRef.current = false

View file

@ -32,6 +32,7 @@ export function useOverlayRouting() {
// so closing them returns there instead of bouncing to /.
const returnPathRef = useRef(NEW_CHAT_ROUTE)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!overlayOpen) {
returnPathRef.current = `${location.pathname}${location.search}${location.hash}`

View file

@ -463,6 +463,7 @@ export function McpTab({ gateway }: { gateway: HermesGateway | null }) {
// in-progress edit — the draft is the user's until they save or reset.
const draftSeeded = useRef(false)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
// profilePending: config still holds the PREVIOUS profile's record right
// after a switch — seeding from it would latch the wrong profile's doc.
@ -525,6 +526,7 @@ export function McpTab({ gateway }: { gateway: HermesGateway | null }) {
// on a fresh success, errorUpdatedAt on a fresh failure. Releasing on error too
// means a failed refetch surfaces the retry UI instead of leaving mutations
// silently no-op forever.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (
profilePending &&

View file

@ -264,6 +264,7 @@ export function StarMap({
}, [])
// (Re)build the radial simulation whenever the graph or size changes.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
sizeRef.current = size
@ -299,6 +300,7 @@ export function StarMap({
}
}, [graph, invalidate, resetFades, size])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
adjacencyRef.current = adjacency
memByIdRef.current = memById
@ -311,12 +313,14 @@ export function StarMap({
document.fonts?.load('1em "JetBrains Mono"').then(invalidate, () => {})
}, [invalidate])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
selectedIdRef.current = selectedId
invalidate()
}, [invalidate, selectedId])
// A fresh graph resets the scrubber to "fully built" (the idle default).
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
camRadiusRef.current = RING_OUTER
snapMotionRef.current = false
@ -362,6 +366,7 @@ export function StarMap({
)
// Playback: sweep reveal 0 → 1 over SWEEP_MS, then stop (play once).
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!playing) {
return
@ -484,6 +489,7 @@ export function StarMap({
// Repaint + repalette when the theme/mode repaints (the shared observer fires
// after applyTheme rewrites the class + inline vars on <html>).
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
themeDirtyRef.current = true
invalidate()
@ -493,6 +499,7 @@ export function StarMap({
// the window is focused — but each frame is cheap (live scramble + a blit of the
// cached static layer). The expensive scene only re-renders when invalidate()
// marks it dirty. Capped to ~30fps; interaction (force) bypasses the cap.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
let raf = 0
const ANIM_MS = 1000 / 30
@ -674,6 +681,7 @@ export function StarMap({
}, [])
// Size the backing canvas (DPR-aware).
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
sizeRef.current = size
dprRef.current = Math.min(2, window.devicePixelRatio || 1)

View file

@ -158,6 +158,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
[aui, rememberInitialDraft]
)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
draftRef.current = draft

View file

@ -40,6 +40,7 @@ export function useElapsedSeconds(active = true, timerKey?: string): number {
lastKey.current = timerKey
}
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!active) {
return

View file

@ -209,6 +209,7 @@ export function CodeEditor({
onSaveRef.current = onSave
formatJsonRef.current = formatJson
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const host = hostRef.current

View file

@ -266,6 +266,7 @@ export const DiffusionCanvas: FC = () => {
useResizeObserver(fitToContainer, canvasRef)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const probe = document.createElement('span')
probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none'
@ -287,6 +288,7 @@ export const DiffusionCanvas: FC = () => {
}
}, [])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')

View file

@ -31,6 +31,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
cwdRef.current = cwd
targetRef.current = target
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
mountedRef.current = true
@ -40,6 +41,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
}
}, [])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
requestTokenRef.current += 1
setOpening(false)

View file

@ -58,6 +58,7 @@ export function NotificationStack() {
}
}, [defaultStack.length])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const latest = notifications[0]

View file

@ -247,6 +247,7 @@ export function FloatingPet() {
// Restore a popped-out pet on boot, once the pet has loaded (so we never spawn
// an empty overlay window). Primary window only; runs at most once.
const restoredRef = useRef(false)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (isSecondaryWindow() || restoredRef.current || !active) {
return

View file

@ -121,10 +121,12 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
const rowOverrideRef = useRef<string | undefined>(rowOverride)
// Keep the override current without re-running the RAF setup effect.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
overrideRef.current = stateOverride
}, [stateOverride])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
rowOverrideRef.current = rowOverride
}, [rowOverride])
@ -152,6 +154,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
return img
}, [info.spritesheetBase64, info.mime])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const canvas = canvasRef.current

View file

@ -100,6 +100,7 @@ export function I18nProvider({ children, configClient = defaultConfigClient, ini
const [saveError, setSaveError] = useState<Error | null>(null)
const localeRef = useRef(locale)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
localeRef.current = locale
setRuntimeI18nLocale(locale)