From f83750bb4c52c1daf07c6539d09434d82e0fc5ab Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:12:05 -0700 Subject: [PATCH] chore(lint): enforce react/exhaustive-effect-dependencies Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/components/ComposerEditor.tsx | 1 + apps/mobile/src/components/MediaVideoPlayer.tsx | 1 + apps/mobile/src/components/VideoPreviewModal.tsx | 6 ++++-- .../src/features/diffs/nativeReviewDiffSurface.ts | 1 + apps/mobile/src/features/files/SourceFileSurface.tsx | 1 + apps/mobile/src/features/home/HomeScreen.tsx | 2 +- .../src/features/layout/AdaptiveWorkspaceLayout.tsx | 1 + .../features/pull-requests/PullRequestRouteScreen.tsx | 2 +- apps/mobile/src/features/review/ReviewSheet.tsx | 1 + .../src/features/threads/NewTaskDraftScreen.tsx | 7 +++++-- .../src/features/threads/ThreadArrangementSheet.tsx | 1 + apps/mobile/src/features/threads/ThreadComposer.tsx | 5 +++-- .../src/features/threads/ThreadDetailScreen.tsx | 1 + apps/mobile/src/features/threads/ThreadFeed.tsx | 2 ++ .../src/features/threads/ThreadMarkdownImage.tsx | 2 ++ .../src/features/threads/ThreadNavigationSidebar.tsx | 2 +- .../src/features/threads/use-composer-command-menu.ts | 1 + .../src/features/workspace/WorkspaceToolbar.ios.tsx | 1 + apps/mobile/src/native/T3ComposerEditor.ios.tsx | 11 +++++++++-- apps/mobile/src/native/T3ComposerEditor.native.tsx | 11 +++++++++-- apps/mobile/src/state/thread-order.ts | 1 + apps/mobile/src/state/use-thread-outbox-drain.ts | 1 + vite.config.ts | 1 + 23 files changed, 50 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 8dfbb5587b621..d1bb8e4ed7783 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -79,6 +79,7 @@ export function ComposerEditor({ () => () => { importRef.current?.abort(); }, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Switching drafts aborts an import owned by the previous draft. [draftKey], ); const pasteContext = async ( diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index ec30709c1d68e..2c4d70e1aa9d8 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -102,6 +102,7 @@ function LoadedMediaVideo(props: { }, ); return () => controller.abort(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A play request or Retry (attempt) reloads the source into the same player. }, [player, props.playRequested, attempt]); return ( diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index 7feae6d930061..697577742ceef 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -87,6 +87,7 @@ function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState { () => undefined, ); }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A different attachment restarts the load even when it resolves to the same cached file URI. }, [attachmentId, fileUri]); const mimeType = videoMimeType(attachment) ?? attachment.mimeType; return { @@ -208,9 +209,10 @@ export function VideoPreviewModal(props: { }) { const isFocused = useIsFocused(); const hasSource = props.source !== null; + const { onRequestClose } = props; useEffect(() => { - if (!isFocused && hasSource) props.onRequestClose(); - }, [isFocused, hasSource, props.onRequestClose]); + if (!isFocused && hasSource) onRequestClose(); + }, [isFocused, hasSource, onRequestClose]); const { source } = props; if (source === null || !isFocused) return null; return source.type === "local" ? ( diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 23ca0675432ee..b82570c11a7fc 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -263,6 +263,7 @@ function useNativeReviewDiffPayload( cancelAnimationFrame(frame); } }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A new resetKey redelivers an unchanged payload after the diff surface resets. }, [method, nativeRef, payload, resetKey, onFailure, onDelivered]); } diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index e0cb29494f1e8..3799554ae3204 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -254,6 +254,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { listRef.current?.scrollToIndex({ index: targetIndex, animated: false, viewPosition: 0.3 }); }); return () => cancelAnimationFrame(frame); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Opening another file at the same line index scrolls the new file to it. }, [props.path, targetIndex]); const renderLine = ({ item, index }: { item: string; index: number }) => ( diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 488cd6ef13a26..0ae5d3b6939b8 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -484,7 +484,7 @@ export function HomeScreen(props: HomeScreenProps) { bumpSnoozeWakeTick((tick) => tick + 1); }, delayMs); return () => clearTimeout(id); - // snoozeWakeTick re-arms clamped timers even when the wake date exceeds 32-bit setTimeout and stays unchanged. + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- snoozeWakeTick re-arms clamped timers even when the wake date exceeds 32-bit setTimeout and stays unchanged. }, [nextSnoozeWakeAt, snoozeWakeTick]); // Called as `props.onSelectThread(...)`, the compiler would cache this handler on the whole // props object, which is new on every render of Home's parent. diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 7dd22f8fa3ee9..e2d758ee9606e 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -148,6 +148,7 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi if (focusedRef.current) { syncRegistration(); } + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Changed pane content replaces its existing native workspace registration. }, [wrappedRender]); useEffect( diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 190d707cc8cf8..e29d3f0292822 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -389,7 +389,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { showAuxiliaryPane("inspector"); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Opening review for another thread reselects its inspector pane. }, [environmentId, showAuxiliaryPane, threadId]); const { error, diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 919e44dec288b..f9b312e40e083 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -513,6 +513,7 @@ export function NewTaskDraftScreen(props: { projects, flow.draftKey, props.initialProjectRef, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Opening a draft from a share, pending task, or draft id re-resolves its project. props.incomingShareId, props.pendingTaskId, props.draftId, @@ -522,6 +523,7 @@ export function NewTaskDraftScreen(props: { setProject, ]); + const { loadBranches } = flow; useEffect(() => { if (!selectedProject) { loadedBranchesProjectKeyRef.current = null; @@ -532,8 +534,8 @@ export function NewTaskDraftScreen(props: { return; } loadedBranchesProjectKeyRef.current = projectKey; - flow.loadBranches(); - }, [flow.loadBranches, selectedProject]); + loadBranches(); + }, [loadBranches, selectedProject]); useEffect(() => { const shareId = props.incomingShareId; @@ -745,6 +747,7 @@ export function NewTaskDraftScreen(props: { consumeShare, cancelledIncomingShareId, flow.draftKey, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- hasImportedIncomingShare reflects the draft store; a change re-checks whether the share still needs importing. hasImportedIncomingShare, incomingShare, isIncomingShareInboxLoading, diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index e77c046cca77d..72e80022a469e 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -288,6 +288,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { .map((row) => `${row.key}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`) .join("|"); useEffect(() => { + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A changed thread order cancels the in-progress drag; stop only touches refs and state setters. stop(); }, [orderVersion]); useEffect( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 9687136761fea..9f94ecd2edc93 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -764,11 +764,12 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps settingsSheetPresentation.open(); }; + const { present: presentSettingsRoute } = settingsRoutePresentation; useEffect(() => { if (settingsSheetPresentation.isActive) { - settingsRoutePresentation.present(settingsRouteSession); + presentSettingsRoute(settingsRouteSession); } - }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.isActive]); + }, [presentSettingsRoute, settingsRouteSession, settingsSheetPresentation.isActive]); useEffect(() => { if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 49294b4e1cba9..85732afaa17d7 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -422,6 +422,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { }, []); useEffect(() => { setKeyboardStateSuspect(false); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Any keyboard visibility or height report clears the suspect flag set when the app resumed. }, [isKeyboardVisible, liveKeyboardHeight]); const handleOwnedInputFocusChange = (focused: boolean) => { ownedInputFocusedRef.current = focused; diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 20999f5b041e2..eca66821c7131 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2197,6 +2197,7 @@ export const ThreadFeed = memo(function ThreadFeed({ props.threadId, props.contentPresentation.kind, ]); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A scope change (thread or presentation) aborts the pending link lookup opened from the old scope. useLayoutEffect(() => () => markdownLinkRequest.current?.abort(), [markdownLinkScope]); const [preview, setPreview] = useState<{ readonly scope: string; @@ -2772,6 +2773,7 @@ export const ThreadFeed = memo(function ThreadFeed({ // commit, and setEndFollow notifies the parent, so it can't run during render. useEffect(() => { if (jumpIndex !== null) landJump(jumpIndex); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Jumping to a different message whose row index is unchanged must still land the jump. }, [jumpToMessageId, jumpIndex]); const reflowLayout = () => ({ diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index 6c054c4a743bd..8bf66c87a4609 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -50,10 +50,12 @@ export function ThreadMarkdownImageView(props: { useEffect(() => { setDecodedSize(null); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A new source invalidates the decoded image dimensions. }, [props.sourceKey]); useEffect(() => { setFailedUri(null); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A new image URI clears the previous load failure. }, [props.uri]); const sourceSize = decodedSize ?? props.knownSize ?? null; diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 1e3e5a03891a4..d684076de8c16 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -330,7 +330,7 @@ function ThreadNavigationSidebarPane( bumpSnoozeWakeTick((tick) => tick + 1); }, delayMs); return () => clearTimeout(id); - // snoozeWakeTick re-arms clamped timers even when the wake date exceeds 32-bit setTimeout and stays unchanged. + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- snoozeWakeTick re-arms clamped timers even when the wake date exceeds 32-bit setTimeout and stays unchanged. }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems: readonly SidebarListItem[] = settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 992a5838a66ef..1615f9bd98b8b 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -337,6 +337,7 @@ export function useComposerCommandMenu({ else failedRefreshesRef.current = null; }, retryLater); }, [ + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Typing in the draft re-checks whether the provider workspace snapshot needs refreshing. draftMessage, environmentId, projectCwd, diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx index 0ac48ac5e233b..2278ee9ba7c87 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx @@ -119,6 +119,7 @@ export function WorkspaceToolbar(props: { if (!tabActive) return; const frame = requestAnimationFrame(restoreSearch); return () => cancelAnimationFrame(frame); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Moving the search bar to the bottom toolbar restores its focus and query. }, [restoreSearch, tabActive, bottomSearch]); const composeButton = nativeToolbarCompose ? ( { // Newer native events make this payload stale; do not record it as applied. @@ -246,7 +246,14 @@ export function ComposerEditor({ controlledEventCount, assumedValue, ); - }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]); + }, [ + assumedValue, + controlledEventCount, + isNativeEcho, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Every changed native payload, including selection-only changes, must update the assumed native snapshot. + controlledDocumentJson, + nativeEventSnapshotsRef, + ]); const acceptNativeEvent = useCallback( (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { const acknowledgedEventCount = acknowledgeComposerNativeEvent( diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index f2921471901c2..dbf6df31c825e 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -233,7 +233,7 @@ export function ComposerEditor({ nativeEventSnapshotsRef.current, mostRecentEventCount, ); - }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); + }, [acknowledgesLatestNativeEvent, mostRecentEventCount, nativeEventSnapshotsRef]); const assumedValue = props.value; useEffect(() => { // Newer native events make this payload stale; do not record it as applied. @@ -243,7 +243,14 @@ export function ComposerEditor({ controlledEventCount, assumedValue, ); - }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]); + }, [ + assumedValue, + controlledEventCount, + isNativeEcho, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Every changed native payload, including selection-only changes, must update the assumed native snapshot. + controlledDocumentJson, + nativeEventSnapshotsRef, + ]); const acceptNativeEvent = useCallback( (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { const acknowledgedEventCount = acknowledgeComposerNativeEvent( diff --git a/apps/mobile/src/state/thread-order.ts b/apps/mobile/src/state/thread-order.ts index 88ed737717d97..e17ab7231c67b 100644 --- a/apps/mobile/src/state/thread-order.ts +++ b/apps/mobile/src/state/thread-order.ts @@ -25,6 +25,7 @@ export function usePendingThreadOrder(nowMinute: string, snoozeWakeTick: number) // Clock-driven wakes can change membership without shell events; retire order holds and re-enable moves then. useEffect(() => { getPendingThreadOrder(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Clock ticks re-evaluate the external pending-order snapshot even without a shell event. }, [nowMinute, snoozeWakeTick]); return pending; } diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index f18bbadd5dcc7..49a94b394c7bf 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -1359,6 +1359,7 @@ export function useThreadOutboxDrain(): void { operableEnvironments, projects, queuedMessagesByThreadKey, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- retryTick re-runs the drain when a retry timer fires. retryTick, restoreQueuedMessage, scheduleQueuedMessageRetry, diff --git a/vite.config.ts b/vite.config.ts index 131f6b4ce6074..7ab571fb636dc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -229,6 +229,7 @@ export default defineConfig({ "react/memo-dependencies": "error", "react/purity": "error", "react/refs": "error", + "react/exhaustive-effect-dependencies": "error", "react/immutability": "error", "react/no-array-index-key": "error", "react/no-unstable-nested-components": "error",