From b111676744570a8f29d3e3ed9506f91f677144f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 22:15:43 +0000 Subject: [PATCH] Drop the controlled-input warning and adopt controlled inputs `TextField.Input`'s `value` prop carried a `@deprecated` tag steering everyone toward `defaultValue`. That was an old-architecture performance concern; we're on the New Architecture now, so the warning is gone and CLAUDE.md says controlled inputs are the default choice. Audited the uncontrolled inputs that warning produced. The interesting ones were carrying workarounds to paper over the fact that the input and the state describing it could drift apart: - Advanced search: `ClearableInput` kept its own `showClear` state and cleared itself through a ref, and `FilterBlock` remounted it (and the already-controlled `AutocompleteInput`) with `key={filter.field}` to reseed it. All three are gone. - Group chat: `EditNamePrompt` took an `inputKey` that `ConversationSettings` bumped on every open to remount the input, because the native bottom sheet keeps children mounted across opens. - Follow dialog and GIF picker: both cleared their search field through a ref alongside the state update. The GIF picker's field had no `value` at all, so its clear button was driven by a separate prop. - Login and signup: both mirrored every field into a ref so submit could read it, keeping two copies of the same string. Signup's `prevEmailValueRef` stays - it tracks the last email we warned about, not the input. - The email dialog froze its field by dropping `onChangeText` after a successful update, which stopped recording edits but didn't stop them being typed. Now it's `editable={false}`. The rest were plain `defaultValue` + `setState` pairs where the state was already the source of truth for validation, character counters and dirty checks: profile and list editing, both alt text dialogs, appeal reason, handle change, app password name. Also fixed `OTPInput`, which was controlled but cleared itself through `clear()` on tap - the digit row renders from `value`, so the code appeared to survive a clear that had already emptied the input. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016imsvP5dtEKBr6hCy1X62K --- CLAUDE.md | 25 +++----------- src/components/ProgressGuide/FollowDialog.tsx | 17 +++------- .../contacts/components/OTPInput.tsx | 7 +++- .../dialogs/EmailDialog/screens/Update.tsx | 9 ++--- .../dialogs/lists/CreateOrEditListDialog.tsx | 4 +-- src/components/forms/TextField.tsx | 7 ---- src/features/gifPicker/GifPickerDialog.tsx | 3 +- .../gifPicker/components/GifPickerHeader.tsx | 7 ++-- src/screens/Login/LoginForm.tsx | 34 ++++++++----------- .../Messages/ConversationSettings/index.tsx | 3 -- .../Messages/ConversationSettings/prompts.tsx | 10 +----- .../Profile/Header/EditProfileDialog.tsx | 4 +-- .../AdvancedSearchDialog/ClearableInput.tsx | 29 +++++----------- .../AdvancedSearchDialog/FilterBlock.tsx | 12 +------ .../components/AdvancedSearchDialog/index.tsx | 6 ++-- .../components/AddAppPasswordDialog.tsx | 1 + .../components/ChangeHandleDialog.tsx | 4 +-- src/screens/Signup/StepInfo/index.tsx | 26 ++++++-------- src/screens/Takendown.tsx | 2 +- src/view/com/composer/GifAltText.tsx | 2 +- .../composer/photos/ImageAltTextDialog.tsx | 2 +- 21 files changed, 72 insertions(+), 142 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d90cbf7cb3..7212eb6165 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -288,8 +288,11 @@ existing usages across the app for a canonical example. ### TextField Compound component at `#/components/forms/TextField` (`TextField.LabelText`, -`TextField.Root`, `TextField.Icon`, `TextField.Input`). Prefer `defaultValue` over -`value` (see Footguns). +`TextField.Root`, `TextField.Icon`, `TextField.Input`). Controlled inputs +(`value` + `onChangeText`) are fine and are usually what you want - the old +advice to reach for `defaultValue` was a New Architecture migration concern and +no longer applies. Reach for `defaultValue` only when nothing outside the input +needs to read the text. ### Typography @@ -507,24 +510,6 @@ This applies to: The Menu component on iOS specifically uses this pattern – see `src/components/Menu/index.tsx:151`. -### Controlled vs Uncontrolled Inputs - -Prefer `defaultValue` over `value` for TextInput on the old architecture: - -```tsx -// Preferred - uncontrolled - - -// Avoid when possible - controlled (can cause performance issues) - -``` - ### Platform-Specific Behavior Some components behave differently across platforms: diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx index 5e6a3a97ca..29649d8ab8 100644 --- a/src/components/ProgressGuide/FollowDialog.tsx +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -139,7 +139,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { const [searchText, setSearchText] = useState(lastSearchText) const moderationOpts = useModerationOpts() const listRef = useRef(null) - const inputRef = useRef>(null) const [headerHeight, setHeaderHeight] = useState(0) const {currentAccount} = useSession() @@ -305,7 +304,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { const onSelectTab = useCallback( (interest: string) => { setSelectedInterest(interest) - inputRef.current?.clear() setSearchText('') listRef.current?.scrollToOffset({ offset: 0, @@ -318,7 +316,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { const listHeader = (
| null> listRef: React.RefObject onSelectTab: (v: string) => void searchText: string @@ -396,8 +391,7 @@ let Header = ({ { setSearchText(text) listRef.current?.scrollToOffset({offset: 0, animated: false}) @@ -668,13 +662,11 @@ function CardOuter({ function SearchInput({ onChangeText, onEscape, - inputRef, - defaultValue, + value, }: { onChangeText: (text: string) => void onEscape: () => void - inputRef: React.RefObject | null> - defaultValue: string + value: string }) { const t = useTheme() const {t: l} = useLingui() @@ -698,9 +690,8 @@ function SearchInput({ fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} /> { innerRef.current?.focus() - innerRef.current?.clear() + /* + * Clear through state, not the input's imperative `clear()`: the digit + * row above renders from `value`, so a native-only clear would leave + * stale digits on screen. + */ + onChange('') }}> {[...value.padEnd(numberOfDigits, ' ')].map((digit, index) => { diff --git a/src/components/dialogs/EmailDialog/screens/Update.tsx b/src/components/dialogs/EmailDialog/screens/Update.tsx index 0261e48c75..d537f7919c 100644 --- a/src/components/dialogs/EmailDialog/screens/Update.tsx +++ b/src/components/dialogs/EmailDialog/screens/Update.tsx @@ -218,12 +218,9 @@ export function Update(_props: ScreenProps) { & { label: string - /** - * @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible. - * - * See https://github.com/facebook/react-native-website/pull/4247 - * - * Note: This guidance no longer applies once we migrate to the New Architecture! - */ value?: string onChangeText?: (value: string) => void isInvalid?: boolean diff --git a/src/features/gifPicker/GifPickerDialog.tsx b/src/features/gifPicker/GifPickerDialog.tsx index 02cb8ced3e..14e1a08e13 100644 --- a/src/features/gifPicker/GifPickerDialog.tsx +++ b/src/features/gifPicker/GifPickerDialog.tsx @@ -115,7 +115,6 @@ function GifPickerBody({ }, [effectiveSearch, isRecentsActive]) const onClearSearch = () => { - textInputRef.current?.clear() setRawSearch('') setActiveCategory('trending') textInputRef.current?.focus() @@ -148,9 +147,9 @@ function GifPickerBody({ <> 0} onEscape={() => control.close()} /> {showPills && ( diff --git a/src/features/gifPicker/components/GifPickerHeader.tsx b/src/features/gifPicker/components/GifPickerHeader.tsx index 62dfea5eb9..c6ab1ba725 100644 --- a/src/features/gifPicker/components/GifPickerHeader.tsx +++ b/src/features/gifPicker/components/GifPickerHeader.tsx @@ -10,16 +10,16 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' export function GifPickerHeader({ inputRef, + value, onChangeText, onClear, onEscape, - canClear, }: { inputRef: Ref> + value: string onChangeText: (text: string) => void onClear: () => void onEscape: () => void - canClear: boolean }) { const {t: l} = useLingui() const t = useTheme() @@ -47,6 +47,7 @@ export function GifPickerHeader({ comment: 'Placeholder text inside the GIF search input. KLIPY is the third-party GIF provider; keep the brand name as-is.', })} + value={value} onChangeText={onChangeText} returnKeyType="search" inputRef={inputRef} @@ -57,7 +58,7 @@ export function GifPickerHeader({ } }} /> - {canClear && ( + {value.length > 0 && (