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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016imsvP5dtEKBr6hCy1X62K
This commit is contained in:
Claude
2026-09-02 22:15:43 +00:00
parent af3fbcc940
commit b111676744
21 changed files with 72 additions and 142 deletions
+5 -20
View File
@@ -288,8 +288,11 @@ existing usages across the app for a canonical example.
### TextField ### TextField
Compound component at `#/components/forms/TextField` (`TextField.LabelText`, Compound component at `#/components/forms/TextField` (`TextField.LabelText`,
`TextField.Root`, `TextField.Icon`, `TextField.Input`). Prefer `defaultValue` over `TextField.Root`, `TextField.Icon`, `TextField.Input`). Controlled inputs
`value` (see Footguns). (`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 ### Typography
@@ -507,24 +510,6 @@ This applies to:
The Menu component on iOS specifically uses this pattern  see `src/components/Menu/index.tsx:151`. 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
<TextField.Input
defaultValue={initialEmail}
onChangeText={setEmail}
/>
// Avoid when possible - controlled (can cause performance issues)
<TextField.Input
value={email}
onChangeText={setEmail}
/>
```
### Platform-Specific Behavior ### Platform-Specific Behavior
Some components behave differently across platforms: Some components behave differently across platforms:
+4 -13
View File
@@ -139,7 +139,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const [searchText, setSearchText] = useState(lastSearchText) const [searchText, setSearchText] = useState(lastSearchText)
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const listRef = useRef<ListMethods>(null) const listRef = useRef<ListMethods>(null)
const inputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
const [headerHeight, setHeaderHeight] = useState(0) const [headerHeight, setHeaderHeight] = useState(0)
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -305,7 +304,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const onSelectTab = useCallback( const onSelectTab = useCallback(
(interest: string) => { (interest: string) => {
setSelectedInterest(interest) setSelectedInterest(interest)
inputRef.current?.clear()
setSearchText('') setSearchText('')
listRef.current?.scrollToOffset({ listRef.current?.scrollToOffset({
offset: 0, offset: 0,
@@ -318,7 +316,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const listHeader = ( const listHeader = (
<Header <Header
guide={guide} guide={guide}
inputRef={inputRef}
listRef={listRef} listRef={listRef}
searchText={searchText} searchText={searchText}
onSelectTab={onSelectTab} onSelectTab={onSelectTab}
@@ -357,7 +354,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
let Header = ({ let Header = ({
guide, guide,
inputRef,
listRef, listRef,
searchText, searchText,
onSelectTab, onSelectTab,
@@ -368,7 +364,6 @@ let Header = ({
interestsDisplayNames, interestsDisplayNames,
}: { }: {
guide?: Follow10ProgressGuide guide?: Follow10ProgressGuide
inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null>
listRef: React.RefObject<ListMethods | null> listRef: React.RefObject<ListMethods | null>
onSelectTab: (v: string) => void onSelectTab: (v: string) => void
searchText: string searchText: string
@@ -396,8 +391,7 @@ let Header = ({
<View style={[web(a.pt_xs), a.pb_xs]}> <View style={[web(a.pt_xs), a.pb_xs]}>
<SearchInput <SearchInput
inputRef={inputRef} value={searchText}
defaultValue={searchText}
onChangeText={text => { onChangeText={text => {
setSearchText(text) setSearchText(text)
listRef.current?.scrollToOffset({offset: 0, animated: false}) listRef.current?.scrollToOffset({offset: 0, animated: false})
@@ -668,13 +662,11 @@ function CardOuter({
function SearchInput({ function SearchInput({
onChangeText, onChangeText,
onEscape, onEscape,
inputRef, value,
defaultValue,
}: { }: {
onChangeText: (text: string) => void onChangeText: (text: string) => void
onEscape: () => void onEscape: () => void
inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null> value: string
defaultValue: string
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -698,9 +690,8 @@ function SearchInput({
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
/> />
<TextInput <TextInput
ref={inputRef}
placeholder={l`Search by name or interest`} placeholder={l`Search by name or interest`}
defaultValue={defaultValue} value={value}
onChangeText={onChangeText} onChangeText={onChangeText}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
@@ -58,7 +58,12 @@ export function OTPInput({
style={[a.w_full, a.relative]} style={[a.w_full, a.relative]}
onPress={() => { onPress={() => {
innerRef.current?.focus() 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('')
}}> }}>
<View style={[a.w_full, a.flex_row, a.gap_sm]}> <View style={[a.w_full, a.flex_row, a.gap_sm]}>
{[...value.padEnd(numberOfDigits, ' ')].map((digit, index) => { {[...value.padEnd(numberOfDigits, ' ')].map((digit, index) => {
@@ -218,12 +218,9 @@ export function Update(_props: ScreenProps<ScreenID.Update>) {
<TextField.Input <TextField.Input
label={l`New email address`} label={l`New email address`}
placeholder={l`alice@example.com`} placeholder={l`alice@example.com`}
defaultValue={state.email} value={state.email}
onChangeText={ editable={state.mutationStatus !== 'success'}
state.mutationStatus === 'success' onChangeText={handleEmailChange}
? undefined
: handleEmailChange
}
keyboardType="email-address" keyboardType="email-address"
autoComplete="email" autoComplete="email"
autoCapitalize="none" autoCapitalize="none"
@@ -421,7 +421,7 @@ function DialogInner({
</TextField.LabelText> </TextField.LabelText>
<TextField.Root isInvalid={displayNameTooLong || displayNameTooShort}> <TextField.Root isInvalid={displayNameTooLong || displayNameTooShort}>
<Dialog.Input <Dialog.Input
defaultValue={displayName} value={displayName}
onChangeText={onChangeDisplayName} onChangeText={onChangeDisplayName}
label={_(msg`Name`)} label={_(msg`Name`)}
placeholder={displayNamePlaceholder} placeholder={displayNamePlaceholder}
@@ -457,7 +457,7 @@ function DialogInner({
</TextField.LabelText> </TextField.LabelText>
<TextField.Root isInvalid={descriptionTooLong}> <TextField.Root isInvalid={descriptionTooLong}>
<Dialog.Input <Dialog.Input
defaultValue={descriptionRt.text} value={descriptionRt.text}
onChangeText={onChangeDescription} onChangeText={onChangeDescription}
multiline multiline
label={_(msg`Description`)} label={_(msg`Description`)}
-7
View File
@@ -156,13 +156,6 @@ export type InputProps = Omit<
'value' | 'onChangeText' | 'placeholder' 'value' | 'onChangeText' | 'placeholder'
> & { > & {
label: string 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 value?: string
onChangeText?: (value: string) => void onChangeText?: (value: string) => void
isInvalid?: boolean isInvalid?: boolean
+1 -2
View File
@@ -115,7 +115,6 @@ function GifPickerBody({
}, [effectiveSearch, isRecentsActive]) }, [effectiveSearch, isRecentsActive])
const onClearSearch = () => { const onClearSearch = () => {
textInputRef.current?.clear()
setRawSearch('') setRawSearch('')
setActiveCategory('trending') setActiveCategory('trending')
textInputRef.current?.focus() textInputRef.current?.focus()
@@ -148,9 +147,9 @@ function GifPickerBody({
<> <>
<GifPickerHeader <GifPickerHeader
inputRef={textInputRef} inputRef={textInputRef}
value={rawSearch}
onChangeText={onChangeSearch} onChangeText={onChangeSearch}
onClear={onClearSearch} onClear={onClearSearch}
canClear={rawSearch.length > 0}
onEscape={() => control.close()} onEscape={() => control.close()}
/> />
{showPills && ( {showPills && (
@@ -10,16 +10,16 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
export function GifPickerHeader({ export function GifPickerHeader({
inputRef, inputRef,
value,
onChangeText, onChangeText,
onClear, onClear,
onEscape, onEscape,
canClear,
}: { }: {
inputRef: Ref<React.ComponentRef<typeof TextInput>> inputRef: Ref<React.ComponentRef<typeof TextInput>>
value: string
onChangeText: (text: string) => void onChangeText: (text: string) => void
onClear: () => void onClear: () => void
onEscape: () => void onEscape: () => void
canClear: boolean
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
@@ -47,6 +47,7 @@ export function GifPickerHeader({
comment: comment:
'Placeholder text inside the GIF search input. KLIPY is the third-party GIF provider; keep the brand name as-is.', 'Placeholder text inside the GIF search input. KLIPY is the third-party GIF provider; keep the brand name as-is.',
})} })}
value={value}
onChangeText={onChangeText} onChangeText={onChangeText}
returnKeyType="search" returnKeyType="search"
inputRef={inputRef} inputRef={inputRef}
@@ -57,7 +58,7 @@ export function GifPickerHeader({
} }
}} }}
/> />
{canClear && ( {value.length > 0 && (
<Button <Button
size="tiny" size="tiny"
color="secondary" color="secondary"
+15 -19
View File
@@ -73,15 +73,13 @@ export const LoginForm = ({
>('none') >('none')
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false) const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false)
const [showResolveError, setShowResolveError] = useState(false) const [showResolveError, setShowResolveError] = useState(false)
const identifierValueRef = useRef(initialHandle || '')
const passwordValueRef = useRef('')
const [identifier, setIdentifier] = useState(initialHandle || '') const [identifier, setIdentifier] = useState(initialHandle || '')
const [password, setPassword] = useState('')
const [identifierFocused, setIdentifierFocused] = useState(false) const [identifierFocused, setIdentifierFocused] = useState(false)
const [authFactorToken, setAuthFactorToken] = useState('') const [authFactorToken, setAuthFactorToken] = useState('')
const identifierRef = useRef<React.ComponentRef<typeof TextInput>>(null) const identifierRef = useRef<React.ComponentRef<typeof TextInput>>(null)
const passwordRef = useRef<React.ComponentRef<typeof TextInput>>(null) const passwordRef = useRef<React.ComponentRef<typeof TextInput>>(null)
const hasFocusedOnce = useRef(false) const hasFocusedOnce = useRef(false)
const [hasPassword, setHasPassword] = useState(false)
const [revealPassword, setRevealPassword] = useState(false) const [revealPassword, setRevealPassword] = useState(false)
const {t: l} = useLingui() const {t: l} = useLingui()
const {login} = useSessionApi() const {login} = useSessionApi()
@@ -119,7 +117,6 @@ export const LoginForm = ({
* transitions away) and clears it on any failure. * transitions away) and clears it on any failure.
*/ */
const attemptLogin = async (service: string, fullIdent: string) => { const attemptLogin = async (service: string, fullIdent: string) => {
const password = passwordValueRef.current
setIsProcessing(true) setIsProcessing(true)
try { try {
@@ -184,10 +181,9 @@ export const LoginForm = ({
setErrorField('none') setErrorField('none')
setShowResolveError(false) setShowResolveError(false)
const identifier = identifierValueRef.current.toLowerCase().trim() const normalizedIdentifier = identifier.toLowerCase().trim()
const password = passwordValueRef.current
if (!identifier) { if (!normalizedIdentifier) {
setError(l`Please enter your username`) setError(l`Please enter your username`)
setErrorField('identifier') setErrorField('identifier')
return return
@@ -202,11 +198,11 @@ export const LoginForm = ({
setIsProcessing(true) setIsProcessing(true)
// try to guess the handle if the user just gave their own username // try to guess the handle if the user just gave their own username
let fullIdent = identifier let fullIdent = normalizedIdentifier
if ( if (
!identifier.includes('@') && // not an email !normalizedIdentifier.includes('@') && // not an email
!identifier.includes('.') && // not a domain !normalizedIdentifier.includes('.') && // not a domain
!identifier.startsWith('did:') && // not a DID !normalizedIdentifier.startsWith('did:') && // not a DID
serviceDescription && serviceDescription &&
serviceDescription.availableUserDomains.length > 0 serviceDescription.availableUserDomains.length > 0
) { ) {
@@ -218,7 +214,7 @@ export const LoginForm = ({
} }
if (!matched) { if (!matched) {
fullIdent = createFullHandle( fullIdent = createFullHandle(
identifier, normalizedIdentifier,
serviceDescription.availableUserDomains[0], serviceDescription.availableUserDomains[0],
) )
} }
@@ -235,7 +231,8 @@ export const LoginForm = ({
let service: string let service: string
let did: string | null let did: string | null
try { try {
;({service, did} = await hostingProvider.resolveService(identifier)) ;({service, did} =
await hostingProvider.resolveService(normalizedIdentifier))
} catch (err) { } catch (err) {
logger.debug('Failed to resolve hosting provider', {error: String(err)}) logger.debug('Failed to resolve hosting provider', {error: String(err)})
setIsProcessing(false) setIsProcessing(false)
@@ -321,9 +318,8 @@ export const LoginForm = ({
autoComplete="username" autoComplete="username"
returnKeyType="next" returnKeyType="next"
textContentType="username" textContentType="username"
defaultValue={initialHandle || ''} value={identifier}
onChangeText={v => { onChangeText={v => {
identifierValueRef.current = v
setIdentifier(v) setIdentifier(v)
if (errorField) setErrorField('none') if (errorField) setErrorField('none')
if (showResolveError) setShowResolveError(false) if (showResolveError) setShowResolveError(false)
@@ -379,10 +375,10 @@ export const LoginForm = ({
returnKeyType="done" returnKeyType="done"
enablesReturnKeyAutomatically={true} enablesReturnKeyAutomatically={true}
secureTextEntry={!revealPassword} secureTextEntry={!revealPassword}
value={password}
onChangeText={v => { onChangeText={v => {
passwordValueRef.current = v setPassword(v)
if (errorField) setErrorField('none') if (errorField) setErrorField('none')
setHasPassword(!!v)
}} }}
onSubmitEditing={() => void onPressNext()} onSubmitEditing={() => void onPressNext()}
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
@@ -412,7 +408,7 @@ export const LoginForm = ({
/> />
<RevealPasswordButton <RevealPasswordButton
active={revealPassword} active={revealPassword}
hasPassword={hasPassword} hasPassword={!!password}
onPress={() => setRevealPassword(r => !r)} onPress={() => setRevealPassword(r => !r)}
/> />
</TextField.Root> </TextField.Root>
@@ -447,7 +443,7 @@ export const LoginForm = ({
autoComplete="one-time-code" autoComplete="one-time-code"
returnKeyType="done" returnKeyType="done"
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
value={authFactorToken} // controlled input due to uncontrolled input not receiving pasted values properly value={authFactorToken}
onChangeText={text => { onChangeText={text => {
setAuthFactorToken(text) setAuthFactorToken(text)
if (errorField) setErrorField('none') if (errorField) setErrorField('none')
@@ -339,11 +339,9 @@ function SettingsHeader({
const groupName = convo.details.name const groupName = convo.details.name
const [newGroupName, setNewGroupName] = useState(groupName) const [newGroupName, setNewGroupName] = useState(groupName)
const [editNameInputKey, setEditNameInputKey] = useState(0)
const openEditNamePrompt = () => { const openEditNamePrompt = () => {
setNewGroupName(groupName) setNewGroupName(groupName)
setEditNameInputKey(k => k + 1)
editNamePrompt.open() editNamePrompt.open()
} }
@@ -591,7 +589,6 @@ function SettingsHeader({
<EditNamePrompt <EditNamePrompt
control={editNamePrompt} control={editNamePrompt}
value={newGroupName} value={newGroupName}
inputKey={editNameInputKey}
onChangeText={setNewGroupName} onChangeText={setNewGroupName}
onConfirm={() => editGroupName({name: newGroupName})} onConfirm={() => editGroupName({name: newGroupName})}
/> />
@@ -12,18 +12,11 @@ import {Text} from '#/components/Typography'
export function EditNamePrompt({ export function EditNamePrompt({
control, control,
value, value,
inputKey,
onChangeText, onChangeText,
onConfirm, onConfirm,
}: { }: {
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
value: string value: string
/**
* Bump this whenever the prompt is opened to remount the (uncontrolled)
* input and reseed it from `value`. Required because the native bottom sheet
* keeps its children mounted across opens.
*/
inputKey: number
onChangeText: (value: string) => void onChangeText: (value: string) => void
onConfirm: () => void onConfirm: () => void
}) { }) {
@@ -45,10 +38,9 @@ export function EditNamePrompt({
<View style={[a.my_sm]}> <View style={[a.my_sm]}>
<TextField.Root isInvalid={nameTooLong}> <TextField.Root isInvalid={nameTooLong}>
<TextField.Input <TextField.Input
key={inputKey}
label={l`Edit group name`} label={l`Edit group name`}
placeholder={l`Group name`} placeholder={l`Group name`}
defaultValue={value} value={value}
onChangeText={onChangeText} onChangeText={onChangeText}
returnKeyType="done" returnKeyType="done"
autoCapitalize="none" autoCapitalize="none"
@@ -311,7 +311,7 @@ function DialogInner({
</TextField.LabelText> </TextField.LabelText>
<TextField.Root isInvalid={displayNameTooLong}> <TextField.Root isInvalid={displayNameTooLong}>
<Dialog.Input <Dialog.Input
defaultValue={displayName} value={displayName}
onChangeText={setDisplayName} onChangeText={setDisplayName}
label={_(msg`Display name`)} label={_(msg`Display name`)}
placeholder={_(msg`e.g. Alice Lastname`)} placeholder={_(msg`e.g. Alice Lastname`)}
@@ -361,7 +361,7 @@ function DialogInner({
</TextField.LabelText> </TextField.LabelText>
<TextField.Root isInvalid={descriptionTooLong}> <TextField.Root isInvalid={descriptionTooLong}>
<Dialog.Input <Dialog.Input
defaultValue={description} value={description}
onChangeText={setDescription} onChangeText={setDescription}
multiline multiline
label={_(msg`Description`)} label={_(msg`Description`)}
@@ -1,5 +1,4 @@
import {useRef, useState} from 'react' import {View} from 'react-native'
import {type TextInput, View} from 'react-native'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
@@ -9,59 +8,47 @@ import * as TextField from '#/components/forms/TextField'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
/** /**
* A text input with a clear (X) button inside it on the right. The input stays * A text input with a clear (X) button inside it on the right.
* uncontrolled (defaultValue + imperative clear) per the codebase's preference;
* local state only drives whether the clear button is shown.
*/ */
export function ClearableInput({ export function ClearableInput({
label, label,
defaultValue, value,
placeholder, placeholder,
onChangeText, onChangeText,
onSubmitEditing, onSubmitEditing,
}: { }: {
label: string label: string
defaultValue: string value: string
placeholder?: string placeholder?: string
onChangeText: (text: string) => void onChangeText: (text: string) => void
onSubmitEditing?: () => void onSubmitEditing?: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const inputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
const [showClear, setShowClear] = useState(defaultValue.length > 0)
return ( return (
<View style={[a.relative]}> <View style={[a.relative]}>
<TextField.Root> <TextField.Root>
<Dialog.Input <Dialog.Input
inputRef={inputRef}
label={label} label={label}
defaultValue={defaultValue} value={value}
placeholder={placeholder} placeholder={placeholder}
keyboardAppearance={t.scheme} keyboardAppearance={t.scheme}
autoCorrect={false} autoCorrect={false}
autoComplete="off" autoComplete="off"
autoCapitalize="none" autoCapitalize="none"
style={[a.pr_2xl]} style={[a.pr_2xl]}
onChangeText={text => { onChangeText={onChangeText}
setShowClear(text.length > 0)
onChangeText(text)
}}
onSubmitEditing={onSubmitEditing} onSubmitEditing={onSubmitEditing}
/> />
</TextField.Root> </TextField.Root>
{showClear && ( {value.length > 0 && (
<View <View
style={[a.absolute, a.justify_center, {top: 0, bottom: 0, right: 8}]}> style={[a.absolute, a.justify_center, {top: 0, bottom: 0, right: 8}]}>
<Button <Button
label={l`Clear`} label={l`Clear`}
onPress={() => { onPress={() => onChangeText('')}
inputRef.current?.clear()
setShowClear(false)
onChangeText('')
}}
size="tiny" size="tiny"
color="secondary" color="secondary"
shape="round"> shape="round">
@@ -154,11 +154,6 @@ export function FilterBlock({
{HANDLE_FIELDS.has(filter.field) ? ( {HANDLE_FIELDS.has(filter.field) ? (
<AutocompleteInput <AutocompleteInput
/*
* Remount on field-type change so the input resets to the cleared
* value; mode changes keep the same field and so preserve the text.
*/
key={filter.field}
label={labels[filter.field].label} label={labels[filter.field].label}
value={filter.value} value={filter.value}
onChangeText={text => onChange({value: text})} onChangeText={text => onChange({value: text})}
@@ -166,13 +161,8 @@ export function FilterBlock({
/> />
) : ( ) : (
<ClearableInput <ClearableInput
/*
* The input is uncontrolled (defaultValue), so remount on field-type
* change to reset it; mode changes keep the same field and text.
*/
key={filter.field}
label={labels[filter.field].label} label={labels[filter.field].label}
defaultValue={filter.value} value={filter.value}
onChangeText={text => onChange({value: text})} onChangeText={text => onChange({value: text})}
onSubmitEditing={onSubmitEditing} onSubmitEditing={onSubmitEditing}
/> />
@@ -246,7 +246,7 @@ function DialogInner({
</TextField.LabelText> </TextField.LabelText>
<ClearableInput <ClearableInput
label={l`Search query`} label={l`Search query`}
defaultValue={query} value={query}
placeholder={l({ placeholder={l({
message: 'cats dogs', message: 'cats dogs',
comment: comment:
@@ -264,7 +264,7 @@ function DialogInner({
</TextField.LabelText> </TextField.LabelText>
<ClearableInput <ClearableInput
label={l`None of these words`} label={l`None of these words`}
defaultValue={negatedWords} value={negatedWords}
placeholder={l({ placeholder={l({
message: 'cows pigs', message: 'cows pigs',
comment: comment:
@@ -281,7 +281,7 @@ function DialogInner({
</TextField.LabelText> </TextField.LabelText>
<ClearableInput <ClearableInput
label={l`This exact phrase`} label={l`This exact phrase`}
defaultValue={exactPhrase} value={exactPhrase}
placeholder={l({ placeholder={l({
message: 'whats up', message: 'whats up',
comment: 'Advanced search: Example of an “exact phrase” search', comment: 'Advanced search: Example of an “exact phrase” search',
@@ -121,6 +121,7 @@ function CreateDialogInner({passwords}: {passwords: string[]}) {
<Dialog.Input <Dialog.Input
label={_(msg`App Password`)} label={_(msg`App Password`)}
placeholder={autogeneratedName} placeholder={autogeneratedName}
value={name}
onChangeText={setName} onChangeText={setName}
returnKeyType="done" returnKeyType="done"
onSubmitEditing={() => createAppPassword()} onSubmitEditing={() => createAppPassword()}
@@ -231,7 +231,7 @@ function ProvidedHandlePage({
<TextField.Icon icon={AtIcon} /> <TextField.Icon icon={AtIcon} />
<Dialog.Input <Dialog.Input
editable={!isPending} editable={!isPending}
defaultValue={subdomain} value={subdomain}
onChangeText={text => setSubdomain(text)} onChangeText={text => setSubdomain(text)}
label={_(msg`New handle`)} label={_(msg`New handle`)}
placeholder={_(msg`e.g. alice`)} placeholder={_(msg`e.g. alice`)}
@@ -387,7 +387,7 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) {
label={_(msg`New handle`)} label={_(msg`New handle`)}
placeholder={_(msg`e.g. alice.com`)} placeholder={_(msg`e.g. alice.com`)}
editable={!isPending} editable={!isPending}
defaultValue={domain} value={domain}
onChangeText={text => { onChangeText={text => {
setDomain(text) setDomain(text)
resetVerification() resetVerification()
+11 -15
View File
@@ -76,10 +76,11 @@ export function StepInfo({
const preemptivelyCompleteActivePolicyUpdate = const preemptivelyCompleteActivePolicyUpdate =
usePreemptivelyCompleteActivePolicyUpdate() usePreemptivelyCompleteActivePolicyUpdate()
const inviteCodeValueRef = useRef<string>(state.inviteCode) /**
const emailValueRef = useRef<string>(state.email) * The last email we ran the "did you really mean this?" check against, so
* the warning is only shown once per distinct address.
*/
const prevEmailValueRef = useRef<string>(state.email) const prevEmailValueRef = useRef<string>(state.email)
const passwordValueRef = useRef<string>(state.password)
const emailInputRef = useRef<React.ComponentRef<typeof TextInput>>(null) const emailInputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
const passwordInputRef = useRef<React.ComponentRef<typeof TextInput>>(null) const passwordInputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
@@ -111,10 +112,8 @@ export function StepInfo({
}, []) }, [])
const onNextPress = () => { const onNextPress = () => {
const inviteCode = inviteCodeValueRef.current const {inviteCode, email, password} = state
const email = emailValueRef.current
const emailChanged = prevEmailValueRef.current !== email const emailChanged = prevEmailValueRef.current !== email
const password = passwordValueRef.current
if (!isOverRegionMinAccessAge) { if (!isOverRegionMinAccessAge) {
return return
@@ -170,9 +169,6 @@ export function StepInfo({
} }
preemptivelyCompleteActivePolicyUpdate() preemptivelyCompleteActivePolicyUpdate()
dispatch({type: 'setInviteCode', value: inviteCode})
dispatch({type: 'setEmail', value: email})
dispatch({type: 'setPassword', value: password})
dispatch({type: 'next'}) dispatch({type: 'next'})
ax.metric('signup:nextPressed', { ax.metric('signup:nextPressed', {
activeStep: state.activeStep, activeStep: state.activeStep,
@@ -207,7 +203,7 @@ export function StepInfo({
<TextField.Icon icon={Ticket} /> <TextField.Icon icon={Ticket} />
<TextField.Input <TextField.Input
onChangeText={value => { onChangeText={value => {
inviteCodeValueRef.current = value.trim() dispatch({type: 'setInviteCode', value: value.trim()})
if ( if (
state.errorField === 'invite-code' && state.errorField === 'invite-code' &&
value.trim().length > 0 value.trim().length > 0
@@ -216,7 +212,7 @@ export function StepInfo({
} }
}} }}
label={l`Required for this provider`} label={l`Required for this provider`}
defaultValue={state.inviteCode} value={state.inviteCode}
autoCapitalize="none" autoCapitalize="none"
autoComplete="email" autoComplete="email"
keyboardType="email-address" keyboardType="email-address"
@@ -239,7 +235,7 @@ export function StepInfo({
testID="emailInput" testID="emailInput"
inputRef={emailInputRef} inputRef={emailInputRef}
onChangeText={value => { onChangeText={value => {
emailValueRef.current = value.trim() dispatch({type: 'setEmail', value: value.trim()})
if (hasWarnedEmail) { if (hasWarnedEmail) {
setHasWarnedEmail(false) setHasWarnedEmail(false)
} }
@@ -252,7 +248,7 @@ export function StepInfo({
} }
}} }}
label={l`Enter your email address`} label={l`Enter your email address`}
defaultValue={state.email} value={state.email}
autoCapitalize="none" autoCapitalize="none"
autoComplete="email" autoComplete="email"
keyboardType="email-address" keyboardType="email-address"
@@ -274,13 +270,13 @@ export function StepInfo({
testID="passwordInput" testID="passwordInput"
inputRef={passwordInputRef} inputRef={passwordInputRef}
onChangeText={value => { onChangeText={value => {
passwordValueRef.current = value dispatch({type: 'setPassword', value})
if (state.errorField === 'password' && value.length >= 8) { if (state.errorField === 'password' && value.length >= 8) {
dispatch({type: 'clearError'}) dispatch({type: 'clearError'})
} }
}} }}
label={l`Choose your password`} label={l`Choose your password`}
defaultValue={state.password} value={state.password}
secureTextEntry secureTextEntry
autoComplete="new-password" autoComplete="new-password"
autoCapitalize="none" autoCapitalize="none"
+1 -1
View File
@@ -162,7 +162,7 @@ export function Takendown() {
}> }>
<TextField.Input <TextField.Input
label={_(msg`Reason for appeal`)} label={_(msg`Reason for appeal`)}
defaultValue={reason} value={reason}
onChangeText={setReason} onChangeText={setReason}
placeholder={_(msg`Why are you appealing?`)} placeholder={_(msg`Why are you appealing?`)}
multiline multiline
+1 -1
View File
@@ -161,7 +161,7 @@ function AltTextInner({
label={_(msg`Alt text`)} label={_(msg`Alt text`)}
placeholder={vendorAltText} placeholder={vendorAltText}
onChangeText={onChange} onChangeText={onChange}
defaultValue={altText} value={altText}
multiline multiline
autoFocus autoFocus
onKeyPress={({nativeEvent}) => { onKeyPress={({nativeEvent}) => {
@@ -129,7 +129,7 @@ const ImageAltTextInner = ({
onChangeText={text => { onChangeText={text => {
setAltText(text) setAltText(text)
}} }}
defaultValue={altText} value={altText}
multiline multiline
autoFocus autoFocus
/> />