From 2bc3a1c71f5a57ec57cc9eb6380f03820f3920e2 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 25 Jun 2026 16:12:01 -0700 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + .../scenes/SettingsScene.biometric.test.tsx | 106 ++++++++++++++++++ src/components/scenes/SettingsScene.tsx | 14 ++- 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/scenes/SettingsScene.biometric.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fe47fb7f1..19430040b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - changed: Migrate Monero to the react-native-monero implementation, replacing edge-currency-monero - changed: Migrate package manager from yarn to npm. - 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. +- fixed: Use Biometrics toggle in Settings reverting to its previous state after leaving and re-entering the scene. ## 4.48.2 (2026-06-03) diff --git a/src/__tests__/scenes/SettingsScene.biometric.test.tsx b/src/__tests__/scenes/SettingsScene.biometric.test.tsx new file mode 100644 index 00000000000..952e222840e --- /dev/null +++ b/src/__tests__/scenes/SettingsScene.biometric.test.tsx @@ -0,0 +1,106 @@ +import { describe, expect, it, jest } from '@jest/globals' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, waitFor } from '@testing-library/react-native' +import * as React from 'react' + +import { SettingsScene } from '../../components/scenes/SettingsScene' +import { FakeProviders, type FakeState } from '../../util/fake/FakeProviders' +import { fakeEdgeAppSceneProps } from '../../util/fake/fakeSceneProps' + +// Stateful biometric backend, standing in for the keychain that +// `edge-login-ui-rn` persists the "Use Biometrics" setting to. The `mock` +// prefix is required for jest to allow referencing it inside the factory. +const mockBiometry = { enabled: true } +jest.mock('edge-login-ui-rn', () => ({ + getSupportedBiometryType: async () => 'FaceID', + isTouchEnabled: async () => mockBiometry.enabled, + enableTouchId: async () => { + mockBiometry.enabled = true + }, + disableTouchId: async () => { + mockBiometry.enabled = false + } +})) + +const ACCOUNT_ID = 'fake-account-id' +const BIOMETRIC_QUERY_KEY = ['biometricState', ACCOUNT_ID] + +const mockState: FakeState = { + core: { + account: { + id: ACCOUNT_ID, + rootLoginId: 'XXX', + currencyConfig: {}, + username: 'some user', + watch: () => () => {} + }, + context: { + logSettings: { defaultLogLevel: 'silent' }, + watch: () => () => {} + } + } +} + +const getCachedTouchEnabled = (client: QueryClient): boolean | undefined => + client.getQueryData<{ isTouchEnabled: boolean }>(BIOMETRIC_QUERY_KEY) + ?.isTouchEnabled + +const renderSettings = (client: QueryClient): ReturnType => + render( + + + + + + ) + +describe('SettingsScene biometric toggle persistence', () => { + it('keeps the cached biometric state in sync after toggling so re-entry shows the set value', async () => { + jest.useRealTimers() + mockBiometry.enabled = true + + // A client we own and can inspect, shared across both scene mounts to + // model the app-level query cache that survives a scene remount. Seed it + // with the loaded biometric state so the toggle renders deterministically. + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }) + client.setQueryData(BIOMETRIC_QUERY_KEY, { + isTouchEnabled: true, + isTouchSupported: true, + biometryType: 'FaceID' + }) + + const rendered = renderSettings(client) + + // The toggle renders once the local state syncs from the cached value. + await rendered.findByText('Use FaceID', {}, { timeout: 15000 }) + expect(getCachedTouchEnabled(client)).toBe(true) + + // Toggle biometrics off. + fireEvent.press(rendered.getByText('Use FaceID')) + + // The persisted backend flips off AND the query cache is kept in sync. + // Before the fix the cache stayed `true`, so re-entering Settings showed + // the stale (on) state. + await waitFor( + () => { + expect(mockBiometry.enabled).toBe(false) + expect(getCachedTouchEnabled(client)).toBe(false) + }, + { timeout: 15000 } + ) + + rendered.unmount() + + // Re-enter Settings: a fresh mount reads the same (now-correct) cache and + // shows the toggle in the state the user left it. + const reentered = renderSettings(client) + await reentered.findByText('Use FaceID', {}, { timeout: 15000 }) + expect(getCachedTouchEnabled(client)).toBe(false) + + reentered.unmount() + }, 60000) +}) diff --git a/src/components/scenes/SettingsScene.tsx b/src/components/scenes/SettingsScene.tsx index f050023beb0..bb03b6775f1 100644 --- a/src/components/scenes/SettingsScene.tsx +++ b/src/components/scenes/SettingsScene.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { asMaybe } from 'cleaners' import type { EdgeLogType } from 'edge-core-js' import { @@ -84,6 +84,8 @@ export const SettingsScene: React.FC = props => { const account = useSelector(state => state.core.account) + const queryClient = useQueryClient() + // Load biometric state locally (not from Redux) const { data: biometricState } = useQuery({ queryKey: ['biometricState', account.id], @@ -223,6 +225,16 @@ export const SettingsScene: React.FC = props => { } else { await disableTouchId(account) } + // Keep the cached biometric state in sync with the value we just + // persisted. Without this, the cached query result stays stale and + // re-entering Settings shows the previous toggle state instead of the + // one the user set. + if (biometricState != null) { + queryClient.setQueryData(['biometricState', account.id], { + ...biometricState, + isTouchEnabled: newValue + }) + } } catch (error: unknown) { // Revert on error setTouchIdEnabled(!newValue)