Skip to content

Commit d3e44ee

Browse files
committed
Sync biometric toggle cache after enable/disable
The Settings biometric (Use Biometrics) toggle read its state from a cached react-query result that was never updated when the user toggled it. After leaving and re-entering Settings the scene remounted and the one-time local-state sync read the stale cached value, showing the wrong toggle state. Update the cached biometric state after persisting the change so re-entry reflects the value the user set.
1 parent 596a7b3 commit d3e44ee

3 files changed

Lines changed: 215 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- changed: Migrate package manager from yarn to npm.
2121
- changed: Deprecate Botanix by switching it to keys-only mode on July 9, 2026.
2222
- fixed: Android build failure from the home screen long-press shortcuts feature, caused by an expo-quick-actions Kotlin compile error under Kotlin 2.3.
23+
- fixed: Use Biometrics toggle in Settings reverting to its previous state after leaving and re-entering the scene.
2324

2425
## 4.48.2 (2026-06-03)
2526

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, expect, it, jest } from '@jest/globals'
2+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
3+
import { fireEvent, render, waitFor } from '@testing-library/react-native'
4+
import * as React from 'react'
5+
6+
import { SettingsScene } from '../../components/scenes/SettingsScene'
7+
import { FakeProviders, type FakeState } from '../../util/fake/FakeProviders'
8+
import { fakeEdgeAppSceneProps } from '../../util/fake/fakeSceneProps'
9+
10+
// Stateful biometric backend, standing in for the keychain that
11+
// `edge-login-ui-rn` persists the "Use Biometrics" setting to. The `mock`
12+
// prefix is required for jest to allow referencing it inside the factory.
13+
// `read` is indirected so a test can hold a refetch in-flight; it defaults to
14+
// reading the persisted `enabled` flag.
15+
const mockBiometry: {
16+
enabled: boolean
17+
read: () => Promise<boolean>
18+
} = {
19+
enabled: true,
20+
read: async () => mockBiometry.enabled
21+
}
22+
jest.mock('edge-login-ui-rn', () => ({
23+
getSupportedBiometryType: async () => 'FaceID',
24+
isTouchEnabled: async () => await mockBiometry.read(),
25+
enableTouchId: async () => {
26+
mockBiometry.enabled = true
27+
},
28+
disableTouchId: async () => {
29+
mockBiometry.enabled = false
30+
}
31+
}))
32+
33+
const ACCOUNT_ID = 'fake-account-id'
34+
const BIOMETRIC_QUERY_KEY = ['biometricState', ACCOUNT_ID]
35+
36+
const mockState: FakeState = {
37+
core: {
38+
account: {
39+
id: ACCOUNT_ID,
40+
rootLoginId: 'XXX',
41+
currencyConfig: {},
42+
username: 'some user',
43+
watch: () => () => {}
44+
},
45+
context: {
46+
logSettings: { defaultLogLevel: 'silent' },
47+
watch: () => () => {}
48+
}
49+
}
50+
}
51+
52+
const getCachedTouchEnabled = (client: QueryClient): boolean | undefined =>
53+
client.getQueryData<{ isTouchEnabled: boolean }>(BIOMETRIC_QUERY_KEY)
54+
?.isTouchEnabled
55+
56+
const renderSettings = (client: QueryClient): ReturnType<typeof render> =>
57+
render(
58+
<FakeProviders initialState={mockState}>
59+
<QueryClientProvider client={client}>
60+
<SettingsScene
61+
{...fakeEdgeAppSceneProps('settingsOverview', undefined)}
62+
/>
63+
</QueryClientProvider>
64+
</FakeProviders>
65+
)
66+
67+
describe('SettingsScene biometric toggle persistence', () => {
68+
it('keeps the cached biometric state in sync after toggling so re-entry shows the set value', async () => {
69+
jest.useRealTimers()
70+
mockBiometry.enabled = true
71+
mockBiometry.read = async () => mockBiometry.enabled
72+
73+
// A client we own and can inspect, shared across both scene mounts to
74+
// model the app-level query cache that survives a scene remount. Seed it
75+
// with the loaded biometric state so the toggle renders deterministically.
76+
const client = new QueryClient({
77+
defaultOptions: { queries: { retry: false } }
78+
})
79+
client.setQueryData(BIOMETRIC_QUERY_KEY, {
80+
isTouchEnabled: true,
81+
isTouchSupported: true,
82+
biometryType: 'FaceID'
83+
})
84+
85+
const rendered = renderSettings(client)
86+
87+
// The toggle renders directly off the cached query value.
88+
await rendered.findByText('Use FaceID', {}, { timeout: 15000 })
89+
expect(getCachedTouchEnabled(client)).toBe(true)
90+
91+
// Toggle biometrics off.
92+
fireEvent.press(rendered.getByText('Use FaceID'))
93+
94+
// The persisted backend flips off AND the query cache is kept in sync.
95+
// Before the fix the cache stayed `true`, so re-entering Settings showed
96+
// the stale (on) state.
97+
await waitFor(
98+
() => {
99+
expect(mockBiometry.enabled).toBe(false)
100+
expect(getCachedTouchEnabled(client)).toBe(false)
101+
},
102+
{ timeout: 15000 }
103+
)
104+
105+
rendered.unmount()
106+
107+
// Re-enter Settings: a fresh mount reads the same (now-correct) cache and
108+
// shows the toggle in the state the user left it.
109+
const reentered = renderSettings(client)
110+
await reentered.findByText('Use FaceID', {}, { timeout: 15000 })
111+
expect(getCachedTouchEnabled(client)).toBe(false)
112+
113+
reentered.unmount()
114+
}, 60000)
115+
116+
it('cancels an in-flight refetch so a stale read cannot overwrite a completed toggle', async () => {
117+
jest.useRealTimers()
118+
mockBiometry.enabled = true
119+
120+
const client = new QueryClient({
121+
defaultOptions: { queries: { retry: false } }
122+
})
123+
// Seed the cache so the toggle renders immediately AND a background refetch
124+
// fires on mount (stale-while-revalidate).
125+
client.setQueryData(BIOMETRIC_QUERY_KEY, {
126+
isTouchEnabled: true,
127+
isTouchSupported: true,
128+
biometryType: 'FaceID'
129+
})
130+
131+
// Hold ONLY the first (mount) refetch in-flight, capturing its resolver so
132+
// we can complete it with the stale pre-toggle value after the toggle
133+
// persists. Any later refetch reads the real persisted flag.
134+
let resolveStaleRefetch: (value: boolean) => void = () => {}
135+
let signalStarted: () => void = () => {}
136+
const staleRefetchStarted = new Promise<void>(resolve => {
137+
signalStarted = resolve
138+
})
139+
let firstRead = true
140+
mockBiometry.read = async () => {
141+
if (firstRead) {
142+
firstRead = false
143+
signalStarted()
144+
return await new Promise<boolean>(resolve => {
145+
resolveStaleRefetch = resolve
146+
})
147+
}
148+
return mockBiometry.enabled
149+
}
150+
151+
const rendered = renderSettings(client)
152+
153+
// Toggle renders from the cached value while the mount refetch is pending.
154+
await rendered.findByText('Use FaceID', {}, { timeout: 15000 })
155+
await staleRefetchStarted
156+
157+
// Toggle biometrics off. handleUpdateTouchId cancels the in-flight refetch,
158+
// optimistically writes false, and persists.
159+
fireEvent.press(rendered.getByText('Use FaceID'))
160+
await waitFor(
161+
() => {
162+
expect(mockBiometry.enabled).toBe(false)
163+
expect(getCachedTouchEnabled(client)).toBe(false)
164+
},
165+
{ timeout: 15000 }
166+
)
167+
168+
// The stale mount refetch now resolves with the OLD (true) value. Because it
169+
// was cancelled, react-query must ignore it and leave the cache at false.
170+
// Without the cancelQueries guard this write flips the toggle back on.
171+
resolveStaleRefetch(true)
172+
await new Promise(resolve => setTimeout(resolve, 100))
173+
expect(getCachedTouchEnabled(client)).toBe(false)
174+
175+
rendered.unmount()
176+
}, 60000)
177+
})

