From ed6754301aaf66cf59cd3a8cb40819930f6855b3 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Tue, 25 Aug 2026 17:38:51 +0200 Subject: [PATCH] Use useEffectEvent for effects with deliberately narrow deps `pnpm lint` runs oxlint with `--quiet`, which hides warnings, so 14 react-hooks/exhaustive-deps warnings were sitting unseen. Five are the shape useEffectEvent exists for: an effect that must read the latest value of something without re-running when it changes. - CustomFeedEmptyState, FindContactsSettings: fire-and-forget metrics that omitted `ax` from the dep array - AutosizedTextarea: the `onUpdateHeight` callback prop - SuggestedLanguage: replaces a `useNonReactiveObject` mirror, which was doing the same job by hand - VideoEmbedInnerWeb: the hls subtitle handler called a captured `updateCuePositions`, so it would have gone stale if that identity changed Warnings: 14 -> 9. The rest are useMemo/useCallback/useImperativeHandle, where useEffectEvent does not apply. Co-Authored-By: Claude Opus 5 (1M context) --- .../VideoEmbedInner/VideoEmbedInnerWeb.tsx | 19 +++++++++++++++++-- src/components/forms/AutosizedTextarea.tsx | 8 ++++++-- src/screens/Settings/FindContactsSettings.tsx | 15 ++++++++++----- .../select-language/SuggestedLanguage.tsx | 13 ++++++++----- src/view/com/posts/CustomFeedEmptyState.tsx | 12 +++++++----- 5 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 947d4ac3d6..a79f41b76b 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -1,4 +1,11 @@ -import {useCallback, useEffect, useId, useRef, useState} from 'react' +import { + useCallback, + useEffect, + useEffectEvent, + useId, + useRef, + useState, +} from 'react' import {View} from 'react-native' import {useLingui} from '@lingui/react/macro' import type * as HlsTypes from 'hls.js' @@ -264,6 +271,14 @@ function useHLS({ }, ) + /* + * The hls handler below must call the latest `updateCuePositions` without the + * effect tearing down and re-attaching every time its identity changes. + */ + const onSubtitleFragProcessed = useEffectEvent(() => { + updateCuePositions() + }) + useEffect(() => { if (!videoRef.current) return if (!Hls) return @@ -302,7 +317,7 @@ function useHLS({ }) hls.on(Hls.Events.SUBTITLE_FRAG_PROCESSED, () => { - updateCuePositions() + onSubtitleFragProcessed() }) hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => { diff --git a/src/components/forms/AutosizedTextarea.tsx b/src/components/forms/AutosizedTextarea.tsx index 7c9727ded1..d32441c88f 100644 --- a/src/components/forms/AutosizedTextarea.tsx +++ b/src/components/forms/AutosizedTextarea.tsx @@ -1,4 +1,4 @@ -import {useEffect, useMemo, useRef, useState} from 'react' +import {useEffect, useEffectEvent, useMemo, useRef, useState} from 'react' import { TextInput, type TextInputContentSizeChangeEvent, @@ -141,13 +141,17 @@ export function AutosizedTextarea({ * Reset native height state after a programmatic clear. Android uses it as * the explicit input height, while iOS uses it to decide when to scroll. */ + const reportHeight = useEffectEvent((height: number) => { + onUpdateHeight?.(height) + }) + const prevRawValue = useRef(rawValue || '') useEffect(() => { if (!IS_NATIVE) return if (rawValue === undefined) return // uncontrolled if (prevRawValue.current?.length && rawValue === '') { setNativeHeight(minInputHeight) - onUpdateHeight?.(minInputHeight) + reportHeight(minInputHeight) } prevRawValue.current = rawValue }, [rawValue, minInputHeight]) diff --git a/src/screens/Settings/FindContactsSettings.tsx b/src/screens/Settings/FindContactsSettings.tsx index bc7ef48f8e..02fadfd665 100644 --- a/src/screens/Settings/FindContactsSettings.tsx +++ b/src/screens/Settings/FindContactsSettings.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useState} from 'react' +import {useCallback, useEffect, useEffectEvent, useState} from 'react' import {type ListRenderItemInfo, View} from 'react-native' import * as Contacts from 'expo-contacts' import {type DidString} from '@atproto/syntax' @@ -61,12 +61,17 @@ export function FindContactsSettingsScreen({}: Props) { const {data, error, refetch} = useContactsSyncStatusQuery() const isFocused = useIsFocused() + + const logPresented = useEffectEvent(() => { + ax.metric('contacts:settings:presented', { + hasPreviouslySynced: !!data?.syncStatus, + matchCount: data?.syncStatus?.matchesCount, + }) + }) + useEffect(() => { if (data && isFocused) { - ax.metric('contacts:settings:presented', { - hasPreviouslySynced: !!data.syncStatus, - matchCount: data.syncStatus?.matchesCount, - }) + logPresented() } }, [data, isFocused]) diff --git a/src/view/com/composer/select-language/SuggestedLanguage.tsx b/src/view/com/composer/select-language/SuggestedLanguage.tsx index 56bca076e1..4bb32c332d 100644 --- a/src/view/com/composer/select-language/SuggestedLanguage.tsx +++ b/src/view/com/composer/select-language/SuggestedLanguage.tsx @@ -1,4 +1,4 @@ -import {useEffect, useMemo, useRef, useState} from 'react' +import {useEffect, useEffectEvent, useMemo, useRef, useState} from 'react' import {Platform, Text as RNText, View} from 'react-native' import {parseLanguageString} from '@atproto/syntax' import { @@ -332,15 +332,18 @@ function GuessedLanguage({ onDeclineOuter() } - const metaRef = useNonReactiveObject(metadata) - useEffect(() => { + const logSuggestion = useEffectEvent(() => { ax.metric('composer:language:suggestLanguage', { os: Platform.OS, suggestedLanguage: language, - currentTargetLanguages: metaRef.current.currentTargetLanguages, + currentTargetLanguages: metadata.currentTargetLanguages, textLength: sanitizeTextForDetection(metadata.rawText).length, }) - }, [ax, language]) + }) + + useEffect(() => { + logSuggestion() + }, [language]) return ( { + hasLoggedDiscoverEmptyErrorRef.current = true + ax.metric('feed:discover:emptyError', {userDid: did}) + }) + useEffect(() => { // Log the empty feed error event if (feedFeedback.feedSourceInfo && currentAccount?.did) { @@ -31,10 +36,7 @@ export function CustomFeedEmptyState() { uri === DISCOVER_FEED_URI && !hasLoggedDiscoverEmptyErrorRef.current ) { - hasLoggedDiscoverEmptyErrorRef.current = true - ax.metric('feed:discover:emptyError', { - userDid: currentAccount.did, - }) + logDiscoverEmptyError(currentAccount.did) } } }, [feedFeedback.feedSourceInfo, currentAccount?.did])