src/components/scenes/SettingsScene.tsx

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useQuery } from '@tanstack/react-query'
1+
import { useQuery, useQueryClient } from '@tanstack/react-query'
22
import { asMaybe } from 'cleaners'
33
import type { EdgeLogType } from 'edge-core-js'
44
import {
@@ -67,6 +67,13 @@ import { asPrivateNetworkingSetting } from '../themed/MaybePrivateNetworkingSett
6767

6868
type Props = EdgeAppSceneProps<'settingsOverview'>
6969

70+
// Shared query key so the query definition and the optimistic cache update
71+
// can never drift apart.
72+
const biometricStateQueryKey = (accountId: string): [string, string] => [
73+
'biometricState',
74+
accountId
75+
]
76+
7077
export const SettingsScene: React.FC<Props> = props => {
7178
const { navigation } = props
7279
const theme = useTheme()
@@ -84,9 +91,14 @@ export const SettingsScene: React.FC<Props> = props => {
8491

8592
const account = useSelector(state => state.core.account)
8693

87-
// Load biometric state locally (not from Redux)
94+
const queryClient = useQueryClient()
95+
96+
// Load biometric state locally (not from Redux). The toggle renders
97+
// directly off this query so its on-screen value can never diverge from the
98+
// cache; handleUpdateTouchId keeps the cache correct via an optimistic
99+
// setQueryData.
88100
const { data: biometricState } = useQuery({
89-
queryKey: ['biometricState', account.id],
101+
queryKey: biometricStateQueryKey(account.id),
90102
queryFn: async () => {
91103
const [touchEnabled, supportedType] = await Promise.all([
92104
isTouchEnabled(account),
@@ -101,18 +113,6 @@ export const SettingsScene: React.FC<Props> = props => {
101113
enabled: account != null
102114
})
103115

104-
// Local state to track touch ID enabled status (can be toggled by user)
105-
const [touchIdEnabled, setTouchIdEnabled] = React.useState<boolean | null>(
106-
null
107-
)
108-
109-
// Sync local state with loaded state
110-
React.useEffect(() => {
111-
if (biometricState != null && touchIdEnabled == null) {
112-
setTouchIdEnabled(biometricState.isTouchEnabled)
113-
}
114-
}, [biometricState, touchIdEnabled])
115-
116116
const supportsTouchId = biometricState?.isTouchSupported ?? false
117117
const username = useWatch(account, 'username')
118118
const allKeys = useWatch(account, 'allKeys')
@@ -214,18 +214,33 @@ export const SettingsScene: React.FC<Props> = props => {
214214
}
215215

216216
const handleUpdateTouchId = useHandler(async () => {
217-
if (touchIdEnabled == null) return
218-
const newValue = !touchIdEnabled
219-
setTouchIdEnabled(newValue)
217+
if (biometricState == null) return
218+
const newValue = !biometricState.isTouchEnabled
219+
const queryKey = biometricStateQueryKey(account.id)
220+
// Cancel any in-flight refetch first: a background fetch started on mount
221+
// reads the pre-toggle keychain value, and if it resolves after we persist
222+
// it would clobber the cache back to the old state and flip the switch.
223+
await queryClient.cancelQueries({ queryKey })
224+
// Optimistically flip the cache so the toggle (which renders off the
225+
// query) updates immediately and stays correct when Settings is left and
226+
// re-entered. Without this the cached value stays stale and re-entry shows
227+
// the previous state.
228+
queryClient.setQueryData(queryKey, {
229+
...biometricState,
230+
isTouchEnabled: newValue
231+
})
220232
try {
221233
if (newValue) {
222234
await enableTouchId(account)
223235
} else {
224236
await disableTouchId(account)
225237
}
226238
} catch (error: unknown) {
227-
// Revert on error
228-
setTouchIdEnabled(!newValue)
239+
// Revert the optimistic update on failure.
240+
queryClient.setQueryData(queryKey, {
241+
...biometricState,
242+
isTouchEnabled: !newValue
243+
})
229244
showError(error)
230245
}
231246
})
@@ -654,11 +669,11 @@ export const SettingsScene: React.FC<Props> = props => {
654669
onPress={handleTogglePinLoginEnabled}
655670
/>
656671
) : null}
657-
{supportsTouchId && !isLightAccount && touchIdEnabled != null ? (
672+
{supportsTouchId && !isLightAccount && biometricState != null ? (
658673
<SettingsSwitchRow
659674
key="useTouchID"
660675
label={touchIdText}
661-
value={touchIdEnabled}
676+
value={biometricState.isTouchEnabled}
662677
onPress={handleUpdateTouchId}
663678
/>
664679
) : null}

0 commit comments

Comments
 (0)